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 @@ -19,6 +19,7 @@
* [#2698](https://github.com/ruby-grape/grape/pull/2698): Collapse `DSL::RequestResponse#extract_handler` type-dispatch into a `case`/`when` - [@ericproulx](https://github.com/ericproulx).
* [#2697](https://github.com/ruby-grape/grape/pull/2697): Extract `Grape::Util::PathNormalizer` from `Grape::Router`; `Grape::Router.normalize_path` is now a deprecated alias - [@ericproulx](https://github.com/ericproulx).
* [#2696](https://github.com/ruby-grape/grape/pull/2696): Reduce per-request allocations on the request hot path; migrate middleware options to `attr_reader` and freeze `@options` post-init - [@ericproulx](https://github.com/ericproulx).
* [#2693](https://github.com/ruby-grape/grape/pull/2693): Introduce `Grape::Exceptions::ErrorResponse` value object to replace the implicit-schema Hash thrown via `throw` - [@ericproulx](https://github.com/ericproulx).
* Your contribution here.

#### Fixes
Expand Down
6 changes: 6 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ end

Reading `options[...]` is unchanged.

#### Throw `:error` payloads are now `Grape::Exceptions::ErrorResponse`

The payload thrown via `throw :error, ...` is now a `Grape::Exceptions::ErrorResponse` value object instead of a `Hash`. If you `catch(:error)` and inspect the payload, switch from `payload[:status]` to `payload.status` (or `payload[:message]` to `payload.message`, etc.). User-defined `throw :error, hash` calls continue to work — `Middleware::Error#error_response` coerces Hashes, exceptions, and `ErrorResponse` instances at the boundary.

Returning or throwing a `Hash` with `:message`, `:status`, and `:headers` from a `rescue_from` handler is now deprecated and will be removed in a future release. Use `error!(...)` or return/throw a `Grape::Exceptions::ErrorResponse` instead.

#### `Grape::Request#grape_routing_args` has been removed

`grape_routing_args` was previously public to support third-party `params_builder` extensions, which have since been removed. With no remaining callers, the method has been removed. If you were calling it externally, read `env[Grape::Env::GRAPE_ROUTING_ARGS]` directly.
Expand Down
9 changes: 3 additions & 6 deletions lib/grape/dsl/inside_route.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,9 @@ def configuration
def error!(message, status = nil, additional_headers = nil, backtrace = nil, original_exception = nil)
status = self.status(status || inheritable_setting.namespace_inheritable[:default_error_status])
headers = additional_headers.present? ? header.merge(additional_headers) : header
throw :error,
message:,
status:,
headers:,
backtrace:,
original_exception:
throw :error, Grape::Exceptions::ErrorResponse.new(
message:, status:, headers:, backtrace:, original_exception:
)
end

# Redirect to a new url.
Expand Down
64 changes: 64 additions & 0 deletions lib/grape/exceptions/error_response.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# frozen_string_literal: true

module Grape
module Exceptions
# Value object representing the payload thrown via `throw :error, ...`
# and consumed by `Middleware::Error#error_response`. Replaces the
# implicit-schema Hash that previously circulated between throw sites
# and the error middleware.
class ErrorResponse
attr_reader :status, :message, :headers, :backtrace, :original_exception

def initialize(status: nil, message: nil, headers: nil, backtrace: nil, original_exception: nil)
@status = status
@message = message
@headers = headers
@backtrace = backtrace
@original_exception = original_exception
end

def to_s
"#<#{self.class.name} status=#{status.inspect} message=#{message.inspect} headers=#{headers.inspect}>"
end

def ==(other)
eql?(other)
end

def eql?(other)
self.class == other.class &&
other.status == status &&
other.message == message &&
other.headers == headers &&
other.backtrace == backtrace &&
other.original_exception == original_exception
end

def hash
[self.class, status, message, headers, backtrace, original_exception].hash
end

def self.from_exception(exception)
new(
status: exception.status,
message: exception.message,
headers: exception.headers,
backtrace: exception.backtrace,
original_exception: exception
)
end

# Normalize heterogeneous inputs into an ErrorResponse. Preserves the
# public contract that users can still `throw :error, hash` from their
# own middleware or `rescue_from` handlers.
def self.coerce(input)
case input
when ErrorResponse then input
when Grape::Exceptions::Base then from_exception(input)
when Hash then new(**input.slice(:status, :message, :headers, :backtrace, :original_exception))
else new
end
end
end
end
end
54 changes: 36 additions & 18 deletions lib/grape/middleware/error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,12 @@ def format_message(message, backtrace, original_exception = nil)
formatter = Grape::ErrorFormatter.formatter_for(current_format, error_formatters, default_error_formatter)
return formatter.call(message, backtrace, options, env, original_exception) if formatter

throw :error,
status: 406,
message: "The requested format '#{current_format}' is not supported.",
backtrace:,
original_exception:
throw :error, Grape::Exceptions::ErrorResponse.new(
status: 406,
message: "The requested format '#{current_format}' is not supported.",
backtrace:,
original_exception:
)
end

def find_handler(klass)
Expand All @@ -76,20 +77,26 @@ def find_handler(klass)
raise
end

def error_response(error = {})
status = error[:status] || default_status
def error_response(error = nil)
payload = Grape::Exceptions::ErrorResponse.coerce(error)

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

def default_rescue_handler(exception)
error_response(message: exception.message, backtrace: exception.backtrace, original_exception: exception)
error_response(
Grape::Exceptions::ErrorResponse.new(
message: exception.message,
backtrace: exception.backtrace,
original_exception: exception
)
)
end

def registered_rescue_handler(klass)
Expand Down Expand Up @@ -144,9 +151,20 @@ def error!(message, status = default_status, headers = {}, backtrace = [], origi
end

def error?(response)
return false unless response.is_a?(Hash)

response.key?(:message) && response.key?(:status) && response.key?(:headers)
case response
when Grape::Exceptions::ErrorResponse
true
when Hash
return false unless response.key?(:message) && response.key?(:status) && response.key?(:headers)

Grape.deprecator.warn(
'Returning or throwing a Hash from a rescue handler is deprecated. ' \
'Use `error!(...)` or a `Grape::Exceptions::ErrorResponse` instead.'
)
true
else
false
end
end
end
end
Expand Down
10 changes: 5 additions & 5 deletions lib/grape/middleware/formatter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def build_formatted_response(status, headers, bodies)
Rack::Response.new(bodymap, status, headers)
end
rescue Grape::Exceptions::InvalidFormatter => e
throw :error, status: 500, message: e.message, backtrace: e.backtrace, original_exception: e
throw :error, Grape::Exceptions::ErrorResponse.new(status: 500, message: e.message, backtrace: e.backtrace, original_exception: e)
end

def fetch_formatter(headers)
Expand Down Expand Up @@ -100,8 +100,8 @@ def read_rack_input(body)
media_type = rack_request.media_type
fmt = media_type ? mime_types[media_type] : default_format

throw :error, status: 415, message: "The provided content-type '#{media_type}' is not supported." unless content_type_for(fmt)
parser = Grape::Parser.parser_for fmt, parsers
throw :error, Grape::Exceptions::ErrorResponse.new(status: 415, message: "The provided content-type '#{media_type}' is not supported.") unless content_type_for(fmt)
parser = Grape::Parser.parser_for fmt, options[:parsers]
return env[Grape::Env::API_REQUEST_BODY] = body unless parser

begin
Expand All @@ -117,7 +117,7 @@ def read_rack_input(body)
rescue Grape::Exceptions::Base => e
raise e
rescue StandardError => e
throw :error, status: 400, message: e.message, backtrace: e.backtrace, original_exception: e
throw :error, Grape::Exceptions::ErrorResponse.new(status: 400, message: e.message, backtrace: e.backtrace, original_exception: e)
end
end

Expand All @@ -139,7 +139,7 @@ def negotiate_content_type
if content_type_for(fmt)
env[Grape::Env::API_FORMAT] = fmt.to_sym
else
throw :error, status: 406, message: "The requested format '#{fmt}' is not supported."
throw :error, Grape::Exceptions::ErrorResponse.new(status: 406, message: "The requested format '#{fmt}' is not supported.")
end
end

Expand Down
2 changes: 1 addition & 1 deletion lib/grape/middleware/versioner/accept_version_header.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def before
private

def not_acceptable!(message)
throw :error, status: 406, headers: error_headers, message:
throw :error, Grape::Exceptions::ErrorResponse.new(status: 406, headers: error_headers, message:)
end
end
end
Expand Down
2 changes: 1 addition & 1 deletion lib/grape/middleware/versioner/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def potential_version_match?(potential_version)
end

def version_not_found!
throw :error, status: 404, message: '404 API Version Not Found', headers: CASCADE_PASS_HEADER
throw :error, Grape::Exceptions::ErrorResponse.new(status: 404, message: '404 API Version Not Found', headers: CASCADE_PASS_HEADER)
end

private
Expand Down
20 changes: 20 additions & 0 deletions spec/grape/exceptions/base_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,26 @@
it { is_expected.to eq(message) }
end

describe '#[]' do
subject(:exception) { described_class.new(status: 418, message: 'a_message', headers: { 'X-Foo' => 'bar' }) }

it 'reads status via symbol index' do
expect(exception[:status]).to eq(418)
end

it 'reads message via symbol index' do
expect(exception[:message]).to eq('a_message')
end

it 'reads headers via symbol index' do
expect(exception[:headers]).to eq('X-Foo' => 'bar')
end

it 'raises NoMethodError for an unknown index' do
expect { exception[:unknown] }.to raise_error(NoMethodError)
end
end

describe '#compose_message' do
subject { described_class.new.__send__(:compose_message, key, **attributes) }

Expand Down
107 changes: 107 additions & 0 deletions spec/grape/exceptions/error_response_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# frozen_string_literal: true

describe Grape::Exceptions::ErrorResponse do
describe '#initialize' do
it 'accepts all known fields and exposes them as readers' do
payload = described_class.new(
status: 422,
message: 'boom',
headers: { 'X-Foo' => 'bar' },
backtrace: ['line 1'],
original_exception: StandardError.new('inner')
)

expect(payload.status).to eq(422)
expect(payload.message).to eq('boom')
expect(payload.headers).to eq('X-Foo' => 'bar')
expect(payload.backtrace).to eq(['line 1'])
expect(payload.original_exception).to be_a(StandardError)
end

it 'defaults all fields to nil' do
payload = described_class.new

expect(payload.status).to be_nil
expect(payload.message).to be_nil
expect(payload.headers).to be_nil
expect(payload.backtrace).to be_nil
expect(payload.original_exception).to be_nil
end
end

describe '#to_s' do
it 'renders status, message, and headers in a readable form' do
headers = { 'X-Foo' => 'bar' }
payload = described_class.new(status: 422, message: 'boom', headers:)

expect(payload.to_s).to eq(%(#<Grape::Exceptions::ErrorResponse status=422 message="boom" headers=#{headers.inspect}>))
end
end

describe '#==' do
let(:exception) { StandardError.new('inner') }
let(:attrs) { { status: 422, message: 'boom', headers: { 'X-Foo' => 'bar' }, backtrace: ['line 1'], original_exception: exception } }
let(:payload) { described_class.new(**attrs) }
let(:twin) { described_class.new(**attrs) }

it 'is equal when every attribute matches' do
expect(payload).to eq(twin)
end

it 'is not equal when any attribute differs' do
expect(payload).not_to eq(described_class.new(**attrs, status: 500))
end

it 'is not equal to a non-ErrorResponse with the same shape' do
expect(described_class.new(status: 422)).not_to eq(Object.new)
end

it 'returns the same hash for equal instances' do
expect(payload.hash).to eq(twin.hash)
end
end

describe '.from_exception' do
it 'extracts status, message, headers, and backtrace from a Grape exception' do
exception = Grape::Exceptions::Base.new(status: 418, message: 'teapot', headers: { 'X-T' => '1' })
payload = described_class.from_exception(exception)

expect(payload.status).to eq(418)
expect(payload.message).to eq('teapot')
expect(payload.headers).to eq('X-T' => '1')
expect(payload.original_exception).to eq(exception)
end
end

describe '.coerce' do
it 'returns the input unchanged when it is already an ErrorResponse' do
input = described_class.new(status: 500)

expect(described_class.coerce(input)).to equal(input)
end

it 'wraps a Grape exception via from_exception' do
exception = Grape::Exceptions::Base.new(status: 404, message: 'gone')
payload = described_class.coerce(exception)

expect(payload).to be_a(described_class)
expect(payload.status).to eq(404)
expect(payload.message).to eq('gone')
expect(payload.original_exception).to eq(exception)
end

it 'builds a new ErrorResponse from a Hash, picking only known keys' do
payload = described_class.coerce(status: 503, message: 'down', irrelevant: 'ignored')

expect(payload.status).to eq(503)
expect(payload.message).to eq('down')
end

it 'returns an empty ErrorResponse for unsupported input' do
payload = described_class.coerce(nil)

expect(payload).to be_a(described_class)
expect(payload.status).to be_nil
end
end
end
Loading
Loading