grape-oas 1.4.0 → 1.5.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +63 -0
  3. data/README.md +27 -10
  4. data/UPGRADING.md +104 -0
  5. data/grape-oas.gemspec +2 -1
  6. data/lib/grape_oas/api_model/api.rb +2 -1
  7. data/lib/grape_oas/api_model/schema.rb +1 -1
  8. data/lib/grape_oas/api_model_builder.rb +3 -2
  9. data/lib/grape_oas/api_model_builders/concerns/content_type_resolver.rb +9 -1
  10. data/lib/grape_oas/api_model_builders/concerns/oas_utilities.rb +13 -0
  11. data/lib/grape_oas/api_model_builders/concerns/route_validations.rb +26 -0
  12. data/lib/grape_oas/api_model_builders/concerns/type_resolver.rb +4 -2
  13. data/lib/grape_oas/api_model_builders/operation.rb +3 -4
  14. data/lib/grape_oas/api_model_builders/path.rb +33 -6
  15. data/lib/grape_oas/api_model_builders/request.rb +22 -29
  16. data/lib/grape_oas/api_model_builders/request_params.rb +121 -44
  17. data/lib/grape_oas/api_model_builders/request_params_support/nested_params_builder.rb +1 -2
  18. data/lib/grape_oas/api_model_builders/request_params_support/param_location_resolver.rb +33 -27
  19. data/lib/grape_oas/api_model_builders/request_params_support/param_schema_builder.rb +8 -27
  20. data/lib/grape_oas/api_model_builders/request_params_support/schema_enhancer.rb +4 -10
  21. data/lib/grape_oas/api_model_builders/response.rb +33 -5
  22. data/lib/grape_oas/api_model_builders/response_parsers/base.rb +11 -0
  23. data/lib/grape_oas/api_model_builders/response_parsers/documentation_responses_parser.rb +5 -2
  24. data/lib/grape_oas/api_model_builders/response_parsers/http_codes_parser.rb +82 -17
  25. data/lib/grape_oas/constants.rb +30 -4
  26. data/lib/grape_oas/documentation_extension.rb +7 -9
  27. data/lib/grape_oas/exporter/concerns/schema_indexer.rb +14 -3
  28. data/lib/grape_oas/exporter/oas2/operation.rb +4 -2
  29. data/lib/grape_oas/exporter/oas2/parameter.rb +125 -22
  30. data/lib/grape_oas/exporter/oas2/paths.rb +1 -0
  31. data/lib/grape_oas/exporter/oas2/response.rb +6 -4
  32. data/lib/grape_oas/exporter/oas2/schema.rb +56 -28
  33. data/lib/grape_oas/exporter/oas2_schema.rb +7 -5
  34. data/lib/grape_oas/exporter/oas3/operation.rb +5 -3
  35. data/lib/grape_oas/exporter/oas3/parameter.rb +4 -2
  36. data/lib/grape_oas/exporter/oas3/paths.rb +1 -0
  37. data/lib/grape_oas/exporter/oas3/request_body.rb +5 -8
  38. data/lib/grape_oas/exporter/oas3/response.rb +6 -9
  39. data/lib/grape_oas/exporter/oas3/schema.rb +232 -84
  40. data/lib/grape_oas/exporter/oas31/schema.rb +12 -2
  41. data/lib/grape_oas/exporter/oas31_schema.rb +1 -1
  42. data/lib/grape_oas/exporter/oas3_schema.rb +3 -2
  43. data/lib/grape_oas/introspectors/entity_introspector.rb +2 -1
  44. data/lib/grape_oas/introspectors/entity_introspector_support/exposure_processor.rb +73 -23
  45. data/lib/grape_oas/introspectors/entity_introspector_support/property_extractor.rb +2 -8
  46. data/lib/grape_oas/introspectors/entity_introspector_support/type_schema_resolver.rb +4 -20
  47. data/lib/grape_oas/range_utils.rb +25 -2
  48. data/lib/grape_oas/type_resolvers/array_resolver.rb +46 -19
  49. data/lib/grape_oas/type_resolvers/base.rb +2 -2
  50. data/lib/grape_oas/type_resolvers/default_resolver.rb +23 -0
  51. data/lib/grape_oas/type_resolvers/dry_type_resolver.rb +1 -1
  52. data/lib/grape_oas/type_resolvers/primitive_resolver.rb +29 -46
  53. data/lib/grape_oas/type_resolvers/registry.rb +31 -15
  54. data/lib/grape_oas/version.rb +1 -1
  55. data/lib/grape_oas.rb +37 -7
  56. metadata +5 -4
  57. data/CONTRIBUTING.md +0 -87
  58. data/RELEASING.md +0 -109
@@ -3,7 +3,13 @@
3
3
  module GrapeOAS
4
4
  module ApiModelBuilders
5
5
  class RequestParams
6
- ROUTE_PARAM_REGEX = /(?<=:)\w+/
6
+ include Concerns::RouteValidations
7
+
8
+ ROUTE_PARAM_REGEX = /(?<=[:*])\w+/
9
+
10
+ def self.path_param_names(path)
11
+ path.sub(/(\(\.[^)]+\))+$/, "").scan(ROUTE_PARAM_REGEX)
12
+ end
7
13
 
8
14
  attr_reader :api, :route, :path_param_name_map
9
15
 
@@ -14,29 +20,43 @@ module GrapeOAS
14
20
  end
15
21
 
16
22
  def build
17
- route_params = route.path.scan(ROUTE_PARAM_REGEX)
18
- all_params = route.options[:params] || {}
23
+ route_params = self.class.path_param_names(route.path)
24
+ all_params = declared_params
19
25
 
20
26
  # Check if we have nested params (bracket notation)
21
27
  has_nested = all_params.keys.any? { |k| k.include?("[") }
22
28
 
23
29
  if has_nested
24
- build_with_nested_params(all_params, route_params)
30
+ body_schema, parameters = build_with_nested_params(all_params, route_params)
25
31
  else
26
- build_flat_params(all_params, route_params)
32
+ body_schema, parameters = build_flat_params(all_params, route_params)
27
33
  end
34
+
35
+ [body_schema, parameters]
28
36
  end
29
37
 
30
38
  private
31
39
 
32
40
  # Builds params when nested structures are detected.
33
41
  def build_with_nested_params(all_params, route_params)
34
- body_schema = nested_params_builder.build(all_params, path_params: route_params)
42
+ body_params = nested_body_params(all_params, route_params)
43
+ body_schema = nested_params_builder.build(body_params, path_params: route_params)
35
44
  non_body_params = extract_non_body_params(all_params, route_params)
36
45
 
37
46
  [body_schema, non_body_params]
38
47
  end
39
48
 
49
+ def nested_body_params(all_params, route_params)
50
+ body_roots = all_params.filter_map do |name, spec|
51
+ next if name.include?("[")
52
+
53
+ location = location_resolver.resolve(name: name, spec: spec, route_params: route_params, route: route)
54
+ name if location == "body"
55
+ end.to_set
56
+
57
+ all_params.select { |name, _spec| body_roots.include?(name.split("[", 2).first) }
58
+ end
59
+
40
60
  # Builds params for flat (non-nested) structures.
41
61
  def build_flat_params(all_params, route_params)
42
62
  body_schema = ApiModel::Schema.new(type: Constants::SchemaTypes::OBJECT)
@@ -66,33 +86,38 @@ module GrapeOAS
66
86
  end
67
87
 
68
88
  # Extracts non-body params (path, query, header) from flat params.
69
- # For non-body HTTP methods (GET, HEAD, DELETE), also includes nested params
70
- # as flat query parameters with bracket notation (e.g., "tax_id[type]"),
71
- # unless request_body is explicitly enabled.
89
+ # Nested params (bracket notation, e.g. "tax_id[type]") are included as
90
+ # flat non-body parameters, taking their parent's resolved location,
91
+ # regardless of HTTP method — a nested Hash explicitly documented
92
+ # `in: "header"` on a write route (POST/PUT/PATCH) still belongs in
93
+ # the header, not the body.
72
94
  def extract_non_body_params(all_params, route_params)
73
95
  params = []
74
- http_method = route.request_method.to_s.downcase
75
- flatten_nested = should_flatten_nested_to_query?(http_method, all_params)
76
96
 
77
97
  all_params.each do |name, spec|
78
98
  # Skip hidden params
79
99
  next if location_resolver.hidden_parameter?(spec)
80
100
 
81
- is_nested = name.include?("[")
82
- is_hash_param = location_resolver.body_param?(spec)
101
+ if name.include?("[")
102
+ root = name.split("[", 2).first
103
+ root_spec = all_params[root] || {}
104
+ next if location_resolver.hidden_parameter?(root_spec)
83
105
 
84
- # For nested bracket params (e.g., "tax_id[type]"), include as query params
85
- # for non-body HTTP methods (unless request_body is explicitly enabled)
86
- if is_nested
87
- next unless flatten_nested
106
+ root_location = location_resolver.resolve(name: root, spec: root_spec, route_params: route_params, route: route)
107
+ next if root_location == "body"
108
+ # A Hash root can itself be a real route capture (e.g. ":filter"),
109
+ # but that doesn't make its bracket children path segments too —
110
+ # only the root itself matches the URL template.
111
+ next if root_location == "path"
88
112
 
89
- params << build_parameter(name, "query", spec[:required] || false, schema_builder.build(spec), spec)
113
+ params << build_parameter(name, root_location, spec[:required] || false, schema_builder.build(spec), spec)
90
114
  next
91
115
  end
92
116
 
93
- # Skip Hash type params (they're handled via nested bracket params above
94
- # or via body schema for POST/PUT/PATCH)
95
- next if is_hash_param
117
+ # Skip Hash type params with nested children of their own (they're
118
+ # handled via the nested bracket branch above or via body schema).
119
+ # A childless Hash falls through to the generic path below.
120
+ next if location_resolver.hash_param?(spec) && all_params.keys.any? { |k| k.start_with?("#{name}[") }
96
121
 
97
122
  location = location_resolver.resolve(
98
123
  name: name,
@@ -109,28 +134,6 @@ module GrapeOAS
109
134
  params
110
135
  end
111
136
 
112
- # Determines whether nested params should be flattened to query params.
113
- # Returns true for GET/HEAD/DELETE unless body is explicitly requested via:
114
- # - route-level `request_body: true` option
115
- # - any parameter with `documentation: { in: 'body' }` or `documentation: { param_type: 'body' }`
116
- def should_flatten_nested_to_query?(http_method, all_params)
117
- return false unless Constants::HttpMethods::BODYLESS_HTTP_METHODS.include?(http_method)
118
-
119
- # If request_body is explicitly enabled at route level, use body schema
120
- return false if route.options.dig(:documentation, :request_body) || route.options[:request_body]
121
-
122
- # If any parameter is explicitly marked as body, use body schema
123
- has_explicit_body_param = all_params.any? do |name, spec|
124
- next false if name.include?("[") # Skip bracket params, check parent Hash params only
125
-
126
- param_type = spec.dig(:documentation, :param_type)&.to_s&.downcase
127
- in_location = spec.dig(:documentation, :in)&.to_s&.downcase
128
- param_type == "body" || in_location == "body"
129
- end
130
-
131
- !has_explicit_body_param
132
- end
133
-
134
137
  def build_parameter(name, location, required, schema, spec)
135
138
  doc = spec[:documentation] || {}
136
139
  style = doc.fetch(:style) { doc["style"] }
@@ -139,7 +142,7 @@ module GrapeOAS
139
142
  ApiModel::Parameter.new(
140
143
  location: location,
141
144
  name: name,
142
- required: required,
145
+ required: location == "path" || required,
143
146
  schema: schema,
144
147
  description: spec[:documentation]&.dig(:desc) || spec[:desc],
145
148
  collection_format: extract_collection_format(spec),
@@ -152,6 +155,80 @@ module GrapeOAS
152
155
  spec.dig(:documentation, :collectionFormat) || spec.dig(:documentation, :collection_format)
153
156
  end
154
157
 
158
+ # Grape 3.x stores documented params in `route.options[:params]`.
159
+ # Grape 4.0 moved them to `Route#params` (grape#2785) and no longer
160
+ # copies the hash into options. Path captures that are not Hash specs
161
+ # (empty-string defaults from the pattern) are dropped.
162
+ def declared_params
163
+ specs = params_from_options || params_from_route
164
+ return {} unless specs.is_a?(Hash)
165
+
166
+ flat = specs.select { |_name, spec| spec.is_a?(Hash) }
167
+ conditional = conditional_param_names
168
+ return flat if conditional.empty?
169
+
170
+ flat.each_with_object({}) do |(name, spec), params|
171
+ params[name] = conditional.include?(name.to_s) ? spec.merge(required: false) : spec
172
+ end
173
+ end
174
+
175
+ # Returns names of params that Grape will only validate conditionally
176
+ # (declared inside a `given` block). Their validators have a
177
+ # `params_scope` with `@dependent_on` set.
178
+ def conditional_param_names
179
+ return Set.new unless route.respond_to?(:app) && route.app.respond_to?(:inheritable_setting)
180
+
181
+ validations = grape_route_validations(route.app.inheritable_setting)
182
+ return Set.new unless validations.is_a?(Array)
183
+
184
+ conditional = Set.new
185
+ unconditional = Set.new
186
+ validations.each do |validator|
187
+ scope, attrs = validator_details(validator)
188
+ next unless scope.respond_to?(:full_name)
189
+
190
+ target = conditional_scope?(scope) ? conditional : unconditional
191
+ Array(attrs).each { |attr| target << scope.full_name(attr) }
192
+ end
193
+ conditional - unconditional
194
+ end
195
+
196
+ # Grape < 3.2 stores validators as hashes; >= 3.2 stores instances.
197
+ def validator_details(validator)
198
+ presence = Grape::Validations::Validators::PresenceValidator
199
+ if validator.is_a?(Hash)
200
+ return unless validator[:validator_class].is_a?(Class) && validator[:validator_class] <= presence
201
+
202
+ [validator[:params_scope], validator[:attributes]]
203
+ elsif validator.is_a?(presence)
204
+ [validator.instance_variable_get(:@scope), validator.attrs]
205
+ end
206
+ end
207
+
208
+ def conditional_scope?(scope)
209
+ while scope
210
+ return true if scope.instance_variable_get(:@dependent_on)&.any?
211
+
212
+ scope = scope.respond_to?(:parent) ? scope.parent : nil
213
+ end
214
+ false
215
+ end
216
+
217
+ def params_from_options
218
+ params = route.options[:params]
219
+ params if params.is_a?(Hash) && params.any?
220
+ end
221
+
222
+ def params_from_route
223
+ return unless route.respond_to?(:params)
224
+ # Grape 3.x Route#params(input = nil) merges path captures; skip it.
225
+ # Grape 4.0 Route#params takes no arguments and is the documentation hash.
226
+ return unless route.method(:params).arity.zero?
227
+
228
+ params = route.params
229
+ params if params.is_a?(Hash) && params.any?
230
+ end
231
+
155
232
  def location_resolver
156
233
  RequestParamsSupport::ParamLocationResolver
157
234
  end
@@ -30,7 +30,6 @@ module GrapeOAS
30
30
 
31
31
  top_level.each do |name, spec|
32
32
  next if path_params.include?(name)
33
- next if ParamLocationResolver.explicit_non_body_param?(spec)
34
33
  next if ParamLocationResolver.hidden_parameter?(spec)
35
34
 
36
35
  child_schema = @schema_builder.build(spec)
@@ -147,7 +146,7 @@ module GrapeOAS
147
146
  schema.additional_properties = doc[:additional_properties] if doc.key?(:additional_properties)
148
147
  schema.unevaluated_properties = doc[:unevaluated_properties] if doc.key?(:unevaluated_properties)
149
148
  schema.format = doc[:format] if doc[:format]
150
- schema.examples = doc[:example] if doc[:example]
149
+ schema.examples = doc[:example] if doc.key?(:example)
151
150
  nullable = SchemaEnhancer.extract_nullable(doc)
152
151
  schema.nullable = (schema.nullable || nullable) if schema.respond_to?(:nullable=)
153
152
  end
@@ -5,6 +5,12 @@ module GrapeOAS
5
5
  module RequestParamsSupport
6
6
  # Resolves the location (path, query, body, header) for a parameter.
7
7
  class ParamLocationResolver
8
+ # Locations `param_type:`/`in:` may explicitly name. An unrecognized
9
+ # value (a typo, or a grape-swagger location this library doesn't
10
+ # resolve to, like `formData`) is treated as unset rather than
11
+ # emitted verbatim, since nothing downstream validates `Parameter#location`.
12
+ VALID_EXPLICIT_LOCATIONS = %w[body query header path cookie].freeze
13
+
8
14
  # Determines the location for a parameter.
9
15
  #
10
16
  # @param name [String] the parameter name
@@ -18,29 +24,14 @@ module GrapeOAS
18
24
  extract_from_spec(spec, route)
19
25
  end
20
26
 
21
- # Checks if a parameter should be in the request body.
22
- # Supports both `param_type: 'body'` and `in: 'body'` for grape-swagger compatibility.
23
- #
24
- # @param spec [Hash] the parameter specification
25
- # @return [Boolean] true if it's a body parameter
26
- def self.body_param?(spec)
27
- param_type = spec.dig(:documentation, :param_type)&.to_s&.downcase
28
- in_location = spec.dig(:documentation, :in)&.to_s&.downcase
29
-
30
- param_type == "body" || in_location == "body" || [Hash, "Hash"].include?(spec[:type])
31
- end
32
-
33
- # Checks if a parameter is explicitly marked as NOT a body param.
34
- # Supports both `param_type` and `in` for grape-swagger compatibility.
27
+ # Checks if a parameter is a Hash type. Hash parents are represented
28
+ # by their children (nested params) or the request body, never
29
+ # themselves as a parameter.
35
30
  #
36
31
  # @param spec [Hash] the parameter specification
37
- # @return [Boolean] true if explicitly non-body
38
- def self.explicit_non_body_param?(spec)
39
- param_type = spec.dig(:documentation, :param_type)&.to_s&.downcase
40
- in_location = spec.dig(:documentation, :in)&.to_s&.downcase
41
- location = param_type || in_location
42
-
43
- location && %w[query header path].include?(location)
32
+ # @return [Boolean] true if the parameter type is Hash
33
+ def self.hash_param?(spec)
34
+ [Hash, "Hash"].include?(spec[:type])
44
35
  end
45
36
 
46
37
  # Checks if a parameter should be hidden from documentation.
@@ -56,6 +47,10 @@ module GrapeOAS
56
47
  hidden
57
48
  end
58
49
 
50
+ def self.route_body_opted_in?(route)
51
+ !!(route.options[:body_name] || route.options.dig(:documentation, :request_body) || route.options[:request_body])
52
+ end
53
+
59
54
  class << self
60
55
  private
61
56
 
@@ -70,24 +65,35 @@ module GrapeOAS
70
65
  # Note: If both `param_type` and `in` are specified, `param_type` takes precedence.
71
66
  # For example, `{ param_type: 'query', in: 'body' }` will be treated as query.
72
67
  #
68
+ # `resolve` already returns "path" for an actual route capture before
69
+ # calling this method, so an explicit `in: "path"` / `param_type: "path"`
70
+ # reaching here is always a mismatch (the name can never appear in the
71
+ # URL template) and is ignored, same as an unrecognized location.
72
+ #
73
73
  # @param spec [Hash] the parameter specification
74
74
  # @param route [Object] the Grape route object
75
75
  # @return [String] the parameter location
76
76
  def extract_from_spec(spec, route)
77
- # If body_name is set on the route, treat non-path params as body by default
78
- param_type = spec.dig(:documentation, :param_type)
79
- in_location = spec.dig(:documentation, :in)
80
- return "body" if route.options[:body_name] && !param_type && !in_location
77
+ location = explicit_location(spec)
78
+ location = nil if location == "path"
79
+ return "body" if route_body_opted_in?(route) && location.nil?
81
80
 
82
81
  # Support both param_type and in for grape-swagger compatibility
83
82
  # param_type takes precedence over in when both are specified
84
- explicit_location = (param_type || in_location)&.to_s&.downcase
85
- return explicit_location if explicit_location
83
+ return location if location
86
84
 
87
85
  # Default: body for write methods (POST/PUT/PATCH), query for read methods (GET/DELETE/HEAD)
88
86
  http_method = route.request_method.to_s.downcase
89
87
  Constants::HttpMethods::BODYLESS_HTTP_METHODS.include?(http_method) ? "query" : "body"
90
88
  end
89
+
90
+ def explicit_location(spec)
91
+ doc = spec[:documentation] || {}
92
+ param_type = doc[:param_type] || doc["param_type"]
93
+ in_location = doc[:in] || doc["in"]
94
+ location = (param_type || in_location)&.to_s&.downcase
95
+ location if VALID_EXPLICIT_LOCATIONS.include?(location)
96
+ end
91
97
  end
92
98
  end
93
99
  end
@@ -36,7 +36,7 @@ module GrapeOAS
36
36
 
37
37
  # is_array: true on a typed array like "[String]" is redundant and would
38
38
  # double-wrap it as Array<Array<...>> via build_primitive_array_schema.
39
- return GrapeOAS.type_resolvers.build_schema(raw_type) if doc[:is_array] && extract_typed_array_member(raw_type)
39
+ return GrapeOAS.type_resolvers.build_schema(raw_type) if doc[:is_array] && TypeResolvers::ArrayResolver.handles?(raw_type)
40
40
  return build_primitive_array_schema(doc_type, raw_type) if doc[:is_array]
41
41
  return build_entity_schema(doc_type) if grape_entity?(doc_type)
42
42
  return build_entity_schema(raw_type) if grape_entity?(raw_type)
@@ -119,19 +119,17 @@ module GrapeOAS
119
119
  type_names = extract_multi_types(type)
120
120
 
121
121
  # OPTIMIZE: [Type, Nil] becomes nullable Type instead of oneOf
122
- if nullable_type_pair?(type_names)
123
- non_nil_type = type_names.find { |t| !nil_type_name?(t) }
122
+ if (nullable_type = Constants.nullable_type(type_names))
124
123
  return ApiModel::Schema.new(
125
- type: resolve_schema_type(non_nil_type),
126
- format: Constants.format_for_type(non_nil_type),
124
+ type: resolve_schema_type(nullable_type),
125
+ format: Constants.format_for_type(nullable_type),
127
126
  nullable: true,
128
127
  )
129
128
  end
130
129
 
131
130
  # General case: build oneOf schema
132
131
  # Filter out nil types - OpenAPI 3.0 uses nullable property instead
133
- has_nil_type = type_names.any? { |t| nil_type_name?(t) }
134
- non_nil_types = type_names.reject { |t| nil_type_name?(t) }
132
+ nil_types, non_nil_types = type_names.partition { |type_name| Constants.nil_type?(type_name) }
135
133
 
136
134
  schemas = non_nil_types.map do |type_name|
137
135
  ApiModel::Schema.new(
@@ -139,26 +137,9 @@ module GrapeOAS
139
137
  format: Constants.format_for_type(type_name),
140
138
  )
141
139
  end
142
- ApiModel::Schema.new(one_of: schemas, nullable: has_nil_type ? true : nil)
143
- end
144
-
145
- # Checks if type_names is a pair of [SomeType, NilType]
146
- def nullable_type_pair?(type_names)
147
- return false unless type_names.size == 2
148
-
149
- type_names.one? { |t| nil_type_name?(t) }
150
- end
151
-
152
- # Checks if the type name represents a nil/null type
153
- def nil_type_name?(type_name)
154
- normalized = type_name.to_s
155
- # Match common nil type patterns:
156
- # - "NilClass" (Ruby's nil type)
157
- # - "Nil" (shorthand)
158
- # - "Foo::Nil", "Types::Nil" (namespaced nil types)
159
- normalized == "NilClass" ||
160
- normalized == "Nil" ||
161
- normalized.end_with?("::Nil")
140
+ schema = ApiModel::Schema.new(one_of: schemas)
141
+ schema.nullable = true if nil_types.any?
142
+ schema
162
143
  end
163
144
 
164
145
  def build_primitive_schema(raw_type, doc)
@@ -13,7 +13,7 @@ module GrapeOAS
13
13
  def self.apply(schema, spec, doc)
14
14
  nullable = extract_nullable(doc)
15
15
 
16
- schema.description ||= doc[:desc]
16
+ schema.description ||= doc[:desc] || spec[:desc]
17
17
  # Preserve existing nullable: true (e.g., from [Type, Nil] optimization)
18
18
  schema.nullable = (schema.nullable || nullable) if schema.respond_to?(:nullable=)
19
19
 
@@ -24,15 +24,9 @@ module GrapeOAS
24
24
  apply_default(schema, spec, doc)
25
25
  end
26
26
 
27
- # Extracts nullable flag from a documentation hash.
28
- #
29
- # @param doc [Hash] the documentation hash
30
- # @return [Boolean] true if nullable
31
- def self.extract_nullable(doc)
32
- doc[:nullable] || (doc[:x].is_a?(Hash) && doc[:x][:nullable]) || false
33
- end
34
-
35
27
  class << self
28
+ include GrapeOAS::ApiModelBuilders::Concerns::OasUtilities
29
+
36
30
  private
37
31
 
38
32
  def apply_additional_properties(schema, doc)
@@ -58,7 +52,7 @@ module GrapeOAS
58
52
 
59
53
  def apply_format_and_example(schema, doc)
60
54
  schema.format = doc[:format] if doc[:format] && schema.respond_to?(:format=)
61
- schema.examples = doc[:example] if doc[:example] && schema.respond_to?(:examples=)
55
+ schema.examples = doc[:example] if doc.key?(:example) && schema.respond_to?(:examples=)
62
56
  end
63
57
 
64
58
  def apply_values(schema, spec)
@@ -48,7 +48,11 @@ module GrapeOAS
48
48
  # Parsers are tried in order of priority
49
49
  def response_specs
50
50
  parser = parsers.find { |p| p.applicable?(route) }
51
- parser ? parser.parse(route) : []
51
+ return [] unless parser
52
+
53
+ parser.parse(route).each do |spec|
54
+ spec[:code] = ResponseParsers::Base.normalize_status_code(spec[:code])
55
+ end
52
56
  end
53
57
 
54
58
  def parsers
@@ -65,6 +69,9 @@ module GrapeOAS
65
69
  # Else if any spec has `one_of:`, build a oneOf response from one_of entries and
66
70
  # any regular specs in the group (this branch only runs when no `as:` entries exist)
67
71
  def build_response_from_group(group_specs)
72
+ first_spec = group_specs.first
73
+ return build_response_without_content(first_spec) if bodyless_status?(first_spec[:code])
74
+
68
75
  has_one_of = group_specs.any? { |s| s[:one_of] && !s[:one_of].empty? }
69
76
  has_as = group_specs.any? { |s| !s[:as].nil? }
70
77
 
@@ -169,14 +176,16 @@ module GrapeOAS
169
176
 
170
177
  # Merges examples from multiple specs
171
178
  def merge_examples(specs)
172
- examples = specs.map { |s| s[:examples] }.compact
179
+ examples = specs.filter_map { |s| s[:examples] }
173
180
  return nil if examples.empty?
174
181
 
175
182
  examples.reduce({}, :merge)
176
183
  end
177
184
 
178
185
  def build_response_from_spec(spec)
186
+ code_str = spec[:code].to_s
179
187
  entity_schema = build_schema(spec[:entity])
188
+ entity_schema = array_schema(entity_schema) if spec[:is_array]
180
189
  schema = wrap_with_root(entity_schema, spec[:entity], is_array: spec[:is_array])
181
190
  media_types = Array(response_content_types).map do |mime|
182
191
  build_media_type(
@@ -189,7 +198,7 @@ module GrapeOAS
189
198
  description = message.is_a?(String) ? message : message&.to_s
190
199
 
191
200
  GrapeOAS::ApiModel::Response.new(
192
- http_status: spec[:code].to_s,
201
+ http_status: code_str,
193
202
  description: description || "Success",
194
203
  media_types: media_types,
195
204
  headers: normalize_headers(spec[:headers]) || headers_from_route,
@@ -198,6 +207,25 @@ module GrapeOAS
198
207
  )
199
208
  end
200
209
 
210
+ def build_response_without_content(spec)
211
+ message = spec[:message]
212
+ description = message.is_a?(String) ? message : message&.to_s
213
+
214
+ GrapeOAS::ApiModel::Response.new(
215
+ http_status: spec[:code].to_s,
216
+ description: description || "Success",
217
+ media_types: [],
218
+ headers: normalize_headers(spec[:headers]) || headers_from_route,
219
+ extensions: spec[:extensions] || extensions_from_route,
220
+ )
221
+ end
222
+
223
+ # RFC 9110 §6.4.1 and §15.3.6: these responses never carry a message body.
224
+ def bodyless_status?(code)
225
+ status = code.to_s.to_i
226
+ status.between?(100, 199) || status == 204 || status == 205 || status == 304
227
+ end
228
+
201
229
  def array_schema(schema)
202
230
  GrapeOAS::ApiModel::Schema.new(
203
231
  type: Constants::SchemaTypes::ARRAY,
@@ -238,7 +266,7 @@ module GrapeOAS
238
266
  # Build schema for response body
239
267
  # Delegates to EntityIntrospector when entity is present
240
268
  def build_schema(entity_class)
241
- return GrapeOAS::ApiModel::Schema.new(type: Constants::SchemaTypes::STRING) unless entity_class
269
+ return GrapeOAS::ApiModel::Schema.new unless entity_class
242
270
 
243
271
  GrapeOAS.introspectors.build_schema(entity_class, stack: [], registry: {})
244
272
  end
@@ -291,7 +319,7 @@ module GrapeOAS
291
319
  end
292
320
 
293
321
  def response_content_types
294
- resolve_content_types
322
+ explicit_media_types(:produces) || resolve_content_types
295
323
  end
296
324
  end
297
325
  end
@@ -29,6 +29,17 @@ module GrapeOAS
29
29
 
30
30
  private
31
31
 
32
+ def normalize_status_code(status)
33
+ return status unless status.is_a?(Symbol)
34
+
35
+ status_name = status.to_s
36
+ return status_name if status_name == "default" || status_name.match?(/\A[1-5](?:\d{2}|XX)\z/)
37
+
38
+ Rack::Utils.status_code(status)
39
+ end
40
+
41
+ module_function :normalize_status_code
42
+
32
43
  # Extract status code from hash, supporting multiple key names
33
44
  def extract_status_code(hash, default_code)
34
45
  hash[:code] || hash[:status] || hash[:http_status] || default_code
@@ -17,10 +17,10 @@ module GrapeOAS
17
17
  doc_resps = route.options.dig(:documentation, :responses)
18
18
  return [] unless doc_resps.is_a?(Hash)
19
19
 
20
- doc_resps.map do |code, doc|
20
+ specs = doc_resps.map do |code, doc|
21
21
  doc = normalize_hash_keys(doc)
22
22
  {
23
- code: code,
23
+ code: normalize_status_code(code),
24
24
  message: extract_description(doc),
25
25
  headers: doc[:headers],
26
26
  entity: extract_entity(doc, route.options[:entity]),
@@ -28,6 +28,9 @@ module GrapeOAS
28
28
  examples: doc[:examples]
29
29
  }
30
30
  end
31
+ return specs if specs.any? { |spec| spec[:code].to_s == HttpCodesParser::DEFAULT_RESPONSE_CODE }
32
+
33
+ specs + HttpCodesParser.new.default_response_specs(route)
31
34
  end
32
35
  end
33
36
  end