grape 3.3.4 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +128 -0
- data/CONTRIBUTING.md +16 -0
- data/README.md +208 -8
- data/UPGRADING.md +756 -6
- data/lib/grape/api/instance.rb +40 -33
- data/lib/grape/content_types.rb +47 -1
- data/lib/grape/dry_types.rb +1 -1
- data/lib/grape/dsl/callbacks.rb +3 -9
- data/lib/grape/dsl/declared.rb +4 -4
- data/lib/grape/dsl/desc.rb +18 -8
- data/lib/grape/dsl/entity.rb +37 -19
- data/lib/grape/dsl/helpers.rb +3 -3
- data/lib/grape/dsl/inside_route.rb +20 -8
- data/lib/grape/dsl/middleware.rb +3 -3
- data/lib/grape/dsl/parameters.rb +54 -28
- data/lib/grape/dsl/request_response.rb +37 -39
- data/lib/grape/dsl/rescue_options.rb +6 -5
- data/lib/grape/dsl/routing.rb +116 -49
- data/lib/grape/dsl/settings.rb +1 -1
- data/lib/grape/dsl/validations.rb +3 -3
- data/lib/grape/dsl/version_options.rb +3 -3
- data/lib/grape/endpoint/options.rb +13 -7
- data/lib/grape/endpoint.rb +141 -118
- data/lib/grape/env.rb +7 -0
- data/lib/grape/error_formatter/base.rb +6 -5
- data/lib/grape/error_formatter.rb +6 -2
- data/lib/grape/exceptions/error_response.rb +4 -1
- data/lib/grape/exceptions/unknown_error_formatter.rb +11 -0
- data/lib/grape/exceptions/validation.rb +9 -8
- data/lib/grape/formatter/json.rb +1 -0
- data/lib/grape/formatter/serializable_hash.rb +1 -0
- data/lib/grape/locale/en.yml +1 -0
- data/lib/grape/middleware/auth/dsl.rb +5 -33
- data/lib/grape/middleware/error.rb +153 -46
- data/lib/grape/middleware/formatter.rb +95 -26
- data/lib/grape/middleware/precomputed_content_types.rb +12 -7
- data/lib/grape/middleware/stack.rb +13 -4
- data/lib/grape/middleware/versioner/base.rb +3 -9
- data/lib/grape/middleware/versioner/path.rb +49 -7
- data/lib/grape/namespace.rb +3 -2
- data/lib/grape/path.rb +8 -69
- data/lib/grape/precompiled_json.rb +50 -0
- data/lib/grape/request.rb +36 -4
- data/lib/grape/router/base_route.rb +47 -10
- data/lib/grape/router/greedy_route.rb +5 -1
- data/lib/grape/router/pattern/path.rb +78 -0
- data/lib/grape/router/pattern.rb +60 -14
- data/lib/grape/router/route.rb +62 -10
- data/lib/grape/router.rb +100 -57
- data/lib/grape/serve_stream/file_body.rb +6 -0
- data/lib/grape/serve_stream/stream_response.rb +6 -0
- data/lib/grape/util/api_description.rb +10 -1
- data/lib/grape/util/cache.rb +21 -2
- data/lib/grape/util/deep_freeze.rb +1 -2
- data/lib/grape/util/freeze_on_new.rb +20 -0
- data/lib/grape/util/inheritable_setting.rb +751 -37
- data/lib/grape/util/media_type.rb +10 -3
- data/lib/grape/util/path_normalizer.rb +12 -9
- data/lib/grape/util/registry.rb +12 -2
- data/lib/grape/util/shadowed_rescue_handlers.rb +49 -0
- data/lib/grape/util/stackable_values.rb +32 -6
- data/lib/grape/validations/attributes_iterator.rb +21 -4
- data/lib/grape/validations/contract_scope.rb +8 -7
- data/lib/grape/validations/oneof_collector.rb +6 -10
- data/lib/grape/validations/params_documentation.rb +2 -2
- data/lib/grape/validations/params_scope.rb +66 -26
- data/lib/grape/validations/single_attribute_iterator.rb +5 -3
- data/lib/grape/validations/types/array_coercer.rb +4 -6
- data/lib/grape/validations/types/custom_type_coercer.rb +7 -1
- data/lib/grape/validations/types/dry_type_coercer.rb +3 -1
- data/lib/grape/validations/types/json.rb +1 -3
- data/lib/grape/validations/types/multiple_type_coercer.rb +5 -3
- data/lib/grape/validations/types/primitive_coercer.rb +12 -9
- data/lib/grape/validations/types/variant_collection_coercer.rb +8 -3
- data/lib/grape/validations/types.rb +5 -5
- data/lib/grape/validations/validations_spec.rb +14 -4
- data/lib/grape/validations/validators/base.rb +4 -7
- data/lib/grape/validations/validators/coerce_validator.rb +8 -2
- data/lib/grape/validations/validators/contract_scope_validator.rb +2 -1
- data/lib/grape/validations/validators/length_validator.rb +4 -2
- data/lib/grape/validations/validators/oneof_validator.rb +2 -0
- data/lib/grape/validations/validators/same_as_validator.rb +1 -0
- data/lib/grape/version.rb +2 -1
- data/lib/grape.rb +23 -2
- metadata +10 -9
- data/lib/grape/middleware/deprecated_options_hash_access.rb +0 -19
- data/lib/grape/util/base_inheritable.rb +0 -43
- data/lib/grape/util/inheritable_values.rb +0 -33
- data/lib/grape/util/reverse_stackable_values.rb +0 -15
|
@@ -23,22 +23,64 @@ module Grape
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def before
|
|
26
|
-
|
|
26
|
+
routed = routed_version
|
|
27
|
+
return env[Grape::Env::API_VERSION] = routed if routed
|
|
28
|
+
|
|
29
|
+
path_info = env[Grape::Env::GRAPE_NORMALIZED_PATH] || Grape::Util::PathNormalizer.call(env[Rack::PATH_INFO])
|
|
27
30
|
return if path_info == '/'
|
|
28
31
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
# `each` rather than `reduce`: Array does not override Enumerable's,
|
|
33
|
+
# so the generic accumulator path costs more than the work it drives
|
|
34
|
+
# on a list that holds at most a mount path and a prefix — and most
|
|
35
|
+
# often nothing at all.
|
|
36
|
+
@prefixes.each { |prefix| path_info = path_info.delete_prefix(prefix) if path_info.start_with?(prefix) }
|
|
32
37
|
|
|
33
38
|
slash_position = path_info.index('/', 1) # omit the first one
|
|
34
|
-
return
|
|
39
|
+
return version_from_first_segment(path_info, slash_position) if slash_position
|
|
35
40
|
|
|
36
|
-
|
|
37
|
-
|
|
41
|
+
version_from_only_segment(path_info)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Under path versioning every route pattern carries the version as a
|
|
47
|
+
# named capture (Pattern::Path#build_parts inserts it), constrained to
|
|
48
|
+
# the declared versions -- so the router has already sliced the segment
|
|
49
|
+
# out and validated it, and re-deriving it from PATH_INFO would repeat
|
|
50
|
+
# the normalize, prefix-strip and slice below for the same answer.
|
|
51
|
+
#
|
|
52
|
+
# Nil, and the path parsed as before, when there are no routing args at
|
|
53
|
+
# all (the middleware used outside a Grape router) and for the greedy
|
|
54
|
+
# routes behind auto-OPTIONS and 405, which capture nothing. An Array
|
|
55
|
+
# means the route declared a +:version+ segment of its own on top of the
|
|
56
|
+
# versioning one: Mustermann then reports the capture as every position
|
|
57
|
+
# it matched rather than the single segment recorded here, so that too
|
|
58
|
+
# is left to the path parse.
|
|
59
|
+
def routed_version
|
|
60
|
+
version = env[Grape::Env::GRAPE_ROUTING_ARGS]&.[](:version)
|
|
61
|
+
version if version.is_a?(String)
|
|
62
|
+
end
|
|
38
63
|
|
|
64
|
+
def version_from_first_segment(path_info, slash_position)
|
|
65
|
+
potential_version = path_info[1..(slash_position - 1)]
|
|
39
66
|
version_not_found! unless potential_version_match?(potential_version)
|
|
40
67
|
env[Grape::Env::API_VERSION] = potential_version
|
|
41
68
|
end
|
|
69
|
+
|
|
70
|
+
# The path is a single segment (e.g. `GET /v1` — the root route of a
|
|
71
|
+
# path-versioned API). Nothing follows to disambiguate a version from a
|
|
72
|
+
# plain path or from a `.format` suffix (`/v1.json`), so the version is
|
|
73
|
+
# only recorded on an exact match against the declared versions, and an
|
|
74
|
+
# unmatched segment is left for the router to resolve — never a 404.
|
|
75
|
+
def version_from_only_segment(path_info)
|
|
76
|
+
candidate = path_info[1..]
|
|
77
|
+
candidate = candidate[0...candidate.rindex('.')] while candidate.include?('.') && !declared_version?(candidate)
|
|
78
|
+
env[Grape::Env::API_VERSION] = candidate if declared_version?(candidate)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def declared_version?(candidate)
|
|
82
|
+
versions.present? && versions.include?(candidate)
|
|
83
|
+
end
|
|
42
84
|
end
|
|
43
85
|
end
|
|
44
86
|
end
|
data/lib/grape/namespace.rb
CHANGED
|
@@ -35,8 +35,9 @@ module Grape
|
|
|
35
35
|
[self.class, space, requirements, options].hash
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
# Join the namespaces from a list of
|
|
39
|
-
#
|
|
38
|
+
# Join the namespaces from a list of Namespace objects to create a path
|
|
39
|
+
# prefix.
|
|
40
|
+
# @param settings [Array] list of Grape::Namespace objects.
|
|
40
41
|
def self.joined_space_path(settings)
|
|
41
42
|
JoinedSpaceCache[joined_space(settings)]
|
|
42
43
|
end
|
data/lib/grape/path.rb
CHANGED
|
@@ -1,73 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Grape
|
|
4
|
-
#
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
+
)
|
|
73
12
|
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Grape
|
|
4
|
+
# Marks a body as JSON that has already been rendered, so the JSON formatters
|
|
5
|
+
# serve it verbatim instead of encoding it a second time.
|
|
6
|
+
#
|
|
7
|
+
# +Grape::Formatter::Json+ calls +to_json+ on whatever the endpoint returned, so a
|
|
8
|
+
# String holding pre-rendered JSON — a blob cached in Redis, a +json_agg+ column read
|
|
9
|
+
# straight from Postgres, output from another serializer — comes back encoded twice:
|
|
10
|
+
#
|
|
11
|
+
# '{"a":1}' => "\"{\\\"a\\\":1}\""
|
|
12
|
+
#
|
|
13
|
+
# Wrapping the body opts that one response out of encoding:
|
|
14
|
+
#
|
|
15
|
+
# get '/cached' do
|
|
16
|
+
# body Grape::PrecompiledJson.new(Rails.cache.read('payload'))
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# An Array is joined into a JSON array without its members being parsed, which is
|
|
20
|
+
# what makes a cached collection cheap — N rendered blobs are spliced together
|
|
21
|
+
# rather than round-tripped:
|
|
22
|
+
#
|
|
23
|
+
# Grape::PrecompiledJson.new(['{"id":1}', '{"id":2}']).to_s # => '[{"id":1},{"id":2}]'
|
|
24
|
+
#
|
|
25
|
+
# +Array#join+ calls +to_s+ on each member, so members may themselves be
|
|
26
|
+
# +PrecompiledJson+ instances.
|
|
27
|
+
#
|
|
28
|
+
# Wrap only the whole body. A wrapper nested inside a Hash or Array that is then
|
|
29
|
+
# handed to an encoder is serialized as an ordinary object — +ActiveSupport::JSON+
|
|
30
|
+
# renders it through +as_json+ as +{"value":"{\"a\":1}"}+ — because no encoder knows
|
|
31
|
+
# to unwrap it. Nothing checks that the String actually holds JSON; that is the
|
|
32
|
+
# caller's responsibility.
|
|
33
|
+
class PrecompiledJson
|
|
34
|
+
# @param value [String, Array<String>] pre-rendered JSON
|
|
35
|
+
def initialize(value)
|
|
36
|
+
@value = value
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [String] the JSON to serve
|
|
40
|
+
# @raise [Grape::Exceptions::InvalidFormatter] if the value is neither a String
|
|
41
|
+
# nor an Array; the formatter middleware turns this into a 500 rather than
|
|
42
|
+
# letting a body Rack cannot serve reach the SPEC check.
|
|
43
|
+
def to_s
|
|
44
|
+
return @value if @value.is_a?(String)
|
|
45
|
+
return "[#{@value.join(',')}]" if @value.is_a?(Array)
|
|
46
|
+
|
|
47
|
+
raise Grape::Exceptions::InvalidFormatter.new(@value.class, 'json')
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/grape/request.rb
CHANGED
|
@@ -141,7 +141,7 @@ module Grape
|
|
|
141
141
|
|
|
142
142
|
def initialize(env, build_params_with: nil)
|
|
143
143
|
super(env)
|
|
144
|
-
@
|
|
144
|
+
@build_params_with = build_params_with
|
|
145
145
|
end
|
|
146
146
|
|
|
147
147
|
def params
|
|
@@ -166,10 +166,17 @@ module Grape
|
|
|
166
166
|
|
|
167
167
|
private
|
|
168
168
|
|
|
169
|
+
# Resolved on first use rather than in the constructor: a request that never
|
|
170
|
+
# reads +params+ -- an endpoint with no validations whose block does not ask
|
|
171
|
+
# for them -- pays neither the registry lookup nor the global config read,
|
|
172
|
+
# and every request builds a Grape::Request.
|
|
173
|
+
def params_builder
|
|
174
|
+
@params_builder ||= Grape::ParamsBuilder.params_builder_for(@build_params_with || Grape.config[:param_builder])
|
|
175
|
+
end
|
|
176
|
+
|
|
169
177
|
def make_params
|
|
170
|
-
params =
|
|
171
|
-
|
|
172
|
-
filtered = routing_args&.except(:version, :route_info)
|
|
178
|
+
params = params_builder.call(rack_params)
|
|
179
|
+
filtered = routing_args_as_params(env[Grape::Env::GRAPE_ROUTING_ARGS])
|
|
173
180
|
return params if filtered.blank?
|
|
174
181
|
|
|
175
182
|
params.deep_merge!(filtered)
|
|
@@ -177,6 +184,31 @@ module Grape
|
|
|
177
184
|
raise Grape::Exceptions::RequestError
|
|
178
185
|
end
|
|
179
186
|
|
|
187
|
+
# The routing args carry two things that are not request params:
|
|
188
|
+
# +:route_info+, which is always Grape's own, and +:version+, which is only
|
|
189
|
+
# Grape's own when the API declared a version — that is captured as a path
|
|
190
|
+
# segment and exposed through +env['api.version']+ instead.
|
|
191
|
+
#
|
|
192
|
+
# An API that declares no version can legitimately name a param +:version+
|
|
193
|
+
# (`route_param :version`, `get '/:version'`), and that capture belongs to
|
|
194
|
+
# the application. Dropping it unconditionally left `params[:version]` nil
|
|
195
|
+
# on a route that had matched, losing the segment silently.
|
|
196
|
+
def routing_args_as_params(routing_args)
|
|
197
|
+
return if routing_args.nil?
|
|
198
|
+
return routing_args.except(:version, :route_info) if grape_owns_version?(routing_args)
|
|
199
|
+
|
|
200
|
+
routing_args.except(:route_info)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# A route reports a +version+ only when the API declared one, which is the
|
|
204
|
+
# case where the captured segment is Grape's rather than the application's.
|
|
205
|
+
def grape_owns_version?(routing_args)
|
|
206
|
+
return false unless routing_args.key?(:version)
|
|
207
|
+
|
|
208
|
+
route = routing_args[:route_info]
|
|
209
|
+
route.respond_to?(:version) && !route.version.nil?
|
|
210
|
+
end
|
|
211
|
+
|
|
180
212
|
# Uses a plain `each_header` block instead of `each_header.with_object`:
|
|
181
213
|
# `with_object` can only pass the block one value plus the memo, so the
|
|
182
214
|
# `k, v` pair would be boxed into a throwaway Array on every header. A
|
|
@@ -7,34 +7,71 @@ module Grape
|
|
|
7
7
|
|
|
8
8
|
delegate_missing_to :@options
|
|
9
9
|
|
|
10
|
-
attr_reader :options, :pattern
|
|
10
|
+
attr_reader :options, :pattern, :prefix, :settings, :namespace
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
# +version+, +anchor+ and +requirements+ shape the matcher, so they are
|
|
13
|
+
# read from the pattern rather than stored again on the route.
|
|
14
|
+
def_delegators :@pattern, :path, :origin, :version, :anchor, :requirements
|
|
15
|
+
def_delegators :@options, :description, *Grape::Util::ApiDescription::DSL_METHODS
|
|
14
16
|
|
|
15
|
-
def initialize(pattern, options = {})
|
|
17
|
+
def initialize(pattern, options = {}, namespace: nil, prefix: nil, settings: nil)
|
|
16
18
|
@pattern = pattern
|
|
17
19
|
@options = options.is_a?(ActiveSupport::OrderedOptions) ? options : ActiveSupport::OrderedOptions.new.update(options)
|
|
20
|
+
@namespace = namespace
|
|
21
|
+
@prefix = prefix
|
|
22
|
+
@settings = settings
|
|
18
23
|
end
|
|
19
24
|
|
|
20
|
-
#
|
|
21
|
-
|
|
22
|
-
|
|
25
|
+
# +success+ and +failure+ are the +desc+ DSL's names for +entity+ and
|
|
26
|
+
# +http_codes+: the block form writes the canonical key, a keyword option
|
|
27
|
+
# keeps the name it was written with, so both spellings are read here.
|
|
28
|
+
# Without this they resolve through +delegate_missing_to+, which answers
|
|
29
|
+
# nil for whichever of the two keys the description did not use.
|
|
30
|
+
def success
|
|
31
|
+
@options[:entity] || @options[:success]
|
|
23
32
|
end
|
|
24
33
|
|
|
25
|
-
def
|
|
26
|
-
@
|
|
34
|
+
def failure
|
|
35
|
+
@options[:http_codes] || @options[:failure]
|
|
27
36
|
end
|
|
28
37
|
|
|
38
|
+
# @deprecated Use {#default_response}, the name grape-swagger asks for.
|
|
39
|
+
# This one has to be written out rather than delegated like the rest:
|
|
40
|
+
# an ActiveSupport::OrderedOptions answers an unknown name with that
|
|
41
|
+
# key's value, but +default+ is not unknown to it — it is +Hash#default+,
|
|
42
|
+
# the Hash's own default value — so a delegator would report nil for
|
|
43
|
+
# every route.
|
|
44
|
+
def default
|
|
45
|
+
Grape.deprecator.warn('`Grape::Router::Route#default` is deprecated. Use `#default_response` instead.')
|
|
46
|
+
default_response
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Assigned eagerly in {#to_regexp} (router compilation) rather than
|
|
50
|
+
# memoized here: this reader is called from request-time route matching
|
|
51
|
+
# on instances shared across threads, so it must not write state.
|
|
52
|
+
attr_reader :regexp_capture_index
|
|
53
|
+
|
|
54
|
+
# The number of the group this route occupies in the union the router
|
|
55
|
+
# compiled it into. Only the union can say what it is, so it is written
|
|
56
|
+
# back by {Router#compile!} right after building one, and like
|
|
57
|
+
# +regexp_capture_index+ it is never assigned at request time.
|
|
58
|
+
attr_reader :regexp_capture_group
|
|
59
|
+
|
|
29
60
|
def pattern_regexp
|
|
30
61
|
@pattern.to_regexp
|
|
31
62
|
end
|
|
32
63
|
|
|
33
64
|
def to_regexp(index)
|
|
34
|
-
@
|
|
65
|
+
@regexp_capture_index = CaptureIndexCache[index]
|
|
35
66
|
Regexp.new("(?<#{regexp_capture_index}>#{pattern_regexp})")
|
|
36
67
|
end
|
|
37
68
|
|
|
69
|
+
# @api private
|
|
70
|
+
# @see #regexp_capture_group
|
|
71
|
+
def resolve_capture_group!(union_named_captures)
|
|
72
|
+
@regexp_capture_group = union_named_captures.fetch(regexp_capture_index).first
|
|
73
|
+
end
|
|
74
|
+
|
|
38
75
|
class CaptureIndexCache < Grape::Util::Cache
|
|
39
76
|
def initialize
|
|
40
77
|
super
|
|
@@ -0,0 +1,78 @@
|
|
|
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 path-settings snapshot taken by
|
|
8
|
+
# {Grape::Util::InheritableSetting#path_settings} (mount paths, root
|
|
9
|
+
# prefix, path-versioning and format) into an +origin+ (the route prefix)
|
|
10
|
+
# and a +suffix+ (the format segment). {Pattern.build} is the entry point
|
|
11
|
+
# that wires this into pattern construction.
|
|
12
|
+
class Path
|
|
13
|
+
DEFAULT_FORMAT_SEGMENT = '(/.:format)'
|
|
14
|
+
NO_VERSIONING_WITH_VALID_PATH_FORMAT_SEGMENT = '(.:format)'
|
|
15
|
+
VERSION_SEGMENT = ':version'
|
|
16
|
+
|
|
17
|
+
attr_reader :origin, :suffix
|
|
18
|
+
|
|
19
|
+
def initialize(raw_path, raw_namespace, settings)
|
|
20
|
+
@origin = PartsCache[build_parts(raw_path, raw_namespace, settings)]
|
|
21
|
+
@suffix = build_suffix(raw_path, raw_namespace, settings)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_s
|
|
25
|
+
"#{origin}#{suffix}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def build_suffix(raw_path, raw_namespace, settings)
|
|
31
|
+
return "(.#{settings.format})" if uses_specific_format?(settings)
|
|
32
|
+
return NO_VERSIONING_WITH_VALID_PATH_FORMAT_SEGMENT if !uses_path_versioning?(settings) || valid_part?(raw_namespace) || valid_part?(raw_path)
|
|
33
|
+
|
|
34
|
+
DEFAULT_FORMAT_SEGMENT
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def build_parts(raw_path, raw_namespace, settings)
|
|
38
|
+
parts = []
|
|
39
|
+
add_part(parts, settings.mount_path)
|
|
40
|
+
add_part(parts, settings.root_prefix)
|
|
41
|
+
parts << VERSION_SEGMENT if uses_path_versioning?(settings)
|
|
42
|
+
add_part(parts, raw_namespace)
|
|
43
|
+
add_part(parts, raw_path)
|
|
44
|
+
parts
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def add_part(parts, value)
|
|
48
|
+
parts << value if value && not_slash?(value)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def not_slash?(value)
|
|
52
|
+
value != '/'
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def uses_specific_format?(settings)
|
|
56
|
+
settings.format && Array(settings.content_types).size == 1
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def uses_path_versioning?(settings)
|
|
60
|
+
settings.version && settings.version_options&.using == :path
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def valid_part?(part)
|
|
64
|
+
part&.match?(/\A\S/) && not_slash?(part)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
class PartsCache < Grape::Util::Cache
|
|
68
|
+
def initialize
|
|
69
|
+
super
|
|
70
|
+
@cache = Hash.new do |h, parts|
|
|
71
|
+
h[parts] = Grape::Util::PathNormalizer.call(parts.join('/'))
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
data/lib/grape/router/pattern.rb
CHANGED
|
@@ -7,17 +7,43 @@ module Grape
|
|
|
7
7
|
|
|
8
8
|
DEFAULT_CAPTURES = %w[format version].freeze
|
|
9
9
|
|
|
10
|
-
attr_reader :origin, :path, :pattern, :to_regexp
|
|
10
|
+
attr_reader :origin, :path, :pattern, :to_regexp, :anchor, :version, :requirements
|
|
11
11
|
|
|
12
12
|
def_delegators :pattern, :params
|
|
13
13
|
def_delegators :to_regexp, :===
|
|
14
14
|
alias match? ===
|
|
15
15
|
|
|
16
|
-
|
|
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
|
+
|
|
24
|
+
def initialize(origin:, suffix:, anchor:, params:, version:, requirements:)
|
|
17
25
|
@origin = origin
|
|
26
|
+
@anchor = anchor
|
|
27
|
+
@version = version
|
|
28
|
+
@requirements = requirements
|
|
18
29
|
@path = PatternCache[[build_path_from_pattern(@origin, anchor), suffix]]
|
|
19
|
-
@pattern = MustermannPattern.new(@path, uri_decode: true, params:, capture: extract_capture(
|
|
30
|
+
@pattern = MustermannPattern.new(@path, uri_decode: true, params:, capture: extract_capture(version, requirements))
|
|
20
31
|
@to_regexp = @pattern.to_regexp
|
|
32
|
+
@captures = @to_regexp.names.any?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# True when the compiled pattern has named captures to extract from a
|
|
36
|
+
# matched path. A fully static path has none — not even +format+, which
|
|
37
|
+
# the suffix spells as a literal — so asking Mustermann for its params
|
|
38
|
+
# would run the regexp a second time (the router's union already matched
|
|
39
|
+
# it) only to hand back an empty Hash.
|
|
40
|
+
#
|
|
41
|
+
# Resolved once here rather than per request: +Regexp#names+ builds an
|
|
42
|
+
# Array and a String per capture, and Ruby offers no predicate that skips
|
|
43
|
+
# that (+named_captures+ builds a Hash, and scanning the source for
|
|
44
|
+
# <tt>(?<</tt> would count lookbehinds, which name nothing).
|
|
45
|
+
def captures?
|
|
46
|
+
@captures
|
|
21
47
|
end
|
|
22
48
|
|
|
23
49
|
def captures_default
|
|
@@ -28,14 +54,38 @@ module Grape
|
|
|
28
54
|
|
|
29
55
|
private
|
|
30
56
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
57
|
+
# The declared versions constrain the +:version+ capture. They are handed
|
|
58
|
+
# to Mustermann as one alternation Regexp rather than as the Array of
|
|
59
|
+
# Strings they arrive in, because an Array capture makes Mustermann build
|
|
60
|
+
# a *converter* for the capture -- and for an Array of plain Strings that
|
|
61
|
+
# converter is the identity function (Mustermann only derives one from a
|
|
62
|
+
# Class or a Symbol, so every entry contributes nothing and the lambda
|
|
63
|
+
# falls through to `|| string`).
|
|
64
|
+
#
|
|
65
|
+
# A non-empty converter table costs every request on the route: it makes
|
|
66
|
+
# Mustermann's +identity_params?+ fast path unreachable, so +params+
|
|
67
|
+
# rebuilds the capture Hash through +map_param+ and calls the do-nothing
|
|
68
|
+
# lambda on each value. Passing a Regexp registers no converter at all.
|
|
69
|
+
#
|
|
70
|
+
# The generated matcher differs in one respect: Mustermann expands an
|
|
71
|
+
# Array entry the way it expands a path literal, so each character also
|
|
72
|
+
# matches its own percent-encoding (+v1+ as <tt>(?:v|%76)(?:1|%31)</tt>).
|
|
73
|
+
# A Regexp is inserted verbatim, so a percent-encoded version segment no
|
|
74
|
+
# longer matches the route. It never reached the endpoint anyway --
|
|
75
|
+
# {Versioner::Base#potential_version_match?} compares the raw segment
|
|
76
|
+
# against the declared versions, so +/api/%76%31/x+ was matched here and
|
|
77
|
+
# then rejected as an unknown version, ending in the same cascading 404
|
|
78
|
+
# the router now returns directly.
|
|
79
|
+
#
|
|
80
|
+
# +Regexp.union+ takes Strings and Regexps only, and +version+ accepts
|
|
81
|
+
# Symbols and Integers too, so the entries are coerced first. A lone one
|
|
82
|
+
# would survive without it -- the single-argument path goes through
|
|
83
|
+
# +Regexp.escape+, which does accept a Symbol -- but a second raises
|
|
84
|
+
# TypeError.
|
|
85
|
+
def extract_capture(version, requirements)
|
|
86
|
+
return requirements if version.blank?
|
|
35
87
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
requirements.merge(capture)
|
|
88
|
+
requirements.merge(version: Regexp.union(Array.wrap(version).map(&:to_s)))
|
|
39
89
|
end
|
|
40
90
|
|
|
41
91
|
def build_path_from_pattern(pattern, anchor)
|
|
@@ -46,10 +96,6 @@ module Grape
|
|
|
46
96
|
"#{pattern}/?*path"
|
|
47
97
|
end
|
|
48
98
|
|
|
49
|
-
def map_str(value)
|
|
50
|
-
Array.wrap(value).map(&:to_s)
|
|
51
|
-
end
|
|
52
|
-
|
|
53
99
|
class PatternCache < Grape::Util::Cache
|
|
54
100
|
def initialize
|
|
55
101
|
super
|
data/lib/grape/router/route.rb
CHANGED
|
@@ -12,15 +12,18 @@ module Grape
|
|
|
12
12
|
|
|
13
13
|
def_delegators :@app, :call
|
|
14
14
|
|
|
15
|
-
def initialize(endpoint, method, pattern, options)
|
|
16
|
-
super(pattern, options)
|
|
15
|
+
def initialize(endpoint, method, pattern, options, forward_match:, params: {}, **route_attributes)
|
|
16
|
+
super(pattern, options, **route_attributes)
|
|
17
17
|
@app = endpoint
|
|
18
18
|
@request_method = upcase_method(method)
|
|
19
|
-
@match_function =
|
|
19
|
+
@match_function = forward_match ? FORWARD_MATCH_METHOD : NON_FORWARD_MATCH_METHOD
|
|
20
|
+
@declared_params = params
|
|
20
21
|
end
|
|
21
22
|
|
|
22
|
-
def
|
|
23
|
-
|
|
23
|
+
def to_head
|
|
24
|
+
head = dup
|
|
25
|
+
head.convert_to_head_request!
|
|
26
|
+
head
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
def apply(app)
|
|
@@ -34,19 +37,68 @@ module Grape
|
|
|
34
37
|
@match_function.call(input, pattern)
|
|
35
38
|
end
|
|
36
39
|
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
# The route's declared params keyed by name — path captures plus any
|
|
41
|
+
# declared body/query params, as their definitions. Used for documentation
|
|
42
|
+
# (e.g. grape-swagger), not for extracting request values.
|
|
43
|
+
def params
|
|
44
|
+
@params ||= pattern.captures_default.merge(@declared_params)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Extract param values from a matched request path. Used by the router.
|
|
48
|
+
#
|
|
49
|
+
# A pattern with no named captures has nothing to extract, so it skips the
|
|
50
|
+
# match entirely and answers nil — the same way {GreedyRoute#params_for}
|
|
51
|
+
# does, and what the router already coerces into the Hash it builds
|
|
52
|
+
# routing args in.
|
|
53
|
+
def params_for(input)
|
|
54
|
+
return unless pattern.captures?
|
|
39
55
|
|
|
40
56
|
parsed = pattern.params(input)
|
|
41
57
|
return unless parsed
|
|
42
58
|
|
|
43
|
-
|
|
59
|
+
params = {}
|
|
60
|
+
parsed.each { |name, value| params[name.to_sym] = tag_utf8!(value) unless value.nil? }
|
|
61
|
+
params
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
protected
|
|
65
|
+
|
|
66
|
+
def convert_to_head_request!
|
|
67
|
+
@request_method = Rack::HEAD
|
|
44
68
|
end
|
|
45
69
|
|
|
46
70
|
private
|
|
47
71
|
|
|
48
|
-
|
|
49
|
-
|
|
72
|
+
# Mustermann decodes path captures out of +PATH_INFO+, which Rack hands us
|
|
73
|
+
# tagged ASCII-8BIT, so path params came back binary while Rack tags query
|
|
74
|
+
# and body params UTF-8. That split makes an API's own declarations
|
|
75
|
+
# disagree with themselves: `values: ['café']` matched `?id=café` but not
|
|
76
|
+
# `/café`, since a binary string never equals the UTF-8 literal it was
|
|
77
|
+
# written as.
|
|
78
|
+
#
|
|
79
|
+
# Re-tag as UTF-8. Nothing obliges a client to send UTF-8: the request
|
|
80
|
+
# target is octets to HTTP, and Rack's SPEC has CGI keys carry non-ASCII
|
|
81
|
+
# as ASCII-8BIT. But UTF-8 is what browsers percent-encode with, what an
|
|
82
|
+
# IRI maps to, and what Rails settles on — ActionDispatch::Journey::Router
|
|
83
|
+
# force_encodes every path capture to UTF-8 after unescaping it.
|
|
84
|
+
#
|
|
85
|
+
# Only the encoding changes; the bytes are untouched. Octets that are not
|
|
86
|
+
# UTF-8 therefore stay invalid and are caught downstream rather than being
|
|
87
|
+
# silently scrubbed into something the client never sent.
|
|
88
|
+
#
|
|
89
|
+
# The re-tag is in place, hence the bang. Mustermann's +Pattern#params+
|
|
90
|
+
# builds a fresh Hash of fresh, unfrozen strings on every call and skips
|
|
91
|
+
# its own Match cache, so the mutation cannot escape this request — which
|
|
92
|
+
# is also what lets the Array branch re-tag its elements with +each+.
|
|
93
|
+
def tag_utf8!(value)
|
|
94
|
+
case value
|
|
95
|
+
when String
|
|
96
|
+
value.force_encoding(Encoding::UTF_8)
|
|
97
|
+
when Array
|
|
98
|
+
value.each { |v| tag_utf8!(v) }
|
|
99
|
+
else
|
|
100
|
+
value
|
|
101
|
+
end
|
|
50
102
|
end
|
|
51
103
|
|
|
52
104
|
def upcase_method(method)
|