From cfaebfd3efb8daeab9393da6eda42aa43c86c947 Mon Sep 17 00:00:00 2001 From: Eric Proulx Date: Mon, 6 Jul 2026 11:39:58 +0200 Subject: [PATCH] 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) --- CHANGELOG.md | 1 + UPGRADING.md | 8 ++++++-- lib/grape/dsl/routing.rb | 16 +++++++++++----- lib/grape/endpoint.rb | 12 ++++++++---- lib/grape/endpoint/options.rb | 4 ++-- spec/grape/dsl/routing_spec.rb | 4 +++- 6 files changed, 31 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4cfc8899..b7cab8a2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ * [#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). * [#2784](https://github.com/ruby-grape/grape/pull/2784): Move HEAD route creation into `Grape::Router::Route#to_head` - [@ericproulx](https://github.com/ericproulx). * [#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). +* [#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). * Your contribution here. #### Fixes diff --git a/UPGRADING.md b/UPGRADING.md index c323388a5..1f0c892fc 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -39,7 +39,7 @@ The owning API is no longer carried on the endpoint's public `options` Hash — #### Route metadata is exposed through readers, not the `options` Hash -A route's computed metadata — `version`, `namespace`, `prefix`, `requirements`, `anchor` and `settings` — is now passed to `Grape::Router::Route` as explicit keyword arguments and exposed as plain readers, instead of being merged into the route's `options` Hash. +A route's computed metadata — `version`, `namespace`, `prefix`, `requirements`, `anchor` and `settings` — is now exposed through plain readers instead of being merged into the route's `options` Hash. `namespace`, `prefix` and `settings` are passed to `Grape::Router::Route` as explicit keyword arguments; `version`, `anchor` and `requirements` are read from the route's pattern (they shape how it matches). The readers are unchanged — keep using them: @@ -52,7 +52,7 @@ route.anchor # => true route.settings # => { ... } ``` -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. +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. #### `Grape::Endpoint` no longer accepts a `format:` keyword @@ -75,6 +75,10 @@ The no-argument form is unchanged — grape-swagger and other documentation cons 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. +#### `requirements` and `anchor` are first-class endpoint inputs, no longer in `route.options` + +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. + ### Upgrading to >= 3.3 #### Minimum required Ruby is now 3.3 diff --git a/lib/grape/dsl/routing.rb b/lib/grape/dsl/routing.rb index ff5a14482..73f0fbbe3 100644 --- a/lib/grape/dsl/routing.rb +++ b/lib/grape/dsl/routing.rb @@ -159,7 +159,7 @@ def mount(mounts, *opts) http_methods: :any, path:, app:, - route_options: { anchor: false }, + anchor: false, api: self ) end @@ -170,6 +170,9 @@ def mount(mounts, *opts) # # @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. # @param paths [String] One or more strings representing the URL segment(s) for this route. + # @param requirements [Hash] Regular-expression constraints for named path params; the route matches only when every requirement is satisfied. + # @param anchor [Boolean] Whether the route is anchored to the whole path. Defaults to `true`; pass `false` for catch-all routes (e.g. `'/(*:path)'`). + # @param route_options [Hash] Any additional custom options, carried through to `route.options`. # # @example Defining a basic route. # class MyAPI < Grape::API @@ -177,12 +180,13 @@ def mount(mounts, *opts) # {hello: 'world'} # end # end - def route(methods, paths = ['/'], route_options = {}, &) + def route(methods, paths = ['/'], requirements: nil, anchor: true, **route_options, &) http_methods = methods == :any ? '*' : methods endpoint_description = inheritable_setting.route[:description] || {} - # +params+ travels as its own endpoint input; the route-options bag keeps - # the description's other keys (+success+, +tags+, …) plus explicit options. + # +params+, +requirements+ and +anchor+ each travel as their own endpoint + # input; the route-options bag keeps the description's other keys + # (+success+, +tags+, …) plus any custom options. params = prepare_params(endpoint_description[:params]) all_route_options = endpoint_description.except(:params) all_route_options.deep_merge!(route_options) if route_options.present? @@ -193,6 +197,8 @@ def route(methods, paths = ['/'], route_options = {}, &) path: paths, api: self, params:, + requirements:, + anchor:, route_options: all_route_options, & ) @@ -204,7 +210,7 @@ def route(methods, paths = ['/'], route_options = {}, &) Grape::HTTP_SUPPORTED_METHODS.each do |supported_method| define_method supported_method.downcase do |path = '/', **options, &block| - route(supported_method, path, options, &block) + route(supported_method, path, **options, &block) end end diff --git a/lib/grape/endpoint.rb b/lib/grape/endpoint.rb index 2e2fe7c72..85ba53e84 100644 --- a/lib/grape/endpoint.rb +++ b/lib/grape/endpoint.rb @@ -56,13 +56,17 @@ def block_to_unbound_method(block) # endpoint; +nil+ for a plain block endpoint. Exposed as {#mounted_app}. # @param params [Hash] the declared params for this endpoint, keyed by name. # Kept out of +route_options+ and read via +config.params+. + # @param requirements [Hash, nil] regular-expression constraints for named + # path params. Read via +config.requirements+. + # @param anchor [Boolean] whether the route anchors to the whole path + # (default +true+). Read via +config.anchor+. # @param options [Hash] attributes of this endpoint, normalized into a # +Grape::Endpoint::Options+ value object. # @option options route_options [Hash] # @note This happens at the time of API definition, so in this context the # endpoint does not know if it will be mounted under a different endpoint. # @yield a block defining what your API should do when this endpoint is hit - def initialize(new_settings, http_methods:, path:, api:, app: nil, params: {}, **options, &block) + def initialize(new_settings, http_methods:, path:, api:, app: nil, params: {}, requirements: nil, anchor: true, **options, &block) self.inheritable_setting = new_settings.point_in_time_copy # 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: {}, * inheritable_setting.namespace_inheritable[:default_error_status] ||= 500 @options = options - @config = Options.new(http_methods:, path:, api:, app:, params:, **options) + @config = Options.new(http_methods:, path:, api:, app:, params:, requirements:, anchor:, **options) # +:app+ is still surfaced on the public options Hash for backwards # compatibility (e.g. grape-swagger); prefer the +mounted_app+ reader. @options[:app] = app if app @@ -289,8 +293,8 @@ def to_routes forward_match = config.app && !config.app.respond_to?(:inheritable_setting) version = prepare_version(inheritable_setting.namespace_inheritable[:version]) prefix = inheritable_setting.namespace_inheritable[:root_prefix] - requirements = prepare_routes_requirements(route_options[:requirements]) - anchor = route_options.fetch(:anchor, true) + requirements = prepare_routes_requirements(config.requirements) + anchor = config.anchor settings = inheritable_setting.route.except(:declared_params, :saved_validations) config.http_methods.flat_map do |method| diff --git a/lib/grape/endpoint/options.rb b/lib/grape/endpoint/options.rb index 7d1ee58c4..689f565c9 100644 --- a/lib/grape/endpoint/options.rb +++ b/lib/grape/endpoint/options.rb @@ -6,8 +6,8 @@ class Endpoint # +Grape::Endpoint.new+. Internal to {Grape::Endpoint}, which builds it # from the +**options+ Hash in #initialize so the public +options+ reader # stays a plain Hash for downstream gems (e.g. grape-swagger). - Options = Data.define(:path, :http_methods, :api, :route_options, :app, :params) do - def initialize(path:, http_methods:, api:, route_options: {}, app: nil, params: {}) + Options = Data.define(:path, :http_methods, :api, :route_options, :app, :params, :requirements, :anchor) do + def initialize(path:, http_methods:, api:, route_options: {}, app: nil, params: {}, requirements: nil, anchor: true) path = Array(path) path << '/' if path.empty? http_methods = Array(http_methods) diff --git a/spec/grape/dsl/routing_spec.rb b/spec/grape/dsl/routing_spec.rb index 50ecb1b7e..57afe0c47 100644 --- a/spec/grape/dsl/routing_spec.rb +++ b/spec/grape/dsl/routing_spec.rb @@ -159,10 +159,12 @@ class << self expect(endpoint_options[:path]).to eq '/foo' expect(endpoint_options[:api]).to eq subject expect(endpoint_options[:params]).to eq(nuz: 'naz') + expect(endpoint_options[:requirements]).to eq(id: /\d/) + expect(endpoint_options[:anchor]).to be(false) expect(endpoint_options[:route_options]).to eq(foo: 'bar', fiz: 'baz') end.and_yield - subject.route(:get, '/foo', { foo: 'bar' }, &proc {}) + subject.route(:get, '/foo', requirements: { id: /\d/ }, anchor: false, foo: 'bar', &proc {}) end end