Skip to content

Commit d713797

Browse files
ericproulxclaude
andauthored
Nest Grape::Path inside Grape::Router::Pattern (#2792)
Grape::Path only ever existed to compute the origin/suffix strings that Grape::Router::Pattern compiles into a matcher — it had a single caller (Endpoint#to_routes) yet sat at the top level of the Grape namespace as if it were public. Move it to Grape::Router::Pattern::Path and add a Pattern.build factory that takes the raw path, namespace and inheritable settings and constructs the Path internally, so the endpoint makes one call instead of building a Path and threading its origin/suffix into Pattern.new. Pattern.new stays value-based (origin:/suffix:/...), so the matcher never learns about the settings-hash shape; only Path (and the thin factory) is settings-aware. Grape::Path is kept as a deprecated constant proxy to the new location. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c8417f2 commit d713797

8 files changed

Lines changed: 208 additions & 159 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
* [#2782](https://github.com/ruby-grape/grape/pull/2782): Remove the dead `format` keyword from `Grape::Router::Pattern` - [@ericproulx](https://github.com/ericproulx).
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).
15+
* [#2792](https://github.com/ruby-grape/grape/pull/2792): Move `Grape::Path` to `Grape::Router::Pattern::Path` and add a `Grape::Router::Pattern.build` factory (`Grape::Path` kept as a deprecated constant) - [@ericproulx](https://github.com/ericproulx).
1516
* [#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).
1617
* [#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).
1718
* Your contribution here.

lib/grape/endpoint.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -299,10 +299,10 @@ def to_routes
299299

300300
config.http_methods.flat_map do |method|
301301
config.path.map do |path|
302-
prepared_path = Path.new(path, namespace, path_settings)
303-
pattern = Grape::Router::Pattern.new(
304-
origin: prepared_path.origin,
305-
suffix: prepared_path.suffix,
302+
pattern = Grape::Router::Pattern.build(
303+
path:,
304+
namespace:,
305+
settings: path_settings,
306306
anchor:,
307307
params:,
308308
version:,

lib/grape/path.rb

Lines changed: 8 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,12 @@
11
# frozen_string_literal: true
22

33
module Grape
4-
# Represents a path to an endpoint.
5-
class Path
6-
DEFAULT_FORMAT_SEGMENT = '(/.:format)'
7-
NO_VERSIONING_WITH_VALID_PATH_FORMAT_SEGMENT = '(.:format)'
8-
VERSION_SEGMENT = ':version'
9-
10-
attr_reader :origin, :suffix
11-
12-
def initialize(raw_path, raw_namespace, settings)
13-
@origin = PartsCache[build_parts(raw_path, raw_namespace, settings)]
14-
@suffix = build_suffix(raw_path, raw_namespace, settings)
15-
end
16-
17-
def to_s
18-
"#{origin}#{suffix}"
19-
end
20-
21-
private
22-
23-
def build_suffix(raw_path, raw_namespace, settings)
24-
return "(.#{settings[:format]})" if uses_specific_format?(settings)
25-
return NO_VERSIONING_WITH_VALID_PATH_FORMAT_SEGMENT if !uses_path_versioning?(settings) || valid_part?(raw_namespace) || valid_part?(raw_path)
26-
27-
DEFAULT_FORMAT_SEGMENT
28-
end
29-
30-
def build_parts(raw_path, raw_namespace, settings)
31-
parts = []
32-
add_part(parts, settings[:mount_path])
33-
add_part(parts, settings[:root_prefix])
34-
parts << VERSION_SEGMENT if uses_path_versioning?(settings)
35-
add_part(parts, raw_namespace)
36-
add_part(parts, raw_path)
37-
parts
38-
end
39-
40-
def add_part(parts, value)
41-
parts << value if value && not_slash?(value)
42-
end
43-
44-
def not_slash?(value)
45-
value != '/'
46-
end
47-
48-
def uses_specific_format?(settings)
49-
return false unless settings.key?(:format) && settings.key?(:content_types)
50-
51-
settings[:format] && Array(settings[:content_types]).size == 1
52-
end
53-
54-
def uses_path_versioning?(settings)
55-
return false unless settings.key?(:version) && settings[:version_options]
56-
57-
settings[:version] && settings[:version_options].using == :path
58-
end
59-
60-
def valid_part?(part)
61-
part&.match?(/^\S/) && not_slash?(part)
62-
end
63-
64-
class PartsCache < Grape::Util::Cache
65-
def initialize
66-
super
67-
@cache = Hash.new do |h, parts|
68-
h[parts] = Grape::Util::PathNormalizer.call(parts.join('/'))
69-
end
70-
end
71-
end
72-
end
4+
# @deprecated +Grape::Path+ moved to {Grape::Router::Pattern::Path}, since it
5+
# is a router-internal detail that only exists to build a
6+
# {Grape::Router::Pattern}. Reference the new constant instead.
7+
Path = ActiveSupport::Deprecation::DeprecatedConstantProxy.new(
8+
'Grape::Path',
9+
'Grape::Router::Pattern::Path',
10+
Grape.deprecator
11+
)
7312
end

lib/grape/router/pattern.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ class Pattern
1313
def_delegators :to_regexp, :===
1414
alias match? ===
1515

16+
# Build a Pattern from a raw path, namespace and the API's inheritable
17+
# settings. {Path} owns the settings-aware assembly of +origin+/+suffix+;
18+
# the Pattern itself stays value-based (see {#initialize}).
19+
def self.build(path:, namespace:, settings:, anchor:, params:, version:, requirements:)
20+
built_path = Path.new(path, namespace, settings)
21+
new(origin: built_path.origin, suffix: built_path.suffix, anchor:, params:, version:, requirements:)
22+
end
23+
1624
def initialize(origin:, suffix:, anchor:, params:, version:, requirements:)
1725
@origin = origin
1826
@anchor = anchor

lib/grape/router/pattern/path.rb

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# frozen_string_literal: true
2+
3+
module Grape
4+
class Router
5+
class Pattern
6+
# Assembles the path template a {Pattern} compiles into a matcher. It turns
7+
# a raw path plus the API's inheritable settings (mount path, root prefix,
8+
# path-versioning and format) into an +origin+ (the route prefix) and a
9+
# +suffix+ (the format segment). {Pattern.build} is the entry point that
10+
# wires this into pattern construction.
11+
class Path
12+
DEFAULT_FORMAT_SEGMENT = '(/.:format)'
13+
NO_VERSIONING_WITH_VALID_PATH_FORMAT_SEGMENT = '(.:format)'
14+
VERSION_SEGMENT = ':version'
15+
16+
attr_reader :origin, :suffix
17+
18+
def initialize(raw_path, raw_namespace, settings)
19+
@origin = PartsCache[build_parts(raw_path, raw_namespace, settings)]
20+
@suffix = build_suffix(raw_path, raw_namespace, settings)
21+
end
22+
23+
def to_s
24+
"#{origin}#{suffix}"
25+
end
26+
27+
private
28+
29+
def build_suffix(raw_path, raw_namespace, settings)
30+
return "(.#{settings[:format]})" if uses_specific_format?(settings)
31+
return NO_VERSIONING_WITH_VALID_PATH_FORMAT_SEGMENT if !uses_path_versioning?(settings) || valid_part?(raw_namespace) || valid_part?(raw_path)
32+
33+
DEFAULT_FORMAT_SEGMENT
34+
end
35+
36+
def build_parts(raw_path, raw_namespace, settings)
37+
parts = []
38+
add_part(parts, settings[:mount_path])
39+
add_part(parts, settings[:root_prefix])
40+
parts << VERSION_SEGMENT if uses_path_versioning?(settings)
41+
add_part(parts, raw_namespace)
42+
add_part(parts, raw_path)
43+
parts
44+
end
45+
46+
def add_part(parts, value)
47+
parts << value if value && not_slash?(value)
48+
end
49+
50+
def not_slash?(value)
51+
value != '/'
52+
end
53+
54+
def uses_specific_format?(settings)
55+
return false unless settings.key?(:format) && settings.key?(:content_types)
56+
57+
settings[:format] && Array(settings[:content_types]).size == 1
58+
end
59+
60+
def uses_path_versioning?(settings)
61+
return false unless settings.key?(:version) && settings[:version_options]
62+
63+
settings[:version] && settings[:version_options].using == :path
64+
end
65+
66+
def valid_part?(part)
67+
part&.match?(/^\S/) && not_slash?(part)
68+
end
69+
70+
class PartsCache < Grape::Util::Cache
71+
def initialize
72+
super
73+
@cache = Hash.new do |h, parts|
74+
h[parts] = Grape::Util::PathNormalizer.call(parts.join('/'))
75+
end
76+
end
77+
end
78+
end
79+
end
80+
end
81+
end

spec/grape/path_spec.rb

Lines changed: 5 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,90 +1,9 @@
11
# frozen_string_literal: true
22

3-
describe Grape::Path do
4-
describe '#origin' do
5-
context 'mount_path' do
6-
it 'is not included when it is nil' do
7-
path = described_class.new(nil, nil, mount_path: '/foo/bar')
8-
expect(path.origin).to eql '/foo/bar'
9-
end
10-
11-
it 'is included when it is not nil' do
12-
path = described_class.new(nil, nil, {})
13-
expect(path.origin).to eql('/')
14-
end
15-
end
16-
17-
context 'root_prefix' do
18-
it 'is not included when it is nil' do
19-
path = described_class.new(nil, nil, {})
20-
expect(path.origin).to eql('/')
21-
end
22-
23-
it 'is included after the mount path' do
24-
path = described_class.new(
25-
nil,
26-
nil,
27-
mount_path: '/foo',
28-
root_prefix: '/hello'
29-
)
30-
31-
expect(path.origin).to eql('/foo/hello')
32-
end
33-
end
34-
35-
it 'uses the namespace after the mount path and root prefix' do
36-
path = described_class.new(
37-
nil,
38-
'namespace',
39-
mount_path: '/foo',
40-
root_prefix: '/hello'
41-
)
42-
43-
expect(path.origin).to eql('/foo/hello/namespace')
44-
end
45-
46-
it 'uses the raw path after the namespace' do
47-
path = described_class.new(
48-
'raw_path',
49-
'namespace',
50-
mount_path: '/foo',
51-
root_prefix: '/hello'
52-
)
53-
54-
expect(path.origin).to eql('/foo/hello/namespace/raw_path')
55-
end
56-
end
57-
58-
describe '#suffix' do
59-
context 'when using a specific format' do
60-
it 'accepts specified format' do
61-
path = described_class.new(nil, nil, format: 'json', content_types: 'application/json')
62-
expect(path.suffix).to eql('(.json)')
63-
end
64-
end
65-
66-
context 'when path versioning is used' do
67-
it "includes a '/'" do
68-
path = described_class.new(nil, nil, version: :v1, version_options: Grape::DSL::VersionOptions.new)
69-
expect(path.suffix).to eql('(/.:format)')
70-
end
71-
end
72-
73-
context 'when path versioning is not used' do
74-
it "does not include a '/' when the path has a namespace" do
75-
path = described_class.new(nil, 'namespace', {})
76-
expect(path.suffix).to eql('(.:format)')
77-
end
78-
79-
it "does not include a '/' when the path has a path" do
80-
path = described_class.new('/path', nil, version: :v1, version_options: Grape::DSL::VersionOptions.new)
81-
expect(path.suffix).to eql('(.:format)')
82-
end
83-
84-
it "includes a '/' otherwise" do
85-
path = described_class.new(nil, nil, version: :v1, version_options: Grape::DSL::VersionOptions.new)
86-
expect(path.suffix).to eql('(/.:format)')
87-
end
88-
end
3+
RSpec.describe 'Grape::Path' do
4+
it 'is deprecated and points at Grape::Router::Pattern::Path' do
5+
expect { Grape::Path.new(nil, nil, {}) }.to raise_error(
6+
ActiveSupport::DeprecationException, /Grape::Path is deprecated.*Grape::Router::Pattern::Path/m
7+
)
898
end
909
end
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe Grape::Router::Pattern::Path do
4+
describe '#origin' do
5+
context 'mount_path' do
6+
it 'is not included when it is nil' do
7+
path = described_class.new(nil, nil, mount_path: '/foo/bar')
8+
expect(path.origin).to eql '/foo/bar'
9+
end
10+
11+
it 'is included when it is not nil' do
12+
path = described_class.new(nil, nil, {})
13+
expect(path.origin).to eql('/')
14+
end
15+
end
16+
17+
context 'root_prefix' do
18+
it 'is not included when it is nil' do
19+
path = described_class.new(nil, nil, {})
20+
expect(path.origin).to eql('/')
21+
end
22+
23+
it 'is included after the mount path' do
24+
path = described_class.new(
25+
nil,
26+
nil,
27+
mount_path: '/foo',
28+
root_prefix: '/hello'
29+
)
30+
31+
expect(path.origin).to eql('/foo/hello')
32+
end
33+
end
34+
35+
it 'uses the namespace after the mount path and root prefix' do
36+
path = described_class.new(
37+
nil,
38+
'namespace',
39+
mount_path: '/foo',
40+
root_prefix: '/hello'
41+
)
42+
43+
expect(path.origin).to eql('/foo/hello/namespace')
44+
end
45+
46+
it 'uses the raw path after the namespace' do
47+
path = described_class.new(
48+
'raw_path',
49+
'namespace',
50+
mount_path: '/foo',
51+
root_prefix: '/hello'
52+
)
53+
54+
expect(path.origin).to eql('/foo/hello/namespace/raw_path')
55+
end
56+
end
57+
58+
describe '#suffix' do
59+
context 'when using a specific format' do
60+
it 'accepts specified format' do
61+
path = described_class.new(nil, nil, format: 'json', content_types: 'application/json')
62+
expect(path.suffix).to eql('(.json)')
63+
end
64+
end
65+
66+
context 'when path versioning is used' do
67+
it "includes a '/'" do
68+
path = described_class.new(nil, nil, version: :v1, version_options: Grape::DSL::VersionOptions.new)
69+
expect(path.suffix).to eql('(/.:format)')
70+
end
71+
end
72+
73+
context 'when path versioning is not used' do
74+
it "does not include a '/' when the path has a namespace" do
75+
path = described_class.new(nil, 'namespace', {})
76+
expect(path.suffix).to eql('(.:format)')
77+
end
78+
79+
it "does not include a '/' when the path has a path" do
80+
path = described_class.new('/path', nil, version: :v1, version_options: Grape::DSL::VersionOptions.new)
81+
expect(path.suffix).to eql('(.:format)')
82+
end
83+
84+
it "includes a '/' otherwise" do
85+
path = described_class.new(nil, nil, version: :v1, version_options: Grape::DSL::VersionOptions.new)
86+
expect(path.suffix).to eql('(/.:format)')
87+
end
88+
end
89+
end
90+
end

0 commit comments

Comments
 (0)