diff --git a/lib/grape/middleware/base.rb b/lib/grape/middleware/base.rb index 05316a563..56f6ebbef 100644 --- a/lib/grape/middleware/base.rb +++ b/lib/grape/middleware/base.rb @@ -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 @@ -78,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) diff --git a/lib/grape/middleware/error.rb b/lib/grape/middleware/error.rb index 89b050e29..8f4338f5b 100644 --- a/lib/grape/middleware/error.rb +++ b/lib/grape/middleware/error.rb @@ -6,26 +6,35 @@ 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 + 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 + + def_delegators :options, + :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, @@ -33,23 +42,6 @@ class Error < Base 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) }) diff --git a/lib/grape/middleware/formatter.rb b/lib/grape/middleware/formatter.rb index 66dcdf9a0..1a505b938 100644 --- a/lib/grape/middleware/formatter.rb +++ b/lib/grape/middleware/formatter.rb @@ -3,27 +3,18 @@ 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 + def initialize(content_types: nil, default_format: :txt, format: nil, formatters: nil, parsers: nil) + super + end + end ALL_MEDIA_TYPES = '*/*' - attr_reader :default_format, :format, :formatters, :parsers - - def initialize(app, **options) - super - @default_format = @options[:default_format] - @format = @options[:format] - @formatters = @options[:formatters] - @parsers = @options[:parsers] - end + def_delegators :options, :default_format, :format, :formatters, :parsers def before negotiate_content_type @@ -101,7 +92,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 diff --git a/lib/grape/middleware/precomputed_content_types.rb b/lib/grape/middleware/precomputed_content_types.rb index ca6b1f7c4..f22d2e17c 100644 --- a/lib/grape/middleware/precomputed_content_types.rb +++ b/lib/grape/middleware/precomputed_content_types.rb @@ -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 +options.content_types+ and + # +options.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 @@ -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(options.content_types) end def mime_types @@ -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] || options.format) || 'text/html' end private diff --git a/lib/grape/middleware/versioner/base.rb b/lib/grape/middleware/versioner/base.rb index 8b807e681..59ccd16a3 100644 --- a/lib/grape/middleware/versioner/base.rb +++ b/lib/grape/middleware/versioner/base.rb @@ -7,12 +7,16 @@ 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 + 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 CASCADE_PASS_HEADER = { 'X-Cascade' => 'pass' }.freeze @@ -21,18 +25,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 :options, :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 = self.options.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 diff --git a/spec/grape/middleware/formatter_spec.rb b/spec/grape/middleware/formatter_spec.rb index 9cf382a50..1e8df01fd 100644 --- a/spec/grape/middleware/formatter_spec.rb +++ b/spec/grape/middleware/formatter_spec.rb @@ -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}')