grape 2.4.0 → 4.0.1
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 +351 -0
- data/CONTRIBUTING.md +18 -10
- data/README.md +457 -228
- data/UPGRADING.md +1240 -1
- data/grape.gemspec +7 -7
- data/lib/grape/api/instance.rb +65 -116
- data/lib/grape/api.rb +53 -62
- data/lib/grape/content_types.rb +58 -3
- data/lib/grape/cookies.rb +2 -6
- data/lib/grape/declared_params_handler.rb +116 -0
- data/lib/grape/dry_types.rb +48 -4
- data/lib/grape/dsl/callbacks.rb +8 -58
- data/lib/grape/dsl/declared.rb +35 -0
- data/lib/grape/dsl/desc.rb +24 -67
- data/lib/grape/dsl/entity.rb +106 -0
- data/lib/grape/dsl/helpers.rb +80 -65
- data/lib/grape/dsl/inside_route.rb +56 -296
- data/lib/grape/dsl/logger.rb +4 -9
- data/lib/grape/dsl/middleware.rb +22 -40
- data/lib/grape/dsl/parameters.rb +86 -93
- data/lib/grape/dsl/request_response.rb +139 -139
- data/lib/grape/dsl/rescue_options.rb +25 -0
- data/lib/grape/dsl/routing.rb +328 -203
- data/lib/grape/dsl/settings.rb +29 -135
- data/lib/grape/dsl/validations.rb +38 -44
- data/lib/grape/dsl/version_options.rb +23 -0
- data/lib/grape/endpoint/options.rb +25 -0
- data/lib/grape/endpoint.rb +285 -245
- data/lib/grape/env.rb +8 -3
- data/lib/grape/error_formatter/base.rb +27 -21
- data/lib/grape/error_formatter/json.rb +6 -0
- data/lib/grape/error_formatter/txt.rb +10 -10
- data/lib/grape/error_formatter.rb +6 -2
- data/lib/grape/exceptions/base.rb +20 -44
- data/lib/grape/exceptions/error_response.rb +48 -0
- data/lib/grape/exceptions/incompatible_option_values.rb +1 -1
- data/lib/grape/exceptions/internal_server_error.rb +16 -0
- data/lib/grape/exceptions/invalid_accept_header.rb +1 -1
- data/lib/grape/exceptions/invalid_formatter.rb +1 -1
- data/lib/grape/exceptions/invalid_message_body.rb +1 -1
- data/lib/grape/exceptions/invalid_version_header.rb +1 -1
- data/lib/grape/exceptions/invalid_versioner_option.rb +1 -1
- data/lib/grape/exceptions/method_not_allowed.rb +1 -1
- data/lib/grape/exceptions/missing_group_type.rb +0 -2
- data/lib/grape/exceptions/missing_mime_type.rb +1 -1
- data/lib/grape/exceptions/request_error.rb +11 -0
- data/lib/grape/exceptions/unknown_auth_strategy.rb +1 -1
- data/lib/grape/exceptions/unknown_error_formatter.rb +11 -0
- data/lib/grape/exceptions/unknown_parameter.rb +1 -1
- data/lib/grape/exceptions/unknown_params_builder.rb +1 -1
- data/lib/grape/exceptions/unknown_validator.rb +1 -1
- data/lib/grape/exceptions/unsupported_group_type.rb +0 -2
- data/lib/grape/exceptions/validation.rb +25 -7
- data/lib/grape/exceptions/validation_array_errors.rb +4 -0
- data/lib/grape/exceptions/validation_errors.rb +21 -23
- data/lib/grape/formatter/json.rb +1 -0
- data/lib/grape/formatter/serializable_hash.rb +6 -9
- data/lib/grape/json.rb +38 -2
- data/lib/grape/locale/en.yml +3 -5
- data/lib/grape/middleware/auth/base.rb +4 -3
- data/lib/grape/middleware/auth/dsl.rb +9 -23
- data/lib/grape/middleware/base.rb +41 -36
- data/lib/grape/middleware/error.rb +271 -73
- data/lib/grape/middleware/formatter.rb +152 -63
- data/lib/grape/middleware/precomputed_content_types.rb +51 -0
- data/lib/grape/middleware/stack.rb +20 -11
- data/lib/grape/middleware/versioner/accept_version_header.rb +3 -3
- data/lib/grape/middleware/versioner/base.rb +40 -48
- data/lib/grape/middleware/versioner/header.rb +5 -23
- data/lib/grape/middleware/versioner/param.rb +2 -2
- data/lib/grape/middleware/versioner/path.rb +54 -7
- data/lib/grape/mountable.rb +22 -0
- data/lib/grape/namespace.rb +19 -11
- data/lib/grape/params_builder/base.rb +2 -0
- data/lib/grape/params_builder/hash_with_indifferent_access.rb +1 -1
- data/lib/grape/params_builder.rb +2 -19
- data/lib/grape/parser/json.rb +1 -1
- data/lib/grape/path.rb +8 -72
- data/lib/grape/precompiled_json.rb +50 -0
- data/lib/grape/request.rb +57 -20
- data/lib/grape/router/base_route.rb +53 -7
- data/lib/grape/router/greedy_route.rb +16 -6
- data/lib/grape/router/mustermann_pattern.rb +44 -0
- data/lib/grape/router/pattern/path.rb +78 -0
- data/lib/grape/router/pattern.rb +65 -37
- data/lib/grape/router/route.rb +66 -18
- data/lib/grape/router.rb +126 -115
- data/lib/grape/serve_stream/file_body.rb +7 -0
- data/lib/grape/serve_stream/sendfile_response.rb +3 -5
- data/lib/grape/serve_stream/stream_response.rb +7 -0
- data/lib/grape/testing.rb +33 -0
- data/lib/grape/util/api_description.rb +67 -0
- data/lib/grape/util/cache.rb +22 -2
- data/lib/grape/util/deep_freeze.rb +34 -0
- data/lib/grape/util/freeze_on_new.rb +20 -0
- data/lib/grape/util/inheritable_setting.rb +777 -39
- data/lib/grape/util/lazy/base.rb +16 -0
- data/lib/grape/util/lazy/block.rb +2 -9
- data/lib/grape/util/lazy/value.rb +2 -9
- data/lib/grape/util/lazy/value_enumerable.rb +13 -16
- data/lib/grape/util/media_type.rb +13 -9
- data/lib/grape/util/path_normalizer.rb +37 -0
- data/lib/grape/util/registry.rb +13 -3
- data/lib/grape/util/shadowed_rescue_handlers.rb +49 -0
- data/lib/grape/util/stackable_values.rb +39 -10
- data/lib/grape/util/translation.rb +42 -0
- data/lib/grape/validations/attributes_iterator.rb +61 -29
- data/lib/grape/validations/coerce_options.rb +21 -0
- data/lib/grape/validations/contract_scope.rb +8 -13
- data/lib/grape/validations/multiple_attributes_iterator.rb +1 -1
- data/lib/grape/validations/oneof_collector.rb +35 -0
- data/lib/grape/validations/param_scope_tracker.rb +62 -0
- data/lib/grape/validations/params_documentation.rb +52 -0
- data/lib/grape/validations/params_scope.rb +217 -304
- data/lib/grape/validations/shared_options.rb +19 -0
- data/lib/grape/validations/single_attribute_iterator.rb +6 -4
- data/lib/grape/validations/types/array_coercer.rb +7 -10
- data/lib/grape/validations/types/custom_type_coercer.rb +47 -85
- data/lib/grape/validations/types/custom_type_collection_coercer.rb +1 -1
- data/lib/grape/validations/types/dry_type_coercer.rb +10 -15
- 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 +14 -33
- data/lib/grape/validations/types/set_coercer.rb +1 -1
- data/lib/grape/validations/types/variant_collection_coercer.rb +16 -3
- data/lib/grape/validations/types.rb +29 -51
- data/lib/grape/validations/validations_spec.rb +164 -0
- data/lib/grape/validations/validators/all_or_none_of_validator.rb +6 -3
- data/lib/grape/validations/validators/allow_blank_validator.rb +10 -5
- data/lib/grape/validations/validators/at_least_one_of_validator.rb +5 -2
- data/lib/grape/validations/validators/base.rb +118 -34
- data/lib/grape/validations/validators/coerce_validator.rb +25 -37
- data/lib/grape/validations/validators/contract_scope_validator.rb +10 -5
- data/lib/grape/validations/validators/default_validator.rb +13 -20
- data/lib/grape/validations/validators/exactly_one_of_validator.rb +10 -3
- data/lib/grape/validations/validators/except_values_validator.rb +14 -4
- data/lib/grape/validations/validators/length_validator.rb +23 -22
- data/lib/grape/validations/validators/multiple_params_base.rb +12 -9
- data/lib/grape/validations/validators/{mutual_exclusion_validator.rb → mutually_exclusive_validator.rb} +4 -2
- data/lib/grape/validations/validators/oneof_validator.rb +51 -0
- data/lib/grape/validations/validators/presence_validator.rb +4 -2
- data/lib/grape/validations/validators/regexp_validator.rb +11 -3
- data/lib/grape/validations/validators/same_as_validator.rb +7 -15
- data/lib/grape/validations/validators/values_validator.rb +34 -26
- data/lib/grape/version.rb +1 -2
- data/lib/grape/xml.rb +8 -1
- data/lib/grape.rb +58 -31
- metadata +62 -39
- data/lib/grape/api/helpers.rb +0 -9
- data/lib/grape/dsl/api.rb +0 -17
- data/lib/grape/dsl/configuration.rb +0 -15
- data/lib/grape/exceptions/conflicting_types.rb +0 -11
- data/lib/grape/exceptions/empty_message_body.rb +0 -11
- data/lib/grape/exceptions/invalid_parameters.rb +0 -11
- data/lib/grape/exceptions/missing_option.rb +0 -11
- data/lib/grape/exceptions/too_deep_parameters.rb +0 -11
- data/lib/grape/exceptions/too_many_multipart_files.rb +0 -11
- data/lib/grape/exceptions/unknown_options.rb +0 -11
- data/lib/grape/extensions/active_support/hash_with_indifferent_access.rb +0 -24
- data/lib/grape/extensions/hash.rb +0 -27
- data/lib/grape/extensions/hashie/mash.rb +0 -24
- data/lib/grape/middleware/globals.rb +0 -14
- data/lib/grape/types/invalid_value.rb +0 -8
- data/lib/grape/util/base_inheritable.rb +0 -43
- data/lib/grape/util/inheritable_values.rb +0 -29
- data/lib/grape/util/reverse_stackable_values.rb +0 -15
- data/lib/grape/util/strict_hash_configuration.rb +0 -108
- data/lib/grape/validations/attributes_doc.rb +0 -60
- data/lib/grape/validations/validator_factory.rb +0 -15
|
@@ -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
|
|
18
|
-
@
|
|
19
|
-
@
|
|
26
|
+
@anchor = anchor
|
|
27
|
+
@version = version
|
|
28
|
+
@requirements = requirements
|
|
29
|
+
@path = PatternCache[[build_path_from_pattern(@origin, anchor), suffix]]
|
|
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,44 +54,46 @@ module Grape
|
|
|
28
54
|
|
|
29
55
|
private
|
|
30
56
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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?
|
|
49
87
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
requirements.merge(capture)
|
|
88
|
+
requirements.merge(version: Regexp.union(Array.wrap(version).map(&:to_s)))
|
|
53
89
|
end
|
|
54
90
|
|
|
55
91
|
def build_path_from_pattern(pattern, anchor)
|
|
56
|
-
if pattern.end_with?('*path')
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
pattern
|
|
60
|
-
elsif pattern.end_with?('/')
|
|
61
|
-
"#{pattern}?*path"
|
|
62
|
-
else
|
|
63
|
-
"#{pattern}/?*path"
|
|
64
|
-
end
|
|
65
|
-
end
|
|
92
|
+
return pattern.dup.insert(pattern.rindex('/') + 1, '?') if pattern.end_with?('*path')
|
|
93
|
+
return pattern if anchor
|
|
94
|
+
return "#{pattern}?*path" if pattern.end_with?('/')
|
|
66
95
|
|
|
67
|
-
|
|
68
|
-
Array.wrap(value).map(&:to_s)
|
|
96
|
+
"#{pattern}/?*path"
|
|
69
97
|
end
|
|
70
98
|
|
|
71
99
|
class PatternCache < Grape::Util::Cache
|
data/lib/grape/router/route.rb
CHANGED
|
@@ -8,23 +8,22 @@ module Grape
|
|
|
8
8
|
FORWARD_MATCH_METHOD = ->(input, pattern) { input.start_with?(pattern.origin) }
|
|
9
9
|
NON_FORWARD_MATCH_METHOD = ->(input, pattern) { pattern.match?(input) }
|
|
10
10
|
|
|
11
|
-
attr_reader :app, :request_method
|
|
11
|
+
attr_reader :app, :request_method, :index
|
|
12
12
|
|
|
13
|
-
def_delegators
|
|
13
|
+
def_delegators :@app, :call
|
|
14
14
|
|
|
15
|
-
def initialize(method,
|
|
15
|
+
def initialize(endpoint, method, pattern, options, forward_match:, params: {}, **route_attributes)
|
|
16
|
+
super(pattern, options, **route_attributes)
|
|
17
|
+
@app = endpoint
|
|
16
18
|
@request_method = upcase_method(method)
|
|
17
|
-
@
|
|
18
|
-
@
|
|
19
|
-
super(options)
|
|
19
|
+
@match_function = forward_match ? FORWARD_MATCH_METHOD : NON_FORWARD_MATCH_METHOD
|
|
20
|
+
@declared_params = params
|
|
20
21
|
end
|
|
21
22
|
|
|
22
|
-
def
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def exec(env)
|
|
27
|
-
@app.call(env)
|
|
23
|
+
def to_head
|
|
24
|
+
head = dup
|
|
25
|
+
head.convert_to_head_request!
|
|
26
|
+
head
|
|
28
27
|
end
|
|
29
28
|
|
|
30
29
|
def apply(app)
|
|
@@ -38,19 +37,68 @@ module Grape
|
|
|
38
37
|
@match_function.call(input, pattern)
|
|
39
38
|
end
|
|
40
39
|
|
|
41
|
-
|
|
42
|
-
|
|
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?
|
|
43
55
|
|
|
44
56
|
parsed = pattern.params(input)
|
|
45
|
-
return
|
|
57
|
+
return unless parsed
|
|
46
58
|
|
|
47
|
-
|
|
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
|
|
48
68
|
end
|
|
49
69
|
|
|
50
70
|
private
|
|
51
71
|
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
54
102
|
end
|
|
55
103
|
|
|
56
104
|
def upcase_method(method)
|
data/lib/grape/router.rb
CHANGED
|
@@ -2,71 +2,53 @@
|
|
|
2
2
|
|
|
3
3
|
module Grape
|
|
4
4
|
class Router
|
|
5
|
-
attr_reader :map, :compiled
|
|
6
|
-
|
|
7
|
-
# Taken from Rails
|
|
8
|
-
# normalize_path("/foo") # => "/foo"
|
|
9
|
-
# normalize_path("/foo/") # => "/foo"
|
|
10
|
-
# normalize_path("foo") # => "/foo"
|
|
11
|
-
# normalize_path("") # => "/"
|
|
12
|
-
# normalize_path("/%ab") # => "/%AB"
|
|
13
|
-
# https://github.com/rails/rails/blob/00cc4ff0259c0185fe08baadaa40e63ea2534f6e/actionpack/lib/action_dispatch/journey/router/utils.rb#L19
|
|
14
|
-
def self.normalize_path(path)
|
|
15
|
-
return +'/' unless path
|
|
16
|
-
|
|
17
|
-
# Fast path for the overwhelming majority of paths that don't need to be normalized
|
|
18
|
-
return path.dup if path == '/' || (path.start_with?('/') && !(path.end_with?('/') || path.match?(%r{%|//})))
|
|
19
|
-
|
|
20
|
-
# Slow path
|
|
21
|
-
encoding = path.encoding
|
|
22
|
-
path = +"/#{path}"
|
|
23
|
-
path.squeeze!('/')
|
|
24
|
-
|
|
25
|
-
unless path == '/'
|
|
26
|
-
path.delete_suffix!('/')
|
|
27
|
-
path.gsub!(/(%[a-f0-9]{2})/) { ::Regexp.last_match(1).upcase }
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
path.force_encoding(encoding)
|
|
31
|
-
end
|
|
32
|
-
|
|
33
5
|
def initialize
|
|
34
6
|
@neutral_map = []
|
|
35
7
|
@neutral_regexes = []
|
|
36
|
-
|
|
37
|
-
|
|
8
|
+
# Plain hashes with no auto-vivifying default: a lookup for an HTTP method
|
|
9
|
+
# that has no routes must not insert a key. `compile!` freezes both maps,
|
|
10
|
+
# so request-time reads never mutate shared state (see #match? / #rotation).
|
|
11
|
+
@map = {}
|
|
12
|
+
@optimized_map = {}
|
|
38
13
|
end
|
|
39
14
|
|
|
40
15
|
def compile!
|
|
41
|
-
return if compiled
|
|
16
|
+
return if @compiled
|
|
42
17
|
|
|
43
|
-
@union = Regexp.union(@neutral_regexes)
|
|
18
|
+
@union = resolve_capture_groups(Regexp.union(@neutral_regexes), @neutral_map)
|
|
44
19
|
@neutral_regexes = nil
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
20
|
+
# Compiled from the routes actually registered rather than from
|
|
21
|
+
# Grape::HTTP_SUPPORTED_METHODS. A route declared with any other verb
|
|
22
|
+
# (`route :purge, '/cache'`) is accepted at definition time and is
|
|
23
|
+
# already collected into the resource's Allow header, so skipping it here
|
|
24
|
+
# left it in @map but out of @optimized_map — the only map #match? reads.
|
|
25
|
+
# The resource then advertised a method that could never be matched and
|
|
26
|
+
# answered every request for it with 405.
|
|
27
|
+
@map.each do |method, routes|
|
|
49
28
|
optimized_map = routes.map.with_index { |route, index| route.to_regexp(index) }
|
|
50
|
-
@optimized_map[method] = Regexp.union(optimized_map)
|
|
29
|
+
@optimized_map[method] = resolve_capture_groups(Regexp.union(optimized_map), routes)
|
|
51
30
|
end
|
|
31
|
+
@map.freeze
|
|
32
|
+
@optimized_map.freeze
|
|
52
33
|
@compiled = true
|
|
53
34
|
end
|
|
54
35
|
|
|
55
36
|
def append(route)
|
|
56
|
-
map[route.request_method] << route
|
|
37
|
+
(@map[route.request_method] ||= []) << route
|
|
57
38
|
end
|
|
58
39
|
|
|
59
|
-
def associate_routes(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
@neutral_map << greedy_route
|
|
63
|
-
end
|
|
40
|
+
def associate_routes(greedy_route)
|
|
41
|
+
@neutral_regexes << greedy_route.to_regexp(@neutral_map.length)
|
|
42
|
+
@neutral_map << greedy_route
|
|
64
43
|
end
|
|
65
44
|
|
|
66
45
|
def call(env)
|
|
67
46
|
with_optimization do
|
|
68
|
-
|
|
69
|
-
|
|
47
|
+
# Published on the env so the middleware the matched route runs -- the
|
|
48
|
+
# path versioner above all -- reads the path this routed on instead of
|
|
49
|
+
# normalizing PATH_INFO a second time.
|
|
50
|
+
input = env[Grape::Env::GRAPE_NORMALIZED_PATH] = Grape::Util::PathNormalizer.call(env[Rack::PATH_INFO])
|
|
51
|
+
transaction(input, env[Rack::REQUEST_METHOD], env)
|
|
70
52
|
end
|
|
71
53
|
end
|
|
72
54
|
|
|
@@ -77,114 +59,143 @@ module Grape
|
|
|
77
59
|
any.endpoint
|
|
78
60
|
end
|
|
79
61
|
|
|
62
|
+
DEFAULT_RESPONSE_HEADERS = Grape::Util::Header.new.merge('X-Cascade' => 'pass').freeze
|
|
63
|
+
DEFAULT_RESPONSE_BODY = ['404 Not Found'].freeze
|
|
64
|
+
|
|
80
65
|
private
|
|
81
66
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
67
|
+
# Resolve +input+ against the compiled routes, in priority order:
|
|
68
|
+
#
|
|
69
|
+
# 1. the routes registered for +method+ — the compiled-union match first,
|
|
70
|
+
# then, when that route cascades, its siblings (see #rotation);
|
|
71
|
+
# 2. the ANY (+'*'+) routes;
|
|
72
|
+
# 3. the greedy neighbour, which answers auto-OPTIONS and 405.
|
|
73
|
+
#
|
|
74
|
+
# Returns nil when nothing answered, leaving the caller to 404. A response
|
|
75
|
+
# that cascades is never final: it is returned only once every later
|
|
76
|
+
# candidate has declined too, so the caller (or a mounting app upstream)
|
|
77
|
+
# can keep looking.
|
|
78
|
+
def transaction(input, method, env)
|
|
79
|
+
exact_route = match?(input, method)
|
|
80
|
+
response = process_route(exact_route, input, env) if exact_route
|
|
81
|
+
return response if halt?(response)
|
|
82
|
+
|
|
83
|
+
# A cascading route has only declined this request. Its siblings — the
|
|
84
|
+
# routes sharing this path but differing in, say, version — must be
|
|
85
|
+
# tried before falling back to the ANY routes and the greedy neighbour.
|
|
86
|
+
# Skipped when nothing matched: the compiled union is the disjunction of
|
|
87
|
+
# the same patterns #rotation walks, so a miss there is a miss here.
|
|
88
|
+
cascaded = !response.nil?
|
|
89
|
+
if cascaded
|
|
90
|
+
response = rotation(input, method, env, exact_route)
|
|
91
|
+
return response if response && !cascade?(response)
|
|
87
92
|
end
|
|
88
|
-
[response, route]
|
|
89
|
-
end
|
|
90
93
|
|
|
91
|
-
|
|
92
|
-
response = nil
|
|
93
|
-
input, method = *extract_input_and_method(env)
|
|
94
|
-
map[method].each do |route|
|
|
95
|
-
next if exact_route == route
|
|
96
|
-
next unless route.match?(input)
|
|
94
|
+
last_neighbor_route = greedy_match?(input)
|
|
97
95
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
response
|
|
102
|
-
end
|
|
96
|
+
# If last_neighbor_route exists and request method is OPTIONS,
|
|
97
|
+
# return response by using #include_allow_header.
|
|
98
|
+
return process_route(last_neighbor_route, input, env, include_allow_header: true) if !cascaded && method == Rack::OPTIONS && last_neighbor_route
|
|
103
99
|
|
|
104
|
-
|
|
105
|
-
input, method = *extract_input_and_method(env)
|
|
100
|
+
star_route = match?(input, '*')
|
|
106
101
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
return response unless cascade
|
|
102
|
+
if star_route
|
|
103
|
+
close_body(response) if response # superseded by the ANY route
|
|
104
|
+
response = process_route(star_route, input, env)
|
|
105
|
+
return response if halt?(response)
|
|
112
106
|
|
|
113
|
-
|
|
114
|
-
response[2].try(:close)
|
|
115
|
-
end
|
|
116
|
-
end
|
|
107
|
+
cascaded ||= !response.nil?
|
|
117
108
|
end
|
|
118
109
|
|
|
119
|
-
|
|
120
|
-
last_neighbor_route = greedy_match?(input)
|
|
110
|
+
return process_route(last_neighbor_route, input, env, include_allow_header: true) if !cascaded && last_neighbor_route
|
|
121
111
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
return call_with_allow_headers(env, last_neighbor_route) if last_neighbor_route && method == Rack::OPTIONS && !last_response_cascade
|
|
125
|
-
|
|
126
|
-
route = match?(input, '*')
|
|
112
|
+
response
|
|
113
|
+
end
|
|
127
114
|
|
|
128
|
-
|
|
115
|
+
# The routes registered for +method+ other than +exact_route+, tried in
|
|
116
|
+
# registration order until one answers without cascading. Returns the last
|
|
117
|
+
# response processed — a cascading one when every sibling declined, so the
|
|
118
|
+
# caller can hand it back — or nil when no sibling matched.
|
|
119
|
+
def rotation(input, method, env, exact_route)
|
|
120
|
+
response = nil
|
|
121
|
+
@map[method]&.each do |route|
|
|
122
|
+
next if exact_route == route
|
|
123
|
+
next unless route.match?(input)
|
|
129
124
|
|
|
130
|
-
|
|
125
|
+
close_body(response) if response # the previous sibling cascaded
|
|
126
|
+
response = process_route(route, input, env)
|
|
127
|
+
break unless cascade?(response)
|
|
128
|
+
end
|
|
129
|
+
response
|
|
130
|
+
end
|
|
131
131
|
|
|
132
|
-
|
|
132
|
+
# Returns true if `response` should be returned as-is from the enclosing
|
|
133
|
+
# transaction. Closes the body as a side effect when the response is
|
|
134
|
+
# cascading so callers can safely try the next match.
|
|
135
|
+
def halt?(response)
|
|
136
|
+
return false unless response
|
|
133
137
|
|
|
134
|
-
|
|
138
|
+
cascade = cascade?(response)
|
|
139
|
+
close_body(response) if cascade
|
|
140
|
+
!cascade
|
|
135
141
|
end
|
|
136
142
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
143
|
+
# Releases a response the router has decided not to return. Rack requires
|
|
144
|
+
# every body it hands out to be closed, and a cascading candidate is
|
|
145
|
+
# discarded as soon as a later one answers.
|
|
146
|
+
def close_body(response)
|
|
147
|
+
body = response[2]
|
|
148
|
+
body.close if body.respond_to?(:close)
|
|
140
149
|
end
|
|
141
150
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
151
|
+
# Routing args are rebuilt for every attempt: when a route cascades
|
|
152
|
+
# (X-Cascade pass), the next candidate must not observe the previous
|
|
153
|
+
# attempt's +route_info+ or path captures.
|
|
154
|
+
def process_route(route, input, env, include_allow_header: false)
|
|
155
|
+
# The path captures are the hash: +route_info+ is written into them
|
|
156
|
+
# rather than merged in from a second one.
|
|
157
|
+
routing_args = route.params_for(input) || {}
|
|
158
|
+
routing_args[:route_info] = route
|
|
159
|
+
env[Grape::Env::GRAPE_ROUTING_ARGS] = routing_args
|
|
160
|
+
env[Grape::Env::GRAPE_ALLOWED_METHODS] = route.allow_header if include_allow_header
|
|
161
|
+
route.call(env)
|
|
145
162
|
end
|
|
146
163
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
164
|
+
# Tells each route the number of the group it ended up as in +union+. The
|
|
165
|
+
# numbering is a property of the union rather than of the route's own
|
|
166
|
+
# pattern -- every route ahead of it contributes however many groups its
|
|
167
|
+
# pattern declares -- so it can only be resolved once the union is built.
|
|
168
|
+
# Returns the union, so a caller can assign it in one expression.
|
|
169
|
+
def resolve_capture_groups(union, routes)
|
|
170
|
+
named_captures = union.named_captures
|
|
171
|
+
routes.each { |route| route.resolve_capture_group!(named_captures) }
|
|
172
|
+
union
|
|
151
173
|
end
|
|
152
174
|
|
|
153
175
|
def with_optimization
|
|
154
|
-
compile!
|
|
176
|
+
compile!
|
|
155
177
|
yield || default_response
|
|
156
178
|
end
|
|
157
179
|
|
|
158
180
|
def default_response
|
|
159
|
-
|
|
160
|
-
[404, headers, ['404 Not Found']]
|
|
181
|
+
[404, DEFAULT_RESPONSE_HEADERS.dup, DEFAULT_RESPONSE_BODY.dup]
|
|
161
182
|
end
|
|
162
183
|
|
|
184
|
+
# Which alternative of the union matched is answered by scanning one group
|
|
185
|
+
# per registered route, so on an API with many of them that scan is what a
|
|
186
|
+
# request costs. The groups are indexed by number rather than by name: a
|
|
187
|
+
# name sends MatchData through the pattern's name table on every lookup,
|
|
188
|
+
# a number indexes the match region directly.
|
|
163
189
|
def match?(input, method)
|
|
164
|
-
@optimized_map[method]
|
|
190
|
+
@optimized_map[method]&.match(input) { |m| @map[method].detect { |route| m[route.regexp_capture_group] } }
|
|
165
191
|
end
|
|
166
192
|
|
|
167
193
|
def greedy_match?(input)
|
|
168
|
-
@union.match(input) { |m| @neutral_map.detect { |route| m[route.
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
def call_with_allow_headers(env, route)
|
|
172
|
-
prepare_env_from_route(env, route)
|
|
173
|
-
env[Grape::Env::GRAPE_ALLOWED_METHODS] = route.options[:allow_header]
|
|
174
|
-
route.options[:endpoint].call(env)
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
def prepare_env_from_route(env, route)
|
|
178
|
-
input, = *extract_input_and_method(env)
|
|
179
|
-
env[Grape::Env::GRAPE_ROUTING_ARGS] = make_routing_args(env[Grape::Env::GRAPE_ROUTING_ARGS], route, input)
|
|
194
|
+
@union.match(input) { |m| @neutral_map.detect { |route| m[route.regexp_capture_group] } }
|
|
180
195
|
end
|
|
181
196
|
|
|
182
197
|
def cascade?(response)
|
|
183
198
|
response && response[1]['X-Cascade'] == 'pass'
|
|
184
199
|
end
|
|
185
|
-
|
|
186
|
-
def string_for(input)
|
|
187
|
-
self.class.normalize_path(input)
|
|
188
|
-
end
|
|
189
200
|
end
|
|
190
201
|
end
|
|
@@ -31,6 +31,13 @@ module Grape
|
|
|
31
31
|
def ==(other)
|
|
32
32
|
path == other.path
|
|
33
33
|
end
|
|
34
|
+
alias eql? ==
|
|
35
|
+
|
|
36
|
+
# Mirrors #==, which keys on the path alone and does not check the
|
|
37
|
+
# class, so the class must stay out of the hash too.
|
|
38
|
+
def hash
|
|
39
|
+
path.hash
|
|
40
|
+
end
|
|
34
41
|
end
|
|
35
42
|
end
|
|
36
43
|
end
|
|
@@ -6,11 +6,9 @@ module Grape
|
|
|
6
6
|
# for using Rack::SendFile middleware
|
|
7
7
|
class SendfileResponse < Rack::Response
|
|
8
8
|
def respond_to?(method_name, include_all = false)
|
|
9
|
-
if method_name == :to_path
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
super
|
|
13
|
-
end
|
|
9
|
+
return @body.respond_to?(:to_path, include_all) if method_name == :to_path
|
|
10
|
+
|
|
11
|
+
super
|
|
14
12
|
end
|
|
15
13
|
|
|
16
14
|
def to_path
|