grape-oas 1.5.1 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eb455b84bab348e43f6f512e31a9c306ec7deb76ddb5e6267231a3c7495615e7
4
- data.tar.gz: 7096893822180fa7da1976ebbc7b20eaea31108aa8829595d2f4b4764e7350da
3
+ metadata.gz: 2d771c74ede3e601d65419900f26aa0ad36d785a649e09184a9e6755f4749fe7
4
+ data.tar.gz: 55bef62d1b83ccbd6f57cc469ec6ff81aea4b00c38fef892542fa5bc2f0588b0
5
5
  SHA512:
6
- metadata.gz: 3cd2cb8c1c03fc8c14c427eb247de87d2a7cc782be02e41bc435e1f400a6eb0d1972dd1ea51e6c8e11f6854b4c4d646692fb80191db9d68b65e4e271604c3772
7
- data.tar.gz: ba055b0ad8596358370103f503f009c80a6dd8395983077b6a1573aab4798c9bd5b5fa1d872c6210a57892151ef70d2a19538d3629823f076322e56274e8be4e
6
+ metadata.gz: 9d3f8cc1eeee662d60fdd0574970ed350416dcc9770df2536b7d7d7f017292bcc4338f47cb203cd2d486189d32d614b56d12c74efa83f4c52eabfb69df171b0d
7
+ data.tar.gz: 86d67570e32b7aafa0290963801c2b0f673fce6d1dde815b93cc2fd5ee8794c898541987382b7910b217e62b58807fdd5f9a6e940d427f82371a23b9ecbfaf84
data/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ 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
+
8
26
  ## [1.5.1] - 2026-09-16
9
27
 
10
28
  ### Fixed
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
@@ -61,6 +61,12 @@ module GrapeOAS
61
61
 
62
62
  hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
63
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
64
70
  end
65
71
  end
66
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
@@ -58,7 +58,7 @@ module GrapeOAS
58
58
  return [] unless data.is_a?(Hash)
59
59
 
60
60
  specs = %i[http_codes failure success].flat_map do |key|
61
- parse_value(data[key], route)
61
+ parse_value(data[key], route, success: key == :success)
62
62
  end
63
63
  default_value = data[:default_response] || data[:default]
64
64
  return specs unless default_value
@@ -112,10 +112,10 @@ module GrapeOAS
112
112
  one_of.map { |entry| normalize_hash_keys(entry) }
113
113
  end
114
114
 
115
- def parse_value(value, route)
115
+ def parse_value(value, route, success:)
116
116
  return [] unless value
117
117
 
118
- entries_for(value).map { |entry| normalize_entry(entry, route) }
118
+ entries_for(value).map { |entry| normalize_entry(entry, route, success: success) }
119
119
  end
120
120
 
121
121
  def entries_for(value)
@@ -149,6 +149,9 @@ module GrapeOAS
149
149
  end
150
150
 
151
151
  def append_entity_spec(specs, entity_value, route)
152
+ explicit_code = (entity_value[:code] if entity_value.is_a?(Hash)) || route.options[:default_status]
153
+ return specs if !explicit_code && specs.any? { |spec| spec[:code] == "2XX" || (200..299).cover?(spec[:code].to_i) }
154
+
152
155
  entity_spec = build_entity_spec(entity_value, route)
153
156
  return specs if specs.any? { |spec| spec[:code].to_i == entity_spec[:code].to_i }
154
157
 
@@ -159,7 +162,7 @@ module GrapeOAS
159
162
  if entity_value.is_a?(Hash)
160
163
  # Hash format: { code: 201, model: Entity, message: "Created" }
161
164
  {
162
- code: entity_value[:code] || 200,
165
+ code: entity_value[:code] || default_response_code(route, success: true),
163
166
  message: entity_value[:message],
164
167
  entity: extract_entity(entity_value, nil),
165
168
  headers: entity_value[:headers],
@@ -172,7 +175,7 @@ module GrapeOAS
172
175
  else
173
176
  # Plain entity class
174
177
  {
175
- code: 200,
178
+ code: default_response_code(route, success: true),
176
179
  message: nil,
177
180
  entity: entity_value,
178
181
  headers: nil,
@@ -184,15 +187,15 @@ module GrapeOAS
184
187
  end
185
188
  end
186
189
 
187
- def normalize_entry(entry, route)
190
+ def normalize_entry(entry, route, success:)
188
191
  spec = case entry
189
192
  when Hash
190
- normalize_hash_entry(entry, route)
193
+ normalize_hash_entry(entry, route, success: success)
191
194
  when Array
192
195
  normalize_array_entry(entry, route)
193
196
  when Class, Module
194
197
  # Plain entity class (e.g., success TestEntity)
195
- normalize_entity_entry(entry, route)
198
+ normalize_entity_entry(entry, route, success: success)
196
199
  else
197
200
  normalize_plain_entry(entry, route)
198
201
  end
@@ -200,10 +203,10 @@ module GrapeOAS
200
203
  spec
201
204
  end
202
205
 
203
- def normalize_hash_entry(entry, route)
204
- default_code = (route.options[:default_status] || 200).to_s
206
+ def normalize_hash_entry(entry, route, success:)
207
+ default_code = default_response_code(route, success: success)
205
208
  {
206
- code: extract_status_code(entry, default_code),
209
+ code: extract_status_code(entry, default_code.to_s),
207
210
  message: extract_description(entry),
208
211
  entity: extract_entity(entry, route.options[:entity]),
209
212
  headers: entry[:headers],
@@ -228,10 +231,10 @@ module GrapeOAS
228
231
  }
229
232
  end
230
233
 
231
- def normalize_entity_entry(entity_class, route)
234
+ def normalize_entity_entry(entity_class, route, success:)
232
235
  # Plain entity class (e.g., success TestEntity)
233
236
  {
234
- code: route.options[:default_status] || 200,
237
+ code: default_response_code(route, success: success),
235
238
  message: nil,
236
239
  entity: entity_class,
237
240
  headers: nil,
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GrapeOAS
4
+ module Exporter
5
+ module Concerns
6
+ module EnumNormalizer
7
+ private
8
+
9
+ def normalize_enum(enum_values, type, preserve_nil: false)
10
+ return nil unless enum_values.is_a?(Array)
11
+
12
+ base_type = base_type_for(type)
13
+ normalized = enum_values.each_with_object([]) do |value, values|
14
+ next if value.nil?
15
+
16
+ normalized_value = case base_type
17
+ when Constants::SchemaTypes::INTEGER then value.to_i if value.respond_to?(:to_i)
18
+ when Constants::SchemaTypes::NUMBER then value.to_f if value.respond_to?(:to_f)
19
+ else value
20
+ end
21
+ values << normalized_value unless normalized_value.nil?
22
+ end
23
+
24
+ normalized.uniq!
25
+ normalized << nil if preserve_nil && enum_values.include?(nil)
26
+ normalized unless normalized.empty?
27
+ end
28
+
29
+ def base_type_for(type)
30
+ type.is_a?(Array) ? (type - [Constants::SchemaTypes::NULL]).first : type
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -7,13 +7,30 @@ module GrapeOAS
7
7
  # Handles building schema indexes from operations and collecting nested schema references.
8
8
  module SchemaIndexer
9
9
  def find_schema_by_canonical_name(canonical_name)
10
- @ref_schemas[canonical_name] || schema_index[canonical_name]
10
+ schema = @ref_schemas[canonical_name] || schema_index[canonical_name]
11
+ ensure_unique_schema_ref_name!(canonical_name) if schema
12
+ schema
11
13
  end
12
14
 
13
15
  def schema_index
14
16
  @schema_index ||= build_schema_index
15
17
  end
16
18
 
19
+ def ensure_unique_schema_ref_name!(canonical_name)
20
+ ref_name = GrapeOAS.schema_ref_name.call(canonical_name)
21
+ canonical_names_by_ref = @canonical_names_by_ref ||= {}
22
+ existing_name = canonical_names_by_ref[ref_name]
23
+
24
+ if existing_name && existing_name != canonical_name
25
+ raise ArgumentError,
26
+ "Schema reference name #{ref_name.inspect} is generated for both " \
27
+ "#{existing_name.inspect} and #{canonical_name.inspect}; " \
28
+ "configure GrapeOAS.schema_ref_name to generate unique names"
29
+ end
30
+
31
+ canonical_names_by_ref[ref_name] = canonical_name
32
+ end
33
+
17
34
  def build_schema_index
18
35
  index = {}
19
36
  # Index schemas from operations
@@ -4,6 +4,8 @@ module GrapeOAS
4
4
  module Exporter
5
5
  module OAS2
6
6
  class Parameter
7
+ include Concerns::EnumNormalizer
8
+
7
9
  PRIMITIVE_MAPPINGS = {
8
10
  Constants::SchemaTypes::INTEGER => { type: Constants::SchemaTypes::INTEGER },
9
11
  "long" => { type: Constants::SchemaTypes::INTEGER, format: "int64" },
@@ -134,26 +136,6 @@ module GrapeOAS
134
136
  result["default"] = schema.default if schema.respond_to?(:default) && !schema.default.nil?
135
137
  end
136
138
 
137
- def normalize_enum(enum_vals, type)
138
- return nil unless enum_vals.is_a?(Array)
139
-
140
- result = enum_vals.each_with_object([]) do |v, acc|
141
- next if v.nil?
142
-
143
- coerced_v = case type
144
- when Constants::SchemaTypes::INTEGER then v.to_i if v.respond_to?(:to_i)
145
- when Constants::SchemaTypes::NUMBER then v.to_f if v.respond_to?(:to_f)
146
- else v
147
- end
148
- acc << coerced_v unless coerced_v.nil?
149
- end
150
-
151
- result.uniq!
152
- return nil if result.empty?
153
-
154
- result
155
- end
156
-
157
139
  def apply_collection_format(result, param, schema)
158
140
  return unless schema.type == Constants::SchemaTypes::ARRAY
159
141
 
@@ -4,6 +4,8 @@ module GrapeOAS
4
4
  module Exporter
5
5
  module OAS2
6
6
  class Schema
7
+ include Concerns::EnumNormalizer
8
+
7
9
  def initialize(schema, ref_tracker = nil, nullable_strategy: nil, composition_extensions: false)
8
10
  @schema = schema
9
11
  @ref_tracker = ref_tracker
@@ -38,7 +40,7 @@ module GrapeOAS
38
40
  "description" => @schema.description&.to_s,
39
41
  "properties" => build_properties(@schema.properties)
40
42
  }
41
- schema_hash["enum"] = normalize_enum(@schema.enum, @schema.type, nullable: nullable?) if @schema.enum
43
+ schema_hash["enum"] = normalize_enum(@schema.enum, @schema.type, preserve_nil: enum_allows_null?(@schema)) if @schema.enum
42
44
  if @schema.items
43
45
  schema_hash["items"] = build_schema_or_ref(@schema.items, include_metadata: false)
44
46
  if !schema_hash["description"] && @schema.items.respond_to?(:description) && @schema.items.description
@@ -99,6 +101,10 @@ module GrapeOAS
99
101
  schema_nullable?(@schema)
100
102
  end
101
103
 
104
+ def enum_allows_null?(schema)
105
+ @nullable_strategy == Constants::NullableStrategy::EXTENSION && schema_nullable?(schema)
106
+ end
107
+
102
108
  # OAS2 keeps a first-alternative fallback for tools that ignore extensions.
103
109
  def build_first_of_schema(composition_type)
104
110
  schemas = composition_type == :any_of ? @schema.any_of : @schema.one_of
@@ -130,7 +136,7 @@ module GrapeOAS
130
136
  result["format"] = @schema.format if @schema.format
131
137
  result["description"] = @schema.description.to_s if @schema.description
132
138
  result["default"] = @schema.default unless @schema.default.nil?
133
- result["enum"] = normalize_enum(@schema.enum, @schema.type, nullable: nullable?) if @schema.enum
139
+ result["enum"] = normalize_enum(@schema.enum, @schema.type, preserve_nil: enum_allows_null?(@schema)) if @schema.enum
134
140
  result.delete("enum") if result.key?("enum") && result["enum"].nil?
135
141
  apply_constraints(result)
136
142
  apply_extensions(result)
@@ -156,7 +162,7 @@ module GrapeOAS
156
162
  result["x-nullable"] = true if @nullable_strategy == Constants::NullableStrategy::EXTENSION && schema_nullable?(schema)
157
163
  result["description"] = schema.description.to_s if schema.description
158
164
  result["default"] = schema.default unless schema.default.nil?
159
- result["enum"] = normalize_enum(schema.enum, schema.type, nullable: schema_nullable?(schema)) if schema.enum
165
+ result["enum"] = normalize_enum(schema.enum, schema.type, preserve_nil: enum_allows_null?(schema)) if schema.enum
160
166
  result.delete("enum") if result.key?("enum") && result["enum"].nil?
161
167
  apply_constraints(result, schema)
162
168
  result.merge!(schema.extensions) if schema.extensions
@@ -175,29 +181,6 @@ module GrapeOAS
175
181
  built
176
182
  end
177
183
  end
178
-
179
- def normalize_enum(enum_vals, type, nullable: false)
180
- return nil unless enum_vals.is_a?(Array)
181
-
182
- has_nil = nullable && enum_vals.include?(nil)
183
-
184
- result = enum_vals.each_with_object([]) do |v, acc|
185
- next if v.nil?
186
-
187
- coerced_v = case type
188
- when Constants::SchemaTypes::INTEGER then v.to_i if v.respond_to?(:to_i)
189
- when Constants::SchemaTypes::NUMBER then v.to_f if v.respond_to?(:to_f)
190
- else v
191
- end
192
- acc << coerced_v unless coerced_v.nil?
193
- end
194
-
195
- result.uniq!
196
- result.push(nil) if has_nil
197
- return nil if result.empty?
198
-
199
- result
200
- end
201
184
  end
202
185
  end
203
186
  end
@@ -4,6 +4,8 @@ module GrapeOAS
4
4
  module Exporter
5
5
  module OAS3
6
6
  class Schema
7
+ include Concerns::EnumNormalizer
8
+
7
9
  def initialize(schema, ref_tracker = nil, nullable_strategy: Constants::NullableStrategy::KEYWORD)
8
10
  @schema = schema
9
11
  @ref_tracker = ref_tracker
@@ -41,7 +43,11 @@ module GrapeOAS
41
43
 
42
44
  end
43
45
  schema_hash["required"] = @schema.required if @schema.required && !@schema.required.empty?
44
- schema_hash["enum"] = normalize_enum(@schema.enum, schema_hash["type"], nullable: nullable?) if @schema.enum
46
+ if @schema.enum
47
+ schema_hash["enum"] = normalize_enum(
48
+ @schema.enum, schema_hash["type"], preserve_nil: enum_allows_null?(@schema, schema_hash["type"]),
49
+ )
50
+ end
45
51
  schema_hash["default"] = @schema.default unless @schema.default.nil?
46
52
  schema_hash
47
53
  end
@@ -88,7 +94,11 @@ module GrapeOAS
88
94
  if schema_nullable?(schema) && @nullable_strategy == Constants::NullableStrategy::TYPE_ARRAY
89
95
  enum_type = Array(enum_type) | ["null"]
90
96
  end
91
- result["enum"] = normalize_enum(schema.enum, enum_type, nullable: schema_nullable?(schema)) if schema.enum
97
+ if schema.enum
98
+ result["enum"] = normalize_enum(
99
+ schema.enum, enum_type, preserve_nil: enum_allows_null?(schema, enum_type, null_union: true),
100
+ )
101
+ end
92
102
  sanitize_enum_against_type(result, type: schema.type)
93
103
  apply_all_constraints(result, schema)
94
104
  result.merge!(schema.extensions) if schema.extensions
@@ -110,12 +120,6 @@ module GrapeOAS
110
120
 
111
121
  private
112
122
 
113
- # Returns the primary non-null type from a type value.
114
- # Assumes at most one non-null type in the array (e.g. ["integer", "null"]).
115
- def base_type_for(type)
116
- type.is_a?(Array) ? (type - ["null"]).first : type
117
- end
118
-
119
123
  # Rewrites `type: file` (or `type: ["file", "null"]`) to the
120
124
  # version-appropriate representation. Type detection lives here;
121
125
  # version-specific attributes are set by `apply_file_schema_attributes!`.
@@ -170,7 +174,11 @@ module GrapeOAS
170
174
  result["format"] = @schema.format if @schema.format
171
175
  result["description"] = @schema.description.to_s if @schema.description
172
176
  result["default"] = @schema.default unless @schema.default.nil?
173
- result["enum"] = normalize_enum(@schema.enum, result["type"], nullable: nullable?) if @schema.enum
177
+ if @schema.enum
178
+ result["enum"] = normalize_enum(
179
+ @schema.enum, result["type"], preserve_nil: enum_allows_null?(@schema, result["type"], null_union: true),
180
+ )
181
+ end
174
182
  sanitize_enum_against_type(result)
175
183
  apply_all_constraints(result)
176
184
  apply_composition_extensions(result)
@@ -305,38 +313,17 @@ module GrapeOAS
305
313
  { "type" => Constants::SchemaTypes::OBJECT, "nullable" => true, "enum" => [nil] }
306
314
  end
307
315
 
308
- def normalize_enum(enum_vals, type, nullable: false)
309
- return nil unless enum_vals.is_a?(Array)
310
-
311
- nullable = (nullable || (type.is_a?(Array) && type.include?(Constants::SchemaTypes::NULL))) &&
312
- enum_null_supported?(type)
313
- resolved_type = base_type_for(type)
314
-
315
- has_nil = nullable && enum_vals.include?(nil)
316
+ def enum_allows_null?(schema, type, null_union: false)
317
+ has_null_type = type.is_a?(Array) && type.include?(Constants::SchemaTypes::NULL)
316
318
 
317
- result = enum_vals.each_with_object([]) do |v, acc|
318
- next if v.nil?
319
-
320
- coerced_v = case resolved_type
321
- when Constants::SchemaTypes::INTEGER then v.to_i if v.respond_to?(:to_i)
322
- when Constants::SchemaTypes::NUMBER then v.to_f if v.respond_to?(:to_f)
323
- else v
324
- end
325
- acc << coerced_v unless coerced_v.nil?
319
+ case @nullable_strategy
320
+ when Constants::NullableStrategy::KEYWORD
321
+ has_null_type || (schema_nullable?(schema) && (!type.nil? || null_union))
322
+ when Constants::NullableStrategy::TYPE_ARRAY
323
+ has_null_type || (schema_nullable?(schema) && null_union)
324
+ else
325
+ false
326
326
  end
327
-
328
- result.uniq!
329
- result.push(nil) if has_nil
330
- return nil if result.empty?
331
-
332
- result
333
- end
334
-
335
- def enum_null_supported?(type)
336
- return true if @nullable_strategy == Constants::NullableStrategy::KEYWORD
337
-
338
- @nullable_strategy == Constants::NullableStrategy::TYPE_ARRAY &&
339
- type.is_a?(Array) && type.include?(Constants::SchemaTypes::NULL)
340
327
  end
341
328
 
342
329
  def apply_numeric_constraints(hash, schema = @schema)
@@ -118,7 +118,8 @@ module GrapeOAS
118
118
 
119
119
  # Determines whether a property should be marked required.
120
120
  # Explicit doc[:required] takes precedence; conditional exposures
121
- # default to false; unconditional exposures default to true.
121
+ # default to false; unconditional exposures fall back to
122
+ # GrapeOAS.entity_exposure_required_default (default `true`).
122
123
  #
123
124
  # @param doc [Hash] normalized documentation hash
124
125
  # @param exposure the entity exposure
@@ -127,7 +128,7 @@ module GrapeOAS
127
128
  return doc[:required] unless doc[:required].nil?
128
129
  return false if conditional?(exposure)
129
130
 
130
- true
131
+ GrapeOAS.entity_exposure_required_default
131
132
  end
132
133
 
133
134
  private
@@ -167,10 +168,28 @@ module GrapeOAS
167
168
  return prop_schema unless is_array
168
169
 
169
170
  array_schema = ApiModel::Schema.new(type: Constants::SchemaTypes::ARRAY, items: prop_schema)
171
+ if PropertyExtractor.extract_nullable(doc)
172
+ array_schema.nullable = true
173
+ if bare_single_ref_allof?(prop_schema)
174
+ array_schema.items = prop_schema.all_of.first
175
+ else
176
+ prop_schema.nullable = false
177
+ end
178
+ end
170
179
  array_schema.examples = doc[:example] if array_valued_example?(doc)
171
180
  array_schema
172
181
  end
173
182
 
183
+ def bare_single_ref_allof?(schema)
184
+ return false unless schema.all_of&.one? && schema.all_of.first.canonical_name
185
+
186
+ empty_schema = ApiModel::Schema.new
187
+ # Comparing defaults keeps future schema attributes from being discarded.
188
+ (ApiModel::Schema::VALID_ATTRIBUTES - %i[nullable all_of]).all? do |attribute|
189
+ schema.public_send(attribute) == empty_schema.public_send(attribute)
190
+ end
191
+ end
192
+
174
193
  # Detects block-based nesting exposures (NestingExposure) that should become
175
194
  # inline object schemas. Only triggers when no entity class is via `using:`.
176
195
  def nesting_exposure?(exposure)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GrapeOAS
4
- VERSION = "1.5.1"
4
+ VERSION = "1.6.0"
5
5
  end
data/lib/grape_oas.rb CHANGED
@@ -91,6 +91,25 @@ module GrapeOAS
91
91
 
92
92
  module_function :logger, :logger=
93
93
 
94
+ # @return [Boolean] whether entity exposures without required metadata
95
+ # default to required
96
+ def entity_exposure_required_default
97
+ return true if @entity_exposure_required_default.nil?
98
+
99
+ @entity_exposure_required_default
100
+ end
101
+
102
+ # @param value [true, false, nil] `nil` resets to the default (`true`)
103
+ def entity_exposure_required_default=(value)
104
+ unless value.nil? || value == true || value == false
105
+ raise ArgumentError, "entity_exposure_required_default must be true, false, or nil (got #{value.class})"
106
+ end
107
+
108
+ @entity_exposure_required_default = value
109
+ end
110
+
111
+ module_function :entity_exposure_required_default, :entity_exposure_required_default=
112
+
94
113
  # Returns the global introspector registry.
95
114
  #
96
115
  # The registry manages introspectors that build schemas from various sources
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: grape-oas
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.1
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Subbota
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-16 00:00:00.000000000 Z
11
+ date: 2026-09-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: grape
@@ -86,6 +86,7 @@ files:
86
86
  - lib/grape_oas/exporter.rb
87
87
  - lib/grape_oas/exporter/base/operation.rb
88
88
  - lib/grape_oas/exporter/base/paths.rb
89
+ - lib/grape_oas/exporter/concerns/enum_normalizer.rb
89
90
  - lib/grape_oas/exporter/concerns/schema_indexer.rb
90
91
  - lib/grape_oas/exporter/concerns/tag_builder.rb
91
92
  - lib/grape_oas/exporter/oas2/operation.rb