Skip to content
Open
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 @@ -43,6 +43,7 @@
* [#2726](https://github.com/ruby-grape/grape/pull/2726): Reuse one `AttributesIterator` per validator and drop the unused `Enumerable` mixin - [@ericproulx](https://github.com/ericproulx).
* [#2728](https://github.com/ruby-grape/grape/pull/2728): Deprecate passing a positional options Hash to `auth`/`http_basic`/`http_digest`; pass keyword arguments instead - [@ericproulx](https://github.com/ericproulx).
* [#2733](https://github.com/ruby-grape/grape/pull/2733): Drop the dead `active_support/core_ext/hash/reverse_merge` require; call `ActiveSupport::HashWithIndifferentAccess.new(...)` directly at call sites - [@ericproulx](https://github.com/ericproulx).
* [#2718](https://github.com/ruby-grape/grape/pull/2718): Generalize middleware options to per-class `Options` `Data` value objects (`Middleware::Error`, `::Formatter`, `::Versioner::Base`); expose them via a new `config` reader, keep `options` Hash for back-compat, deprecate `Options#[]` Hash-style access - [@ericproulx](https://github.com/ericproulx).
* Your contribution here.

#### Fixes
Expand Down
39 changes: 39 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,45 @@ http_basic(realm: 'API')
auth :http_digest, realm: 'API', opaque: 'secret'
```

#### Middleware options now route through per-class `Options` `Data` value objects

`Grape::Middleware::Error`, `Grape::Middleware::Formatter`, and `Grape::Middleware::Versioner::Base` each declare an `Options` `Data.define` and route their `**options` kwargs through it on `initialize`. This means **unknown kwargs now raise `ArgumentError`** instead of being silently swallowed:

```ruby
# previously: silently swallowed (Formatter doesn't actually read :rescue_options)
Grape::Middleware::Formatter.new(app, rescue_options: { backtrace: true })

# now: ArgumentError (unknown keyword: :rescue_options)
```

Each `Options` class accepts exactly the kwargs the middleware actually reads. The supported sets:

- `Middleware::Error::Options`: `all_rescue_handler`, `base_only_rescue_handlers`, `content_types`, `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_options`.
- `Middleware::Formatter::Options`: `content_types`, `default_format`, `format`, `formatters`, `parsers`.
- `Middleware::Versioner::Base::Options`: `content_types`, `format`, `mount_path`, `pattern`, `prefix`, `version_options`, `versions`.

The `Hash`-based `options` reader on `Grape::Middleware::Base` continues to return a frozen Hash representation of the Data (`config.to_h.freeze`) for back-compat with subclasses that read `options[:key]`. A new `config` reader exposes the typed Data instance — prefer the named accessors going forward:

```ruby
# back-compat (still works)
options[:format]

# preferred
config.format
# or, on converted middlewares, just `format` (provided via def_delegators)
```

`Options#[]` is defined as a Hash-style shim with a deprecation warning so legacy `data[:key]` callers get a migration nudge:

```ruby
# emits Grape.deprecator warning
Grape::Middleware::Error::Options.new[:format]
```

#### `DEFAULT_OPTIONS` constants on converted middlewares are deprecated

`Grape::Middleware::Error::DEFAULT_OPTIONS`, `Grape::Middleware::Formatter::DEFAULT_OPTIONS`, and `Grape::Middleware::Versioner::Base::DEFAULT_OPTIONS` still exist as a frozen `Hash` representation of the `Options` defaults (`Options.new.to_h.freeze`), for back-compat with any code that referenced these constants directly. They will be removed in a future release; introspect the `Options` `Data` class itself instead.

#### `Grape::Middleware::Globals` removed

`Grape::Middleware::Globals` and the three env constants it set (`Grape::Env::GRAPE_REQUEST`, `Grape::Env::GRAPE_REQUEST_HEADERS`, `Grape::Env::GRAPE_REQUEST_PARAMS`) have been deleted. The middleware was introduced in 2013 (commit `9987090b`) but never mounted by Grape's own stack — the `Grape::Request` it built is now constructed directly inside `Grape::Endpoint`. Nothing in `lib/` read those env keys.
Expand Down
18 changes: 15 additions & 3 deletions lib/grape/middleware/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,25 @@ module Middleware
class Base
include Grape::DSL::Headers

attr_reader :app, :env, :options
attr_reader :app, :env, :options, :config

# @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 and exposed via {#config}; {#options} keeps returning a
# frozen Hash representation for back-compat with subclasses that read
# `options[:key]`. Otherwise the kwargs 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
if self.class.const_defined?(:Options)
# Search ancestors so subclasses (e.g. Versioner::Path → Versioner::Base)
# inherit their parent's Options Data class without redeclaring it.
@config = self.class::Options.new(**options)
@options = @config.to_h.freeze
else
@options = merge_default_options(options).freeze
end
@app_response = nil
end

Expand Down
19 changes: 19 additions & 0 deletions lib/grape/middleware/deprecated_options_hash_access.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# frozen_string_literal: true

module Grape
module Middleware
# Mixin for per-middleware +Options+ +Data+ classes that need to keep
# accepting legacy +data[:key]+ Hash-style access while nudging callers
# toward the named accessor. Emits a +Grape.deprecator+ warning then
# forwards to +public_send(key)+.
module DeprecatedOptionsHashAccess
def [](key)
Grape.deprecator.warn(
"`#{self.class.name}#[]` is deprecated. " \
"Use the named accessor `#{key}` instead."
)
public_send(key) if members.include?(key)
end
end
end
end
72 changes: 35 additions & 37 deletions lib/grape/middleware/error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,50 +6,48 @@ class Error < Base
extend Forwardable
include PrecomputedContentTypes

DEFAULT_OPTIONS = {
all_rescue_handler: nil,
base_only_rescue_handlers: nil,
default_error_formatter: nil,
default_message: '',
default_status: 500,
error_formatters: nil,
format: :txt,
grape_exceptions_rescue_handler: nil,
internal_grape_exceptions_rescue_handler: nil,
rescue_all: false,
rescue_grape_exceptions: false,
rescue_handlers: nil,
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_options
Options = Data.define(
:all_rescue_handler, :base_only_rescue_handlers, :content_types,
: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_options
) do
include Grape::Middleware::DeprecatedOptionsHashAccess

def initialize(
all_rescue_handler: nil, base_only_rescue_handlers: nil, content_types: nil,
default_error_formatter: nil, default_message: '', default_status: 500,
error_formatters: nil, format: :txt,
grape_exceptions_rescue_handler: nil, internal_grape_exceptions_rescue_handler: nil,
rescue_all: false, rescue_grape_exceptions: false, rescue_handlers: nil,
rescue_options: nil
)
# `rescue_options:` arrives nil from `Endpoint#error_middleware_options`
# when no `rescue_from` has been called — fall back to the documented
# defaults rather than letting nil propagate to `def_delegator
# :rescue_options, :backtrace`.
rescue_options ||= Grape::DSL::RescueOptions.new
super
end
end

# @deprecated Kept as a frozen Hash representation of the {Options}
# defaults for back-compat. Will be removed in a future release.
DEFAULT_OPTIONS = Options.new.to_h.freeze

def_delegators :config,
: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_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
@all_rescue_handler = @options[:all_rescue_handler]
@base_only_rescue_handlers = @options[:base_only_rescue_handlers]
@default_error_formatter = @options[:default_error_formatter]
@default_message = @options[:default_message]
@default_status = @options[:default_status]
@error_formatters = @options[:error_formatters]
@format = @options[:format]
@grape_exceptions_rescue_handler = @options[:grape_exceptions_rescue_handler]
@internal_grape_exceptions_rescue_handler = @options[:internal_grape_exceptions_rescue_handler]
@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)
@env = env
error_response(catch(:error) { return @app.call(@env) })
Expand Down
31 changes: 14 additions & 17 deletions lib/grape/middleware/formatter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,24 @@
module Grape
module Middleware
class Formatter < Base
extend Forwardable
include PrecomputedContentTypes

DEFAULT_OPTIONS = {
content_types: nil,
default_format: :txt,
format: nil,
formatters: nil,
parsers: nil
}.freeze
Options = Data.define(:content_types, :default_format, :format, :formatters, :parsers) do
include Grape::Middleware::DeprecatedOptionsHashAccess

ALL_MEDIA_TYPES = '*/*'
def initialize(content_types: nil, default_format: :txt, format: nil, formatters: nil, parsers: nil)
super
end
end

attr_reader :default_format, :format, :formatters, :parsers
# @deprecated Kept as a frozen Hash representation of the {Options}
# defaults for back-compat. Will be removed in a future release.
DEFAULT_OPTIONS = Options.new.to_h.freeze

def initialize(app, **options)
super
@default_format = @options[:default_format]
@format = @options[:format]
@formatters = @options[:formatters]
@parsers = @options[:parsers]
end
ALL_MEDIA_TYPES = '*/*'

def_delegators :config, :default_format, :format, :formatters, :parsers

def before
negotiate_content_type
Expand Down Expand Up @@ -101,7 +98,7 @@ def read_rack_input(body)
fmt = media_type ? mime_types[media_type] : default_format

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]
parser = Grape::Parser.parser_for fmt, parsers
return env[Grape::Env::API_REQUEST_BODY] = body unless parser

begin
Expand Down
11 changes: 6 additions & 5 deletions lib/grape/middleware/precomputed_content_types.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ module Grape
module Middleware
# Include in a middleware subclass that needs content-type negotiation.
# Provides +content_types+ / +mime_types+ / +content_type_for+ /
# +content_type+ resolved from +options[:content_types]+ and
# +options[:format]+, and warms those caches on the parent instance at
# initialization so per-request +dup+s inherit them (avoiding
# +content_type+ resolved from +config.content_types+ and
# +config.format+ — so the consuming middleware's +Options+ Data class
# must declare both fields. Warms those caches on the parent instance
# at initialization so per-request +dup+s inherit them (avoiding
# ~1 µs/request of +with_indifferent_access+ recomputation).
#
# Opt-in: plain +Grape::Middleware::Base+ subclasses that don't need
Expand All @@ -20,7 +21,7 @@ def initialize(app, **options)
end

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

def mime_types
Expand All @@ -32,7 +33,7 @@ def content_type_for(format)
end

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

private
Expand Down
32 changes: 19 additions & 13 deletions lib/grape/middleware/versioner/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,22 @@ class Base < Grape::Middleware::Base
extend Forwardable
include Grape::Middleware::PrecomputedContentTypes

DEFAULT_OPTIONS = {
mount_path: nil,
pattern: /.*/i,
prefix: nil,
version_options: Grape::DSL::VersionOptions.new
}.freeze
Options = Data.define(
:content_types, :format, :mount_path, :pattern, :prefix, :version_options, :versions
) do
include Grape::Middleware::DeprecatedOptionsHashAccess

def initialize(
content_types: nil, format: nil, mount_path: nil, pattern: /.*/i, prefix: nil,
version_options: Grape::DSL::VersionOptions.new, versions: nil
)
super
end
end

# @deprecated Kept as a frozen Hash representation of the {Options}
# defaults for back-compat. Will be removed in a future release.
DEFAULT_OPTIONS = Options.new.to_h.freeze

CASCADE_PASS_HEADER = { 'X-Cascade' => 'pass' }.freeze

Expand All @@ -21,18 +31,14 @@ def self.inherited(klass)
Versioner.register(klass)
end

attr_reader :available_media_types, :error_headers, :mount_path, :pattern,
:prefix, :version_options, :versions
attr_reader :available_media_types, :error_headers, :versions

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

def initialize(app, **options)
super
@version_options = @options[:version_options]
@mount_path = @options[:mount_path]
@pattern = @options[:pattern]
@prefix = @options[:prefix]
@versions = @options[:versions]&.map(&:to_s) # making sure versions are strings to ease potential match
@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
end
Expand Down
1 change: 0 additions & 1 deletion spec/grape/middleware/formatter_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,6 @@ def self.call(_, _)
it 'adds the backtrace and original_exception to the error output' do
subject = described_class.new(
app,
rescue_options: { backtrace: true, original_exception: true },
parsers: { json: ->(_object, _env) { raise StandardError, 'fail' } }
)
io = StringIO.new('{invalid}')
Expand Down
Loading