Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
* [#2862](https://github.com/ruby-grape/grape/pull/2862): Rename the `desc` `default` key to `default_response`, the name grape-swagger reads, deprecating `default` (see UPGRADING) - [@ericproulx](https://github.com/ericproulx).
* [#2866](https://github.com/ruby-grape/grape/pull/2866): Take `as` as a keyword argument in `requires` and `optional` instead of carrying it in the options Hash, where it was dispatched as a no-op `AsValidator` - [@ericproulx](https://github.com/ericproulx).
* [#2867](https://github.com/ruby-grape/grape/pull/2867): Extract the shared body of `requires` and `optional` into one private declaration, and drop `validate_attributes`, whose only step defaulted a type that no declaration could reach - [@ericproulx](https://github.com/ericproulx).
* [#2870](https://github.com/ruby-grape/grape/pull/2870): Take `declared_params_scope` as a keyword argument in `push_declared_params` instead of stamping it into the caller's options Hash - [@ericproulx](https://github.com/ericproulx).
* [#2869](https://github.com/ruby-grape/grape/pull/2869): Add `Grape::PrecompiledJson`, a body wrapper the JSON formatters serve verbatim instead of encoding a second time - [@ericproulx](https://github.com/ericproulx).
* [#2878](https://github.com/ruby-grape/grape/pull/2878): Build an error response's backtrace only when `rescue_from ..., backtrace: true` asked for one (see UPGRADING) - [@ericproulx](https://github.com/ericproulx).
* [#2873](https://github.com/ruby-grape/grape/pull/2873): Read `Grape.config[:param_builder]` instead of routing the per-request lookup through dry-configurable's `method_missing` - [@ericproulx](https://github.com/ericproulx).
Expand Down Expand Up @@ -84,14 +85,12 @@
* [#2815](https://github.com/ruby-grape/grape/pull/2815): Fix line-anchored regexes: `type: JSON` payloads containing a blank line were silently coerced to `nil` - [@ericproulx](https://github.com/ericproulx).
* [#2817](https://github.com/ruby-grape/grape/pull/2817): Remove request-time mutable state from Array and custom-type coercers, which also makes an `Array`/`Set` of an uncoercible element type raise when the API is defined instead of on the first request that supplies it (see UPGRADING) - [@ericproulx](https://github.com/ericproulx).
* [#2819](https://github.com/ruby-grape/grape/pull/2819): Freeze coercers at construction, synchronize `Grape::Util::Cache` lookups, and assign `Route#regexp_capture_index` eagerly - [@ericproulx](https://github.com/ericproulx).
* [#2824](https://github.com/ruby-grape/grape/pull/2824): Fix cascaded routes (`X-Cascade: pass`) leaking `route_info` and path captures into the next matched route's `route` and `params` - [@ericproulx](https://github.com/ericproulx).
* [#2825](https://github.com/ruby-grape/grape/pull/2825): Stop `present` entity autodetection from picking up a top-level `::Entity` constant - [@ericproulx](https://github.com/ericproulx).
* [#2827](https://github.com/ruby-grape/grape/pull/2827): Make the `cascade` DSL getter return the configured value (`cascade false` read back as `true`) - [@ericproulx](https://github.com/ericproulx).
* [#2829](https://github.com/ruby-grape/grape/pull/2829): Fix a cascading route handing over only to the last route registered for the path, making a middle version (3+ mounted versions with a catch-all) answer 406 - [@ericproulx](https://github.com/ericproulx).
* [#2826](https://github.com/ruby-grape/grape/pull/2826): Fix `api.version` not being set for the root route of a path-versioned API (`GET /v1`) - [@ericproulx](https://github.com/ericproulx).
* [#2844](https://github.com/ruby-grape/grape/pull/2844): Look an entity up by the presented object's own class before treating it as a collection, so `represent` is no longer skipped for models that respond to `#first` or `#klass` - [@ericproulx](https://github.com/ericproulx).
* [#2847](https://github.com/ruby-grape/grape/pull/2847): Match `Accept` media types case-insensitively, so a differently-cased header still negotiates the content type and resolves a vendor version - [@ericproulx](https://github.com/ericproulx).
* [#2834](https://github.com/ruby-grape/grape/pull/2834): Restore the #2824 fix for cascaded routes leaking `route_info` and path captures, silently reverted by #2829 - [@ericproulx](https://github.com/ericproulx).
* [#2838](https://github.com/ruby-grape/grape/pull/2838): Reject request params nested in more arrays than the block declares, instead of silently unwrapping them and passing validation, and report `type: Array[JSON]` errors against the element that failed - [@ericproulx](https://github.com/ericproulx).
* [#2842](https://github.com/ruby-grape/grape/pull/2842): Warn at definition time when a `rescue_from` class is already covered by one registered earlier in the same scope, since the later handler never runs - [@ericproulx](https://github.com/ericproulx).
* [#2853](https://github.com/ruby-grape/grape/pull/2853): Restore, behind a deprecation warning, the trailing positional options Hash of `requires`, `optional` and `use`, which #2618 turned into a parameter name - [@ericproulx](https://github.com/ericproulx).
Expand All @@ -118,6 +117,7 @@
* [#2910](https://github.com/ruby-grape/grape/pull/2910): Restore `Grape::Middleware::Formatter`'s in-place content-type negotiation as `ensure_content_type!`, which #2908 had turned into a copy of the response headers on every response - [@ericproulx](https://github.com/ericproulx).
* [#2911](https://github.com/ruby-grape/grape/pull/2911): Copy response headers with `merge!` instead of `merge` in `Grape::API::Instance#call` and `Grape::Middleware::Error`, which allocated a `Grape::Util::Header` only to discard it - [@ericproulx](https://github.com/ericproulx).
* [#2907](https://github.com/ruby-grape/grape/pull/2907): Stop `Grape::Endpoint::Options` from appending the default `'/'` into the path Array it was given, so declaring a route with an empty Array of paths inside a `namespace`, `resource`, `group` or `route_param` block no longer mutates the caller's Array — and raises `FrozenError` at boot when it is frozen - [@ericproulx](https://github.com/ericproulx).
* [#2912](https://github.com/ruby-grape/grape/pull/2912): Correct the 4.0.0 release notes and file the `http_digest` removal note under `Upgrading to >= 4.0.0` - [@ericproulx](https://github.com/ericproulx).
* Your contribution here.

### 3.3.5 (2026-07-30)
Expand Down
42 changes: 22 additions & 20 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,26 @@ desc 'Get users', detail: 'Returns every user'

A leftover positional Hash now raises `ArgumentError` rather than warning. A call that already used bare keyword syntax, or a block, is unaffected.

#### `http_digest` is removed

`Grape::Middleware::Auth::DSL#http_digest` is gone. Calling it now raises `NoMethodError` while the API class is being defined.

Nothing it could reach has existed since **2.0.0**, which removed `Rack::Auth::Digest` along with Grape's `:http_digest` strategy ([#2361](https://github.com/ruby-grape/grape/pull/2361)) after Rack 3 dropped digest authentication. The method survived that removal and kept recording its settings happily, so an API declaring `http_digest` still booted — and then raised `Grape::Exceptions::UnknownAuthStrategy` on the *first request*, from inside the middleware build, as an uncaught exception rather than a response. Failing while the class is defined is the point of removing it.

**If you registered your own `:http_digest` strategy**, it still works; call `auth` directly:

```ruby
Grape::Middleware::Auth::Strategies.add(:http_digest, MyDigestStrategy, ->(settings) { [settings[:realm]] })

class API < Grape::API
auth :http_digest, realm: 'API Authorization', opaque: 'secret' do |username|
# ...
end
end
```

The removed method supplied two defaults that `auth` does not, so pass them explicitly if you were relying on them: `realm` defaulted to `'API Authorization'`, and `opaque` to `'secret'` (nested inside `realm` when `realm` was itself a Hash).

#### A `rescue_from` handler can no longer return or throw a Hash

Deprecated in 3.3. A handler that returned `{ message:, status:, headers: }` was read as an error response. Say what you mean instead:
Expand Down Expand Up @@ -746,6 +766,8 @@ Grape::Exceptions::ValidationErrors.new(errors: [validation, validation_array_er

# after
Grape::Exceptions::ValidationErrors.new(exceptions: [validation, validation_array_errors], headers:)
```

#### `Grape::Exceptions::ValidationErrors` no longer mixes in `Enumerable`

`Grape::Exceptions::ValidationErrors` no longer includes `Enumerable` and no longer defines a public `#each`. The Enumerable surface (`#each`, `#map`, `#select`, `#to_a`, etc.) was undocumented and untested; the documented accessors — `#errors`, `#full_messages`, `#message`, `#as_json` — are unchanged.
Expand Down Expand Up @@ -806,26 +828,6 @@ http_basic(realm: 'API')
auth :my_strategy, realm: 'API'
```

#### `http_digest` is removed

`Grape::Middleware::Auth::DSL#http_digest` is gone. Calling it now raises `NoMethodError` while the API class is being defined.

Nothing it could reach has existed since **2.0.0**, which removed `Rack::Auth::Digest` along with Grape's `:http_digest` strategy ([#2361](https://github.com/ruby-grape/grape/pull/2361)) after Rack 3 dropped digest authentication. The method survived that removal and kept recording its settings happily, so an API declaring `http_digest` still booted — and then raised `Grape::Exceptions::UnknownAuthStrategy` on the *first request*, from inside the middleware build, as an uncaught exception rather than a response. Failing while the class is defined is the point of removing it.

**If you registered your own `:http_digest` strategy**, it still works; call `auth` directly:

```ruby
Grape::Middleware::Auth::Strategies.add(:http_digest, MyDigestStrategy, ->(settings) { [settings[:realm]] })

class API < Grape::API
auth :http_digest, realm: 'API Authorization', opaque: 'secret' do |username|
# ...
end
end
```

The removed method supplied two defaults that `auth` does not, so pass them explicitly if you were relying on them: `realm` defaulted to `'API Authorization'`, and `opaque` to `'secret'` (nested inside `realm` when `realm` was itself a Hash).

#### Middleware options now route through per-class `Options` `Data` value objects

`Grape::Middleware::Error`, `Grape::Middleware::Formatter`, and `Grape::Middleware::Versioner::Base` each declare an `Options` `Data.define` and route their `**options` kwargs through it on `initialize`. This means **unknown kwargs now raise `ArgumentError`** instead of being silently swallowed:
Expand Down
12 changes: 6 additions & 6 deletions benchmark/version_throughput/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,22 @@ Cross-version throughput benchmark for Grape. Measures `BenchAPI.call(env)` requ

| File | Role |
|---|---|
| `app.rb` | The API under test. Kept deliberately small and version-agnostic — only DSL surface stable across Grape 3.x, so the same file can run against `3.0.0 … master` without edits. |
| `app.rb` | The API under test. Kept deliberately small and version-agnostic — only DSL surface stable across Grape 3.x, so the same file can run against `3.0.1 … master` without edits. |
| `bench.rb` | Single-version benchmark. Loads `app.rb`, sanity-checks the response, runs `Benchmark.ips` (2s warmup + 5s measure), prints one `RESULT,<ips>,<μs>,<stddev>,<yjit>` line for the orchestrator. |
| `run.rb` | Orchestrator. For each version: writes a `Gemfile`, runs `bundle install` under `tmp/bench-versions/<version>/`, exec's `bench.rb` once without YJIT and once with `--yjit` (if available), parses results, writes `RESULTS.md`. |
| `RESULTS.md` | Generated report — overwritten on every run. |

## Usage

```sh
# default: 3.0.0, 3.1.0, 3.2.0, 3.3.0, 3.3.5, master
# default: 3.0.1, 3.1.1, 3.2.1, 3.3.5, master
ruby benchmark/version_throughput/run.rb

# subset
GRAPE_VERSIONS="3.3.5,master" ruby benchmark/version_throughput/run.rb

# different Ruby (e.g. one built with YJIT)
RBENV_VERSION=4.0.3 ruby benchmark/version_throughput/run.rb
RBENV_VERSION=4.0.6 ruby benchmark/version_throughput/run.rb
```

`master` is benched against the working tree (`gemspec path: <repo root>`), so unstaged changes are picked up. All other versions resolve to released gems on rubygems.org.
Expand All @@ -30,11 +30,11 @@ RBENV_VERSION=4.0.3 ruby benchmark/version_throughput/run.rb

Each version produces a row in `RESULTS.md`, listed oldest to newest so the table reads as a timeline:

| Version | No-YJIT (i/s) | μs/req | vs prev | vs 3.0.0 | YJIT (i/s) | μs/req | vs prev | vs 3.0.0 | YJIT speedup |
| Version | No-YJIT (i/s) | μs/req | vs prev | vs 3.0.1 | YJIT (i/s) | μs/req | vs prev | vs 3.0.1 | YJIT speedup |
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| … | … | … | … | … | … | … | … | … | … |

The two delta columns per pass are the point of the report: `vs prev` is the release-over-release change, `vs <first>` the cumulative change since the oldest benched version (the header names it — `vs 3.0.0` with the default list, whatever comes first in `GRAPE_VERSIONS` otherwise). A one-line summary under the table restates first → last for both passes.
The two delta columns per pass are the point of the report: `vs prev` is the release-over-release change, `vs <first>` the cumulative change since the oldest benched version (the header names it — `vs 3.0.1` with the default list, whatever comes first in `GRAPE_VERSIONS` otherwise). A one-line summary under the table restates first → last for both passes.

YJIT columns are only emitted if the running Ruby was built with YJIT support (`run.rb` probes via `ruby --yjit -e 'exit(defined?(RubyVM::YJIT) ? 0 : 1)'`). Without YJIT the table keeps its `± stddev` column and still carries both deltas.

Expand All @@ -45,7 +45,7 @@ YJIT columns are only emitted if the running Ruby was built with YJIT support (`
- **`master` vs released gems is not apples-to-apples for code paths that changed.** If a refactor moved code between files, both numbers still measure the same `app.rb` request — that's the point — but interpret deltas as "end-to-end request cost" rather than per-method.
- **YJIT speedup is `(yjit_ips - no_yjit_ips) / no_yjit_ips`.** Both passes share the same Ruby binary; only the `--yjit` flag differs.
- **Deltas are computed per pass, on i/s.** A version that failed to bench is skipped as a reference, so `vs prev` always points at the closest version that actually produced a number — an `error:` row never breaks the chain.
- **Cumulative deltas compound the noise floor.** Read `vs 3.0.0` for the shape of the trend, not as a precise figure.
- **Cumulative deltas compound the noise floor.** Read `vs 3.0.1` for the shape of the trend, not as a precise figure.

## Adding a version

Expand Down
21 changes: 10 additions & 11 deletions benchmark/version_throughput/RESULTS.md
Original file line number Diff line number Diff line change
@@ -1,26 +1,25 @@
# Grape throughput by version

Generated: 2026-09-04 21:50:19 CEST
Ruby: ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [arm64-darwin25]
Generated: 2026-09-07 17:13:53 CEST
Ruby: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [arm64-darwin25]
Host: Darwin 25.6.0 arm64
YJIT available: true

Single-threaded `Benchmark.ips`, 2s warmup + 5s measure, `BenchAPI.call(env)` against `/api/v1/hello` returning a small JSON object. Reproduce with `ruby benchmark/version_throughput/run.rb`.

| Version | No-YJIT (i/s) | μs/req | vs prev | vs 3.0.0 | YJIT (i/s) | μs/req | vs prev | vs 3.0.0 | YJIT speedup |
| Version | No-YJIT (i/s) | μs/req | vs prev | vs 3.0.1 | YJIT (i/s) | μs/req | vs prev | vs 3.0.1 | YJIT speedup |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| 3.0.0 | 34,135 | 29.30 | — | — | 55,271 | 18.09 | — | — | +61.9% |
| 3.1.0 | 44,942 | 22.25 | +31.7% | +31.7% | 83,835 | 11.93 | +51.7% | +51.7% | +86.5% |
| 3.2.0 | 46,015 | 21.73 | +2.4% | +34.8% | 86,605 | 11.55 | +3.3% | +56.7% | +88.2% |
| 3.3.0 | 64,924 | 15.40 | +41.1% | +90.2% | 133,003 | 7.52 | +53.6% | +140.6% | +104.9% |
| 3.3.5 | 64,404 | 15.53 | -0.8% | +88.7% | 134,421 | 7.44 | +1.1% | +143.2% | +108.7% |
| master | 118,769 | 8.42 | +84.4% | +247.9% | 229,512 | 4.36 | +70.7% | +315.2% | +93.2% |
| 3.0.1 | 34,512 | 28.98 | — | — | 52,487 | 19.05 | — | — | +52.1% |
| 3.1.1 | 45,656 | 21.90 | +32.3% | +32.3% | 85,284 | 11.73 | +62.5% | +62.5% | +86.8% |
| 3.2.1 | 47,440 | 21.08 | +3.9% | +37.5% | 90,745 | 11.02 | +6.4% | +72.9% | +91.3% |
| 3.3.5 | 67,834 | 14.74 | +43.0% | +96.6% | 137,675 | 7.26 | +51.7% | +162.3% | +103.0% |
| master | 120,877 | 8.27 | +78.2% | +250.2% | 230,153 | 4.34 | +67.2% | +338.5% | +90.4% |

Over time, 3.0.0 → master: **+247.9%** without YJIT, **+315.2%** with YJIT.
Over time, 3.0.1 → master: **+250.2%** without YJIT, **+338.5%** with YJIT.

## Notes
- All versions exercised through the same `BenchAPI` definition (kept stable in `app.rb`).
- `vs prev` compares throughput against the previous benched version and `vs 3.0.0` against the first one; read those columns for improvement over time.
- `vs prev` compares throughput against the previous benched version and `vs 3.0.1` against the first one; read those columns for improvement over time.
- Results are noisy at this scale (±5-8%); rerun if a number looks off.
- `YJIT speedup` is `(yjit_ips - no_yjit_ips) / no_yjit_ips`.
- YJIT pass uses `ruby --yjit`; both passes share the same Ruby binary.
2 changes: 1 addition & 1 deletion benchmark/version_throughput/app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# Version-agnostic Grape API used by version_throughput benchmark.
# Kept tiny and using only DSL surface that's been stable across Grape 3.x —
# so the same script can be exec'd against 3.0.0 ... master without changes.
# so the same script can be exec'd against 3.0.1 ... master without changes.
require 'grape'

class BenchAPI < Grape::API
Expand Down
6 changes: 3 additions & 3 deletions benchmark/version_throughput/run.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@
# ruby benchmark/version_throughput/run.rb
#
# To bench against a specific subset:
# GRAPE_VERSIONS="3.0.0,3.3.5,master" ruby benchmark/version_throughput/run.rb
# GRAPE_VERSIONS="3.0.1,3.3.5,master" ruby benchmark/version_throughput/run.rb
#
# Versions must be listed oldest to newest: the delta columns compare each
# row against the one above it and against the first row.
#
# To run a YJIT-enabled Ruby that isn't the project default:
# RBENV_VERSION=4.0.3 ruby benchmark/version_throughput/run.rb
# RBENV_VERSION=4.0.6 ruby benchmark/version_throughput/run.rb

require 'fileutils'
require 'open3'
Expand All @@ -30,7 +30,7 @@
HERE = __dir__
TMP = File.join(ROOT, 'tmp', 'bench-versions')

DEFAULT_VERSIONS = %w[3.0.0 3.1.0 3.2.0 3.3.0 3.3.5 master].freeze
DEFAULT_VERSIONS = %w[3.0.1 3.1.1 3.2.1 3.3.5 master].freeze
versions = (ENV['GRAPE_VERSIONS']&.split(',')&.map(&:strip) || DEFAULT_VERSIONS).freeze

def gemfile_for(version)
Expand Down
Loading