From c8ec0b0d78afed4c666f56615411c1f193169f4d Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Mon, 7 Sep 2026 17:20:06 +0200 Subject: [PATCH] Correct the 4.0.0 release notes and refresh the version benchmark Auditing the 4.0.0 CHANGELOG against every commit since v3.3.5 turned up three defects. #2824 and #2834 were both listed under 4.0.0, and #2834 was listed a second time under 3.3.5. The three entries describe one net change: #2824 fixed cascaded routes leaking route_info and path captures, #2829's squash silently replayed a stale router.rb over it, and #2834 restored it. The fix shipped in 3.3.5, so 4.0.0 keeps only #2829, whose "hand over to every remaining route" fix is genuinely new here. #2870 had no entry at all. It is the last of the #2864-#2867 params-DSL stack and the only merged lib-affecting PR in the range without one. The `http_digest` removal note sat under "Upgrading to >= 3.3", but #2849 is not in v3.3.5 and the section's own text says the method now raises NoMethodError -- which is only true from 4.0.0. Moved into the 4.0.0 block, next to the auth/http_basic positional-Hash entry. While there, closed a ```ruby fence opened at the ValidationErrors keyword-rename entry and never terminated, which was swallowing the heading and prose of the section that follows it. The version benchmark now benches the latest patch of each major/minor (3.0.1, 3.1.1, 3.2.1) rather than the .0 releases, with RESULTS.md regenerated on Ruby 4.0.6; README.md and app.rb follow the new list. Co-authored-by: Claude Opus 5 --- CHANGELOG.md | 4 +-- UPGRADING.md | 42 +++++++++++++------------ benchmark/version_throughput/README.md | 12 +++---- benchmark/version_throughput/RESULTS.md | 21 ++++++------- benchmark/version_throughput/app.rb | 2 +- benchmark/version_throughput/run.rb | 6 ++-- 6 files changed, 44 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c9492667..2fc0b81bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). @@ -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). @@ -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) diff --git a/UPGRADING.md b/UPGRADING.md index 1ddad98ea..965065fc5 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -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: @@ -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. @@ -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: diff --git a/benchmark/version_throughput/README.md b/benchmark/version_throughput/README.md index 666cb484a..b7bc60305 100644 --- a/benchmark/version_throughput/README.md +++ b/benchmark/version_throughput/README.md @@ -6,7 +6,7 @@ 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,,<μs>,,` line for the orchestrator. | | `run.rb` | Orchestrator. For each version: writes a `Gemfile`, runs `bundle install` under `tmp/bench-versions//`, 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. | @@ -14,14 +14,14 @@ Cross-version throughput benchmark for Grape. Measures `BenchAPI.call(env)` requ ## 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: `), so unstaged changes are picked up. All other versions resolve to released gems on rubygems.org. @@ -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 ` 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 ` 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. @@ -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 diff --git a/benchmark/version_throughput/RESULTS.md b/benchmark/version_throughput/RESULTS.md index aaa532fbf..32c64c6a1 100644 --- a/benchmark/version_throughput/RESULTS.md +++ b/benchmark/version_throughput/RESULTS.md @@ -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. diff --git a/benchmark/version_throughput/app.rb b/benchmark/version_throughput/app.rb index 8e5a14105..44bad0490 100644 --- a/benchmark/version_throughput/app.rb +++ b/benchmark/version_throughput/app.rb @@ -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 diff --git a/benchmark/version_throughput/run.rb b/benchmark/version_throughput/run.rb index 1e36784ab..b901b4b7f 100644 --- a/benchmark/version_throughput/run.rb +++ b/benchmark/version_throughput/run.rb @@ -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' @@ -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)