grape-oas 1.5.0 → 1.6.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 (26) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/UPGRADING.md +38 -0
  4. data/lib/grape_oas/api_model_builder.rb +5 -4
  5. data/lib/grape_oas/api_model_builders/request.rb +8 -13
  6. data/lib/grape_oas/api_model_builders/request_params.rb +38 -41
  7. data/lib/grape_oas/api_model_builders/request_params_support/nested_params_builder.rb +0 -1
  8. data/lib/grape_oas/api_model_builders/request_params_support/param_location_resolver.rb +33 -27
  9. data/lib/grape_oas/api_model_builders/request_params_support/param_schema_builder.rb +7 -26
  10. data/lib/grape_oas/api_model_builders/request_params_support/schema_enhancer.rb +1 -1
  11. data/lib/grape_oas/api_model_builders/response.rb +5 -1
  12. data/lib/grape_oas/api_model_builders/response_parsers/base.rb +17 -0
  13. data/lib/grape_oas/api_model_builders/response_parsers/default_response_parser.rb +4 -7
  14. data/lib/grape_oas/api_model_builders/response_parsers/documentation_responses_parser.rb +5 -2
  15. data/lib/grape_oas/api_model_builders/response_parsers/http_codes_parser.rb +96 -28
  16. data/lib/grape_oas/constants.rb +14 -0
  17. data/lib/grape_oas/exporter/concerns/enum_normalizer.rb +35 -0
  18. data/lib/grape_oas/exporter/concerns/schema_indexer.rb +18 -1
  19. data/lib/grape_oas/exporter/oas2/parameter.rb +55 -30
  20. data/lib/grape_oas/exporter/oas2/schema.rb +9 -26
  21. data/lib/grape_oas/exporter/oas3/schema.rb +26 -39
  22. data/lib/grape_oas/introspectors/entity_introspector_support/exposure_processor.rb +40 -6
  23. data/lib/grape_oas/type_resolvers/array_resolver.rb +7 -14
  24. data/lib/grape_oas/version.rb +1 -1
  25. data/lib/grape_oas.rb +19 -0
  26. metadata +3 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e76945cff9198556752b881ff62da8c288a18e3f6447a36b7668d516ed575f4f
4
- data.tar.gz: 8f36610d66d2d5935004972fdc063e74316b2a576be030ce7d8a0a903fa43f79
3
+ metadata.gz: 2d771c74ede3e601d65419900f26aa0ad36d785a649e09184a9e6755f4749fe7
4
+ data.tar.gz: 55bef62d1b83ccbd6f57cc469ec6ff81aea4b00c38fef892542fa5bc2f0588b0
5
5
  SHA512:
6
- metadata.gz: df261c7d71e8c228c096484cda70c821e4cdd58f1aad04b31bf402513dd414eff69512a38d9c6ef131de418fd19403c0ddadfc4ae7fe0bd8e12078ffc66e285a
7
- data.tar.gz: 233f3e233f053f12f7b9fb2eb4635c8969233b4d8848b61048bf273512cffed54ce9499f473b90b56875c0d429f4d12a46f74148689fa122002a7e049fc1f383
6
+ metadata.gz: 9d3f8cc1eeee662d60fdd0574970ed350416dcc9770df2536b7d7d7f017292bcc4338f47cb203cd2d486189d32d614b56d12c74efa83f4c52eabfb69df171b0d
7
+ data.tar.gz: 86d67570e32b7aafa0290963801c2b0f673fce6d1dde815b93cc2fd5ee8794c898541987382b7910b217e62b58807fdd5f9a6e940d427f82371a23b9ecbfaf84
data/CHANGELOG.md CHANGED
@@ -5,6 +5,44 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.6.0] - 2026-09-21
9
+
10
+ ### Added
11
+
12
+ - [#89](https://github.com/numbata/grape-oas/pull/89): Add `GrapeOAS.entity_exposure_required_default` to make entity exposures without explicit required metadata optional in generated schemas - [@abeljim8am](https://github.com/abeljim8am).
13
+
14
+ ### Fixed
15
+
16
+ - [#158](https://github.com/numbata/grape-oas/pull/158): Emit bare item references for nullable arrays of entities instead of redundant `allOf` wrappers - [@numbata](https://github.com/numbata).
17
+ - [#72](https://github.com/numbata/grape-oas/pull/72): Infer `201` for POST success responses without an explicit status; honor entity `default_status:` for all methods, omitting content for bodyless statuses - [@olivier-thatch](https://github.com/olivier-thatch).
18
+ - [#154](https://github.com/numbata/grape-oas/pull/154): Keep nullable enum members aligned with the emitted null representation across OAS versions - [@numbata](https://github.com/numbata).
19
+ - [#153](https://github.com/numbata/grape-oas/pull/153): Reject colliding schema reference names instead of silently overwriting definitions - [@numbata](https://github.com/numbata).
20
+ - [#106](https://github.com/numbata/grape-oas/pull/106): Apply `nullable` to the array, not its items, on `is_array: true` entity exposures - [@olivier-thatch](https://github.com/olivier-thatch).
21
+
22
+ ### Changed
23
+
24
+ - [#155](https://github.com/numbata/grape-oas/pull/155): Fail generation when explicit `models:` entries cannot be resolved or introspected instead of silently omitting them - [@numbata](https://github.com/numbata).
25
+
26
+ ## [1.5.1] - 2026-09-16
27
+
28
+ ### Fixed
29
+
30
+ - [#110](https://github.com/numbata/grape-oas/pull/110): Honor Grape `desc` `default` / `default_response` as OAS `responses.default` - [@numbata](https://github.com/numbata).
31
+ - [#136](https://github.com/numbata/grape-oas/pull/136): Preserve top-level Grape `desc:` metadata on request-body properties - [@numbata](https://github.com/numbata).
32
+ - [#140](https://github.com/numbata/grape-oas/pull/140): Preserve nullable entity scalars documented with `types:` - [@numbata](https://github.com/numbata).
33
+ - [#142](https://github.com/numbata/grape-oas/pull/142): Normalize symbolic response status keys to numeric HTTP codes - [@numbata](https://github.com/numbata).
34
+ - [#145](https://github.com/numbata/grape-oas/pull/145): Preserve explicitly body-located GET, HEAD, and DELETE parameters - [@numbata](https://github.com/numbata).
35
+ - [#146](https://github.com/numbata/grape-oas/pull/146): Preserve declared path metadata inside nested body parameter groups and always mark path parameters as required, per OAS, even when their Grape declaration is optional - [@numbata](https://github.com/numbata).
36
+ - [#149](https://github.com/numbata/grape-oas/pull/149): Preserve explicitly query/header/path-located nested parameters on POST, PUT, and PATCH routes; reject unrecognized `param_type:`/`in:` values and `path` locations without a matching route capture instead of emitting invalid output; drop OAS 2.0-invalid `cookie` parameters with a warning instead of emitting them - [@numbata](https://github.com/numbata).
37
+ - [#152](https://github.com/numbata/grape-oas/pull/152): Emit only spec-valid OAS 2.0 non-body parameters and warn when unsupported schemas are dropped - [@numbata](https://github.com/numbata).
38
+
39
+ ### Changed
40
+
41
+ - [#145](https://github.com/numbata/grape-oas/pull/145): Apply route-level body opt-in consistently to flat parameters and `body_name` contracts - [@numbata](https://github.com/numbata).
42
+
43
+ - [#108](https://github.com/numbata/grape-oas/pull/108): Bump actions/checkout from 6 to 7 - [@dependabot[bot]](https://github.com/dependabot[bot]).
44
+ - [#137](https://github.com/numbata/grape-oas/pull/137): Test against Grape 4.0 in CI - [@numbata](https://github.com/numbata).
45
+
8
46
  ## [1.5.0] - 2026-09-07
9
47
 
10
48
  ### Added
data/UPGRADING.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # Upgrading grape-oas
2
2
 
3
+ ### Upgrading to >= 1.6.0
4
+
5
+ #### Schema reference-name collisions stop generation
6
+
7
+ Distinct schemas that map to the same OAS reference name now raise
8
+ `ArgumentError` instead of silently overwriting one schema. The error identifies
9
+ both canonical names and the shared reference name.
10
+
11
+ Rename one of the schemas or configure a mapping that produces unique names for
12
+ your application. For example, dots distinguish Ruby namespaces from underscores
13
+ while remaining valid in OAS reference names:
14
+
15
+ ```ruby
16
+ GrapeOAS.schema_ref_name = ->(name) { name.gsub("::", ".") }
17
+ ```
18
+
19
+ #### Invalid explicit models stop generation
20
+
21
+ Entries passed through `models:` that cannot be resolved or introspected now
22
+ stop generation instead of silently disappearing from the generated document.
23
+ Exceptions from custom introspectors propagate with their original type and
24
+ backtrace.
25
+
26
+ Check model names, remove unsupported entries, and fix errors raised by custom
27
+ introspectors before generating the document again.
28
+
29
+ #### POST success responses use consistent defaults
30
+
31
+ POST `success:` declarations without an explicit response code or
32
+ `default_status:` now infer status `201`. Entity response paths that previously
33
+ hardcoded `200` now use the same precedence. Undocumented POST responses already
34
+ inferred `201`.
35
+
36
+ If a POST endpoint returns `200`, set `code: 200` in the response declaration or
37
+ `default_status: 200` on the route. Explicit response codes take precedence over
38
+ `default_status:`. Entity responses now honor `default_status:` for every HTTP
39
+ method; bodyless statuses such as `204` omit response content.
40
+
3
41
  ### Upgrading to >= 1.5.0
4
42
 
5
43
  When upgrading from 1.4.0, regenerate your OpenAPI documents and review the diff before regenerating clients.
@@ -54,11 +54,12 @@ module GrapeOAS
54
54
  def build_registered_schemas(models)
55
55
  return [] unless models
56
56
 
57
- Array(models).filter_map do |model|
57
+ Array(models).map do |model|
58
58
  model = model.constantize if model.is_a?(String)
59
- GrapeOAS.introspectors.build_schema(model, stack: [], registry: {})
60
- rescue StandardError
61
- nil
59
+ schema = GrapeOAS.introspectors.build_schema(model, stack: [], registry: {})
60
+ raise ArgumentError, "No introspector can build a schema for #{model.inspect}" unless schema
61
+
62
+ schema
62
63
  end
63
64
  end
64
65
  end
@@ -17,9 +17,11 @@ module GrapeOAS
17
17
  end
18
18
 
19
19
  def build
20
- body_schema, route_params = GrapeOAS::ApiModelBuilders::RequestParams
21
- .new(api: api, route: route, path_param_name_map: path_param_name_map)
22
- .build
20
+ body_schema, route_params = RequestParams.new(
21
+ api: api,
22
+ route: route,
23
+ path_param_name_map: path_param_name_map,
24
+ ).build
23
25
 
24
26
  contract_schema = build_contract_schema
25
27
 
@@ -39,15 +41,6 @@ module GrapeOAS
39
41
  private
40
42
 
41
43
  def append_request_body(body_schema)
42
- # OAS spec says GET/HEAD/DELETE "MAY ignore" request bodies
43
- # Skip by default unless explicitly allowed via documentation option
44
- http_method = operation.http_method.to_s.downcase
45
- if Constants::HttpMethods::BODYLESS_HTTP_METHODS.include?(http_method)
46
- allow_body = route.options.dig(:documentation, :request_body) ||
47
- route.options[:request_body]
48
- return unless allow_body
49
- end
50
-
51
44
  media_ext = media_type_extensions(Constants::MimeTypes::JSON)
52
45
 
53
46
  # Set canonical_name if not already set (e.g., DryIntrospector may have set it for polymorphism)
@@ -235,7 +228,9 @@ module GrapeOAS
235
228
  http_method = operation.http_method.to_s.downcase
236
229
  return false unless Constants::HttpMethods::BODYLESS_HTTP_METHODS.include?(http_method)
237
230
 
238
- !(route.options.dig(:documentation, :request_body) || route.options[:request_body])
231
+ # Parameter-level body annotations apply to Grape params only; contracts
232
+ # retain route-level body opt-in semantics.
233
+ !RequestParamsSupport::ParamLocationResolver.route_body_opted_in?(route)
239
234
  end
240
235
 
241
236
  def build_query_parameter(name, schema, required, doc = {})
@@ -27,22 +27,36 @@ module GrapeOAS
27
27
  has_nested = all_params.keys.any? { |k| k.include?("[") }
28
28
 
29
29
  if has_nested
30
- build_with_nested_params(all_params, route_params)
30
+ body_schema, parameters = build_with_nested_params(all_params, route_params)
31
31
  else
32
- build_flat_params(all_params, route_params)
32
+ body_schema, parameters = build_flat_params(all_params, route_params)
33
33
  end
34
+
35
+ [body_schema, parameters]
34
36
  end
35
37
 
36
38
  private
37
39
 
38
40
  # Builds params when nested structures are detected.
39
41
  def build_with_nested_params(all_params, route_params)
40
- 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)
41
44
  non_body_params = extract_non_body_params(all_params, route_params)
42
45
 
43
46
  [body_schema, non_body_params]
44
47
  end
45
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
+
46
60
  # Builds params for flat (non-nested) structures.
47
61
  def build_flat_params(all_params, route_params)
48
62
  body_schema = ApiModel::Schema.new(type: Constants::SchemaTypes::OBJECT)
@@ -72,33 +86,38 @@ module GrapeOAS
72
86
  end
73
87
 
74
88
  # Extracts non-body params (path, query, header) from flat params.
75
- # For non-body HTTP methods (GET, HEAD, DELETE), also includes nested params
76
- # as flat query parameters with bracket notation (e.g., "tax_id[type]"),
77
- # 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.
78
94
  def extract_non_body_params(all_params, route_params)
79
95
  params = []
80
- http_method = route.request_method.to_s.downcase
81
- flatten_nested = should_flatten_nested_to_query?(http_method, all_params)
82
96
 
83
97
  all_params.each do |name, spec|
84
98
  # Skip hidden params
85
99
  next if location_resolver.hidden_parameter?(spec)
86
100
 
87
- is_nested = name.include?("[")
88
- 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)
89
105
 
90
- # For nested bracket params (e.g., "tax_id[type]"), include as query params
91
- # for non-body HTTP methods (unless request_body is explicitly enabled)
92
- if is_nested
93
- 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"
94
112
 
95
- 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)
96
114
  next
97
115
  end
98
116
 
99
- # Skip Hash type params (they're handled via nested bracket params above
100
- # or via body schema for POST/PUT/PATCH)
101
- 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}[") }
102
121
 
103
122
  location = location_resolver.resolve(
104
123
  name: name,
@@ -115,28 +134,6 @@ module GrapeOAS
115
134
  params
116
135
  end
117
136
 
118
- # Determines whether nested params should be flattened to query params.
119
- # Returns true for GET/HEAD/DELETE unless body is explicitly requested via:
120
- # - route-level `request_body: true` option
121
- # - any parameter with `documentation: { in: 'body' }` or `documentation: { param_type: 'body' }`
122
- def should_flatten_nested_to_query?(http_method, all_params)
123
- return false unless Constants::HttpMethods::BODYLESS_HTTP_METHODS.include?(http_method)
124
-
125
- # If request_body is explicitly enabled at route level, use body schema
126
- return false if route.options.dig(:documentation, :request_body) || route.options[:request_body]
127
-
128
- # If any parameter is explicitly marked as body, use body schema
129
- has_explicit_body_param = all_params.any? do |name, spec|
130
- next false if name.include?("[") # Skip bracket params, check parent Hash params only
131
-
132
- param_type = spec.dig(:documentation, :param_type)&.to_s&.downcase
133
- in_location = spec.dig(:documentation, :in)&.to_s&.downcase
134
- param_type == "body" || in_location == "body"
135
- end
136
-
137
- !has_explicit_body_param
138
- end
139
-
140
137
  def build_parameter(name, location, required, schema, spec)
141
138
  doc = spec[:documentation] || {}
142
139
  style = doc.fetch(:style) { doc["style"] }
@@ -145,7 +142,7 @@ module GrapeOAS
145
142
  ApiModel::Parameter.new(
146
143
  location: location,
147
144
  name: name,
148
- required: required,
145
+ required: location == "path" || required,
149
146
  schema: schema,
150
147
  description: spec[:documentation]&.dig(:desc) || spec[:desc],
151
148
  collection_format: extract_collection_format(spec),
@@ -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)
@@ -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
@@ -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
 
@@ -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
@@ -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
@@ -50,6 +61,12 @@ module GrapeOAS
50
61
 
51
62
  hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
52
63
  end
64
+
65
+ def default_response_code(route, success:)
66
+ return route.options[:default_status] if route.options[:default_status]
67
+
68
+ success && route.request_method.to_s.upcase == "POST" ? 201 : 200
69
+ end
53
70
  end
54
71
  end
55
72
  end
@@ -3,8 +3,9 @@
3
3
  module GrapeOAS
4
4
  module ApiModelBuilders
5
5
  module ResponseParsers
6
- # Parser that creates a default 200 response when no responses are defined
7
- # This is the fallback parser used when no other parsers are applicable
6
+ # Parser that creates a default success response when no responses are defined.
7
+ # Status code defaults to 201 for POST routes and 200 for all other methods.
8
+ # This is the fallback parser used when no other parsers are applicable.
8
9
  class DefaultResponseParser
9
10
  include Base
10
11
 
@@ -14,12 +15,8 @@ module GrapeOAS
14
15
  end
15
16
 
16
17
  def parse(route)
17
- inferred = route.options[:default_status]
18
- inferred ||= route.request_method.to_s.upcase == "POST" ? 201 : 200
19
- default_code = inferred.to_s
20
-
21
18
  [{
22
- code: default_code,
19
+ code: default_response_code(route, success: true).to_s,
23
20
  message: "Success",
24
21
  entity: route.options[:entity],
25
22
  headers: nil
@@ -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