grape 4.0.1 → 4.1.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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +102 -1
  3. data/README.md +195 -95
  4. data/UPGRADING.md +364 -0
  5. data/grape.gemspec +1 -1
  6. data/lib/grape/api/instance.rb +57 -30
  7. data/lib/grape/api.rb +50 -20
  8. data/lib/grape/content_types.rb +26 -9
  9. data/lib/grape/cookies.rb +1 -1
  10. data/lib/grape/declared_params_handler.rb +35 -7
  11. data/lib/grape/dsl/declared.rb +5 -4
  12. data/lib/grape/dsl/desc.rb +1 -2
  13. data/lib/grape/dsl/entity.rb +35 -19
  14. data/lib/grape/dsl/headers.rb +3 -3
  15. data/lib/grape/dsl/helpers.rb +5 -8
  16. data/lib/grape/dsl/inside_route.rb +33 -27
  17. data/lib/grape/dsl/logger.rb +10 -4
  18. data/lib/grape/dsl/middleware.rb +4 -9
  19. data/lib/grape/dsl/mounting.rb +149 -0
  20. data/lib/grape/dsl/parameters.rb +78 -104
  21. data/lib/grape/dsl/request_response.rb +35 -22
  22. data/lib/grape/dsl/routing.rb +17 -191
  23. data/lib/grape/dsl/settings.rb +1 -1
  24. data/lib/grape/dsl/version_options.rb +2 -2
  25. data/lib/grape/dsl/versioning.rb +98 -0
  26. data/lib/grape/endpoint/state.rb +15 -0
  27. data/lib/grape/endpoint.rb +229 -137
  28. data/lib/grape/error_formatter/base.rb +28 -13
  29. data/lib/grape/error_formatter/json.rb +3 -0
  30. data/lib/grape/error_formatter/jsonl.rb +15 -0
  31. data/lib/grape/error_formatter/xml.rb +3 -1
  32. data/lib/grape/exceptions/error_response.rb +23 -7
  33. data/lib/grape/exceptions/halt.rb +39 -0
  34. data/lib/grape/exceptions/validation.rb +9 -9
  35. data/lib/grape/exceptions/validation_errors.rb +12 -2
  36. data/lib/grape/formatter/jsonl.rb +34 -0
  37. data/lib/grape/formatter/markdown.rb +17 -0
  38. data/lib/grape/formatter/md.rb +11 -0
  39. data/lib/grape/locale/en.yml +1 -0
  40. data/lib/grape/middleware/auth/base.rb +16 -1
  41. data/lib/grape/middleware/auth/strategy_info.rb +2 -2
  42. data/lib/grape/middleware/base.rb +19 -13
  43. data/lib/grape/middleware/error.rb +133 -35
  44. data/lib/grape/middleware/formatter.rb +235 -94
  45. data/lib/grape/middleware/precomputed_content_types.rb +14 -2
  46. data/lib/grape/middleware/stack.rb +7 -8
  47. data/lib/grape/middleware/versioner/base.rb +26 -10
  48. data/lib/grape/middleware/versioner/header.rb +70 -15
  49. data/lib/grape/middleware/versioner/param.rb +3 -5
  50. data/lib/grape/middleware/versioner/path.rb +20 -11
  51. data/lib/grape/middleware/versioner.rb +1 -1
  52. data/lib/grape/mountable.rb +2 -4
  53. data/lib/grape/namespace.rb +2 -3
  54. data/lib/grape/parser/jsonl.rb +19 -0
  55. data/lib/grape/request.rb +46 -8
  56. data/lib/grape/router/base_route.rb +41 -0
  57. data/lib/grape/router/greedy_route.rb +1 -1
  58. data/lib/grape/router/mustermann_pattern.rb +1 -1
  59. data/lib/grape/router/pattern/path.rb +1 -1
  60. data/lib/grape/router/pattern.rb +11 -5
  61. data/lib/grape/router/route.rb +42 -11
  62. data/lib/grape/router/route_buckets.rb +246 -0
  63. data/lib/grape/router.rb +262 -32
  64. data/lib/grape/util/endpoint_configuration.rb +7 -1
  65. data/lib/grape/util/inheritable_setting.rb +80 -161
  66. data/lib/grape/util/lazy/value.rb +52 -11
  67. data/lib/grape/util/media_type.rb +38 -15
  68. data/lib/grape/util/path_normalizer.rb +7 -2
  69. data/lib/grape/util/registry.rb +9 -2
  70. data/lib/grape/util/scrub.rb +33 -0
  71. data/lib/grape/util/translation.rb +6 -6
  72. data/lib/grape/validations/attributes_iterator.rb +73 -37
  73. data/lib/grape/validations/coerce_options.rb +1 -1
  74. data/lib/grape/validations/params_documentation.rb +15 -7
  75. data/lib/grape/validations/params_scope.rb +176 -43
  76. data/lib/grape/validations/types/custom_type_coercer.rb +2 -3
  77. data/lib/grape/validations/types/custom_type_collection_coercer.rb +1 -1
  78. data/lib/grape/validations/types/dry_type_coercer.rb +20 -12
  79. data/lib/grape/validations/types/multiple_type_coercer.rb +1 -2
  80. data/lib/grape/validations/types/primitive_coercer.rb +17 -0
  81. data/lib/grape/validations/types/variant_collection_coercer.rb +24 -10
  82. data/lib/grape/validations/types.rb +12 -18
  83. data/lib/grape/validations/validations_spec.rb +53 -22
  84. data/lib/grape/validations/validators/allow_blank_validator.rb +2 -2
  85. data/lib/grape/validations/validators/as_validator.rb +2 -3
  86. data/lib/grape/validations/validators/base.rb +124 -25
  87. data/lib/grape/validations/validators/coerce_validator.rb +2 -3
  88. data/lib/grape/validations/validators/default_validator.rb +18 -2
  89. data/lib/grape/validations/validators/except_values_validator.rb +16 -3
  90. data/lib/grape/validations/validators/hash_elements_validator.rb +34 -0
  91. data/lib/grape/validations/validators/length_validator.rb +24 -16
  92. data/lib/grape/validations/validators/multiple_params_base.rb +0 -4
  93. data/lib/grape/validations/validators/values_validator.rb +18 -24
  94. data/lib/grape/version.rb +1 -1
  95. data/lib/grape.rb +3 -0
  96. metadata +18 -11
  97. data/lib/grape/util/lazy/value_array.rb +0 -21
  98. data/lib/grape/util/lazy/value_enumerable.rb +0 -31
  99. data/lib/grape/util/lazy/value_hash.rb +0 -21
  100. data/lib/grape/validations/multiple_attributes_iterator.rb +0 -13
  101. data/lib/grape/validations/single_attribute_iterator.rb +0 -28
@@ -32,19 +32,7 @@ module Grape
32
32
  with = payload.delete(:with)
33
33
  end
34
34
 
35
- presenter = with || env[Grape::Env::API_ENDPOINT].entity_class_for_obj(payload)
36
-
37
- unless presenter || env[Grape::Env::GRAPE_ROUTING_ARGS].nil?
38
- # env['api.endpoint'].route does not work when the error occurs within a middleware
39
- # the Endpoint does not have a valid env at this moment
40
- http_codes = env[Grape::Env::GRAPE_ROUTING_ARGS][:route_info].http_codes || []
41
- found_code = http_codes.find do |http_code|
42
- (http_code[0].to_i == env[Grape::Env::API_ENDPOINT].status) && http_code[2].respond_to?(:represent)
43
- end if env[Grape::Env::API_ENDPOINT].request
44
-
45
- presenter = found_code[2] if found_code
46
- end
47
-
35
+ presenter = with || env[Grape::Env::API_ENDPOINT].entity_class_for_obj(payload) || failure_presenter(payload, env)
48
36
  return payload unless presenter
49
37
 
50
38
  embeds = { env: }
@@ -64,6 +52,33 @@ module Grape
64
52
 
65
53
  private
66
54
 
55
+ # The entity the route's failure codes name for the status being
56
+ # answered: those entities document a structured error body.
57
+ #
58
+ # A String message has no attributes for one to expose -- presenting
59
+ # it raised, and the failsafe answered 500 in place of the status
60
+ # +error!+ asked for -- so it is left for #wrap_message. And a route's
61
+ # failure presenters are written for the errors the endpoint itself
62
+ # raises: one raised by middleware before the endpoint ran -- it has
63
+ # no request yet -- keeps the shape that middleware gave it rather than
64
+ # being fit to an entity it was not written for.
65
+ def failure_presenter(payload, env)
66
+ return if payload.is_a?(String)
67
+
68
+ routing_args = env[Grape::Env::GRAPE_ROUTING_ARGS]
69
+ endpoint = env[Grape::Env::API_ENDPOINT]
70
+ return if routing_args.nil? || !endpoint.request
71
+
72
+ presenter_for_status(routing_args[:route_info].failure, endpoint.status)
73
+ end
74
+
75
+ # The entity +failure+ names for +status+, among the +[code, message,
76
+ # entity]+ entries a route's failure codes are.
77
+ def presenter_for_status(failure, status)
78
+ found_code = Array(failure).find { |http_code| http_code[0].to_i == status && http_code[2].respond_to?(:represent) }
79
+ found_code[2] if found_code
80
+ end
81
+
67
82
  def inherited(klass)
68
83
  super
69
84
  ErrorFormatter.register(klass)
@@ -23,8 +23,11 @@ module Grape
23
23
  { error: ensure_utf8(message) }
24
24
  end
25
25
 
26
+ # Re-encoding a String that already is valid UTF-8 only copies it, for
27
+ # about 260 ns on every error response.
26
28
  def ensure_utf8(message)
27
29
  return message unless message.respond_to? :encode
30
+ return message if message.is_a?(String) && message.encoding == Encoding::UTF_8 && message.valid_encoding?
28
31
 
29
32
  message.encode('UTF-8', invalid: :replace, undef: :replace)
30
33
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grape
4
+ module ErrorFormatter
5
+ # An error renders as the JSON error formatter renders it, ended by a
6
+ # newline, so the body is a JSON Lines document of one line.
7
+ class Jsonl < Json
8
+ class << self
9
+ def format_structured_message(structured_message)
10
+ "#{super}\n"
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -3,8 +3,10 @@
3
3
  module Grape
4
4
  module ErrorFormatter
5
5
  class Xml < Base
6
+ # Base#wrap_message always hands over a Hash, which ActiveSupport's
7
+ # conversions (required by grape.rb) give +to_xml+.
6
8
  def self.format_structured_message(structured_message)
7
- structured_message.respond_to?(:to_xml) ? structured_message.to_xml(root: :error) : structured_message.to_s
9
+ structured_message.to_xml(root: :error)
8
10
  end
9
11
  end
10
12
  end
@@ -2,13 +2,29 @@
2
2
 
3
3
  module Grape
4
4
  module Exceptions
5
- # Value object representing the payload thrown via `throw :error, ...`
6
- # and consumed by `Middleware::Error#error_response`. Replaces the
7
- # implicit-schema Hash that previously circulated between throw sites
8
- # and the error middleware.
9
- ErrorResponse = Data.define(:status, :message, :headers, :backtrace, :original_exception) do
5
+ # The frozen payload thrown via `throw :error, ...` (or raised by `error!`
6
+ # inside a `Grape::Exceptions::Halt`) and consumed by
7
+ # `Middleware::Error#error_response`. Replaces the implicit-schema Hash
8
+ # that previously circulated between throw sites and the error middleware.
9
+ #
10
+ # A plain frozen class rather than a +Data+: every error response builds
11
+ # at least two (the payload, then the one +error_response+ fills the
12
+ # defaults into), and +Data+'s constructor takes its keywords as a Hash it
13
+ # then validates, about 450 ns apiece. A Ruby method keeps keyword
14
+ # arguments on the stack. It answers only what is read off it: its
15
+ # readers.
16
+ class ErrorResponse
17
+ MEMBERS = %i[status message headers backtrace original_exception].freeze
18
+
19
+ attr_reader(*MEMBERS)
20
+
10
21
  def initialize(status: nil, message: nil, headers: nil, backtrace: nil, original_exception: nil)
11
- super
22
+ @status = status
23
+ @message = message
24
+ @headers = headers
25
+ @backtrace = backtrace
26
+ @original_exception = original_exception
27
+ freeze
12
28
  end
13
29
 
14
30
  def to_s
@@ -38,7 +54,7 @@ module Grape
38
54
  when Grape::Exceptions::Base
39
55
  from_exception(input)
40
56
  when Hash
41
- new(**input.slice(:status, :message, :headers, :backtrace, :original_exception))
57
+ new(**input.slice(*MEMBERS))
42
58
  else
43
59
  new
44
60
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grape
4
+ module Exceptions
5
+ # Raised by +error!+ to end the request with an error response. It carries
6
+ # the ErrorResponse a +throw :error+ carries, and +Middleware::Error+
7
+ # renders it the same way: as it is, without consulting +rescue_from+.
8
+ #
9
+ # It is raised rather than thrown so that it leaves a block the way an
10
+ # exception does. Active Record rolls a transaction back only when an
11
+ # exception leaves its block, and commits it on a +throw+. A +throw+ cannot
12
+ # leave the fiber or thread it was thrown in either, while +Async::Task#wait+
13
+ # and +Thread#value+ re-raise an exception in the request's own.
14
+ #
15
+ # A StandardError on purpose: async treats any other exception leaving a
16
+ # task as fatal and stops its reactor. It is not a Grape::Exceptions::Base,
17
+ # so a +rescue+ of those around +error!+ still lets it through.
18
+ class Halt < StandardError
19
+ extend Forwardable
20
+
21
+ EMPTY_BACKTRACE = [].freeze
22
+
23
+ attr_reader :response
24
+
25
+ def_delegators :response, :status, :headers
26
+
27
+ # Keywords spelled out rather than forwarded with **, which would gather
28
+ # them into a Hash on every error! only to spread it again.
29
+ def initialize(status: nil, message: nil, headers: nil, backtrace: nil, original_exception: nil)
30
+ @response = ErrorResponse.new(status:, message:, headers:, backtrace:, original_exception:)
31
+ super(message)
32
+ # Pre-seed the backtrace so Ruby's raise skips capture, as
33
+ # Grape::Exceptions::Validation does: +error!+ is how a route answers
34
+ # with an error, and the backtrace would only point at that route.
35
+ set_backtrace(EMPTY_BACKTRACE)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -9,16 +9,16 @@ module Grape
9
9
 
10
10
  def initialize(params:, message: nil, status: nil, headers: nil)
11
11
  @params = Array(params)
12
- translated =
13
- if message
14
- @message_key = case message
15
- when Symbol then message
16
- when Hash then message[:key]
17
- end
18
- translate_message(message)
19
- end
12
+ # Without a message, StandardError#message answers with this class's
13
+ # name, and that reached the response -- from a custom validator with
14
+ # no default_message_key, say.
15
+ message ||= :invalid
16
+ @message_key = case message
17
+ when Symbol then message
18
+ when Hash then message[:key]
19
+ end
20
20
 
21
- super(status:, message: translated, headers:)
21
+ super(status:, message: translate_message(message), headers:)
22
22
  # Pre-seed the backtrace so Ruby's raise skips capture. Validation errors are
23
23
  # a hot path (raised per bad attribute) and end up as 400 Bad Request responses;
24
24
  # backtraces here point into Grape internals and have no diagnostic value.
@@ -23,7 +23,19 @@ module Grape
23
23
  as_json.to_json
24
24
  end
25
25
 
26
+ # Translated once, when the error is built for its #message, and handed
27
+ # out as a copy from then on. Every lookup here is an I18n call of a few
28
+ # microseconds, and the README's recipe for answering with the list,
29
+ # +error!({ messages: e.full_messages }, 400)+, asked for all of them a
30
+ # second time. It also keeps the list the same as #message, which was
31
+ # already fixed at that point, if the locale changes in between.
26
32
  def full_messages
33
+ (@full_messages ||= translate_full_messages).dup
34
+ end
35
+
36
+ private
37
+
38
+ def translate_full_messages
27
39
  messages = errors.flat_map do |attributes, errs|
28
40
  errs.map do |error|
29
41
  translate(
@@ -39,8 +51,6 @@ module Grape
39
51
  messages
40
52
  end
41
53
 
42
- private
43
-
44
54
  def translate_attributes(keys)
45
55
  keys.map do |key|
46
56
  translate(key, scope: 'grape.errors.attributes', default: key.to_s)
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grape
4
+ module Formatter
5
+ # Renders JSON Lines (https://jsonlines.org): one JSON value per line, each
6
+ # line ended by a newline. An Array renders one line per element, and any
7
+ # other object one line. A String is served as it is, taken to be JSON
8
+ # Lines already.
9
+ #
10
+ # An Enumerator renders as it is read rather than up front, so the server
11
+ # streams each line as the enumerator yields its element:
12
+ #
13
+ # get :export do
14
+ # User.find_each # without a block, an Enumerator
15
+ # end
16
+ class Jsonl < Base
17
+ class << self
18
+ def call(object, env)
19
+ return object if object.is_a?(String)
20
+ return Grape::ServeStream::StreamResponse.new(object.lazy.map { |element| line(element, env) }) if object.is_a?(Enumerator)
21
+ return object.to_ary.map { |element| line(element, env) }.join if object.respond_to?(:to_ary)
22
+
23
+ line(object, env)
24
+ end
25
+
26
+ private
27
+
28
+ def line(object, env)
29
+ "#{Formatter::Json.call(object, env)}\n"
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grape
4
+ module Formatter
5
+ class Markdown < Base
6
+ # Md inherits this, so the format named in the error comes from the class
7
+ # name, the way Grape::Util::Registry derives the name each class is
8
+ # registered under.
9
+ def self.call(object, _env)
10
+ return object.to_markdown if object.respond_to?(:to_markdown)
11
+ return object if object.is_a?(String)
12
+
13
+ raise Grape::Exceptions::InvalidFormatter.new(object.class, name.demodulize.underscore)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grape
4
+ module Formatter
5
+ # Registers Markdown's rendering under +md+ too, for an API that declares
6
+ # +content_type :md+ rather than +:markdown+ (see
7
+ # Grape::ContentTypes::FORMAT_SYNONYMS).
8
+ class Md < Markdown
9
+ end
10
+ end
11
+ end
@@ -12,6 +12,7 @@ en:
12
12
  except_values: 'has a value not allowed'
13
13
  incompatible_option_values: '%{option1}: %{value1} is incompatible with %{option2}: %{value2}'
14
14
  internal_server_error: 'Internal Server Error'
15
+ invalid: 'is invalid'
15
16
  invalid_accept_header:
16
17
  problem: 'invalid accept header'
17
18
  resolution: '%{message}'
@@ -12,10 +12,25 @@ module Grape
12
12
  raise Grape::Exceptions::UnknownAuthStrategy.new(strategy: options[:type]) unless @auth_strategy
13
13
  end
14
14
 
15
+ # Base#call copies the middleware per request so #call! can keep the
16
+ # env in an ivar, and all that reads it is the strategy's block, to find
17
+ # the endpoint that checks the credentials. The block takes the
18
+ # endpoint from the env it was built for instead, so the request is
19
+ # answered by the one instance the stack built.
20
+ def call(env)
21
+ authenticate(env).to_a
22
+ end
23
+
15
24
  def call!(env)
16
25
  @env = env
26
+ authenticate(env)
27
+ end
28
+
29
+ private
30
+
31
+ def authenticate(env)
17
32
  @auth_strategy.create(app, options) do |*args|
18
- context.instance_exec(*args, &options[:proc])
33
+ env[Grape::Env::API_ENDPOINT].instance_exec(*args, &options[:proc])
19
34
  end.call(env)
20
35
  end
21
36
  end
@@ -4,10 +4,10 @@ module Grape
4
4
  module Middleware
5
5
  module Auth
6
6
  StrategyInfo = Struct.new(:auth_class, :settings_fetcher) do
7
- def create(app, options, &block)
7
+ def create(app, options, &)
8
8
  strategy_args = settings_fetcher.call(options)
9
9
 
10
- auth_class.new(app, *strategy_args, &block)
10
+ auth_class.new(app, *strategy_args, &)
11
11
  end
12
12
  end
13
13
  end
@@ -35,12 +35,7 @@ module Grape
35
35
  begin
36
36
  @app_response = @app.call(@env)
37
37
  ensure
38
- begin
39
- after_response = after
40
- rescue StandardError => e
41
- warn "caught error of type #{e.class} in after callback inside #{self.class.name} : #{e.message}"
42
- raise e
43
- end
38
+ after_response = reporting_after_errors { after }
44
39
  end
45
40
 
46
41
  response = after_response || @app_response
@@ -54,7 +49,7 @@ module Grape
54
49
 
55
50
  # @abstract
56
51
  # Called after the application is called in the middleware lifecycle.
57
- # @return [Response, nil] a Rack SPEC response or nil to call the application afterwards.
52
+ # @return [Response, nil] a Rack SPEC response, or nil to keep the application's.
58
53
  def after; end
59
54
 
60
55
  def rack_request
@@ -79,13 +74,24 @@ module Grape
79
74
 
80
75
  private
81
76
 
77
+ # Runs what the middleware does once the app has answered. An error it
78
+ # raises is reported before it goes on: a +rescue_from :all+ handler
79
+ # turns it into a 500 without logging it, so this may be the only trace
80
+ # of it.
81
+ def reporting_after_errors
82
+ yield
83
+ rescue StandardError => e
84
+ warn "caught error of type #{e.class} in after callback inside #{self.class.name} : #{e.message}"
85
+ raise e
86
+ end
87
+
88
+ # +response+ is a Rack triplet, unless +after+ or the app handed back a
89
+ # Rack::Response.
82
90
  def merge_headers(response)
83
- return if @header.blank?
91
+ return if @header.nil? || @header.empty?
92
+ return response.headers.merge!(@header) if response.is_a?(Rack::Response)
84
93
 
85
- case response
86
- when Rack::Response then response.headers.merge!(@header)
87
- when Array then response[1].merge!(@header)
88
- end
94
+ response[1].merge!(@header)
89
95
  end
90
96
 
91
97
  def merge_default_options(options)
@@ -112,7 +118,7 @@ module Grape
112
118
  end
113
119
 
114
120
  def try_scrub(obj)
115
- obj.respond_to?(:valid_encoding?) && !obj.valid_encoding? ? obj.scrub : obj
121
+ Grape::Util::Scrub.scrub(obj)
116
122
  end
117
123
  end
118
124
  end