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
@@ -15,14 +15,31 @@ module Grape
15
15
 
16
16
  MIME_TYPES = Grape::ContentTypes::DEFAULTS.except(:serializable_hash).invert.freeze
17
17
 
18
+ # Formats known by more than one name. A request naming one of them -- by
19
+ # extension, +?format=+ or +default_format+ -- is served under whichever
20
+ # the API declared, so declaring +:md+ answers +.markdown+ too, through
21
+ # the formatter, parser and error formatter declared for +:md+. Both
22
+ # spellings are keys: an extension arrives as a String, +default_format+
23
+ # as a Symbol.
24
+ FORMAT_SYNONYMS = {
25
+ md: :markdown, 'md' => :markdown,
26
+ markdown: :md, 'markdown' => :md
27
+ }.freeze
28
+
18
29
  def content_types_for(from_settings)
19
30
  from_settings.presence || DEFAULTS
20
31
  end
21
32
 
33
+ # Identity rather than +==+ for the defaults, and the pairs rather than
34
+ # the registry for the cache key, because Hash equality ignores order and
35
+ # this table's order is the API's preference: the +*/*+ every browser
36
+ # sends picks the first media type declared. Compared by value, the first
37
+ # registry built with a set of content types fixed that order for every
38
+ # later one declaring the same set in another order.
22
39
  def mime_types_for(from_settings)
23
- return MIME_TYPES if from_settings == Grape::ContentTypes::DEFAULTS
40
+ return MIME_TYPES if from_settings.equal?(Grape::ContentTypes::DEFAULTS)
24
41
 
25
- MimeTypesCache[from_settings]
42
+ MimeTypesCache[from_settings.to_a]
26
43
  end
27
44
 
28
45
  # Every format under both spellings in one plain Hash, so a lookup is a
@@ -51,11 +68,11 @@ module Grape
51
68
  end
52
69
 
53
70
  # Both tables below are derived from nothing but the content-type registry,
54
- # and one content-type-aware middleware is built per API instance — so an
55
- # app mounting N APIs held N copies of tables it only ever reads. Keying
56
- # the cache on the registry itself collapses them: Hash keys compare by
57
- # value, so every API that registers the same content types shares one
58
- # table.
71
+ # and every endpoint builds its own content-type-aware middlewares — so an
72
+ # app held one copy per endpoint of tables it only ever reads. Keying the
73
+ # cache on the registry collapses them: keys compare by value, so every
74
+ # registry with the same content types shares one table -- declared in
75
+ # the same order, for MimeTypesCache (see #mime_types_for).
59
76
  #
60
77
  # Both the key and the table are frozen: the caller's registry stays
61
78
  # reachable (through +middleware.options[:content_types]+, among others)
@@ -65,8 +82,8 @@ module Grape
65
82
  class MimeTypesCache < Grape::Util::Cache
66
83
  def initialize
67
84
  super
68
- @cache = Hash.new do |h, from_settings|
69
- h[from_settings.dup.freeze] = from_settings.invert.transform_keys! { |mime_type| Grape::ContentTypes.media_type(mime_type) }.freeze
85
+ @cache = Hash.new do |h, content_types|
86
+ h[content_types.freeze] = content_types.to_h.invert.transform_keys! { |mime_type| Grape::ContentTypes.media_type(mime_type) }.freeze
70
87
  end
71
88
  end
72
89
  end
data/lib/grape/cookies.rb CHANGED
@@ -30,7 +30,7 @@ module Grape
30
30
  send_cookies << name
31
31
  end
32
32
 
33
- # see https://github.com/rack/rack/blob/main/lib/rack/utils.rb#L338-L340
33
+ # Same attributes Rack::Utils uses to delete a cookie
34
34
  def delete(name, **opts)
35
35
  self.[]=(name, opts.merge(DELETED_COOKIES_ATTRS))
36
36
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Grape
4
4
  class DeclaredParamsHandler
5
- def initialize(include_missing: true, evaluate_given: false, stringify: false, contract_key_map: nil)
5
+ def initialize(include_missing: true, evaluate_given: false, stringify: false, contract_key_map: [])
6
6
  @include_missing = include_missing
7
7
  @evaluate_given = evaluate_given
8
8
  @stringify = stringify
@@ -20,7 +20,16 @@ module Grape
20
20
 
21
21
  private
22
22
 
23
+ # An element of an Array scope that is not a Hash has no keys to filter,
24
+ # so it is returned as it came in, the way a leaf value is. Validation
25
+ # lets one through wherever nothing in the scope is required of it: an
26
+ # optional scope passes over blank elements, and a scope whose params are
27
+ # all optional checks nothing on an element that is not a Hash. Filtering
28
+ # it anyway meant building an empty copy of it, which raised for +nil+,
29
+ # +false+ and numbers and indexed a String with a Symbol.
23
30
  def recursive_declared(passed_params, declared_params:, route_params:, renamed_params:, params_nested_path: [])
31
+ return passed_params unless passed_params.is_a?(Array) || passed_params.respond_to?(:key?)
32
+
24
33
  res = if passed_params.is_a?(Array)
25
34
  passed_params.map do |passed_param|
26
35
  recursive_declared(passed_param, declared_params:, params_nested_path:, renamed_params:, route_params:)
@@ -29,7 +38,7 @@ module Grape
29
38
  declared_hash(passed_params, declared_params:, params_nested_path:, renamed_params:, route_params:)
30
39
  end
31
40
 
32
- @contract_key_map&.each { |key_map| key_map.write(passed_params, res) }
41
+ @contract_key_map.each { |key_map| key_map.write(passed_params, res) }
33
42
 
34
43
  res
35
44
  end
@@ -63,8 +72,13 @@ module Grape
63
72
  memo_key = build_memo_key(params_nested_path, parent, renamed_params)
64
73
  passed_children = passed_params[parent] || passed_params.class.new
65
74
  nested_path = nested_path_for(params_nested_path, parent)
75
+ # A value that is neither a Hash nor an Array where children are declared
76
+ # -- left unvalidated by a +given+ whose dependency is not met, say -- is
77
+ # returned as it came in, as #recursive_declared returns an element like
78
+ # it.
79
+ has_passed_children = !passed_children.respond_to?(:any?) || passed_children.any?
66
80
 
67
- memo[memo_key] = handle_passed_param(nested_path, route_params:, has_passed_children: passed_children.any?) do
81
+ memo[memo_key] = handle_passed_param(nested_path, route_params:, has_passed_children:) do
68
82
  recursive_declared(
69
83
  passed_children,
70
84
  declared_params: children,
@@ -87,8 +101,11 @@ module Grape
87
101
  end
88
102
  end
89
103
 
104
+ # The lookup key is the param's whole path, built fresh -- two Arrays and a
105
+ # String per declared param -- and only an API using +as:+ has anything to
106
+ # find with it, so the common empty table is not asked.
90
107
  def build_memo_key(params_nested_path, declared_param, renamed_params)
91
- renamed_param_name = renamed_params[nested_path_for(params_nested_path, declared_param)]
108
+ renamed_param_name = renamed_params[nested_path_for(params_nested_path, declared_param)] unless renamed_params.empty?
92
109
  param = renamed_param_name || declared_param
93
110
  @stringify ? param.to_s : param.to_sym
94
111
  end
@@ -106,11 +123,22 @@ module Grape
106
123
  type = route_params.dig(key, :type)
107
124
  return yield if type.nil?
108
125
 
109
- return {} if type == 'Hash' && route_params.keys.none? { |k| k != key && k.start_with?("#{key}[") }
126
+ empty_value_for(type, key, route_params) || yield
127
+ end
128
+
129
+ # The empty value a param of +type+ declares when it was not passed, or
130
+ # nil when +type+ has none. A fresh one every time: the caller may fill it.
131
+ def empty_value_for(type, key, route_params)
132
+ return {} if type == 'Hash' && no_child_params?(route_params, "#{key}[")
110
133
  return [] if type == 'Array' || (type.start_with?('[') && !type.include?(','))
111
- return Set.new if type == 'Set' || type.start_with?('#<Set', 'Set')
112
134
 
113
- yield
135
+ Set.new if type.start_with?('#<Set', 'Set')
136
+ end
137
+
138
+ # The prefix is built once by the caller: interpolated inside the block, it
139
+ # was a new String for every param on the route.
140
+ def no_child_params?(route_params, prefix)
141
+ route_params.keys.none? { |k| k.start_with?(prefix) }
114
142
  end
115
143
  end
116
144
  end
@@ -15,10 +15,11 @@ module Grape
15
15
  # consisting only of keys that have been declared by a
16
16
  # `params` statement against the current/target endpoint or parent
17
17
  # namespaces.
18
- # @param params [Hash] The initial hash to filter. Usually this will just be `params`
19
- # @param options [Hash] Can pass `:include_missing`, `:stringify` and `:include_parent_namespaces`
20
- # options. `:include_parent_namespaces` defaults to true, hence must be set to false if
21
- # you want only to return params declared against the current/target endpoint.
18
+ # @param passed_params [Hash] The initial hash to filter. Usually this will just be `params`
19
+ # @param include_parent_namespaces [Boolean] false to only return params declared against the current/target endpoint
20
+ # @param include_missing [Boolean] include declared params that were not passed
21
+ # @param evaluate_given [Boolean] drop params whose +given+ condition is not met
22
+ # @param stringify [Boolean] return String keys
22
23
  def declared(passed_params, include_parent_namespaces: true, include_missing: true, evaluate_given: false, stringify: false)
23
24
  raise MethodNotYetAvailable unless before_filter_passed
24
25
 
@@ -9,8 +9,7 @@ module Grape
9
9
  # @param description [String] descriptive string for this endpoint
10
10
  # or namespace
11
11
  # @param options [Hash] other properties you can set to describe the
12
- # endpoint or namespace. Optional. Pass these as keyword arguments;
13
- # passing a positional options Hash is deprecated.
12
+ # endpoint or namespace, as keyword arguments. Optional.
14
13
  # @option options :detail [String] additional detail about this endpoint
15
14
  # @option options :summary [String] summary for this endpoint
16
15
  # @option options :params [Hash] param types and info. normally, you set
@@ -26,35 +26,41 @@ module Grape
26
26
  # admin: current_user.admin?
27
27
  # end
28
28
  def present(*args, root: nil, with: nil, **options)
29
- key, object = args.count == 2 && args.first.is_a?(Symbol) ? args : [nil, args.first]
29
+ # No [nil, object] Array to destructure for the one-argument form.
30
+ object = args.first
31
+ key, object = args if args.count == 2 && object.is_a?(Symbol)
30
32
  entity_class = with || entity_class_for_obj(object)
31
33
  representation = entity_class ? entity_representation_for(entity_class, object, options) : object
32
34
  representation = { root => representation } if root
33
35
 
34
- if key
35
- representation = body&.merge(key => representation) || { key => representation }
36
- elsif entity_class.present? && body
37
- raise ArgumentError, "representation of type `#{representation.class}` cannot be merged" unless representation.respond_to?(:merge)
38
-
39
- representation = body.merge(representation)
40
- end
41
-
42
- body representation
36
+ body merged_with_body(representation, key, entity_class)
43
37
  end
44
38
 
45
39
  # Attempt to locate the Entity class for a given object, if not given
46
- # explicitly. This is done by looking for the presence of Klass::Entity,
47
- # where Klass is the class of the `object` parameter, or one of its
48
- # ancestors. Object is excluded from the search: top-level constants
49
- # live on it, so a global ::Entity class is not a representer.
40
+ # explicitly: a +represent+ registration or Klass::Entity for the object's
41
+ # class or an ancestor, then the same for its element class. Object is
42
+ # excluded: a global ::Entity class is not a representer.
50
43
  # @param object [Object] the object to locate the Entity class for
51
44
  # @return [Class] the located Entity class, or nil if none is found
52
45
  def entity_class_for_obj(object)
53
- entity_for_class(object.class) || entity_for_class(element_class(object))
46
+ # Read once for both lookups: it walks every scope up to the API's root,
47
+ # and any object that answers #first (a Hash among them) needs both.
48
+ representations = inheritable_setting.representations
49
+ entity_for_class(object.class, representations) || entity_for_class(element_class(object), representations)
54
50
  end
55
51
 
56
52
  private
57
53
 
54
+ # +representation+ merged into the body an earlier +present+ set: under
55
+ # +key+ when there is one, and whole when it came from an entity.
56
+ def merged_with_body(representation, key, entity_class)
57
+ return body&.merge(key => representation) || { key => representation } if key
58
+ return representation unless entity_class && body
59
+ raise ArgumentError, "representation of type `#{representation.class}` cannot be merged" unless representation.respond_to?(:merge)
60
+
61
+ body.merge(representation)
62
+ end
63
+
58
64
  # The class standing in for a collection or wrapper: ActiveRecord::Relation
59
65
  # and the like expose #klass, anything else falls back to the class of its
60
66
  # first element.
@@ -76,16 +82,22 @@ module Grape
76
82
  end
77
83
 
78
84
  # @param klass [Class, nil] the class to look an entity up for.
85
+ # @param representations [Hash, nil] the +represent+ registrations in scope.
79
86
  # @return [Class, nil] the registered or conventionally named entity.
80
- def entity_for_class(klass)
87
+ def entity_for_class(klass, representations)
81
88
  return if klass.nil?
82
89
 
83
- representations = inheritable_setting.representations
84
90
  if representations
85
91
  potential = klass.ancestors.detect { |ancestor| representations.key?(ancestor) }
86
92
  return representations[potential] if potential && representations[potential]
87
93
  end
88
94
 
95
+ conventional_entity_for(klass) if klass.const_defined?(:Entity)
96
+ end
97
+
98
+ # The +Entity+ constant of the nearest of +klass+'s ancestors defining
99
+ # one, when it can represent.
100
+ def conventional_entity_for(klass)
89
101
  owner = klass.ancestors.detect { |ancestor| ancestor != Object && ancestor.const_defined?(:Entity, false) }
90
102
  return unless owner
91
103
 
@@ -93,13 +105,17 @@ module Grape
93
105
  entity if entity.respond_to?(:represent)
94
106
  end
95
107
 
108
+ # The embeds go straight into the call: collected into a Hash of their
109
+ # own and splatted, they cost every +present+ a second Hash.
110
+ #
96
111
  # @param entity_class [Class] the entity class to use for representation.
97
112
  # @param object [Object] the object to represent.
98
113
  # @param options [Hash] additional options forwarded to the entity's `represent` call.
99
114
  # @return the representation of the given object as done through the given entity_class.
100
115
  def entity_representation_for(entity_class, object, options)
101
- embeds = env.key?(Grape::Env::API_VERSION) ? { env:, version: env[Grape::Env::API_VERSION] } : { env: }
102
- entity_class.represent(object, **embeds, **options)
116
+ return entity_class.represent(object, env:, **options) unless env.key?(Grape::Env::API_VERSION)
117
+
118
+ entity_class.represent(object, env:, version: env[Grape::Env::API_VERSION], **options)
103
119
  end
104
120
  end
105
121
  end
@@ -4,10 +4,10 @@ module Grape
4
4
  module DSL
5
5
  module Headers
6
6
  # This method has four responsibilities:
7
- # 1. Set a specifc header value by key
8
- # 2. Retrieve a specifc header value by key
7
+ # 1. Set a specific header value by key
8
+ # 2. Retrieve a specific header value by key
9
9
  # 3. Retrieve all headers that have been set
10
- # 4. Delete a specifc header key-value pair
10
+ # 4. Delete a specific header key-value pair
11
11
  def header(key = nil, val = nil)
12
12
  if key
13
13
  val ? header[key] = val : header.delete(key)
@@ -6,8 +6,8 @@ module Grape
6
6
  # Add helper methods that will be accessible from any
7
7
  # endpoint within this namespace (and child namespaces).
8
8
  #
9
- # When called without a block, all known helpers within this scope
10
- # are included.
9
+ # When called with neither modules nor a block, returns a module
10
+ # including all known helpers within this scope.
11
11
  #
12
12
  # @param [Array] new_modules optional array of modules to include
13
13
  # @param [Block] block optional block of methods to include
@@ -77,11 +77,8 @@ module Grape
77
77
 
78
78
  # When +Grape.config.warn_on_helper_overrides+ is enabled, emit a
79
79
  # warning to +$stderr+ for any helper method that masks an instance
80
- # method on +Grape::Endpoint+. Helpers are mixed into the endpoint's
81
- # singleton class and therefore take precedence over +Endpoint+
82
- # instance methods — usually intentional, but a common source of
83
- # surprise when the framework gains a method that already collides
84
- # with an existing helper name.
80
+ # method on +Grape::Endpoint+. Helpers are included into a subclass of
81
+ # the endpoint (see Endpoint#build_prototype), so they take precedence.
85
82
  def warn_on_endpoint_overrides(mod)
86
83
  overridden = mod.instance_methods(false).select { |m| Grape::Endpoint.method_defined?(m) }
87
84
  return if overridden.empty?
@@ -113,7 +110,7 @@ module Grape
113
110
  protected
114
111
 
115
112
  def process_named_params
116
- return if @named_params.blank?
113
+ return unless @named_params
117
114
 
118
115
  api.inheritable_setting.add_named_params(@named_params)
119
116
  end
@@ -9,7 +9,15 @@ module Grape
9
9
  # Backward compatibility: alias exception class to previous location
10
10
  MethodNotYetAvailable = Declared::MethodNotYetAvailable
11
11
 
12
- # The API version as specified in the URL.
12
+ # The body #redirect renders when the caller gives none, by status.
13
+ REDIRECT_MESSAGES = {
14
+ 301 => 'This resource has been moved permanently to %<url>s.',
15
+ 302 => 'This resource has been moved temporarily to %<url>s.',
16
+ 303 => 'An alternate resource is located at %<url>s.'
17
+ }.freeze
18
+ private_constant :REDIRECT_MESSAGES
19
+
20
+ # The API version negotiated for this request.
13
21
  def version
14
22
  env[Grape::Env::API_VERSION]
15
23
  end
@@ -23,13 +31,14 @@ module Grape
23
31
  #
24
32
  # @param message [String] The message to display.
25
33
  # @param status [Integer] The HTTP Status Code. Defaults to default_error_status, 500 if not set.
26
- # @param additional_headers [Hash] Addtional headers for the response.
34
+ # @param additional_headers [Hash] Additional headers for the response.
27
35
  # @param backtrace [Array<String>] The backtrace of the exception that caused the error.
28
36
  # @param original_exception [Exception] The original exception that caused the error.
37
+ # @raise [Grape::Exceptions::Halt] always, carrying the response.
29
38
  def error!(message, status = nil, additional_headers = nil, backtrace = nil, original_exception = nil)
30
39
  resolved_status = self.status(status || inheritable_setting.default_error_status)
31
40
  headers = additional_headers.present? ? header.merge(additional_headers) : header
32
- throw :error, Grape::Exceptions::ErrorResponse.new(
41
+ raise Grape::Exceptions::Halt.new(
33
42
  message:, status: resolved_status, headers:, backtrace:, original_exception:
34
43
  )
35
44
  end
@@ -40,26 +49,27 @@ module Grape
40
49
  # @param permanent [Boolean] default false.
41
50
  # @param body default a short message including the URL.
42
51
  def redirect(url, permanent: false, body: nil)
43
- body_message = body
44
- if permanent
45
- status 301
46
- body_message ||= "This resource has been moved permanently to #{url}."
47
- elsif http_version == 'HTTP/1.1' && !request.get?
48
- status 303
49
- body_message ||= "An alternate resource is located at #{url}."
50
- else
51
- status 302
52
- body_message ||= "This resource has been moved temporarily to #{url}."
53
- end
52
+ code = if permanent
53
+ 301
54
+ elsif http_version == 'HTTP/1.1' && !request.get?
55
+ 303
56
+ else
57
+ 302
58
+ end
59
+ status code
54
60
  header 'Location', url
55
- content_type 'text/plain'
61
+ # A caller-supplied body keeps the API's format, and the content type
62
+ # that goes with it: it may be structured, and the txt formatter would
63
+ # render a Hash through `to_s`.
64
+ return body(body) if body
65
+
56
66
  # Render the message Grape generated as the plain text it is. Setting
57
67
  # only the header left it to the API's own formatter, which on a JSON
58
68
  # API returned the sentence wrapped in quotes under a text/plain content
59
- # type. A caller-supplied body keeps the API's format: it may be
60
- # structured, and the txt formatter would render a Hash through `to_s`.
61
- api_format :txt unless body
62
- body body_message
69
+ # type.
70
+ content_type 'text/plain'
71
+ api_format :txt
72
+ body format(REDIRECT_MESSAGES.fetch(code), url:)
63
73
  end
64
74
 
65
75
  # Set or retrieve the HTTP status code.
@@ -67,13 +77,9 @@ module Grape
67
77
  # @param status [Integer] The HTTP Status Code to return for this request.
68
78
  def status(status = nil)
69
79
  return @status || default_status if status.nil?
80
+ raise ArgumentError, 'status code must be Integer or Symbol' unless status.is_a?(Symbol) || status.is_a?(Integer)
70
81
 
71
- case status
72
- when Symbol, Integer
73
- @status = Rack::Utils.status_code(status)
74
- else
75
- raise ArgumentError, 'status code must be Integer or Symbol'
76
- end
82
+ @status = Rack::Utils.status_code(status)
77
83
  end
78
84
 
79
85
  # Set response content-type
@@ -121,7 +127,7 @@ module Grape
121
127
  #
122
128
  # @example
123
129
  # get '/file' do
124
- # sendfile FileStreamer.new(...)
130
+ # sendfile '/path/to/file'
125
131
  # end
126
132
  #
127
133
  # GET /file # => "contents of file"
@@ -205,7 +211,7 @@ module Grape
205
211
  def default_status
206
212
  request_method = env[Rack::REQUEST_METHOD]
207
213
  return 201 if request_method == Rack::POST
208
- return 204 if request_method == Rack::DELETE && @body.blank?
214
+ return 204 if request_method == Rack::DELETE && Grape::Util::Scrub.blank?(@body)
209
215
 
210
216
  200
211
217
  end
@@ -3,14 +3,20 @@
3
3
  module Grape
4
4
  module DSL
5
5
  module Logger
6
- # Set or retrive the configured logger. If none was configured, this
6
+ # Set or retrieve the configured logger. If none was configured, this
7
7
  # method will create a new one, logging to stdout.
8
+ #
9
+ # A logger belongs to the API it is set on, and to the APIs mounted
10
+ # under it that set none of their own. It used to be kept in the
11
+ # process-wide settings, so setting one on any API replaced it on every
12
+ # other. Only the default is still shared: an API that sets a level on
13
+ # it at class level reads it off its base instance, and has to find
14
+ # that level again on each instance it is mounted as.
8
15
  # @param logger [Object] the new logger to use
9
16
  def logger(logger = nil)
10
- global_settings = inheritable_setting.global
11
- return global_settings[:logger] = logger if logger
17
+ return top_level_setting.logger = logger if logger
12
18
 
13
- global_settings[:logger] || global_settings[:logger] = ::Logger.new($stdout)
19
+ top_level_setting.logger || (inheritable_setting.global[:logger] ||= ::Logger.new($stdout))
14
20
  end
15
21
  end
16
22
  end
@@ -10,24 +10,19 @@ module Grape
10
10
  # @param middleware_class [Class] The class of the middleware you'd like
11
11
  # to inject.
12
12
  def use(middleware_class, *args, &block)
13
- arr = [:use, middleware_class, *args]
14
- arr << block if block
15
-
16
- inheritable_setting.add_middleware(arr)
13
+ inheritable_setting.add_middleware([:use, middleware_class, *args, block])
17
14
  end
18
15
 
19
16
  %i[insert insert_before insert_after].each do |method_name|
20
17
  define_method method_name do |*args, &block|
21
- arr = [method_name, *args]
22
- arr << block if block
23
-
24
- inheritable_setting.add_middleware(arr)
18
+ inheritable_setting.add_middleware([method_name, *args, block])
25
19
  end
26
20
  end
27
21
 
28
22
  # Retrieve an array of the middleware classes
29
23
  # and arguments that are currently applied to the
30
- # application.
24
+ # application, each ending in the block it was
25
+ # given, +nil+ when none.
31
26
  def middleware
32
27
  inheritable_setting.middleware
33
28
  end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grape
4
+ module DSL
5
+ module Mounting
6
+ def given(conditional_option, &)
7
+ return unless conditional_option
8
+
9
+ mounted(&)
10
+ end
11
+
12
+ def mounted(&block)
13
+ evaluate_as_instance_with_configuration(block, lazy: true)
14
+ end
15
+
16
+ def mount(mounts, opts = {})
17
+ mount_opts = opts
18
+ if opts[:refresh_already_mounted]
19
+ Grape.deprecator.warn('`refresh_already_mounted` is not a `mount` option and will be ignored in a future release.')
20
+ drop_endpoints_mounted_for(mounts)
21
+ # Dropped before the recursion below re-enters with the same options,
22
+ # so a Grape API does not warn once per mount and once per instance.
23
+ mount_opts = opts.except(:refresh_already_mounted)
24
+ end
25
+
26
+ normalize_mounts(mounts).each_pair do |app, path|
27
+ if app.respond_to?(:mount_instance)
28
+ mount({ app.mount_instance(configuration: mount_opts[:with] || {}) => path }, mount_opts)
29
+ next
30
+ end
31
+
32
+ endpoints << Grape::Endpoint.new(
33
+ mounted_setting(app, path),
34
+ http_methods: '*',
35
+ path:,
36
+ app:,
37
+ anchor: false,
38
+ api: self
39
+ )
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ # The settings the endpoint mounting +app+ at +path+ is built with. Past
46
+ # #mount's +mount_instance+ branch, a Grape app is an already instantiated
47
+ # Grape::API::Instance (vs. a bare Rack app): it inherits this API's
48
+ # settings and is built from its own.
49
+ def mounted_setting(app, path)
50
+ return inheritable_setting unless app.is_a?(Grape::Mountable)
51
+
52
+ app.top_level_setting.add_mount_path(Grape::Util::PathNormalizer.call(path))
53
+ app.inherit_settings(inheritable_setting)
54
+ app.change!
55
+ change!
56
+ app.top_level_setting
57
+ end
58
+
59
+ # Re-mount +mounts+, replacing any endpoint already mounted for the same
60
+ # base API rather than adding a second one. Called by
61
+ # {Grape::API.refresh_mount_step} when a class-level method runs after the
62
+ # API was mounted.
63
+ #
64
+ # A bare Rack app is put back where it was mounted. It is routed as an
65
+ # ANY route, and those are tried in the order they were declared, so
66
+ # re-mounting it last let a catch-all declared after it answer first. A
67
+ # mounted Grape API still moves to the end: its routes answer for
68
+ # specific methods, where moving them would change which of two
69
+ # routes on the same path answers.
70
+ def refresh_mounted_api(mounts, opts = {})
71
+ namespace = inheritable_setting.namespace_path
72
+ normalize_mounts(mounts).each_pair do |app, path|
73
+ position = endpoints.index { |endpoint| mounted_at?(endpoint, app, Array(path), namespace) } unless app.respond_to?(:mount_instance)
74
+ drop_endpoints_mounted_for(app => path)
75
+ mount({ app => path }, opts)
76
+ endpoints.insert(position, endpoints.pop) if position
77
+ end
78
+ end
79
+
80
+ # A mounted Grape API is stored as the throwaway instance +mount+ built
81
+ # for it, never as the class that was written, so endpoints are matched on
82
+ # the base API both of them share -- and on where it was mounted, the
83
+ # path and the current namespace: the same API mounted at two paths, or
84
+ # in two namespaces, is two mounts, and refreshing one must leave the
85
+ # other in place.
86
+ def drop_endpoints_mounted_for(mounts)
87
+ namespace = inheritable_setting.namespace_path
88
+ normalize_mounts(mounts).each_pair do |app, path|
89
+ paths = Array(path)
90
+ endpoints.delete_if do |endpoint|
91
+ dropped = mounted_at?(endpoint, app, paths, namespace)
92
+ release_mount_instance(endpoint.mounted_app) if dropped
93
+ dropped
94
+ end
95
+ end
96
+ end
97
+
98
+ # A Grape API keeps every instance +mount+ builds for it, so that steps
99
+ # recorded later replay onto each (see Grape::API::Instance.base=). Once
100
+ # the endpoint serving one is dropped, nothing serves it: its API lets go
101
+ # of it, and of the instances built for the APIs mounted inside it.
102
+ # Otherwise every refresh left another copy of each mounted API behind.
103
+ def release_mount_instance(mounted)
104
+ return unless mounted.respond_to?(:base) && mounted.base.respond_to?(:instances)
105
+
106
+ mounted.base.instances.delete(mounted)
107
+ mounted.endpoints.each { |endpoint| release_mount_instance(endpoint.mounted_app) }
108
+ end
109
+
110
+ # A bare app mounts at the root. The test is +Hash+ rather than
111
+ # +respond_to?(:each_pair)+ because a Struct or an OpenStruct answers that
112
+ # too, and neither can express an app => path mapping — their keys are
113
+ # member names. Reading one as a mapping would silently mount nonsense
114
+ # instead of mounting the app itself.
115
+ def normalize_mounts(mounts)
116
+ mounts.is_a?(Hash) ? mounts : { mounts => '/' }
117
+ end
118
+
119
+ def mounted_at?(endpoint, app, paths, namespace)
120
+ same_mounted_app?(endpoint.mounted_app, app) && endpoint.path == paths && endpoint.namespace == namespace
121
+ end
122
+
123
+ # Two mounts refer to the same app when they share the same base Grape
124
+ # API. +mount+ turns every mounted Grape API into a throwaway
125
+ # +mount_instance+ (a fresh +Class.new+ per mount), so object identity
126
+ # never holds across mounts; comparing the base is the real signal.
127
+ # Plain Rack apps have no base and are mounted as-is, so they fall back
128
+ # to object identity.
129
+ def same_mounted_app?(mounted, app)
130
+ return mounted.base.equal?(app.base) if mounted.respond_to?(:base) && app.respond_to?(:base)
131
+
132
+ mounted.equal?(app)
133
+ end
134
+
135
+ def evaluate_as_instance_with_configuration(block, lazy: false)
136
+ lazy_block = Grape::Util::Lazy::Block.new do |configuration|
137
+ value_for_configuration = configuration
138
+ self.configuration = value_for_configuration.evaluate if value_for_configuration.is_a?(Grape::Util::Lazy::Base)
139
+ response = instance_eval(&block)
140
+ self.configuration = value_for_configuration
141
+ response
142
+ end
143
+ return lazy_block if @base && base_instance? && lazy
144
+
145
+ lazy_block.evaluate_from(configuration)
146
+ end
147
+ end
148
+ end
149
+ end