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.
Files changed (170) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +351 -0
  3. data/CONTRIBUTING.md +18 -10
  4. data/README.md +457 -228
  5. data/UPGRADING.md +1240 -1
  6. data/grape.gemspec +7 -7
  7. data/lib/grape/api/instance.rb +65 -116
  8. data/lib/grape/api.rb +53 -62
  9. data/lib/grape/content_types.rb +58 -3
  10. data/lib/grape/cookies.rb +2 -6
  11. data/lib/grape/declared_params_handler.rb +116 -0
  12. data/lib/grape/dry_types.rb +48 -4
  13. data/lib/grape/dsl/callbacks.rb +8 -58
  14. data/lib/grape/dsl/declared.rb +35 -0
  15. data/lib/grape/dsl/desc.rb +24 -67
  16. data/lib/grape/dsl/entity.rb +106 -0
  17. data/lib/grape/dsl/helpers.rb +80 -65
  18. data/lib/grape/dsl/inside_route.rb +56 -296
  19. data/lib/grape/dsl/logger.rb +4 -9
  20. data/lib/grape/dsl/middleware.rb +22 -40
  21. data/lib/grape/dsl/parameters.rb +86 -93
  22. data/lib/grape/dsl/request_response.rb +139 -139
  23. data/lib/grape/dsl/rescue_options.rb +25 -0
  24. data/lib/grape/dsl/routing.rb +328 -203
  25. data/lib/grape/dsl/settings.rb +29 -135
  26. data/lib/grape/dsl/validations.rb +38 -44
  27. data/lib/grape/dsl/version_options.rb +23 -0
  28. data/lib/grape/endpoint/options.rb +25 -0
  29. data/lib/grape/endpoint.rb +285 -245
  30. data/lib/grape/env.rb +8 -3
  31. data/lib/grape/error_formatter/base.rb +27 -21
  32. data/lib/grape/error_formatter/json.rb +6 -0
  33. data/lib/grape/error_formatter/txt.rb +10 -10
  34. data/lib/grape/error_formatter.rb +6 -2
  35. data/lib/grape/exceptions/base.rb +20 -44
  36. data/lib/grape/exceptions/error_response.rb +48 -0
  37. data/lib/grape/exceptions/incompatible_option_values.rb +1 -1
  38. data/lib/grape/exceptions/internal_server_error.rb +16 -0
  39. data/lib/grape/exceptions/invalid_accept_header.rb +1 -1
  40. data/lib/grape/exceptions/invalid_formatter.rb +1 -1
  41. data/lib/grape/exceptions/invalid_message_body.rb +1 -1
  42. data/lib/grape/exceptions/invalid_version_header.rb +1 -1
  43. data/lib/grape/exceptions/invalid_versioner_option.rb +1 -1
  44. data/lib/grape/exceptions/method_not_allowed.rb +1 -1
  45. data/lib/grape/exceptions/missing_group_type.rb +0 -2
  46. data/lib/grape/exceptions/missing_mime_type.rb +1 -1
  47. data/lib/grape/exceptions/request_error.rb +11 -0
  48. data/lib/grape/exceptions/unknown_auth_strategy.rb +1 -1
  49. data/lib/grape/exceptions/unknown_error_formatter.rb +11 -0
  50. data/lib/grape/exceptions/unknown_parameter.rb +1 -1
  51. data/lib/grape/exceptions/unknown_params_builder.rb +1 -1
  52. data/lib/grape/exceptions/unknown_validator.rb +1 -1
  53. data/lib/grape/exceptions/unsupported_group_type.rb +0 -2
  54. data/lib/grape/exceptions/validation.rb +25 -7
  55. data/lib/grape/exceptions/validation_array_errors.rb +4 -0
  56. data/lib/grape/exceptions/validation_errors.rb +21 -23
  57. data/lib/grape/formatter/json.rb +1 -0
  58. data/lib/grape/formatter/serializable_hash.rb +6 -9
  59. data/lib/grape/json.rb +38 -2
  60. data/lib/grape/locale/en.yml +3 -5
  61. data/lib/grape/middleware/auth/base.rb +4 -3
  62. data/lib/grape/middleware/auth/dsl.rb +9 -23
  63. data/lib/grape/middleware/base.rb +41 -36
  64. data/lib/grape/middleware/error.rb +271 -73
  65. data/lib/grape/middleware/formatter.rb +152 -63
  66. data/lib/grape/middleware/precomputed_content_types.rb +51 -0
  67. data/lib/grape/middleware/stack.rb +20 -11
  68. data/lib/grape/middleware/versioner/accept_version_header.rb +3 -3
  69. data/lib/grape/middleware/versioner/base.rb +40 -48
  70. data/lib/grape/middleware/versioner/header.rb +5 -23
  71. data/lib/grape/middleware/versioner/param.rb +2 -2
  72. data/lib/grape/middleware/versioner/path.rb +54 -7
  73. data/lib/grape/mountable.rb +22 -0
  74. data/lib/grape/namespace.rb +19 -11
  75. data/lib/grape/params_builder/base.rb +2 -0
  76. data/lib/grape/params_builder/hash_with_indifferent_access.rb +1 -1
  77. data/lib/grape/params_builder.rb +2 -19
  78. data/lib/grape/parser/json.rb +1 -1
  79. data/lib/grape/path.rb +8 -72
  80. data/lib/grape/precompiled_json.rb +50 -0
  81. data/lib/grape/request.rb +57 -20
  82. data/lib/grape/router/base_route.rb +53 -7
  83. data/lib/grape/router/greedy_route.rb +16 -6
  84. data/lib/grape/router/mustermann_pattern.rb +44 -0
  85. data/lib/grape/router/pattern/path.rb +78 -0
  86. data/lib/grape/router/pattern.rb +65 -37
  87. data/lib/grape/router/route.rb +66 -18
  88. data/lib/grape/router.rb +126 -115
  89. data/lib/grape/serve_stream/file_body.rb +7 -0
  90. data/lib/grape/serve_stream/sendfile_response.rb +3 -5
  91. data/lib/grape/serve_stream/stream_response.rb +7 -0
  92. data/lib/grape/testing.rb +33 -0
  93. data/lib/grape/util/api_description.rb +67 -0
  94. data/lib/grape/util/cache.rb +22 -2
  95. data/lib/grape/util/deep_freeze.rb +34 -0
  96. data/lib/grape/util/freeze_on_new.rb +20 -0
  97. data/lib/grape/util/inheritable_setting.rb +777 -39
  98. data/lib/grape/util/lazy/base.rb +16 -0
  99. data/lib/grape/util/lazy/block.rb +2 -9
  100. data/lib/grape/util/lazy/value.rb +2 -9
  101. data/lib/grape/util/lazy/value_enumerable.rb +13 -16
  102. data/lib/grape/util/media_type.rb +13 -9
  103. data/lib/grape/util/path_normalizer.rb +37 -0
  104. data/lib/grape/util/registry.rb +13 -3
  105. data/lib/grape/util/shadowed_rescue_handlers.rb +49 -0
  106. data/lib/grape/util/stackable_values.rb +39 -10
  107. data/lib/grape/util/translation.rb +42 -0
  108. data/lib/grape/validations/attributes_iterator.rb +61 -29
  109. data/lib/grape/validations/coerce_options.rb +21 -0
  110. data/lib/grape/validations/contract_scope.rb +8 -13
  111. data/lib/grape/validations/multiple_attributes_iterator.rb +1 -1
  112. data/lib/grape/validations/oneof_collector.rb +35 -0
  113. data/lib/grape/validations/param_scope_tracker.rb +62 -0
  114. data/lib/grape/validations/params_documentation.rb +52 -0
  115. data/lib/grape/validations/params_scope.rb +217 -304
  116. data/lib/grape/validations/shared_options.rb +19 -0
  117. data/lib/grape/validations/single_attribute_iterator.rb +6 -4
  118. data/lib/grape/validations/types/array_coercer.rb +7 -10
  119. data/lib/grape/validations/types/custom_type_coercer.rb +47 -85
  120. data/lib/grape/validations/types/custom_type_collection_coercer.rb +1 -1
  121. data/lib/grape/validations/types/dry_type_coercer.rb +10 -15
  122. data/lib/grape/validations/types/json.rb +1 -3
  123. data/lib/grape/validations/types/multiple_type_coercer.rb +5 -3
  124. data/lib/grape/validations/types/primitive_coercer.rb +14 -33
  125. data/lib/grape/validations/types/set_coercer.rb +1 -1
  126. data/lib/grape/validations/types/variant_collection_coercer.rb +16 -3
  127. data/lib/grape/validations/types.rb +29 -51
  128. data/lib/grape/validations/validations_spec.rb +164 -0
  129. data/lib/grape/validations/validators/all_or_none_of_validator.rb +6 -3
  130. data/lib/grape/validations/validators/allow_blank_validator.rb +10 -5
  131. data/lib/grape/validations/validators/at_least_one_of_validator.rb +5 -2
  132. data/lib/grape/validations/validators/base.rb +118 -34
  133. data/lib/grape/validations/validators/coerce_validator.rb +25 -37
  134. data/lib/grape/validations/validators/contract_scope_validator.rb +10 -5
  135. data/lib/grape/validations/validators/default_validator.rb +13 -20
  136. data/lib/grape/validations/validators/exactly_one_of_validator.rb +10 -3
  137. data/lib/grape/validations/validators/except_values_validator.rb +14 -4
  138. data/lib/grape/validations/validators/length_validator.rb +23 -22
  139. data/lib/grape/validations/validators/multiple_params_base.rb +12 -9
  140. data/lib/grape/validations/validators/{mutual_exclusion_validator.rb → mutually_exclusive_validator.rb} +4 -2
  141. data/lib/grape/validations/validators/oneof_validator.rb +51 -0
  142. data/lib/grape/validations/validators/presence_validator.rb +4 -2
  143. data/lib/grape/validations/validators/regexp_validator.rb +11 -3
  144. data/lib/grape/validations/validators/same_as_validator.rb +7 -15
  145. data/lib/grape/validations/validators/values_validator.rb +34 -26
  146. data/lib/grape/version.rb +1 -2
  147. data/lib/grape/xml.rb +8 -1
  148. data/lib/grape.rb +58 -31
  149. metadata +62 -39
  150. data/lib/grape/api/helpers.rb +0 -9
  151. data/lib/grape/dsl/api.rb +0 -17
  152. data/lib/grape/dsl/configuration.rb +0 -15
  153. data/lib/grape/exceptions/conflicting_types.rb +0 -11
  154. data/lib/grape/exceptions/empty_message_body.rb +0 -11
  155. data/lib/grape/exceptions/invalid_parameters.rb +0 -11
  156. data/lib/grape/exceptions/missing_option.rb +0 -11
  157. data/lib/grape/exceptions/too_deep_parameters.rb +0 -11
  158. data/lib/grape/exceptions/too_many_multipart_files.rb +0 -11
  159. data/lib/grape/exceptions/unknown_options.rb +0 -11
  160. data/lib/grape/extensions/active_support/hash_with_indifferent_access.rb +0 -24
  161. data/lib/grape/extensions/hash.rb +0 -27
  162. data/lib/grape/extensions/hashie/mash.rb +0 -24
  163. data/lib/grape/middleware/globals.rb +0 -14
  164. data/lib/grape/types/invalid_value.rb +0 -8
  165. data/lib/grape/util/base_inheritable.rb +0 -43
  166. data/lib/grape/util/inheritable_values.rb +0 -29
  167. data/lib/grape/util/reverse_stackable_values.rb +0 -15
  168. data/lib/grape/util/strict_hash_configuration.rb +0 -108
  169. data/lib/grape/validations/attributes_doc.rb +0 -60
  170. data/lib/grape/validations/validator_factory.rb +0 -15
data/lib/grape/json.rb CHANGED
@@ -1,8 +1,44 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Grape
4
- if defined?(::MultiJson)
5
- Json = ::MultiJson
4
+ if defined?(::MultiJSON)
5
+ # Since multi_json 1.21.0, MultiJSON.dump is deprecated in favor of
6
+ # MultiJSON.generate (removed in 2.0). Keep Grape's dump surface but route
7
+ # it to the non-deprecated name — identical output, no deprecation warning.
8
+ # https://github.com/sferik/multi_json/blob/v1.21.1/CHANGELOG.md#deprecated
9
+ module Json
10
+ ParseError = ::MultiJSON::ParseError
11
+
12
+ class << self
13
+ def dump(object)
14
+ ::MultiJSON.generate(object)
15
+ end
16
+
17
+ # parse is not deprecated; it's re-exposed (not renamed) because this
18
+ # facade is its own module and no longer inherits MultiJSON's methods.
19
+ def parse(source)
20
+ ::MultiJSON.parse(source)
21
+ end
22
+ end
23
+ end
24
+ elsif defined?(::MultiJson)
25
+ # Legacy multi_json (< 1.21) predates generate/parse and only exposes
26
+ # dump/load. Map Grape's surface onto them so the call sites stay
27
+ # engine-agnostic (these names are not deprecated on < 1.21).
28
+ module Json
29
+ # Mutually exclusive with the MultiJSON branch above; only one runs.
30
+ ParseError = ::MultiJson::ParseError # rubocop:disable Lint/ConstantReassignment
31
+
32
+ class << self
33
+ def dump(object)
34
+ ::MultiJson.dump(object)
35
+ end
36
+
37
+ def parse(source)
38
+ ::MultiJson.load(source)
39
+ end
40
+ end
41
+ end
6
42
  else
7
43
  Json = ::JSON
8
44
  Json::ParseError = Json::ParserError
@@ -8,11 +8,10 @@ en:
8
8
  at_least_one: 'are missing, at least one parameter must be provided'
9
9
  blank: 'is empty'
10
10
  coerce: 'is invalid'
11
- conflicting_types: 'query params contains conflicting types'
12
- empty_message_body: 'empty message body supplied with %{body_format} content-type'
13
11
  exactly_one: 'are missing, exactly one parameter must be provided'
14
12
  except_values: 'has a value not allowed'
15
13
  incompatible_option_values: '%{option1}: %{value1} is incompatible with %{option2}: %{value2}'
14
+ internal_server_error: 'Internal Server Error'
16
15
  invalid_accept_header:
17
16
  problem: 'invalid accept header'
18
17
  resolution: '%{message}'
@@ -20,7 +19,6 @@ en:
20
19
  invalid_message_body:
21
20
  problem: 'message body does not match declared format'
22
21
  resolution: 'when specifying %{body_format} as content-type, you must pass valid %{body_format} in the request''s ''body'' '
23
- invalid_parameters: 'query params contains invalid format or byte sequence'
24
22
  invalid_response: 'Invalid response'
25
23
  invalid_version_header:
26
24
  problem: 'invalid version header'
@@ -45,12 +43,12 @@ en:
45
43
  resolution: 'eg: version ''v1'', using: :header, vendor: ''twitter'''
46
44
  summary: 'when version using header, you must specify :vendor option'
47
45
  mutual_exclusion: 'are mutually exclusive'
46
+ oneof: 'does not match any of the allowed schemas'
48
47
  presence: 'is missing'
49
48
  regexp: 'is invalid'
50
49
  same_as: 'is not the same as %{parameter}'
51
- too_deep_parameters: 'query params are recursively nested over the specified limit (%{limit})'
52
- too_many_multipart_files: 'the number of uploaded files exceeded the system''s configured limit (%{limit})'
53
50
  unknown_auth_strategy: 'unknown auth strategy: %{strategy}'
51
+ unknown_error_formatter: 'unknown error formatter: %{error_formatter_type}'
54
52
  unknown_options: 'unknown options: %{options}'
55
53
  unknown_parameter: 'unknown parameter: %{param}'
56
54
  unknown_params_builder: 'unknown params_builder: %{params_builder_type}'
@@ -6,9 +6,10 @@ module Grape
6
6
  class Base < Grape::Middleware::Base
7
7
  def initialize(app, **options)
8
8
  super
9
- @auth_strategy = Grape::Middleware::Auth::Strategies[options[:type]].tap do |auth_strategy|
10
- raise Grape::Exceptions::UnknownAuthStrategy.new(strategy: options[:type]) unless auth_strategy
11
- end
9
+ return unless options.key?(:type)
10
+
11
+ @auth_strategy = Grape::Middleware::Auth::Strategies[options[:type]]
12
+ raise Grape::Exceptions::UnknownAuthStrategy.new(strategy: options[:type]) unless @auth_strategy
12
13
  end
13
14
 
14
15
  def call!(env)
@@ -4,34 +4,20 @@ module Grape
4
4
  module Middleware
5
5
  module Auth
6
6
  module DSL
7
- def auth(type = nil, options = {}, &block)
8
- if type
9
- namespace_inheritable(:auth, options.reverse_merge(type: type.to_sym, proc: block))
10
- use Grape::Middleware::Auth::Base, namespace_inheritable(:auth)
11
- else
12
- namespace_inheritable(:auth)
13
- end
7
+ def auth(type = nil, **options, &block)
8
+ return inheritable_setting.auth unless type
9
+
10
+ inheritable_setting.auth = { type: type.to_sym, proc: block }.merge!(options)
11
+ use Grape::Middleware::Auth::Base, inheritable_setting.auth
14
12
  end
15
13
 
16
14
  # Add HTTP Basic authorization to the API.
17
15
  #
18
- # @param [Hash] options A hash of options.
19
- # @option options [String] :realm "API Authorization" The HTTP Basic realm.
20
- def http_basic(options = {}, &block)
21
- options[:realm] ||= 'API Authorization'
22
- auth :http_basic, options, &block
23
- end
24
-
25
- def http_digest(options = {}, &block)
16
+ # @param options [Hash] a hash of options
17
+ # @option options [String] :realm "API Authorization" the HTTP Basic realm
18
+ def http_basic(**options, &)
26
19
  options[:realm] ||= 'API Authorization'
27
-
28
- if options[:realm].respond_to?(:values_at)
29
- options[:realm][:opaque] ||= 'secret'
30
- else
31
- options[:opaque] ||= 'secret'
32
- end
33
-
34
- auth :http_digest, options, &block
20
+ auth(:http_basic, **options, &)
35
21
  end
36
22
  end
37
23
  end
@@ -5,13 +5,23 @@ module Grape
5
5
  class Base
6
6
  include Grape::DSL::Headers
7
7
 
8
- attr_reader :app, :env, :options
8
+ attr_reader :app, :env, :options, :config
9
9
 
10
10
  # @param [Rack Application] app The standard argument for a Rack middleware.
11
- # @param [Hash] options A hash of options, simply stored for use by subclasses.
11
+ # @param [Hash] options Options forwarded to the subclass. When the
12
+ # subclass declares an `Options` Data class, the kwargs are routed
13
+ # through it and exposed via {#config}; {#options} keeps returning a
14
+ # frozen Hash representation for back-compat with subclasses that read
15
+ # `options[:key]`. Otherwise the kwargs are deep-merged with the
16
+ # subclass's `DEFAULT_OPTIONS` Hash (legacy path) and frozen.
12
17
  def initialize(app, **options)
13
18
  @app = app
14
- @options = merge_default_options(options)
19
+ if (config_class = options_data_class)
20
+ @config = config_class.new(**options)
21
+ @options = @config.to_h.freeze
22
+ else
23
+ @options = merge_default_options(options).freeze
24
+ end
15
25
  @app_response = nil
16
26
  end
17
27
 
@@ -61,53 +71,48 @@ module Grape
61
71
  @app_response = Rack::Response[*@app_response]
62
72
  end
63
73
 
64
- def content_types
65
- @content_types ||= Grape::ContentTypes.content_types_for(options[:content_types])
66
- end
67
-
68
- def mime_types
69
- @mime_types ||= Grape::ContentTypes.mime_types_for(content_types)
70
- end
71
-
72
- def content_type_for(format)
73
- content_types_indifferent_access[format]
74
- end
75
-
76
- def content_type
77
- content_type_for(env[Grape::Env::API_FORMAT] || options[:format]) || 'text/html'
78
- end
79
-
80
74
  def query_params
81
75
  rack_request.GET
82
- rescue Rack::QueryParser::ParamsTooDeepError
83
- raise Grape::Exceptions::TooDeepParameters.new(Rack::Utils.param_depth_limit)
84
- rescue Rack::Utils::ParameterTypeError
85
- raise Grape::Exceptions::ConflictingTypes
76
+ rescue *Grape::RACK_ERRORS
77
+ raise Grape::Exceptions::RequestError
86
78
  end
87
79
 
88
80
  private
89
81
 
90
82
  def merge_headers(response)
91
- return unless headers.is_a?(Hash)
83
+ return if @header.blank?
92
84
 
93
85
  case response
94
- when Rack::Response then response.headers.merge!(headers)
95
- when Array then response[1].merge!(headers)
86
+ when Rack::Response then response.headers.merge!(@header)
87
+ when Array then response[1].merge!(@header)
96
88
  end
97
89
  end
98
90
 
99
- def content_types_indifferent_access
100
- @content_types_indifferent_access ||= content_types.with_indifferent_access
91
+ def merge_default_options(options)
92
+ return default_options.deep_merge(options) if respond_to?(:default_options)
93
+
94
+ default_options_constant&.deep_merge(options) || options
101
95
  end
102
96
 
103
- def merge_default_options(options)
104
- if respond_to?(:default_options)
105
- default_options.deep_merge(options)
106
- elsif self.class.const_defined?(:DEFAULT_OPTIONS)
107
- self.class::DEFAULT_OPTIONS.deep_merge(options)
108
- else
109
- options
110
- end
97
+ # self.class::Options honours middleware inheritance (e.g. Versioner::Path
98
+ # → Versioner::Base) and, unlike const_defined?, never resolves a
99
+ # top-level ::Options on Object. Absent an own/inherited Options class,
100
+ # the lookup raises NameError and we fall back to the legacy path.
101
+ def options_data_class
102
+ self.class::Options
103
+ rescue NameError
104
+ nil
105
+ end
106
+
107
+ # Same idea as {#options_data_class} for the legacy DEFAULT_OPTIONS Hash.
108
+ def default_options_constant
109
+ self.class::DEFAULT_OPTIONS
110
+ rescue NameError
111
+ nil
112
+ end
113
+
114
+ def try_scrub(obj)
115
+ obj.respond_to?(:valid_encoding?) && !obj.valid_encoding? ? obj.scrub : obj
111
116
  end
112
117
  end
113
118
  end
@@ -3,24 +3,60 @@
3
3
  module Grape
4
4
  module Middleware
5
5
  class Error < Base
6
- DEFAULT_OPTIONS = {
7
- default_status: 500,
8
- default_message: '',
9
- format: :txt,
10
- rescue_all: false,
11
- rescue_grape_exceptions: false,
12
- rescue_subclasses: true,
13
- rescue_options: {
14
- backtrace: false,
15
- original_exception: false
16
- }.freeze
17
- }.freeze
18
-
19
- def initialize(app, **options)
20
- super
21
- self.class.include(options[:helpers]) if options[:helpers]
6
+ extend Forwardable
7
+ include PrecomputedContentTypes
8
+
9
+ Options = Data.define(
10
+ :all_rescue_handler, :base_only_rescue_handlers, :content_types,
11
+ :default_error_formatter, :default_message, :default_status,
12
+ :error_formatters, :format,
13
+ :grape_exceptions_rescue_handler, :internal_grape_exceptions_rescue_handler,
14
+ :rescue_all, :rescue_grape_exceptions, :rescue_handlers, :rescue_options
15
+ ) do
16
+ def initialize(
17
+ all_rescue_handler: nil, base_only_rescue_handlers: nil, content_types: nil,
18
+ default_error_formatter: nil, default_message: '', default_status: 500,
19
+ error_formatters: nil, format: :txt,
20
+ grape_exceptions_rescue_handler: nil, internal_grape_exceptions_rescue_handler: nil,
21
+ rescue_all: false, rescue_grape_exceptions: false, rescue_handlers: nil,
22
+ rescue_options: nil
23
+ )
24
+ # `rescue_options:` and `default_error_formatter:` arrive nil from
25
+ # `Endpoint#error_middleware_options` when the API called no
26
+ # `rescue_from` and no `format` — and an explicit nil bypasses the
27
+ # keyword defaults above, so restore them here rather than letting nil
28
+ # propagate to `def_delegator :rescue_options, :backtrace` or to the
29
+ # formatter lookup in `#format_message`.
30
+ super(
31
+ rescue_options: rescue_options || Grape::DSL::RescueOptions.new,
32
+ default_error_formatter: default_error_formatter || Grape::ErrorFormatter::Txt,
33
+ all_rescue_handler:, base_only_rescue_handlers:, content_types:,
34
+ default_message:, default_status:, error_formatters:, format:,
35
+ grape_exceptions_rescue_handler:, internal_grape_exceptions_rescue_handler:,
36
+ rescue_all:, rescue_grape_exceptions:, rescue_handlers:
37
+ )
38
+ end
22
39
  end
23
40
 
41
+ def_delegators :config,
42
+ :all_rescue_handler, :base_only_rescue_handlers, :default_error_formatter,
43
+ :default_message, :default_status, :error_formatters, :format,
44
+ :grape_exceptions_rescue_handler, :internal_grape_exceptions_rescue_handler,
45
+ :rescue_all, :rescue_grape_exceptions, :rescue_handlers, :rescue_options
46
+
47
+ # +:backtrace+ / +:original_exception+ on the rescue options become
48
+ # +#include_backtrace+ / +#include_original_exception+ on the middleware,
49
+ # which is what the formatter call site reads.
50
+ def_delegator :rescue_options, :backtrace, :include_backtrace
51
+ def_delegator :rescue_options, :original_exception, :include_original_exception
52
+
53
+ # Emitted by {#render_failsafe_response} once even the framework's own message
54
+ # could not be rendered. Deliberately built without a formatter, an i18n
55
+ # lookup or anything else that could be the thing that is broken.
56
+ FAILSAFE_STATUS = 500
57
+ FAILSAFE_MESSAGE = '500 Internal Server Error'
58
+ FAILSAFE_CONTENT_TYPE = 'text/plain'
59
+
24
60
  def call!(env)
25
61
  @env = env
26
62
  error_response(catch(:error) { return @app.call(@env) })
@@ -31,109 +67,271 @@ module Grape
31
67
  private
32
68
 
33
69
  def rack_response(status, headers, message)
34
- message = Rack::Utils.escape_html(message) if headers[Rack::CONTENT_TYPE] == 'text/html'
35
- Rack::Response.new(Array.wrap(message), Rack::Utils.status_code(status), Grape::Util::Header.new.merge(headers))
70
+ body = html_content_type?(headers[Rack::CONTENT_TYPE]) ? Rack::Utils.escape_html(message) : message
71
+ Rack::Response.new(Array.wrap(body), Rack::Utils.status_code(status), Grape::Util::Header.new.merge!(headers))
36
72
  end
37
73
 
38
- def format_message(message, backtrace, original_exception = nil)
39
- format = env[Grape::Env::API_FORMAT] || options[:format]
40
- formatter = Grape::ErrorFormatter.formatter_for(format, options[:error_formatters], options[:default_error_formatter])
41
- return formatter.call(message, backtrace, options, env, original_exception) if formatter
74
+ # Escaping must key off the media type only, case-insensitively. Comparing
75
+ # the raw header against 'text/html' would let a parameterized value such
76
+ # as 'text/html; charset=utf-8' (or a differently-cased 'Text/HTML', which
77
+ # browsers still treat as HTML) skip escaping and reflect an unescaped
78
+ # message into an HTML response. Such a header can be set from several
79
+ # places (a registered content type, or a custom Content-Type passed to
80
+ # error!/rescue_from), but they all render here.
81
+ def html_content_type?(content_type)
82
+ Grape::ContentTypes.media_type(content_type).to_s.casecmp?('text/html')
83
+ end
42
84
 
43
- throw :error,
44
- status: 406,
45
- message: "The requested format '#{format}' is not supported.",
46
- backtrace: backtrace,
47
- original_exception: original_exception
85
+ # The registry answers nil for a format nothing is registered for, and
86
+ # the API's +default_error_formatter+ — +ErrorFormatter::Txt+ unless the
87
+ # API set another — takes it from there, so this always has something
88
+ # callable. The +throw :error, 406+ that used to stand in for a missing
89
+ # formatter could not work anyway: nothing catches +:error+ around this
90
+ # call (+#call!+ has left its +catch+ by the time +error_response+ runs),
91
+ # so it raised +UncaughtThrowError+ and the request answered with the
92
+ # failsafe 500 rather than the 406 it named.
93
+ def format_message(error)
94
+ current_format = env[Grape::Env::API_FORMAT] || format
95
+ formatter = Grape::ErrorFormatter.formatter_for(current_format, error_formatters) || default_error_formatter
96
+ formatter.call(error:, env:, include_backtrace:, include_original_exception:)
48
97
  end
49
98
 
50
99
  def find_handler(klass)
51
- rescue_handler_for_base_only_class(klass) ||
52
- rescue_handler_for_class_or_its_ancestor(klass) ||
100
+ registered_entry = registered_rescue_handler_entry(klass)
101
+
102
+ grape_exceptions_precedence_handler(klass, registered_entry) ||
103
+ registered_entry&.last ||
53
104
  rescue_handler_for_grape_exception(klass) ||
54
105
  rescue_handler_for_any_class(klass) ||
55
106
  raise
56
107
  end
57
108
 
58
- def error_response(error = {})
59
- status = error[:status] || options[:default_status]
60
- env[Grape::Env::API_ENDPOINT].status(status) # error! may not have been called
61
- message = error[:message] || options[:default_message]
62
- headers = { Rack::CONTENT_TYPE => content_type }.tap do |h|
63
- h.merge!(error[:headers]) if error[:headers].is_a?(Hash)
64
- end
65
- backtrace = error[:backtrace] || error[:original_exception]&.backtrace || []
66
- original_exception = error.is_a?(Exception) ? error : error[:original_exception] || nil
67
- rack_response(status, headers, format_message(message, backtrace, original_exception))
109
+ def error_response(error = nil)
110
+ raw = Grape::Exceptions::ErrorResponse.coerce(error)
111
+ headers = { Rack::CONTENT_TYPE => content_type }
112
+ headers.merge!(raw.headers) if raw.headers.is_a?(Hash)
113
+ payload = raw.with(
114
+ status: raw.status || default_status,
115
+ message: raw.message || default_message,
116
+ headers:,
117
+ backtrace: resolved_backtrace(raw)
118
+ )
119
+ env[Grape::Env::API_ENDPOINT].status(payload.status) # error! may not have been called
120
+ render_response(payload)
121
+ end
122
+
123
+ # The backtrace an error formatter is handed. Assembled only when the API
124
+ # asked for one with `rescue_from ..., backtrace: true`: reading it off
125
+ # the exception is not free — +Exception#backtrace+ builds the whole Array
126
+ # of location strings — and every built-in formatter drops it otherwise.
127
+ # A formatter that wants one regardless still has +original_exception+.
128
+ def resolved_backtrace(raw)
129
+ return [] unless include_backtrace
130
+
131
+ raw.backtrace || raw.original_exception&.backtrace || []
132
+ end
133
+
134
+ # Rendering runs inside #call!'s own rescue clause, so it is not covered by
135
+ # that rescue: an error formatter that raises on the payload it was handed
136
+ # takes the exception out through every middleware above Grape. By this
137
+ # point Grape has committed to answering with an error, so it answers with
138
+ # one that does not depend on the payload rather than dropping the request.
139
+ #
140
+ # +Grape.config.raise_rendering_errors+ opts back out, for an application
141
+ # that would rather have the exception propagate as it did before.
142
+ def render_response(payload)
143
+ rack_response(payload.status, payload.headers, format_message(payload))
144
+ rescue StandardError => e
145
+ raise if Grape.config.raise_rendering_errors
146
+
147
+ record_rendering_failure(e)
148
+ render_failsafe_response
149
+ end
150
+
151
+ # The exception is published on the rack env (see {#expose_exception}) and
152
+ # written to +rack.errors+ so it reaches the log with no tracker installed.
153
+ # Rails writes to $stderr from its failsafe branch for the same reason:
154
+ # deferring the logging to the application is not an option when the
155
+ # application's own error rendering is what broke.
156
+ def record_rendering_failure(error)
157
+ expose_exception(error)
158
+ env[Rack::RACK_ERRORS]&.write("Grape could not render the error response: #{error.class}: #{error.message}\n")
159
+ end
160
+
161
+ # First retry the API's own format with the framework's InternalServerError,
162
+ # whose message is a static string and so cannot be what defeated the first
163
+ # attempt. Should even that fail — a wholesale broken formatter, rather than
164
+ # one payload it choked on — drop the formatter entirely. Both attempts call
165
+ # {#format_message} directly rather than re-entering {#error_response}, so
166
+ # this path cannot recurse.
167
+ def render_failsafe_response
168
+ headers = { Rack::CONTENT_TYPE => content_type }
169
+ rack_response(FAILSAFE_STATUS, headers, format_message(failsafe_payload(headers)))
170
+ rescue StandardError
171
+ rack_response(FAILSAFE_STATUS, { Rack::CONTENT_TYPE => FAILSAFE_CONTENT_TYPE }, FAILSAFE_MESSAGE)
172
+ end
173
+
174
+ # Publish an exception Grape swallowed, on both keys. +grape.exception+ is
175
+ # Grape's own and has been set on these paths all along; +rack.exception+ is
176
+ # what the ecosystem actually reads to find an exception that never
177
+ # propagated — sentry-ruby collects +env['rack.exception'] ||
178
+ # env['sinatra.error']+ — so a tracker mounted above Grape keeps reporting
179
+ # these with no application change.
180
+ def expose_exception(error)
181
+ env[Grape::Env::GRAPE_EXCEPTION] = error
182
+ env[Grape::Env::RACK_EXCEPTION] = error
183
+ end
184
+
185
+ def failsafe_payload(headers)
186
+ Grape::Exceptions::ErrorResponse.new(
187
+ status: FAILSAFE_STATUS,
188
+ message: Grape::Exceptions::InternalServerError.new.message,
189
+ headers:,
190
+ backtrace: [],
191
+ original_exception: nil
192
+ )
68
193
  end
69
194
 
70
195
  def default_rescue_handler(exception)
71
- error_response(message: exception.message, backtrace: exception.backtrace, original_exception: exception)
196
+ error_response(
197
+ Grape::Exceptions::ErrorResponse.new(
198
+ message: exception.message,
199
+ backtrace: exception.backtrace,
200
+ original_exception: exception
201
+ )
202
+ )
203
+ end
204
+
205
+ def registered_rescue_handler(klass)
206
+ registered_rescue_handler_entry(klass)&.last
207
+ end
208
+
209
+ # The matched entry rather than just its handler, so callers can tell
210
+ # *which* class matched — see {#grape_exceptions_precedence_handler}.
211
+ # @return [Array(Class, #call), nil]
212
+ def registered_rescue_handler_entry(klass)
213
+ rescue_handler_entry_from(base_only_rescue_handlers) { |err| klass == err } ||
214
+ rescue_handler_entry_from(rescue_handlers) { |err| klass <= err }
72
215
  end
73
216
 
74
- def rescue_handler_for_base_only_class(klass)
75
- error, handler = options[:base_only_rescue_handlers]&.find { |err, _handler| klass == err }
217
+ def rescue_handler_entry_from(handlers)
218
+ error, handler = handlers&.find { |err, _handler| yield(err) }
76
219
 
77
220
  return unless error
78
221
 
79
- handler || method(:default_rescue_handler)
222
+ [error, handler || method(:default_rescue_handler)]
80
223
  end
81
224
 
82
- def rescue_handler_for_class_or_its_ancestor(klass)
83
- error, handler = options[:rescue_handlers]&.find { |err, _handler| klass <= err }
225
+ # +rescue_from :grape_exceptions+ is an opt-in to keep Grape's own errors
226
+ # rendering with their own status — a validation failure stays a 400
227
+ # instead of becoming whatever the app's catch-all returns.
228
+ #
229
+ # It only ever worked against +rescue_from :all+, which lives in
230
+ # +all_rescue_handler+ and is consulted last. Spelled as a class instead,
231
+ # +rescue_from StandardError+ is a *registered* handler, matched first,
232
+ # and Grape's exceptions are StandardErrors — so the opt-in silently did
233
+ # nothing and validation errors came back as 500s either way.
234
+ #
235
+ # Let it win over a handler that only matched through a non-Grape
236
+ # ancestor. One registered for a Grape exception class is more specific
237
+ # than the opt-in and still wins, so an explicit
238
+ # +rescue_from Grape::Exceptions::ValidationErrors+ keeps its handler.
239
+ #
240
+ # InvalidVersionHeader is left alone: it must keep reaching Rack so the
241
+ # next versioned route is tried.
242
+ def grape_exceptions_precedence_handler(klass, registered_entry)
243
+ return unless rescue_grape_exceptions
244
+ return unless klass <= Grape::Exceptions::Base
245
+ return if klass == Grape::Exceptions::InvalidVersionHeader
84
246
 
85
- return unless error
247
+ matched, = registered_entry
248
+ return if matched.nil? || matched <= Grape::Exceptions::Base
86
249
 
87
- handler || method(:default_rescue_handler)
250
+ grape_exceptions_rescue_handler || method(:error_response)
88
251
  end
89
252
 
90
253
  def rescue_handler_for_grape_exception(klass)
91
254
  return unless klass <= Grape::Exceptions::Base
92
255
  return method(:error_response) if klass == Grape::Exceptions::InvalidVersionHeader
93
- return unless options[:rescue_grape_exceptions] || !options[:rescue_all]
256
+ return unless rescue_grape_exceptions || !rescue_all
94
257
 
95
- options[:grape_exceptions_rescue_handler] || method(:error_response)
258
+ grape_exceptions_rescue_handler || method(:error_response)
96
259
  end
97
260
 
98
261
  def rescue_handler_for_any_class(klass)
99
262
  return unless klass <= StandardError
100
- return unless options[:rescue_all] || options[:rescue_grape_exceptions]
263
+ return unless rescue_all || rescue_grape_exceptions
101
264
 
102
- options[:all_rescue_handler] || method(:default_rescue_handler)
265
+ all_rescue_handler || method(:default_rescue_handler)
103
266
  end
104
267
 
105
- def run_rescue_handler(handler, error, endpoint)
106
- if handler.instance_of?(Symbol)
107
- raise NoMethodError, "undefined method '#{handler}'" unless respond_to?(handler)
108
-
109
- handler = public_method(handler)
110
- end
111
-
268
+ def run_rescue_handler(handler, error, endpoint, redispatched: false)
269
+ callable = handler.is_a?(Symbol) ? endpoint.public_method(handler) : handler
112
270
  response = catch(:error) do
113
- handler.arity.zero? ? endpoint.instance_exec(&handler) : endpoint.instance_exec(error, &handler)
271
+ callable.arity.zero? ? endpoint.instance_exec(&callable) : endpoint.instance_exec(error, &callable)
272
+ rescue StandardError => e
273
+ return redispatch(e, endpoint, redispatched)
114
274
  end
115
275
 
116
- if error?(response)
117
- error_response(response)
118
- elsif response.is_a?(Rack::Response)
119
- response
120
- else
121
- run_rescue_handler(method(:default_rescue_handler), Grape::Exceptions::InvalidResponse.new, endpoint)
122
- end
276
+ return error_response(response) if error?(response)
277
+ return response if response.is_a?(Rack::Response)
278
+
279
+ run_rescue_handler(method(:default_rescue_handler), Grape::Exceptions::InvalidResponse.new, endpoint)
280
+ end
281
+
282
+ # Route an exception raised inside a +rescue_from+ block.
283
+ #
284
+ # * If we have already redispatched once (the redispatched handler
285
+ # itself raised), go straight to {#framework_default} — bounds the
286
+ # chain at one redispatch.
287
+ # * Else if the exception has a registered +rescue_from+ handler,
288
+ # run it.
289
+ # * Else if it's a +Grape::Exceptions::Base+ subclass, render it
290
+ # through +error_response+ with its own +status+ and +message+.
291
+ # * Else fall through to {#safe_default}, which lets the user opt
292
+ # in via +rescue_from :internal_grape_exceptions+ or, failing
293
+ # that, applies the framework default.
294
+ def redispatch(error, endpoint, already_redispatched)
295
+ return framework_default(endpoint) if already_redispatched
296
+
297
+ registered = registered_rescue_handler(error.class)
298
+
299
+ return run_rescue_handler(registered, error, endpoint, redispatched: true) if registered
300
+ return run_rescue_handler(method(:error_response), error, endpoint, redispatched: true) if error.is_a?(Grape::Exceptions::Base)
301
+
302
+ safe_default(error, endpoint)
123
303
  end
124
304
 
125
- def error!(message, status = options[:default_status], headers = {}, backtrace = [], original_exception = nil)
305
+ # The unrecognised-error path. Exposes the original exception on
306
+ # the rack env so upstream Rack middleware (loggers, error
307
+ # trackers) can observe it. If the user registered a
308
+ # +rescue_from :internal_grape_exceptions+ handler, that handler
309
+ # runs and owns the response. Otherwise the framework renders the
310
+ # generic +InternalServerError+ — never the original exception's
311
+ # message. The framework deliberately does no logging of its own
312
+ # here; that's the application's call.
313
+ def safe_default(error, endpoint)
314
+ expose_exception(error)
315
+ return run_rescue_handler(internal_grape_exceptions_rescue_handler, error, endpoint, redispatched: true) if internal_grape_exceptions_rescue_handler
316
+
317
+ framework_default(endpoint)
318
+ end
319
+
320
+ def framework_default(endpoint)
321
+ run_rescue_handler(method(:default_rescue_handler), Grape::Exceptions::InternalServerError.new, endpoint)
322
+ end
323
+
324
+ def error!(message, status = default_status, headers = {}, backtrace = [], original_exception = nil)
126
325
  env[Grape::Env::API_ENDPOINT].status(status) # not error! inside route
127
- rack_response(
128
- status, headers.reverse_merge(Rack::CONTENT_TYPE => content_type),
129
- format_message(message, backtrace, original_exception)
326
+ merged_headers = { Rack::CONTENT_TYPE => content_type }.merge!(headers)
327
+ error = Grape::Exceptions::ErrorResponse.new(
328
+ status:, message:, headers: merged_headers, backtrace:, original_exception:
130
329
  )
330
+ rack_response(status, merged_headers, format_message(error))
131
331
  end
132
332
 
133
333
  def error?(response)
134
- return false unless response.is_a?(Hash)
135
-
136
- response.key?(:message) && response.key?(:status) && response.key?(:headers)
334
+ response.is_a?(Grape::Exceptions::ErrorResponse)
137
335
  end
138
336
  end
139
337
  end