Skip to content
Closed
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
49 changes: 40 additions & 9 deletions lib/grape/middleware/formatter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading