[DRAFT] Generalize middleware options to per-class Data value objects#2718
Draft
ericproulx wants to merge 2 commits into
Draft
[DRAFT] Generalize middleware options to per-class Data value objects#2718ericproulx wants to merge 2 commits into
ericproulx wants to merge 2 commits into
Conversation
Danger ReportWarnings
MarkdownsHere's an example of a CHANGELOG.md entry: * [#2718](https://github.com/ruby-grape/grape/pull/2718): [draft] generalize middleware options to per-class data value objects - [@ericproulx](https://github.com/ericproulx). |
ba9b2e7 to
e707766
Compare
Demonstration / discussion PR. Right now every middleware that wants
typed accessors over its options has to hand-write the same boilerplate:
DEFAULT_OPTIONS = { foo: nil, bar: nil, ... }.freeze
attr_reader :foo, :bar, ...
def initialize(app, **options)
super
@foo = @options[:foo]
@bar = @options[:bar]
...
end
Since `@options` was already frozen by design (Middleware::Base#initialize
post-PR #2696), the natural next step is to replace the Hash with a
per-subclass `Options = Data.define(...)` and let `Forwardable` cover the
accessor wiring.
Mechanism added in this draft:
- `Grape::Middleware::OptionsCompat` — a small mixin Options classes
include to keep the legacy `options[:key]` idiom working (notably for
`Middleware::Base#content_types` and `#content_type`). Unknown keys
return `nil` to match Hash semantics.
- `Middleware::Base#initialize` detects `self.class::Options` and routes
kwargs through `Options.new(**options)`. Subclasses that still rely on
`DEFAULT_OPTIONS` Hash + deep_merge keep working unchanged.
Demonstrated on `Middleware::Formatter`:
- Replaces 5-line DEFAULT_OPTIONS Hash + 4-line `attr_reader` list +
6-line initialize body with:
Options = Data.define(:content_types, :default_format, :format,
:formatters, :parsers) do
include Grape::Middleware::OptionsCompat
def initialize(content_types: nil, default_format: :txt, format: nil,
formatters: nil, parsers: nil)
super
end
end
def_delegators :options, :default_format, :format, :formatters, :parsers
- Defaults move from the freeze'd Hash to `#initialize` signature.
- Immutability is implicit (Data instances).
Behaviour change: passing an unknown kwarg to a middleware whose `Options`
class doesn't declare it now raises `ArgumentError` instead of being
silently swallowed by `**options`. One formatter spec was passing
`rescue_options:` (dead weight; Formatter doesn't read it) — dropped.
If this direction is acceptable, follow-ups would convert
`Middleware::Error`, `Versioner::Base`, etc., each shedding the same
boilerplate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Building on the merged refactor/version-guard-clause (VersionOptions
Data) and feature/error-formatter-kwargs-2527 (RescueOptions Data),
convert the remaining two middlewares that include
`PrecomputedContentTypes` to per-class `Options = Data.define(...)`:
- Middleware::Error: 14-field Options replacing the 14-line
DEFAULT_OPTIONS Hash + the 12-line attr_reader / ivar-set initialize
body. `rescue_options:` defaults to `Grape::DSL::RescueOptions.new`;
initialize coerces an explicit nil (from
`Endpoint#error_middleware_options` when no `rescue_from` was called)
to the default. `Forwardable.def_delegators :options, ...` covers
every accessor; the existing `def_delegator :rescue_options, :backtrace,
:include_backtrace` (and `:original_exception`) carry through unchanged.
- Middleware::Versioner::Base: 7-field Options (adds `content_types:` /
`format:` so the mixin's accessor reads land cleanly). `version_options:`
defaults to `Grape::DSL::VersionOptions.new`. The four
`def_delegators :version_options, :cascade, :parameter, :strict, :vendor`
stay; `mount_path` / `pattern` / `prefix` / `version_options` are now
delegated via `def_delegators :options, ...`.
With every PrecomputedContentTypes consumer now using an Options Data
class, switch the mixin to accessor reads:
options.content_types # was options[:content_types]
options.format # was options[:format]
…and delete `Grape::Middleware::OptionsCompat` entirely.
Two supporting tweaks:
- `Middleware::Base#build_options` switches `const_defined?(:Options,
false)` → `const_defined?(:Options)` so Versioner subclasses
(`Path`, `Header`, `Param`, `AcceptVersionHeader`) inherit
`Versioner::Base::Options` without redeclaring it.
- `Middleware::Formatter#read_rack_input` switches `options[:parsers]`
to the existing `parsers` delegator.
`Filter` and `Auth::Base` / `Auth::*` remain on the legacy
`DEFAULT_OPTIONS` Hash path — `Base#build_options` keeps the fallback,
so they continue to work unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
e707766 to
13add91
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Discussion / direction-check
Following the per-feature
VersionOptions/RescueOptionswork in #2712 and #2716, every middleware that wants typed option accessors gets its ownOptions = Data.define(...)and usesForwardablefor the accessors. Since@optionsis already frozen by design (post-#2696), the Hash buys us nothing the Data class doesn't get for free, and the per-middleware DEFAULT_OPTIONS / attr_reader / ivar-set boilerplate evaporates.Still opening as draft to confirm the direction before this lands. This branch now contains #2712 and #2716 merged in (along with the prep-PR #2719 that moved content-type helpers out of
Base) plus the conversion of everyPrecomputedContentTypesconsumer.Mechanism
Middleware::Base#initializedetectsself.class::Options(ancestor search included, so subclasses inherit their parent's Options Data class without redeclaring) and routes the kwargs throughOptions.new(**options). Subclasses without anOptionsconstant still flow through theDEFAULT_OPTIONSHash +deep_mergepath (Filter,Auth::*) unchanged.PrecomputedContentTypes— already the only place inlib/that touchedoptions[:content_types]/options[:format]after #2719 — switches to accessor reads (options.content_types,options.format). The transitionalGrape::Middleware::OptionsCompatHash-like[]shim is deleted.Converted middlewares
Middleware::Formatter(5 fields).Middleware::Error(14 fields).rescue_options:defaults toGrape::DSL::RescueOptions.newand the initializer coerces an explicit nil (passed byEndpoint#error_middleware_optionswhen norescue_fromwas called) to the default.Middleware::Versioner::Base(7 fields). Addscontent_types:/format:so the mixin's accessor reads land cleanly.version_options:defaults toGrape::DSL::VersionOptions.new.FilterandAuth::Base/Auth::*are unchanged — they don't includePrecomputedContentTypesand don't benefit from typed accessors.What this drops
DEFAULT_OPTIONS+attr_reader+@ivar = @options[:key]boilerplate.Grape::Middleware::OptionsCompatshim.options[:key]lookup insidelib/grape/middleware/except for the unconvertedFilter/Auth::*(which still need the Hash path).Behaviour change
Passing an unknown kwarg to a middleware whose
Optionsclass doesn't declare it now raisesArgumentErrorinstead of being silently swallowed by**options. One formatter spec was passingrescue_options:(dead weight; Formatter doesn't actually read it) — dropped earlier in this draft.That stricter contract is exactly what made
version_options/rescue_optionscleaner in their respective PRs.Open questions
Middleware::Foo::Optionsreads well asFoo::Options.new(...). Alternative: top-levelGrape::Middleware::FooOptions. Preference?Filter/Auth::*conversion: leave them on the Hash path, or convert in a follow-up?Test plan
bundle exec rspec— 2308 examples, 0 failureslib/grape/middleware/🤖 Generated with Claude Code