Skip to content
Draft
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 @@ -26,6 +26,7 @@
* [#2706](https://github.com/ruby-grape/grape/pull/2706): Refactor `ParamsScope#validates` and `ParamsDocumentation` around a frozen `Grape::Validations::ValidationsSpec` value object; the validations hash supplied by the DSL is no longer mutated and the helper chain becomes pure - [@ericproulx](https://github.com/ericproulx).
* [#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).
* [#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).
* [#2712](https://github.com/ruby-grape/grape/pull/2712): Switch `error_formatter` to keyword arguments; expose `status:` and `headers:` - [@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 @@ -2689,12 +2689,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 the error context as keyword arguments — `message:`, `backtrace:`, `options:`, `env:`, `status:`, `headers:`, and `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, ->(message:, backtrace:, status:, **) {
"error #{status}: #{message} from #{backtrace}"
}
end
```
Expand All @@ -2703,8 +2703,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(message:, backtrace:, status:, **)
{ status: status, message: message, backtrace: backtrace }
end
end

Expand Down
35 changes: 35 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,41 @@ Upgrading Grape

### Upgrading to >= 3.3

#### `error_formatter` now receives keyword arguments

Custom error formatters now receive their context as keyword arguments and gain two previously-inaccessible fields, `status:` and `headers:`. The middleware-options hash is no longer threaded through whole; the two booleans formatters actually consulted from it (`rescue_options[:backtrace]`, `rescue_options[:original_exception]`) are forwarded as flat kwargs `include_backtrace:` / `include_original_exception:`. The new signature, with defaults for everything past `message:`:

```ruby
def call(message:, backtrace: [], include_backtrace: false, env: nil, status: nil, headers: {},
original_exception: nil, include_original_exception: false)
```

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 just the keys you need with `**`
error_formatter :txt, ->(message:, backtrace:, status:, **) { ... }

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

If a custom formatter previously dug `options[:rescue_options][:backtrace]`, read `include_backtrace:` directly; same for `:original_exception` → `include_original_exception:`. 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
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
2 changes: 1 addition & 1 deletion lib/grape/endpoint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,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
14 changes: 11 additions & 3 deletions lib/grape/error_formatter/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,19 @@ module Grape
module ErrorFormatter
class Base
class << self
def call(message, backtrace, options = {}, env = nil, original_exception = nil)
# Custom error formatters override +call+. The signature is keyword-only
# so additional context can be threaded through without forcing every
# downstream formatter to absorb a new positional argument. Defaults
# mirror the prior positional signature and keep overrides resilient to
# future kwargs added on the call site. See
# +Grape::Middleware::Error#format_message+ for the call site.
# +status+/+headers+ are part of the public contract for overrides; the
# base implementation does not consume them.
def call(message:, backtrace: [], include_backtrace: false, env: nil, status: nil, headers: {}, original_exception: nil, include_original_exception: false) # rubocop:disable Lint/UnusedMethodArgument
wrapped_message = wrap_message(present(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] = backtrace if include_backtrace && backtrace.present?
wrapped_message[:original_exception] = original_exception.inspect if include_original_exception && original_exception
end

format_structured_message(wrapped_message)
Expand Down
35 changes: 23 additions & 12 deletions lib/grape/middleware/error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
module Grape
module Middleware
class Error < Base
extend Forwardable
include PrecomputedContentTypes

DEFAULT_OPTIONS = {
Expand All @@ -18,16 +19,19 @@ class Error < Base
rescue_all: false,
rescue_grape_exceptions: false,
rescue_handlers: nil,
rescue_options: {
backtrace: false,
original_exception: false
}.freeze
rescue_options: Grape::DSL::RescueOptions.new
}.freeze

attr_reader :all_rescue_handler, :base_only_rescue_handlers, :default_error_formatter,
:default_message, :default_status, :error_formatters, :format,
:grape_exceptions_rescue_handler, :internal_grape_exceptions_rescue_handler,
:rescue_all, :rescue_grape_exceptions, :rescue_handlers
:rescue_all, :rescue_grape_exceptions, :rescue_handlers, :rescue_options

# +:backtrace+ / +:original_exception+ on the rescue options become
# +#include_backtrace+ / +#include_original_exception+ on the middleware,
# which is what the formatter call site reads.
def_delegator :rescue_options, :backtrace, :include_backtrace
def_delegator :rescue_options, :original_exception, :include_original_exception

def initialize(app, **options)
super
Expand All @@ -43,6 +47,7 @@ def initialize(app, **options)
@rescue_all = @options[:rescue_all]
@rescue_grape_exceptions = @options[:rescue_grape_exceptions]
@rescue_handlers = @options[:rescue_handlers]
@rescue_options = @options[:rescue_options] || Grape::DSL::RescueOptions.new
end

def call!(env)
Expand All @@ -59,10 +64,15 @@ def rack_response(status, headers, message)
Rack::Response.new(Array.wrap(message), Rack::Utils.status_code(status), Grape::Util::Header.new.merge(headers))
end

def format_message(message, backtrace, original_exception = nil)
def format_message(message, backtrace, status:, headers:, original_exception: nil)
current_format = env[Grape::Env::API_FORMAT] || format
formatter = Grape::ErrorFormatter.formatter_for(current_format, error_formatters, default_error_formatter)
return formatter.call(message, backtrace, options, env, original_exception) if formatter
if formatter
return formatter.call(
message:, backtrace:, env:, status:, headers:, original_exception:,
include_backtrace:, include_original_exception:
)
end

throw :error, Grape::Exceptions::ErrorResponse.new(
status: 406,
Expand All @@ -82,13 +92,13 @@ def find_handler(klass)
def error_response(error = nil)
payload = Grape::Exceptions::ErrorResponse.coerce(error)

status = payload.status || options[:default_status]
status = payload.status || default_status
env[Grape::Env::API_ENDPOINT].status(status) # error! may not have been called
message = payload.message || options[:default_message]
message = payload.message || default_message
headers = { Rack::CONTENT_TYPE => content_type }
headers.merge!(payload.headers) if payload.headers.is_a?(Hash)
backtrace = payload.backtrace || payload.original_exception&.backtrace || []
rack_response(status, headers, format_message(message, backtrace, payload.original_exception))
rack_response(status, headers, format_message(message, backtrace, status:, headers:, original_exception: payload.original_exception))
end

def default_rescue_handler(exception)
Expand Down Expand Up @@ -188,9 +198,10 @@ def framework_default(endpoint)

def error!(message, status = default_status, headers = {}, backtrace = [], original_exception = nil)
env[Grape::Env::API_ENDPOINT].status(status) # not error! inside route
merged_headers = headers.reverse_merge(Rack::CONTENT_TYPE => content_type)
rack_response(
status, headers.reverse_merge(Rack::CONTENT_TYPE => content_type),
format_message(message, backtrace, original_exception)
status, merged_headers,
format_message(message, backtrace, status:, headers: merged_headers, original_exception:)
)
end

Expand Down
17 changes: 16 additions & 1 deletion spec/grape/api_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2511,7 +2511,7 @@ def rescue_all_errors
context 'class' do
let(:custom_error_formatter) do
Class.new do
def self.call(message, _backtrace, _options, _env, _original_exception)
def self.call(message:, **)
"message: #{message} @backtrace"
end
end
Expand Down Expand Up @@ -2548,6 +2548,21 @@ def self.call(message, _backtrace, _options, _env, _original_exception)
end
end

context 'with status and headers exposed (issue 2527)' do
it 'passes the HTTP status and headers into a custom error formatter' do
subject.format :txt
subject.error_formatter :txt, ->(message:, status:, headers:, **) { "[#{status}] #{message} (#{headers['x-marker']})" }
subject.rescue_from :all do
error!('boom', 418, 'x-marker' => 'hit')
end
subject.get('/exception') { raise 'rain!' }

get '/exception'
expect(last_response.status).to eq(418)
expect(last_response.body).to eq('[418] boom (hit)')
end
end

it 'rescues all errors and return :json' do
subject.rescue_from :all
subject.format :json
Expand Down
12 changes: 7 additions & 5 deletions spec/grape/dsl/request_response_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -199,37 +199,39 @@
end

describe 'list of exceptions is passed' do
let(:default_rescue_options) { [Grape::DSL::RescueOptions.new] }

it 'sets hash of exceptions as rescue handlers' do
subject.rescue_from StandardError
expect(subject.inheritable_setting.namespace_reverse_stackable[:rescue_handlers]).to eq([{ StandardError => nil }])
expect(subject.inheritable_setting.namespace_stackable[:rescue_options]).to eq([{}])
expect(subject.inheritable_setting.namespace_stackable[:rescue_options]).to eq(default_rescue_options)
end

it 'rescues only base handlers if rescue_subclasses: false option is passed' do
subject.rescue_from StandardError, rescue_subclasses: false
expect(subject.inheritable_setting.namespace_reverse_stackable[:base_only_rescue_handlers]).to eq([{ StandardError => nil }])
expect(subject.inheritable_setting.namespace_stackable[:rescue_options]).to eq([{ rescue_subclasses: false }])
expect(subject.inheritable_setting.namespace_stackable[:rescue_options]).to eq(default_rescue_options)
end

it 'sets given proc as rescue handler for each key in hash' do
rescue_handler_proc = proc {}
subject.rescue_from StandardError, rescue_handler_proc
expect(subject.inheritable_setting.namespace_reverse_stackable[:rescue_handlers]).to eq([{ StandardError => rescue_handler_proc }])
expect(subject.inheritable_setting.namespace_stackable[:rescue_options]).to eq([{}])
expect(subject.inheritable_setting.namespace_stackable[:rescue_options]).to eq(default_rescue_options)
end

it 'sets given block as rescue handler for each key in hash' do
rescue_handler_proc = proc {}
subject.rescue_from StandardError, &rescue_handler_proc
expect(subject.inheritable_setting.namespace_reverse_stackable[:rescue_handlers]).to eq([{ StandardError => rescue_handler_proc }])
expect(subject.inheritable_setting.namespace_stackable[:rescue_options]).to eq([{}])
expect(subject.inheritable_setting.namespace_stackable[:rescue_options]).to eq(default_rescue_options)
end

it 'sets a rescue handler declared through :with option for each key in hash' do
with_block = -> { 'hello' }
subject.rescue_from StandardError, with: with_block
expect(subject.inheritable_setting.namespace_reverse_stackable[:rescue_handlers]).to eq([{ StandardError => with_block }])
expect(subject.inheritable_setting.namespace_stackable[:rescue_options]).to eq([{}])
expect(subject.inheritable_setting.namespace_stackable[:rescue_options]).to eq(default_rescue_options)
end
end
end
Expand Down
10 changes: 4 additions & 6 deletions spec/grape/middleware/exception_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -190,9 +190,7 @@ def call(_env)
rescue_all: true,
format: :custom,
error_formatters: {
custom: lambda do |message, _backtrace, _options, _env, _original_exception|
{ custom_formatter: message }.inspect
end
custom: ->(message:, **) { { custom_formatter: message }.inspect }
}
}
end
Expand Down Expand Up @@ -231,7 +229,7 @@ def call(_env)
{
rescue_all: true,
format: :json,
rescue_options: { backtrace: true, original_exception: true }
rescue_options: Grape::DSL::RescueOptions.new(backtrace: true, original_exception: true)
}
end

Expand All @@ -247,7 +245,7 @@ def call(_env)
{
rescue_all: true,
format: :xml,
rescue_options: { backtrace: true, original_exception: true }
rescue_options: Grape::DSL::RescueOptions.new(backtrace: true, original_exception: true)
}
end

Expand All @@ -263,7 +261,7 @@ def call(_env)
{
rescue_all: true,
format: :txt,
rescue_options: { backtrace: true, original_exception: true }
rescue_options: Grape::DSL::RescueOptions.new(backtrace: true, original_exception: true)
}
end

Expand Down
Loading