Skip to content

[DRAFT] Generalize middleware options to per-class Data value objects#2718

Draft
ericproulx wants to merge 8 commits into
masterfrom
draft/middleware-options-data
Draft

[DRAFT] Generalize middleware options to per-class Data value objects#2718
ericproulx wants to merge 8 commits into
masterfrom
draft/middleware-options-data

Conversation

@ericproulx
Copy link
Copy Markdown
Contributor

@ericproulx ericproulx commented May 14, 2026

Discussion / direction-check

Following the per-feature VersionOptions / RescueOptions work in #2712 and #2716, every middleware that wants typed option accessors gets its own Options = Data.define(...) and uses Forwardable for the accessors. Since @options is 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 every PrecomputedContentTypes consumer.

Mechanism

Middleware::Base#initialize detects self.class::Options (ancestor search included, so subclasses inherit their parent's Options Data class without redeclaring) and routes the kwargs through Options.new(**options). Subclasses without an Options constant still flow through the DEFAULT_OPTIONS Hash + deep_merge path (Filter, Auth::*) unchanged.

PrecomputedContentTypes — already the only place in lib/ that touched options[:content_types] / options[:format] after #2719 — switches to accessor reads (options.content_types, options.format). The transitional Grape::Middleware::OptionsCompat Hash-like [] shim is deleted.

Converted middlewares

  • Middleware::Formatter (5 fields).
  • Middleware::Error (14 fields). rescue_options: defaults to Grape::DSL::RescueOptions.new and the initializer coerces an explicit nil (passed by Endpoint#error_middleware_options when no rescue_from was called) to the default.
  • Middleware::Versioner::Base (7 fields). Adds content_types: / format: so the mixin's accessor reads land cleanly. version_options: defaults to Grape::DSL::VersionOptions.new.

Filter and Auth::Base / Auth::* are unchanged — they don't include PrecomputedContentTypes and don't benefit from typed accessors.

What this drops

  • ~30 lines per converted middleware of DEFAULT_OPTIONS + attr_reader + @ivar = @options[:key] boilerplate.
  • The Grape::Middleware::OptionsCompat shim.
  • Every options[:key] lookup inside lib/grape/middleware/ except for the unconverted Filter / Auth::* (which still need the Hash path).

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 actually read it) — dropped earlier in this draft.

That stricter contract is exactly what made version_options / rescue_options cleaner in their respective PRs.

Open questions

  1. Naming: Middleware::Foo::Options reads well as Foo::Options.new(...). Alternative: top-level Grape::Middleware::FooOptions. Preference?
  2. Filter / Auth::* conversion: leave them on the Hash path, or convert in a follow-up?
  3. Rollout cadence: this draft bundles everything; alternative is one PR per middleware after the prep PRs (Pass Grape::Exceptions::ErrorResponse to error_formatter#call #2712, Refactor DSL::Routing#version: guard clause, explicit kwargs, value object #2716, Move content-type helpers from Middleware::Base into PrecomputedContentTypes #2719) merge.

Test plan

  • bundle exec rspec — 2308 examples, 0 failures
  • RuboCop clean on lib/grape/middleware/
  • Decision on direction before un-drafting

🤖 Generated with Claude Code

ericproulx and others added 3 commits May 14, 2026 16:12
Three changes that touch the same method:

1. Lift the body out of `if args.any? ... end` into a
   `return @versions&.last if args.empty?` guard.

2. Replace `**options` with explicit kwargs (`using:`, `cascade:`,
   `parameter:`, `strict:`, `vendor:`) and bake the defaults into the
   signature. The per-call `reverse_merge(using: :path)` is gone.

3. Introduce `Grape::DSL::VersionOptions` (built on `Data.define`)
   as the canonical representation of resolved version options.
   Threaded end-to-end:
   - `#version` builds and stores a `VersionOptions` on
     `inheritable_setting.namespace_inheritable[:version_options]`.
   - `Path#uses_path_versioning?`, `Endpoint#build_stack`, and
     `API::Instance#cascade?` read fields via accessors instead of
     `[:key]`.
   - The versioner middleware (`Versioner::Base`) now takes a
     `VersionOptions` directly, not a Hash. `Forwardable.def_delegators`
     forwards `cascade`/`parameter`/`strict`/`vendor` to it; the four
     ivars and per-call assignments are dropped. `DEFAULT_OPTIONS`
     stores a `VersionOptions.new(...)` so direct-mount callers still
     get safe defaults.

Public `.version` DSL surface unchanged — still accepts kwargs the same
way; the value object is an internal-only representation.

`version_options` is read for exactly these five keys across the
codebase: `:using` (`endpoint.rb`, `path.rb`), and `:cascade` /
`:parameter` / `:strict` / `:vendor` (`Versioner::Base`, plus
`:cascade` in `API::Instance#cascade?`). Any other kwarg passed to
`.version` was previously swallowed by the splat with no effect.

Spec fixtures updated:

- `spec/shared/versioning_examples.rb` (14 sites): `**macro_options`
  -> `**macro_options.except(:format)`. The `:format` key stays in
  the macro hash so the test helper (`versioned_helpers.rb`) can still
  build the `HTTP_ACCEPT` header.
- `spec/grape/exceptions/invalid_accept_header_spec.rb` (8 sites):
  dropped `format: :json` from direct `.version` calls.
- `spec/grape/dsl/routing_spec.rb`: assertion now expects a
  `VersionOptions` instance with the full default set.
- `spec/grape/path_spec.rb`: literal `version_options: { using: :path }`
  hashes replaced with `VersionOptions.new(...)` constructors.
- `spec/grape/middleware/versioner/{header,accept_version_header,param}_spec.rb`:
  fixtures construct `VersionOptions` instances; mid-test
  `@options[:version_options][:strict] = X` assignments rewritten as
  `@options[:version_options] = @options[:version_options].with(strict: X)`
  (Data instances are immutable).

No production behaviour change beyond `.version` raising
`ArgumentError` for genuinely unknown kwargs (previously silently
ignored).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The error_formatter contract previously took five positional arguments —
`(message, backtrace, options, env, original_exception)` — which made it
impossible to thread additional context through to custom formatters
without breaking every existing override. Switch to keyword arguments and
add two fields that have been asked for in #2527:

    def call(message:, backtrace:, options:, env:,
             status:, headers:, original_exception:)

`status:` makes JSON:API-style error bodies straightforward (the spec
embeds the HTTP status code in the response). `headers:` lets formatters
react to the response content-type or trace the marker headers set by
`error!`. Both were previously only reachable via
`env[Grape::Env::API_ENDPOINT].status` and friends.

This is a contract break — existing custom formatters that re-declare
`call` with the positional signature will need to migrate. See
UPGRADING.md for the before/after.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 14, 2026

Danger Report

No issues found.

View run

ericproulx and others added 5 commits May 14, 2026 20:16
…ntTypes

`Middleware::Base#content_types` / `#mime_types` / `#content_type_for` /
`#content_type` / `#content_types_indifferent_access` are only consumed by
middleware that include `PrecomputedContentTypes` — namely `Formatter`,
`Error`, and `Versioner::Base`. The other concrete middlewares
(`Filter`, `Auth::Base`) never call them.

Move the five methods into `PrecomputedContentTypes`. `Base` keeps the
ivar plumbing (`@options`, `@env`, `@app`), `Rack::Request` wrapper,
header merge, and `query_params`; the content-type concern lives with
the mixin that already takes responsibility for warming its caches.

This narrows `Base` to the storage / lifecycle layer and unblocks future
work to give per-middleware `Options` value objects without `Base`
reaching back into the option hash. No behaviour change — every
middleware that called those helpers already included
`PrecomputedContentTypes`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
@ericproulx ericproulx force-pushed the draft/middleware-options-data branch from ba9b2e7 to e707766 Compare May 14, 2026 18:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant