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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +128 -0
  3. data/CONTRIBUTING.md +16 -0
  4. data/README.md +208 -8
  5. data/UPGRADING.md +756 -6
  6. data/lib/grape/api/instance.rb +40 -33
  7. data/lib/grape/content_types.rb +47 -1
  8. data/lib/grape/dry_types.rb +1 -1
  9. data/lib/grape/dsl/callbacks.rb +3 -9
  10. data/lib/grape/dsl/declared.rb +4 -4
  11. data/lib/grape/dsl/desc.rb +18 -8
  12. data/lib/grape/dsl/entity.rb +37 -19
  13. data/lib/grape/dsl/helpers.rb +3 -3
  14. data/lib/grape/dsl/inside_route.rb +20 -8
  15. data/lib/grape/dsl/middleware.rb +3 -3
  16. data/lib/grape/dsl/parameters.rb +54 -28
  17. data/lib/grape/dsl/request_response.rb +37 -39
  18. data/lib/grape/dsl/rescue_options.rb +6 -5
  19. data/lib/grape/dsl/routing.rb +116 -49
  20. data/lib/grape/dsl/settings.rb +1 -1
  21. data/lib/grape/dsl/validations.rb +3 -3
  22. data/lib/grape/dsl/version_options.rb +3 -3
  23. data/lib/grape/endpoint/options.rb +13 -7
  24. data/lib/grape/endpoint.rb +141 -118
  25. data/lib/grape/env.rb +7 -0
  26. data/lib/grape/error_formatter/base.rb +6 -5
  27. data/lib/grape/error_formatter.rb +6 -2
  28. data/lib/grape/exceptions/error_response.rb +4 -1
  29. data/lib/grape/exceptions/unknown_error_formatter.rb +11 -0
  30. data/lib/grape/exceptions/validation.rb +9 -8
  31. data/lib/grape/formatter/json.rb +1 -0
  32. data/lib/grape/formatter/serializable_hash.rb +1 -0
  33. data/lib/grape/locale/en.yml +1 -0
  34. data/lib/grape/middleware/auth/dsl.rb +5 -33
  35. data/lib/grape/middleware/error.rb +153 -46
  36. data/lib/grape/middleware/formatter.rb +95 -26
  37. data/lib/grape/middleware/precomputed_content_types.rb +12 -7
  38. data/lib/grape/middleware/stack.rb +13 -4
  39. data/lib/grape/middleware/versioner/base.rb +3 -9
  40. data/lib/grape/middleware/versioner/path.rb +49 -7
  41. data/lib/grape/namespace.rb +3 -2
  42. data/lib/grape/path.rb +8 -69
  43. data/lib/grape/precompiled_json.rb +50 -0
  44. data/lib/grape/request.rb +36 -4
  45. data/lib/grape/router/base_route.rb +47 -10
  46. data/lib/grape/router/greedy_route.rb +5 -1
  47. data/lib/grape/router/pattern/path.rb +78 -0
  48. data/lib/grape/router/pattern.rb +60 -14
  49. data/lib/grape/router/route.rb +62 -10
  50. data/lib/grape/router.rb +100 -57
  51. data/lib/grape/serve_stream/file_body.rb +6 -0
  52. data/lib/grape/serve_stream/stream_response.rb +6 -0
  53. data/lib/grape/util/api_description.rb +10 -1
  54. data/lib/grape/util/cache.rb +21 -2
  55. data/lib/grape/util/deep_freeze.rb +1 -2
  56. data/lib/grape/util/freeze_on_new.rb +20 -0
  57. data/lib/grape/util/inheritable_setting.rb +751 -37
  58. data/lib/grape/util/media_type.rb +10 -3
  59. data/lib/grape/util/path_normalizer.rb +12 -9
  60. data/lib/grape/util/registry.rb +12 -2
  61. data/lib/grape/util/shadowed_rescue_handlers.rb +49 -0
  62. data/lib/grape/util/stackable_values.rb +32 -6
  63. data/lib/grape/validations/attributes_iterator.rb +21 -4
  64. data/lib/grape/validations/contract_scope.rb +8 -7
  65. data/lib/grape/validations/oneof_collector.rb +6 -10
  66. data/lib/grape/validations/params_documentation.rb +2 -2
  67. data/lib/grape/validations/params_scope.rb +66 -26
  68. data/lib/grape/validations/single_attribute_iterator.rb +5 -3
  69. data/lib/grape/validations/types/array_coercer.rb +4 -6
  70. data/lib/grape/validations/types/custom_type_coercer.rb +7 -1
  71. data/lib/grape/validations/types/dry_type_coercer.rb +3 -1
  72. data/lib/grape/validations/types/json.rb +1 -3
  73. data/lib/grape/validations/types/multiple_type_coercer.rb +5 -3
  74. data/lib/grape/validations/types/primitive_coercer.rb +12 -9
  75. data/lib/grape/validations/types/variant_collection_coercer.rb +8 -3
  76. data/lib/grape/validations/types.rb +5 -5
  77. data/lib/grape/validations/validations_spec.rb +14 -4
  78. data/lib/grape/validations/validators/base.rb +4 -7
  79. data/lib/grape/validations/validators/coerce_validator.rb +8 -2
  80. data/lib/grape/validations/validators/contract_scope_validator.rb +2 -1
  81. data/lib/grape/validations/validators/length_validator.rb +4 -2
  82. data/lib/grape/validations/validators/oneof_validator.rb +2 -0
  83. data/lib/grape/validations/validators/same_as_validator.rb +1 -0
  84. data/lib/grape/version.rb +2 -1
  85. data/lib/grape.rb +23 -2
  86. metadata +10 -9
  87. data/lib/grape/middleware/deprecated_options_hash_access.rb +0 -19
  88. data/lib/grape/util/base_inheritable.rb +0 -43
  89. data/lib/grape/util/inheritable_values.rb +0 -33
  90. data/lib/grape/util/reverse_stackable_values.rb +0 -15
data/lib/grape/router.rb CHANGED
@@ -2,14 +2,6 @@
2
2
 
3
3
  module Grape
4
4
  class Router
5
- # @deprecated Use {Grape::Util::PathNormalizer.call} instead.
6
- def self.normalize_path(path)
7
- Grape.deprecator.warn(
8
- '`Grape::Router.normalize_path` is deprecated. Use `Grape::Util::PathNormalizer.call` instead.'
9
- )
10
- Grape::Util::PathNormalizer.call(path)
11
- end
12
-
13
5
  def initialize
14
6
  @neutral_map = []
15
7
  @neutral_regexes = []
@@ -23,14 +15,18 @@ module Grape
23
15
  def compile!
24
16
  return if @compiled
25
17
 
26
- @union = Regexp.union(@neutral_regexes)
18
+ @union = resolve_capture_groups(Regexp.union(@neutral_regexes), @neutral_map)
27
19
  @neutral_regexes = nil
28
- (Grape::HTTP_SUPPORTED_METHODS + ['*']).each do |method|
29
- next unless @map.key?(method)
30
-
31
- routes = @map[method]
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|
32
28
  optimized_map = routes.map.with_index { |route, index| route.to_regexp(index) }
33
- @optimized_map[method] = Regexp.union(optimized_map)
29
+ @optimized_map[method] = resolve_capture_groups(Regexp.union(optimized_map), routes)
34
30
  end
35
31
  @map.freeze
36
32
  @optimized_map.freeze
@@ -48,10 +44,11 @@ module Grape
48
44
 
49
45
  def call(env)
50
46
  with_optimization do
51
- input = Grape::Util::PathNormalizer.call(env[Rack::PATH_INFO])
52
- method = env[Rack::REQUEST_METHOD]
53
- response, route = identity(input, method, env)
54
- response || rotation(input, method, env, route)
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)
55
52
  end
56
53
  end
57
54
 
@@ -67,52 +64,69 @@ module Grape
67
64
 
68
65
  private
69
66
 
70
- def identity(input, method, env)
71
- route = nil
72
- response = transaction(input, method, env) do
73
- route = match?(input, method)
74
- process_route(route, input, env) if route
75
- end
76
- [response, route]
77
- end
78
-
79
- def rotation(input, method, env, exact_route)
80
- response = nil
81
- @map[method]&.each do |route|
82
- next if exact_route == route
83
- next unless route.match?(input)
84
-
85
- response = process_route(route, input, env)
86
- break unless cascade?(response)
87
- end
88
- response
89
- end
90
-
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.
91
78
  def transaction(input, method, env)
92
- response = yield
79
+ exact_route = match?(input, method)
80
+ response = process_route(exact_route, input, env) if exact_route
93
81
  return response if halt?(response)
94
82
 
95
- last_response_cascade = !response.nil?
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)
92
+ end
93
+
96
94
  last_neighbor_route = greedy_match?(input)
97
95
 
98
96
  # If last_neighbor_route exists and request method is OPTIONS,
99
97
  # return response by using #include_allow_header.
100
- return process_route(last_neighbor_route, input, env, include_allow_header: true) if !last_response_cascade && method == Rack::OPTIONS && last_neighbor_route
101
-
102
- route = match?(input, '*')
98
+ return process_route(last_neighbor_route, input, env, include_allow_header: true) if !cascaded && method == Rack::OPTIONS && last_neighbor_route
103
99
 
104
- return last_neighbor_route.call(env) if last_neighbor_route && last_response_cascade && route
100
+ star_route = match?(input, '*')
105
101
 
106
- if route
107
- route_response = process_route(route, input, env)
108
- return route_response if halt?(route_response)
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)
109
106
 
110
- last_response_cascade = !route_response.nil?
107
+ cascaded ||= !response.nil?
111
108
  end
112
109
 
113
- return process_route(last_neighbor_route, input, env, include_allow_header: true) if !last_response_cascade && last_neighbor_route
110
+ return process_route(last_neighbor_route, input, env, include_allow_header: true) if !cascaded && last_neighbor_route
114
111
 
115
- nil
112
+ response
113
+ end
114
+
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)
124
+
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
116
130
  end
117
131
 
118
132
  # Returns true if `response` should be returned as-is from the enclosing
@@ -122,18 +136,42 @@ module Grape
122
136
  return false unless response
123
137
 
124
138
  cascade = cascade?(response)
125
- response[2].close if cascade && response[2].respond_to?(:close)
139
+ close_body(response) if cascade
126
140
  !cascade
127
141
  end
128
142
 
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)
149
+ end
150
+
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.
129
154
  def process_route(route, input, env, include_allow_header: false)
130
- route_params = route.params(input)
131
- env[Grape::Env::GRAPE_ROUTING_ARGS] ||= { route_info: route }
132
- env[Grape::Env::GRAPE_ROUTING_ARGS].merge!(route_params) if route_params.present?
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
133
160
  env[Grape::Env::GRAPE_ALLOWED_METHODS] = route.allow_header if include_allow_header
134
161
  route.call(env)
135
162
  end
136
163
 
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
173
+ end
174
+
137
175
  def with_optimization
138
176
  compile!
139
177
  yield || default_response
@@ -143,12 +181,17 @@ module Grape
143
181
  [404, DEFAULT_RESPONSE_HEADERS.dup, DEFAULT_RESPONSE_BODY.dup]
144
182
  end
145
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.
146
189
  def match?(input, method)
147
- @optimized_map[method]&.match(input) { |m| @map[method].detect { |route| m[route.regexp_capture_index] } }
190
+ @optimized_map[method]&.match(input) { |m| @map[method].detect { |route| m[route.regexp_capture_group] } }
148
191
  end
149
192
 
150
193
  def greedy_match?(input)
151
- @union.match(input) { |m| @neutral_map.detect { |route| m[route.regexp_capture_index] } }
194
+ @union.match(input) { |m| @neutral_map.detect { |route| m[route.regexp_capture_group] } }
152
195
  end
153
196
 
154
197
  def cascade?(response)
@@ -32,6 +32,12 @@ module Grape
32
32
  path == other.path
33
33
  end
34
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
35
41
  end
36
42
  end
37
43
  end
@@ -19,6 +19,12 @@ module Grape
19
19
  stream == other.stream
20
20
  end
21
21
  alias eql? ==
22
+
23
+ # Mirrors #==, which keys on the stream alone and does not check the
24
+ # class, so the class must stay out of the hash too.
25
+ def hash
26
+ stream.hash
27
+ end
22
28
  end
23
29
  end
24
30
  end
@@ -6,7 +6,7 @@ module Grape
6
6
  DSL_METHODS = %i[
7
7
  body_name
8
8
  consumes
9
- default
9
+ default_response
10
10
  deprecated
11
11
  detail
12
12
  entity
@@ -38,6 +38,15 @@ module Grape
38
38
  alias success entity
39
39
  alias failure http_codes
40
40
 
41
+ # @deprecated Use {#default_response}. The description is read back
42
+ # through an ActiveSupport::OrderedOptions, where +default+ is
43
+ # +Hash#default+ rather than a key lookup, and grape-swagger has always
44
+ # asked the route for +default_response+.
45
+ def default(value)
46
+ Grape.deprecator.warn('`default` in a `desc` block is deprecated. Use `default_response` instead.')
47
+ default_response(value)
48
+ end
49
+
41
50
  def configuration
42
51
  @configuration ||= eval_endpoint_config(@endpoint_configuration)
43
52
  end
@@ -2,16 +2,35 @@
2
2
 
3
3
  module Grape
4
4
  module Util
5
+ # Base class for the lazily-filled lookup caches (coercers, dry-types,
6
+ # path parts, ...). Subclasses assign a default-block +Hash+ to +@cache+
7
+ # in their +initialize+.
8
+ #
9
+ # Lookups are synchronized: caches are written at API *definition* time
10
+ # (a +params+ block runs when the class body is evaluated), which is not
11
+ # covered by the compile-time +Grape::API::Instance::LOCK+ and can happen
12
+ # concurrently — e.g. parallel eager loading, or two threads autoloading
13
+ # different API files. A +Monitor+ (reentrant) rather than a +Mutex+,
14
+ # because a cache miss may re-enter the same cache: building a
15
+ # multiple-type coercer through +Types::CoercerCache+ builds its member
16
+ # coercers through +Types.build_coercer+, which lands in the same cache.
5
17
  class Cache
6
18
  include Singleton
7
19
 
8
20
  attr_reader :cache
9
21
 
22
+ def initialize
23
+ @monitor = Monitor.new
24
+ end
25
+
26
+ def [](key)
27
+ @monitor.synchronize { @cache[key] }
28
+ end
29
+
10
30
  class << self
11
31
  extend Forwardable
12
32
 
13
- def_delegators :cache, :[]
14
- def_delegators :instance, :cache
33
+ def_delegators :instance, :[], :cache
15
34
  end
16
35
  end
17
36
  end
@@ -10,9 +10,8 @@ module Grape
10
10
  #
11
11
  # Intentionally left unfrozen:
12
12
  # - Procs / lambdas — may be deferred DB-backed callables
13
- # - Coercers (e.g. ArrayCoercer) — use lazy ivar memoization at request time
14
13
  # - Classes / Modules — shared constants that must remain open
15
- # - ParamsScope — self-freezes at the end of its own initialize
14
+ # - Coercers and ParamsScope — self-freeze at construction
16
15
  def deep_freeze(obj)
17
16
  case obj
18
17
  when Hash
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grape
4
+ module Util
5
+ # Extend into a class whose instances are shared across requests:
6
+ # +new+ returns a frozen instance, so any later ivar write — e.g.
7
+ # request-time memoization — raises FrozenError instead of being a
8
+ # latent data race.
9
+ #
10
+ # Must stay a +new+ wrapper (never an +initialize+ wrapper): the freeze
11
+ # runs after the entire initialize chain, so subclasses may assign ivars
12
+ # after +super+. Extending a hierarchy base covers its subclasses —
13
+ # singleton classes inherit along the class hierarchy.
14
+ module FreezeOnNew
15
+ def new(...)
16
+ super.freeze
17
+ end
18
+ end
19
+ end
20
+ end