Skip to content

Commit 269e2b3

Browse files
ericproulxclaude
andcommitted
Make requirements and anchor first-class endpoint inputs
`route`'s documented options `requirements:` and `anchor:` are now explicit keyword arguments rather than opaque keys in the `route_options` bag, with `**route_options` still catching any custom options. They travel as their own endpoint inputs (`Grape::Endpoint::Options` gains `:requirements` and `:anchor` members; `Grape::Endpoint.new` gains matching keywords) and `to_routes` reads them via `config.requirements` / `config.anchor` instead of `route_options[...]`. The verb methods forward `**options` unchanged, so `get(':id', requirements: {...}, anchor: false)` still works. Like `params`, they no longer appear in `route.options`; the effective value comes from the `route.requirements` / `route.anchor` readers. Mounts pass `anchor: false` as a keyword too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2e21378 commit 269e2b3

6 files changed

Lines changed: 30 additions & 13 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
* [#2783](https://github.com/ruby-grape/grape/pull/2783): Read a route's `version`, `anchor` and `requirements` from its pattern instead of storing them again on the route - [@ericproulx](https://github.com/ericproulx).
1414
* [#2784](https://github.com/ruby-grape/grape/pull/2784): Move HEAD route creation into `Grape::Router::Route#to_head` - [@ericproulx](https://github.com/ericproulx).
1515
* [#2785](https://github.com/ruby-grape/grape/pull/2785): Make route `params` a first-class endpoint input and split `Grape::Router::Route#params` into `#params` (declared definitions) and `#params_for(input)` (extracted values) - [@ericproulx](https://github.com/ericproulx).
16+
* [#2786](https://github.com/ruby-grape/grape/pull/2786): Make route `requirements` and `anchor` explicit keyword arguments and first-class endpoint inputs instead of opaque `route_options` keys - [@ericproulx](https://github.com/ericproulx).
1617
* Your contribution here.
1718

1819
#### Fixes

‎UPGRADING.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ route.anchor # => true
5252
route.settings # => { ... }
5353
```
5454

55-
What changed is the raw bag. `route.options` now holds only what was declared for the route, so it no longer carries the *computed* values for these keys — `route.options[:version]`, `[:namespace]`, `[:prefix]` and `[:settings]` return `nil`. Nothing in Grape or grape-swagger read them that way (grape-swagger uses the `route.prefix` and `route.settings` readers). For `requirements` and `anchor`, which can be supplied as route options (e.g. a mount's `anchor: false`), any explicitly declared value still appears in `route.options`; the effective value always comes from the reader.
55+
What changed is the raw bag. `route.options` now holds only what was declared for the route, so it no longer carries the *computed* values for these keys — `route.options[:version]`, `[:namespace]`, `[:prefix]` and `[:settings]` return `nil`. Nothing in Grape or grape-swagger read them that way (grape-swagger uses the `route.prefix` and `route.settings` readers). `requirements` and `anchor` — which can be supplied as route options (e.g. a mount's `anchor: false`) — are covered separately below: they too are now first-class endpoint inputs and likewise no longer appear in `route.options`. The effective value always comes from the reader.
5656

5757
#### `Grape::Endpoint` no longer accepts a `format:` keyword
5858

@@ -75,6 +75,10 @@ The no-argument form is unchanged — grape-swagger and other documentation cons
7575

7676
A route's declared params were previously carried inside the `route_options` bag and reachable as `route.options[:params]`. They are now composed into their own endpoint input (`Grape::Endpoint::Options` gains a `:params` member and `Grape::Endpoint.new` a `params:` keyword) and exposed only through `route.params`. `route.options[:params]` now returns `nil`. Nothing in Grape or grape-swagger read it that way — grape-swagger uses the `route.params` method — so this only affects code that reached into the options Hash for params directly.
7777

78+
#### `requirements` and `anchor` are first-class endpoint inputs, no longer in `route.options`
79+
80+
Like `params` above, a route's `requirements` and `anchor` were previously carried inside the `route_options` bag. They are now composed into their own endpoint inputs (`Grape::Endpoint::Options` gains `:requirements` and `:anchor` members, and `Grape::Endpoint.new` gains `requirements:` and `anchor:` keywords) and exposed only through the `route.requirements` and `route.anchor` readers. `route.options[:requirements]` and `route.options[:anchor]` now return `nil` — including for a mount's `anchor: false`. Nothing in Grape or grape-swagger read them that way, so this only affects code that reached into the options Hash for these keys directly.
81+
7882
### Upgrading to >= 3.3
7983

8084
#### Minimum required Ruby is now 3.3

‎lib/grape/dsl/routing.rb‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ def mount(mounts, *opts)
159159
http_methods: :any,
160160
path:,
161161
app:,
162-
route_options: { anchor: false },
162+
anchor: false,
163163
api: self
164164
)
165165
end
@@ -170,19 +170,23 @@ def mount(mounts, *opts)
170170
#
171171
# @param methods [HTTP Verb] One or more HTTP verbs that are accepted by this route. Set to `:any` if you want any verb to be accepted.
172172
# @param paths [String] One or more strings representing the URL segment(s) for this route.
173+
# @param requirements [Hash] Regular-expression constraints for named path params; the route matches only when every requirement is satisfied.
174+
# @param anchor [Boolean] Whether the route is anchored to the whole path. Defaults to `true`; pass `false` for catch-all routes (e.g. `'/(*:path)'`).
175+
# @param route_options [Hash] Any additional custom options, carried through to `route.options`.
173176
#
174177
# @example Defining a basic route.
175178
# class MyAPI < Grape::API
176179
# route(:any, '/hello') do
177180
# {hello: 'world'}
178181
# end
179182
# end
180-
def route(methods, paths = ['/'], route_options = {}, &)
183+
def route(methods, paths = ['/'], requirements: nil, anchor: true, **route_options, &)
181184
http_methods = methods == :any ? '*' : methods
182185
endpoint_description = inheritable_setting.route[:description] || {}
183186

184-
# +params+ travels as its own endpoint input; the route-options bag keeps
185-
# the description's other keys (+success+, +tags+, …) plus explicit options.
187+
# +params+, +requirements+ and +anchor+ each travel as their own endpoint
188+
# input; the route-options bag keeps the description's other keys
189+
# (+success+, +tags+, …) plus any custom options.
186190
params = prepare_params(endpoint_description[:params])
187191
all_route_options = endpoint_description.except(:params)
188192
all_route_options.deep_merge!(route_options) if route_options.present?
@@ -193,6 +197,8 @@ def route(methods, paths = ['/'], route_options = {}, &)
193197
path: paths,
194198
api: self,
195199
params:,
200+
requirements:,
201+
anchor:,
196202
route_options: all_route_options,
197203
&
198204
)
@@ -204,7 +210,7 @@ def route(methods, paths = ['/'], route_options = {}, &)
204210

205211
Grape::HTTP_SUPPORTED_METHODS.each do |supported_method|
206212
define_method supported_method.downcase do |path = '/', **options, &block|
207-
route(supported_method, path, options, &block)
213+
route(supported_method, path, **options, &block)
208214
end
209215
end
210216

‎lib/grape/endpoint.rb‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,17 @@ def block_to_unbound_method(block)
5656
# endpoint; +nil+ for a plain block endpoint. Exposed as {#mounted_app}.
5757
# @param params [Hash] the declared params for this endpoint, keyed by name.
5858
# Kept out of +route_options+ and read via +config.params+.
59+
# @param requirements [Hash, nil] regular-expression constraints for named
60+
# path params. Read via +config.requirements+.
61+
# @param anchor [Boolean] whether the route anchors to the whole path
62+
# (default +true+). Read via +config.anchor+.
5963
# @param options [Hash] attributes of this endpoint, normalized into a
6064
# +Grape::Endpoint::Options+ value object.
6165
# @option options route_options [Hash]
6266
# @note This happens at the time of API definition, so in this context the
6367
# endpoint does not know if it will be mounted under a different endpoint.
6468
# @yield a block defining what your API should do when this endpoint is hit
65-
def initialize(new_settings, http_methods:, path:, api:, app: nil, params: {}, **options, &block)
69+
def initialize(new_settings, http_methods:, path:, api:, app: nil, params: {}, requirements: nil, anchor: true, **options, &block)
6670
self.inheritable_setting = new_settings.point_in_time_copy
6771

6872
# now +namespace_stackable(:declared_params)+ contains all params defined for
@@ -75,7 +79,7 @@ def initialize(new_settings, http_methods:, path:, api:, app: nil, params: {}, *
7579
inheritable_setting.namespace_inheritable[:default_error_status] ||= 500
7680

7781
@options = options
78-
@config = Options.new(http_methods:, path:, api:, app:, params:, **options)
82+
@config = Options.new(http_methods:, path:, api:, app:, params:, requirements:, anchor:, **options)
7983
# +:app+ is still surfaced on the public options Hash for backwards
8084
# compatibility (e.g. grape-swagger); prefer the +mounted_app+ reader.
8185
@options[:app] = app if app
@@ -289,8 +293,8 @@ def to_routes
289293
forward_match = config.app && !config.app.respond_to?(:inheritable_setting)
290294
version = prepare_version(inheritable_setting.namespace_inheritable[:version])
291295
prefix = inheritable_setting.namespace_inheritable[:root_prefix]
292-
requirements = prepare_routes_requirements(route_options[:requirements])
293-
anchor = route_options.fetch(:anchor, true)
296+
requirements = prepare_routes_requirements(config.requirements)
297+
anchor = config.anchor
294298
settings = inheritable_setting.route.except(:declared_params, :saved_validations)
295299

296300
config.http_methods.flat_map do |method|

‎lib/grape/endpoint/options.rb‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ class Endpoint
66
# +Grape::Endpoint.new+. Internal to {Grape::Endpoint}, which builds it
77
# from the +**options+ Hash in #initialize so the public +options+ reader
88
# stays a plain Hash for downstream gems (e.g. grape-swagger).
9-
Options = Data.define(:path, :http_methods, :api, :route_options, :app, :params) do
10-
def initialize(path:, http_methods:, api:, route_options: {}, app: nil, params: {})
9+
Options = Data.define(:path, :http_methods, :api, :route_options, :app, :params, :requirements, :anchor) do
10+
def initialize(path:, http_methods:, api:, route_options: {}, app: nil, params: {}, requirements: nil, anchor: true)
1111
path = Array(path)
1212
path << '/' if path.empty?
1313
http_methods = Array(http_methods)

‎spec/grape/dsl/routing_spec.rb‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,12 @@ class << self
159159
expect(endpoint_options[:path]).to eq '/foo'
160160
expect(endpoint_options[:api]).to eq subject
161161
expect(endpoint_options[:params]).to eq(nuz: 'naz')
162+
expect(endpoint_options[:requirements]).to eq(id: /\d/)
163+
expect(endpoint_options[:anchor]).to be(false)
162164
expect(endpoint_options[:route_options]).to eq(foo: 'bar', fiz: 'baz')
163165
end.and_yield
164166

165-
subject.route(:get, '/foo', { foo: 'bar' }, &proc {})
167+
subject.route(:get, '/foo', requirements: { id: /\d/ }, anchor: false, foo: 'bar', &proc {})
166168
end
167169
end
168170

0 commit comments

Comments
 (0)