Skip to content
Draft
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,14 @@
* [#2707](https://github.com/ruby-grape/grape/pull/2707): Tighten six guard conditions in `lib/` via De Morgan and `blank?`/`present?`/`include?` rewrites; no behaviour change - [@ericproulx](https://github.com/ericproulx).
* [#2708](https://github.com/ruby-grape/grape/pull/2708): Tighten dynamic `define_method` in `DSL::Callbacks` and `DSL::Routing` - [@ericproulx](https://github.com/ericproulx).
* [#2709](https://github.com/ruby-grape/grape/pull/2709): Lift trailing `if/else` into guard clauses; tighten `Util::Lazy::ValueEnumerable` - [@ericproulx](https://github.com/ericproulx).
* [#2716](https://github.com/ruby-grape/grape/pull/2716): Refactor `DSL::Routing#version`: guard clause, explicit kwargs in place of `**options`, and a `Grape::DSL::VersionOptions` value object stored internally - [@ericproulx](https://github.com/ericproulx).
* [#2702](https://github.com/ruby-grape/grape/pull/2702): Add `oneof:` option for `requires`/`optional` to accept a Hash parameter matching one of several variant schemas (resolves [#2385](https://github.com/ruby-grape/grape/issues/2385)) - [@ericproulx](https://github.com/ericproulx).
* [#2715](https://github.com/ruby-grape/grape/pull/2715): Normalize `==` / `eql?` aliasing across value-like classes - [@ericproulx](https://github.com/ericproulx).
* [#2710](https://github.com/ruby-grape/grape/pull/2710): Tidy up `Grape::DeclaredParamsHandler` - [@ericproulx](https://github.com/ericproulx).
* [#2712](https://github.com/ruby-grape/grape/pull/2712): Pass a `Grape::Exceptions::ErrorResponse` value object to `error_formatter#call` instead of separate kwargs - [@ericproulx](https://github.com/ericproulx).
* [#2714](https://github.com/ruby-grape/grape/pull/2714): Drop unused `Grape::Middleware::Globals` and its `grape.request*` env constants - [@ericproulx](https://github.com/ericproulx).
* [#2717](https://github.com/ruby-grape/grape/pull/2717): Convert `Grape::Exceptions::ErrorResponse` to a `Data` value object - [@ericproulx](https://github.com/ericproulx).
* [#2719](https://github.com/ruby-grape/grape/pull/2719): Move content-type helpers from `Middleware::Base` into `PrecomputedContentTypes` - [@ericproulx](https://github.com/ericproulx).
* Your contribution here.

#### Fixes
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2739,12 +2739,12 @@ end

The error format will match the request format. See "Content-Types" below.

Custom error formatters for existing and additional types can be defined with a proc.
Custom error formatters for existing and additional types can be defined with a proc. The formatter receives a `Grape::Exceptions::ErrorResponse` value object as `error:` plus three context kwargs — `env:`, `include_backtrace:`, `include_original_exception:`. Pull just the keys you need with `**` to ignore the rest:

```ruby
class Twitter::API < Grape::API
error_formatter :txt, ->(message, backtrace, options, env, original_exception) {
"error: #{message} from #{backtrace}"
error_formatter :txt, ->(error:, **) {
"error #{error.status}: #{error.message} from #{error.backtrace}"
}
end
```
Expand All @@ -2753,8 +2753,8 @@ You can also use a module or class.

```ruby
module CustomFormatter
def self.call(message, backtrace, options, env, original_exception)
{ message: message, backtrace: backtrace }
def self.call(error:, **)
{ status: error.status, message: error.message, backtrace: error.backtrace }
end
end

Expand Down
49 changes: 49 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,55 @@ end

The original implementation is preserved in git history at [`6b4111b3:lib/grape/middleware/globals.rb`](https://github.com/ruby-grape/grape/blob/6b4111b3/lib/grape/middleware/globals.rb).

#### `error_formatter` now receives a `Grape::Exceptions::ErrorResponse` value object

Custom error formatters now receive a frozen `Grape::Exceptions::ErrorResponse` as the `error:` keyword argument, alongside three request-time context kwargs. The new signature:

```ruby
def call(error:, env: nil, include_backtrace: false, include_original_exception: false)
```

`error` is the same value object the middleware uses internally, with `status` / `message` / `headers` / `backtrace` / `original_exception` accessors. The two `include_*` booleans are forwarded from the matching `rescue_from` options (previously buried inside `options[:rescue_options]`).

Existing positional formatters break and need to be updated:

```ruby
# Before
error_formatter :txt, ->(message, backtrace, options, env, original_exception) { ... }

module CustomFormatter
def self.call(message, backtrace, options, env, original_exception)
...
end
end

# After — pick fields off `error`
error_formatter :txt, ->(error:, **) { "[#{error.status}] #{error.message}" }

module CustomFormatter
def self.call(error:, **)
{ status: error.status, message: error.message, backtrace: error.backtrace }
end
end
```

Migration:

| Old positional arg | New |
| --- | --- |
| `message` | `error.message` |
| `backtrace` | `error.backtrace` |
| `original_exception` | `error.original_exception` |
| `options[:rescue_options][:backtrace]` | `include_backtrace` (kwarg) |
| `options[:rescue_options][:original_exception]` | `include_original_exception` (kwarg) |
| `env` | `env` (kwarg, still passed) |
| HTTP status | `error.status` (newly exposed) |
| Response headers | `error.headers` (newly exposed) |

The remaining middleware-options keys (`default_status`, `format`, `rescue_handlers`, …) were framework-internal and have never been part of the documented contract.

The change resolves [#2527](https://github.com/ruby-grape/grape/issues/2527): the HTTP `status` and the response `headers` are now part of the formatter contract, so JSON:API–style error bodies (which embed the status code) and header-aware formatters can be written without reaching into `env[Grape::Env::API_ENDPOINT]`.

#### `Grape::Middleware::Base#options` is now frozen

`@options` is frozen at the end of `Grape::Middleware::Base#initialize` (after `merge_default_options`). The hash is initialized once and treated as immutable for the lifetime of the middleware. Custom middleware that mutates `options[...]` at runtime will now raise `FrozenError`.
Expand Down
2 changes: 1 addition & 1 deletion lib/grape/api/instance.rb
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ def call(env)
def cascade?
namespace_inheritable = self.class.inheritable_setting.namespace_inheritable
return namespace_inheritable[:cascade] if namespace_inheritable.key?(:cascade)
return namespace_inheritable[:version_options][:cascade] if namespace_inheritable[:version_options]&.key?(:cascade)
return namespace_inheritable[:version_options].cascade if namespace_inheritable[:version_options]

true
end
Expand Down
26 changes: 11 additions & 15 deletions lib/grape/dsl/request_response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,15 @@ def default_error_status(new_status = nil)
# @param [Array] exception_classes A list of classes that you want to rescue, or
# the symbol :all to rescue from all exceptions.
# @param [Block] block Execution block to handle the given exception.
# @param [Hash] options Options for the rescue usage.
# @option options [Boolean] :backtrace Include a backtrace in the rescue response.
# @option options [Boolean] :rescue_subclasses Also rescue subclasses of exception classes
# @param [Proc] handler Execution proc to handle the given exception as an
# alternative to passing a block.
def rescue_from(*args, with: nil, **options, &block)
# @param [Proc] with Execution proc to handle the given exception as an alternative
# to passing a block.
# @param [Boolean] rescue_subclasses Also rescue subclasses of exception classes;
# defaults to +true+.
# @param [Boolean] backtrace Include the rescued exception's backtrace in the
# rescue response body.
# @param [Boolean] original_exception Include +inspect+ of the rescued exception
# in the rescue response body.
def rescue_from(*args, with: nil, rescue_subclasses: true, backtrace: false, original_exception: false, &block)
handler = extract_handler(args, with:, block:)

if args.include?(:all)
Expand All @@ -101,18 +104,11 @@ def rescue_from(*args, with: nil, **options, &block)
elsif args.include?(:internal_grape_exceptions)
inheritable_setting.namespace_inheritable[:internal_grape_exceptions_rescue_handler] = handler
else
handler_type =
case options[:rescue_subclasses]
when nil, true
:rescue_handlers
else
:base_only_rescue_handlers
end

handler_type = rescue_subclasses ? :rescue_handlers : :base_only_rescue_handlers
inheritable_setting.namespace_reverse_stackable[handler_type] = args.to_h { |arg| [arg, handler] }
end

inheritable_setting.namespace_stackable[:rescue_options] = options
inheritable_setting.namespace_stackable[:rescue_options] = RescueOptions.new(backtrace:, original_exception:)
end

# Allows you to specify a default representation entity for a
Expand Down
24 changes: 24 additions & 0 deletions lib/grape/dsl/rescue_options.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

module Grape
module DSL
# Immutable value object holding the response-shaping booleans accepted
# by +Grape::DSL::RequestResponse#rescue_from+. Stored on the
# inheritable settings as +namespace_stackable[:rescue_options]+ and
# delegated to by +Grape::Middleware::Error+ (which forwards
# +backtrace+/+original_exception+ to the formatter as
# +include_backtrace+/+include_original_exception+).
#
# Defaults are duplicated on +#initialize+ here and on +#rescue_from+'s
# signature on purpose: keeping them on both sides means each entry point
# is self-documenting without needing to import a shared constant — the
# DSL signature shows what a user sees in the IDE, and the Data object
# has working defaults when constructed directly (middleware
# `DEFAULT_OPTIONS`, spec fixtures, etc.). The two must stay in lockstep.
RescueOptions = Data.define(:backtrace, :original_exception) do
def initialize(backtrace: false, original_exception: false)
super
end
end
end
end
55 changes: 38 additions & 17 deletions lib/grape/dsl/routing.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ def cascade(value = nil)

# Specify an API version.
#
# Called without arguments, returns the most recently declared version
# (or +nil+). Called with one or more version strings, registers them
# and stores a {Grape::DSL::VersionOptions} value object on the
# inheritable settings; when given a block, the registration applies
# within a nested namespace.
#
# @example API with legacy support.
# class MyAPI < Grape::API
# version 'v2'
Expand All @@ -38,26 +44,41 @@ def cascade(value = nil)
# end
# end
#
def version(*args, **options, &block)
if args.any?
options = options.reverse_merge(using: :path)
requested_versions = args.flatten.map(&:to_s)

raise Grape::Exceptions::MissingVendorOption.new if options[:using] == :header && !options.key?(:vendor)

@versions = versions | requested_versions

if block
within_namespace do
inheritable_setting.namespace_inheritable[:version] = requested_versions
inheritable_setting.namespace_inheritable[:version_options] = options

instance_eval(&block)
end
else
# @param args [Array<String, Symbol>] one or more version identifiers.
# @param using [Symbol] versioning strategy — one of +:path+ (default),
# +:header+, +:param+, or +:accept_version_header+.
# @param cascade [Boolean] forward to subsequent routes via the
# +X-Cascade+ header on version mismatch. Defaults to +true+.
# @param parameter [String] name of the query/body parameter that
# carries the version when +using: :param+. Defaults to +'apiver'+.
# @param strict [Boolean] reject requests that don't supply a usable
# version (header strategies). Defaults to +false+.
# @param vendor [String, nil] vendor segment for the +:header+
# strategy (+application/vnd.<vendor>-<version>+); required when
# +using: :header+.
# @yield optional block to scope routes under this version.
# @return [String, nil] the most recently declared version.
# @raise [Grape::Exceptions::MissingVendorOption] when +using: :header+
# is supplied without a +:vendor+.
def version(*args, using: :path, cascade: true, parameter: 'apiver', strict: false, vendor: nil, &block)
return @versions&.last if args.empty?

raise Grape::Exceptions::MissingVendorOption.new if using == :header && vendor.nil?

requested_versions = args.flatten.map(&:to_s)
options = VersionOptions.new(using:, cascade:, parameter:, strict:, vendor:)

@versions = versions | requested_versions

if block
within_namespace do
inheritable_setting.namespace_inheritable[:version] = requested_versions
inheritable_setting.namespace_inheritable[:version_options] = options
instance_eval(&block)
end
else
inheritable_setting.namespace_inheritable[:version] = requested_versions
inheritable_setting.namespace_inheritable[:version_options] = options
end

@versions&.last
Expand Down
23 changes: 23 additions & 0 deletions lib/grape/dsl/version_options.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true

module Grape
module DSL
# Immutable value object holding the resolved options from
# +Grape::DSL::Routing#version+. Stored on the inheritable settings as
# +namespace_inheritable[:version_options]+ and read by internal call
# sites (`Path`, `Endpoint`, `API::Instance#cascade?`,
# `Middleware::Versioner::Base`) via accessors.
#
# Defaults are duplicated on +#initialize+ here and on +#version+'s
# signature on purpose: keeping them on both sides means each entry point
# is self-documenting without needing to import a shared constant — the
# DSL signature shows what a user sees in the IDE, and the Data object
# has working defaults when constructed directly (middleware
# `DEFAULT_OPTIONS`, spec fixtures, etc.). The two must stay in lockstep.
VersionOptions = Data.define(:using, :cascade, :parameter, :strict, :vendor) do
def initialize(using: :path, cascade: true, parameter: 'apiver', strict: false, vendor: nil)
super
end
end
end
end
7 changes: 4 additions & 3 deletions lib/grape/endpoint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -310,9 +310,10 @@ def build_stack
stack.concat inheritable_setting.namespace_stackable[:middleware]

if inheritable_setting.namespace_inheritable[:version].present?
stack.use Grape::Middleware::Versioner.using(inheritable_setting.namespace_inheritable[:version_options][:using]),
version_options = inheritable_setting.namespace_inheritable[:version_options]
stack.use Grape::Middleware::Versioner.using(version_options.using),
versions: inheritable_setting.namespace_inheritable[:version].flatten,
version_options: inheritable_setting.namespace_inheritable[:version_options],
version_options:,
prefix: inheritable_setting.namespace_inheritable[:root_prefix],
mount_path: inheritable_setting.namespace_stackable[:mount_path].first
end
Expand Down Expand Up @@ -340,7 +341,7 @@ def error_middleware_options(format, content_types)
rescue_grape_exceptions: ns_inh[:rescue_grape_exceptions],
default_error_formatter: ns_inh[:default_error_formatter],
error_formatters: ns_stack.namespace_stackable_with_hash(:error_formatters),
rescue_options: ns_stack.namespace_stackable_with_hash(:rescue_options),
rescue_options: ns_stack.namespace_stackable[:rescue_options]&.last,
rescue_handlers:,
base_only_rescue_handlers: ns_stack.namespace_stackable_with_hash(:base_only_rescue_handlers),
all_rescue_handler: ns_inh[:all_rescue_handler],
Expand Down
15 changes: 11 additions & 4 deletions lib/grape/error_formatter/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,18 @@ module Grape
module ErrorFormatter
class Base
class << self
def call(message, backtrace, options = {}, env = nil, original_exception = nil)
wrapped_message = wrap_message(present(message, env))
# Custom error formatters override +call+. The +error+ is a frozen
# {Grape::Exceptions::ErrorResponse} carrying +status+/+message+/
# +headers+/+backtrace+/+original_exception+. +env+ is the Rack env
# (needed by entity-presenter resolution). +include_backtrace+ and
# +include_original_exception+ are the request-time toggles set by
# +rescue_from+; the base implementation embeds the corresponding
# fields in the response body when they are true.
def call(error:, env: nil, include_backtrace: false, include_original_exception: false)
wrapped_message = wrap_message(present(error.message, env))
if wrapped_message.is_a?(Hash)
wrapped_message[:backtrace] = backtrace if backtrace.present? && options.dig(:rescue_options, :backtrace)
wrapped_message[:original_exception] = original_exception.inspect if original_exception && options.dig(:rescue_options, :original_exception)
wrapped_message[:backtrace] = error.backtrace if include_backtrace && error.backtrace.present?
wrapped_message[:original_exception] = error.original_exception.inspect if include_original_exception && error.original_exception
end

format_structured_message(wrapped_message)
Expand Down
35 changes: 13 additions & 22 deletions lib/grape/middleware/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ class Base
attr_reader :app, :env, :options

# @param [Rack Application] app The standard argument for a Rack middleware.
# @param [Hash] options A hash of options, simply stored for use by subclasses.
# @param [Hash] options Options forwarded to the subclass. When the
# subclass declares an `Options` Data class, the kwargs are routed
# through it. Otherwise they are deep-merged with the subclass's
# `DEFAULT_OPTIONS` Hash (legacy path) and frozen.
def initialize(app, **options)
@app = app
@options = merge_default_options(options).freeze
@options = build_options(options)
@app_response = nil
end

Expand Down Expand Up @@ -61,22 +64,6 @@ def response
@app_response = Rack::Response[*@app_response]
end

def content_types
@content_types ||= Grape::ContentTypes.content_types_for(options[:content_types])
end

def mime_types
@mime_types ||= Grape::ContentTypes.mime_types_for(content_types)
end

def content_type_for(format)
content_types_indifferent_access[format]
end

def content_type
content_type_for(env[Grape::Env::API_FORMAT] || options[:format]) || 'text/html'
end

def query_params
rack_request.GET
rescue *Grape::RACK_ERRORS
Expand All @@ -85,10 +72,6 @@ def query_params

private

def content_types_indifferent_access
@content_types_indifferent_access ||= content_types.with_indifferent_access
end

def merge_headers(response)
return if @header.blank?

Expand All @@ -98,6 +81,14 @@ def merge_headers(response)
end
end

def build_options(options)
# Search ancestors so subclasses (e.g. Versioner::Path → Versioner::Base)
# inherit their parent's Options Data class without redeclaring it.
return self.class::Options.new(**options) if self.class.const_defined?(:Options)

merge_default_options(options).freeze
end

def merge_default_options(options)
if respond_to?(:default_options)
default_options.deep_merge(options)
Expand Down
Loading
Loading