Skip to content

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

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

[DRAFT] Generalize middleware options to per-class Data value objects#2718
ericproulx wants to merge 2 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

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 14, 2026

Danger Report

Warnings

  • Unless you're refactoring existing code or improving documentation, please update CHANGELOG.md.

Markdowns

Here'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).

View run

@ericproulx ericproulx force-pushed the draft/middleware-options-data branch from ba9b2e7 to e707766 Compare May 14, 2026 18:48
ericproulx and others added 2 commits May 16, 2026 23:52
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 e707766 to 13add91 Compare May 16, 2026 21:53
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