From 921e21f4405a82234ec5b63721050627fa3b6081 Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Thu, 10 Sep 2026 21:09:42 +0200 Subject: [PATCH] Answer the formatter's common Accept headers from a table built once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An API that pins no `format` negotiates one from the Accept header on every request that sends one, through `Rack::Utils.best_q_match`: about 1.7 µs for `Accept: application/json`. The answer depends only on the header and `mime_types`, which is fixed once the middleware is built. `FormatForAcceptCache` works it out once for every registered media type, `*/*` and no Accept header, and `format_from_header` looks the header up there. Any other header misses and is negotiated as before, and only registered media types are keys, so client input cannot grow a table. Each entry is computed by the same function the full path runs, now a class method so the cache can call it, so a hit answers exactly what the full path would. The keys are valid strings that scrubbing leaves alone, so only a miss scrubs the header. Every endpoint builds its own formatter, so the table is shared per `mime_types`, which `Grape::ContentTypes` already shares per content-type registry. An API that pins a format never negotiates from the header and builds no table. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + lib/grape/middleware/formatter.rb | 49 +++++++++++++++++++++++++------ 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdc7d3a3e..803495036 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ * [#2918](https://github.com/ruby-grape/grape/pull/2918): Skip the dry-types round trip when a value already is the declared type - [@ericproulx](https://github.com/ericproulx). * [#2917](https://github.com/ruby-grape/grape/pull/2917): Read path captures out of the router's union match instead of re-running the route's pattern - [@ericproulx](https://github.com/ericproulx). * [#2921](https://github.com/ruby-grape/grape/pull/2921): Pin the router's request-time isolation regressions through requests instead of its instance variables - [@ericproulx](https://github.com/ericproulx). +* [#2924](https://github.com/ruby-grape/grape/pull/2924): Answer the formatter's common Accept headers from a table built once instead of negotiating them per request - [@ericproulx](https://github.com/ericproulx). * Your contribution here. ### 4.0.0 (2026-09-07) diff --git a/lib/grape/middleware/formatter.rb b/lib/grape/middleware/formatter.rb index 775c026fd..00d2d41c5 100644 --- a/lib/grape/middleware/formatter.rb +++ b/lib/grape/middleware/formatter.rb @@ -13,6 +13,40 @@ def initialize(content_types: nil, default_format: :txt, format: nil, formatters ALL_MEDIA_TYPES = '*/*' + # @api private + # The format an Accept header asks for out of +mime_types+, or nil. + # Callers scrub the header first. + # + # Media types are case-insensitive (RFC 9110 §8.3.1) but the registered + # ones are spelled in lower case and Rack matches them literally, so an + # `Accept: TEXT/PLAIN` found nothing and fell through to the default + # format — the client quietly got something other than what it asked for. + def self.format_for_accept(accept_header, mime_types) + return if accept_header.blank? || accept_header == ALL_MEDIA_TYPES + + media_type = Rack::Utils.best_q_match(accept_header.downcase, mime_types.keys) + mime_types[media_type] if media_type + end + + # +format_for_accept+ answers from the header and +mime_types+ alone. A + # client that names the format it wants sends one of those media types as + # it is registered (+application/json+), and most others send +*/*+ or + # nothing, so those answers are worked out once instead of re-running + # Rack's q-value match on every request. Any other header misses and is + # negotiated as before; only registered media types are keys, so a client + # cannot grow a table. + # + # Shared per +mime_types+, which Grape::ContentTypes already shares per + # content-type registry: every endpoint builds its own formatter. + class FormatForAcceptCache < Grape::Util::Cache + def initialize + super + @cache = Hash.new do |h, mime_types| + h[mime_types] = [*mime_types.keys, ALL_MEDIA_TYPES, nil].to_h { |accept| [accept, Formatter.format_for_accept(accept, mime_types)] }.freeze + end + end + end + # The request methods that can carry a body worth parsing. See # {#read_body_input?}, which tests the env against this before anything # asks for a Rack::Request. QUERY is here because its content *is* the @@ -36,6 +70,8 @@ def initialize(app, **options) @formatters = config.formatters @parsers = config.parsers mime_types + # Only an API that pins no format negotiates one from the Accept header. + @format_for_accept = FormatForAcceptCache[mime_types] unless format end def before @@ -221,16 +257,11 @@ def format_from_query query_params['format'] end - # Media types are case-insensitive (RFC 9110 §8.3.1) but the registered - # ones are spelled in lower case and Rack matches them literally, so an - # `Accept: TEXT/PLAIN` found nothing and fell through to the default - # format — the client quietly got something other than what it asked for. + # The keys are registered media types -- valid strings, which scrubbing + # leaves alone -- so only a miss needs the header scrubbed. def format_from_header - accept_header = try_scrub(env['HTTP_ACCEPT']) - return if accept_header.blank? || accept_header == ALL_MEDIA_TYPES - - media_type = Rack::Utils.best_q_match(accept_header.downcase, mime_types.keys) - mime_types[media_type] if media_type + accept_header = env['HTTP_ACCEPT'] + @format_for_accept.fetch(accept_header) { Formatter.format_for_accept(try_scrub(accept_header), mime_types) } end end end