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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
* [#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).
* [#2935](https://github.com/ruby-grape/grape/pull/2935): Return the elements of an Array params scope that are not a Hash from `declared` instead of raising - [@ericproulx](https://github.com/ericproulx).
* [#2937](https://github.com/ruby-grape/grape/pull/2937): Forward the class methods kept off an API class to its base instance explicitly, and pin what `delegate_missing_to` still answers - [@ericproulx](https://github.com/ericproulx).
* [#2936](https://github.com/ruby-grape/grape/pull/2936): Speed up header versioning, content negotiation, params validation and error responses on the request path - [@ericproulx](https://github.com/ericproulx).
* Your contribution here.

### 4.0.0 (2026-09-07)
Expand Down
14 changes: 14 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
Upgrading Grape
===============

### Upgrading to >= 4.1.0

#### The header versioner's `api.*` env values are frozen

`version ..., using: :header` now answers the Accept headers most requests send from a table built once, so every request sending the same header is handed the same parsed media type ([#2936](https://github.com/ruby-grape/grape/pull/2936)). The strings it writes into the env — `api.type`, `api.subtype`, `api.vendor`, `api.version` and `api.format` — are therefore frozen, as is `Grape::Util::MediaType` itself. Code that altered one of them in place now raises `FrozenError`; build a new String instead:

```ruby
# Before
env['api.version'] << '-beta'

# After
env['api.version'] = "#{env['api.version']}-beta"
```

### Upgrading to >= 4.0.0

#### A positional options Hash is no longer accepted by `auth`, `http_basic` or `desc`
Expand Down
5 changes: 4 additions & 1 deletion lib/grape/declared_params_handler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,11 @@ def declare_leaf(passed_params, declared_param:, params_nested_path:, memo:, ren
end
end

# The lookup key is the param's whole path, built fresh -- two Arrays and a
# String per declared param -- and only an API using +as:+ has anything to
# find with it, so the common empty table is not asked.
def build_memo_key(params_nested_path, declared_param, renamed_params)
renamed_param_name = renamed_params[nested_path_for(params_nested_path, declared_param)]
renamed_param_name = renamed_params[nested_path_for(params_nested_path, declared_param)] if renamed_params.any?
param = renamed_param_name || declared_param
@stringify ? param.to_s : param.to_sym
end
Expand Down
3 changes: 3 additions & 0 deletions lib/grape/error_formatter/json.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,11 @@ def wrap_message(message)
{ error: ensure_utf8(message) }
end

# Re-encoding a String that already is valid UTF-8 only copies it, for
# about 260 ns on every error response.
def ensure_utf8(message)
return message unless message.respond_to? :encode
return message if message.is_a?(String) && message.encoding == Encoding::UTF_8 && message.valid_encoding?

message.encode('UTF-8', invalid: :replace, undef: :replace)
end
Expand Down
14 changes: 12 additions & 2 deletions lib/grape/exceptions/validation_errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,19 @@ def to_json(*_opts)
as_json.to_json
end

# Translated once, when the error is built for its #message, and handed
# out as a copy from then on. Every lookup here is an I18n call of a few
# microseconds, and the README's recipe for answering with the list,
# +error!({ messages: e.full_messages }, 400)+, asked for all of them a
# second time. It also keeps the list the same as #message, which was
# already fixed at that point, if the locale changes in between.
def full_messages
(@full_messages ||= translate_full_messages).dup
end

private

def translate_full_messages
messages = errors.flat_map do |attributes, errs|
errs.map do |error|
translate(
Expand All @@ -39,8 +51,6 @@ def full_messages
messages
end

private

def translate_attributes(keys)
keys.map do |key|
translate(key, scope: 'grape.errors.attributes', default: key.to_s)
Expand Down
26 changes: 23 additions & 3 deletions lib/grape/middleware/error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,13 @@ def call!(env)

private

# +headers+ is handed over as it is: Rack::Response copies it into a
# Headers of its own on every supported Rack (Rack 2.2 through
# +HeaderHash[]+, which only adopts a Hash that already is one), and every
# caller builds it fresh for this response.
def rack_response(status, headers, message)
body = html_content_type?(headers[Rack::CONTENT_TYPE]) ? Rack::Utils.escape_html(message) : message
Rack::Response.new(Array.wrap(body), Rack::Utils.status_code(status), Grape::Util::Header.new.merge!(headers))
Rack::Response.new(Array.wrap(body), Rack::Utils.status_code(status), headers)
end

# Escaping must key off the media type only, case-insensitively. Comparing
Expand Down Expand Up @@ -192,11 +196,13 @@ def failsafe_payload(headers)
)
end

# No +backtrace:+: #resolved_backtrace reads it off +original_exception+
# when the API asked for one, and only then, since building it is the
# dearest part of rendering the error.
def default_rescue_handler(exception)
error_response(
Grape::Exceptions::ErrorResponse.new(
message: exception.message,
backtrace: exception.backtrace,
original_exception: exception
)
)
Expand Down Expand Up @@ -268,7 +274,7 @@ def rescue_handler_for_any_class(klass)
def run_rescue_handler(handler, error, endpoint, redispatched: false)
callable = handler.is_a?(Symbol) ? endpoint.public_method(handler) : handler
response = catch(:error) do
callable.arity.zero? ? endpoint.instance_exec(&callable) : endpoint.instance_exec(error, &callable)
call_rescue_handler(callable, error, endpoint)
rescue StandardError => e
return redispatch(e, endpoint, redispatched)
end
Expand All @@ -279,6 +285,20 @@ def run_rescue_handler(handler, error, endpoint, redispatched: false)
run_rescue_handler(method(:default_rescue_handler), Grape::Exceptions::InvalidResponse.new, endpoint)
end

# A +rescue_from+ block runs as the endpoint. A Method (the middleware's
# own handlers, or the endpoint's for a +with:+ Symbol) is already bound
# to a receiver that instance_exec cannot change, so it is called as it
# is rather than turned into a Proc first.
def call_rescue_handler(callable, error, endpoint)
if callable.is_a?(Method)
callable.arity.zero? ? callable.call : callable.call(error)
elsif callable.arity.zero?
endpoint.instance_exec(&callable)
else
endpoint.instance_exec(error, &callable)
end
end

# Route an exception raised inside a +rescue_from+ block.
#
# * If we have already redispatched once (the redispatched handler
Expand Down
65 changes: 53 additions & 12 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 @@ -152,13 +188,23 @@ def read_rack_input(body)
end
env[Rack::RACK_REQUEST_FORM_INPUT] = env[Rack::RACK_INPUT]
end
rescue Grape::Exceptions::Base => e
raise e
rescue StandardError => e
rescue ForeignParserError => e
throw :error, Grape::Exceptions::ErrorResponse.new(status: 400, message: e.message, backtrace: e.backtrace, original_exception: e)
end
end

# What a parser raises that is not a Grape error, and so is answered as a
# 400 here. A Grape error goes on to the error middleware as it is. It
# used to be rescued just to be raised again, and re-raising at request
# depth cost about 25 µs -- paid by every malformed body, since the
# built-in parsers report one as InvalidMessageBody.
module ForeignParserError
def self.===(exception)
exception.is_a?(StandardError) && !exception.is_a?(Grape::Exceptions::Base)
end
end
private_constant :ForeignParserError

# this middleware will not try to format the following content-types since Rack already handles them
# when calling Rack's `params` function
# - application/x-www-form-urlencoded
Expand Down Expand Up @@ -221,16 +267,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
11 changes: 10 additions & 1 deletion lib/grape/middleware/versioner/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,20 @@ def self.inherited(klass)

attr_reader :available_media_types, :error_headers, :versions

# Read off ivars rather than delegated through +version_options+ into
# +config+: the versioners ask for +vendor+, +strict+ or +parameter+ on
# every request, and each read went two Forwardable frames and two Data
# readers deep for a value fixed when the middleware was built.
attr_reader :cascade, :parameter, :strict, :vendor

def_delegators :config, :mount_path, :prefix, :version_options
def_delegators :version_options, :cascade, :parameter, :strict, :vendor

def initialize(app, **options)
super
@cascade = version_options.cascade
@parameter = version_options.parameter
@strict = version_options.strict
@vendor = version_options.vendor
@versions = config.versions&.map(&:to_s) # making sure versions are strings to ease potential match
@error_headers = cascade ? CASCADE_PASS_HEADER : {}
@available_media_types = build_available_media_types
Expand Down
38 changes: 37 additions & 1 deletion lib/grape/middleware/versioner/header.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,42 @@ module Versioner
# X-Cascade header to alert Grape::Router to attempt the next matched
# route.
class Header < Base
# Accept headers answered up front besides the declared media types:
# +*/*+, what curl, Net::HTTP and most HTTP libraries send unless told
# otherwise, and no Accept header at all.
COMMON_ACCEPT_HEADERS = ['*/*', nil].freeze

# +MediaType.best_quality+ answers from the header and
# +available_media_types+ alone, and the latter is fixed once the
# middleware is built. Most requests send one of a handful of headers --
# a declared media type spelled as declared (+application/vnd.acme-v1+json+)
# or one of COMMON_ACCEPT_HEADERS -- so their answers are worked out
# once instead of re-running Rack's q-value match and a parse on every
# request. Any other header (a q-value list, another casing) is not a
# key and takes the full path, so a client cannot grow a table.
#
# Shared per list: every endpoint builds its own versioner, and all of
# an API's declare the same media types. The key is a frozen copy, as
# the middleware's own list stays reachable through
# +available_media_types+.
class MediaTypeForAcceptCache < Grape::Util::Cache
def initialize
super
@cache = Hash.new do |h, available_media_types|
declared = available_media_types.map(&:-@).freeze
h[declared] = [*declared, *COMMON_ACCEPT_HEADERS].each_with_object({}) do |accept, media_types|
media_type = Grape::Util::MediaType.best_quality(accept, declared)
media_types[accept] = media_type if media_type
end.freeze
end
end
end

def initialize(app, **options)
super
@media_type_for_accept = MediaTypeForAcceptCache[available_media_types]
end

def before
match_best_quality_media_type! do |media_type|
env.update(
Expand All @@ -40,7 +76,7 @@ def match_best_quality_media_type!
return unless vendor

strict_header_checks!
media_type = Grape::Util::MediaType.best_quality(accept_header, available_media_types)
media_type = @media_type_for_accept[accept_header] || Grape::Util::MediaType.best_quality(accept_header, available_media_types)
return yield media_type if media_type

fail!
Expand Down
17 changes: 11 additions & 6 deletions lib/grape/util/media_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,19 @@ class MediaType
# in the case they will be compared in.
VENDOR_VERSION_HEADER_REGEX = /\Avnd\.(?<vendor>[a-z0-9.\-_!^]+?)(?:-(?<version>[a-z0-9*.]+))?(?:\+(?<format>[a-z0-9*\-.]+))?\z/

# Immutable, strings included: the header versioner shares one instance
# per declared media type across every request that sends it, and these
# strings are what it writes into the env. The arguments are copied
# rather than frozen, as they are the caller's.
def initialize(type:, subtype:)
@type = type
@subtype = subtype
VENDOR_VERSION_HEADER_REGEX.match(subtype) do |m|
@vendor = m[:vendor]
@version = m[:version]
@format = m[:format]
@type = -type
@subtype = -subtype
VENDOR_VERSION_HEADER_REGEX.match(@subtype) do |m|
@vendor = m[:vendor].freeze
@version = m[:version].freeze
@format = m[:format].freeze
end
freeze
end

def ==(other)
Expand Down
Loading
Loading