grape-oas 1.4.0 → 1.5.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -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 +14 -16
  16. data/lib/grape_oas/api_model_builders/request_params.rb +83 -3
  17. data/lib/grape_oas/api_model_builders/request_params_support/nested_params_builder.rb +1 -1
  18. data/lib/grape_oas/api_model_builders/request_params_support/param_schema_builder.rb +1 -1
  19. data/lib/grape_oas/api_model_builders/request_params_support/schema_enhancer.rb +3 -9
  20. data/lib/grape_oas/api_model_builders/response.rb +28 -4
  21. data/lib/grape_oas/constants.rb +16 -4
  22. data/lib/grape_oas/documentation_extension.rb +7 -9
  23. data/lib/grape_oas/exporter/concerns/schema_indexer.rb +14 -3
  24. data/lib/grape_oas/exporter/oas2/operation.rb +4 -2
  25. data/lib/grape_oas/exporter/oas2/parameter.rb +73 -13
  26. data/lib/grape_oas/exporter/oas2/paths.rb +1 -0
  27. data/lib/grape_oas/exporter/oas2/response.rb +6 -4
  28. data/lib/grape_oas/exporter/oas2/schema.rb +56 -28
  29. data/lib/grape_oas/exporter/oas2_schema.rb +7 -5
  30. data/lib/grape_oas/exporter/oas3/operation.rb +5 -3
  31. data/lib/grape_oas/exporter/oas3/parameter.rb +4 -2
  32. data/lib/grape_oas/exporter/oas3/paths.rb +1 -0
  33. data/lib/grape_oas/exporter/oas3/request_body.rb +5 -8
  34. data/lib/grape_oas/exporter/oas3/response.rb +6 -9
  35. data/lib/grape_oas/exporter/oas3/schema.rb +232 -84
  36. data/lib/grape_oas/exporter/oas31/schema.rb +12 -2
  37. data/lib/grape_oas/exporter/oas31_schema.rb +1 -1
  38. data/lib/grape_oas/exporter/oas3_schema.rb +3 -2
  39. data/lib/grape_oas/introspectors/entity_introspector.rb +2 -1
  40. data/lib/grape_oas/introspectors/entity_introspector_support/exposure_processor.rb +55 -20
  41. data/lib/grape_oas/introspectors/entity_introspector_support/property_extractor.rb +2 -8
  42. data/lib/grape_oas/introspectors/entity_introspector_support/type_schema_resolver.rb +4 -20
  43. data/lib/grape_oas/range_utils.rb +25 -2
  44. data/lib/grape_oas/type_resolvers/array_resolver.rb +53 -19
  45. data/lib/grape_oas/type_resolvers/base.rb +2 -2
  46. data/lib/grape_oas/type_resolvers/default_resolver.rb +23 -0
  47. data/lib/grape_oas/type_resolvers/dry_type_resolver.rb +1 -1
  48. data/lib/grape_oas/type_resolvers/primitive_resolver.rb +29 -46
  49. data/lib/grape_oas/type_resolvers/registry.rb +31 -15
  50. data/lib/grape_oas/version.rb +1 -1
  51. data/lib/grape_oas.rb +37 -7
  52. metadata +5 -4
  53. data/CONTRIBUTING.md +0 -87
  54. data/RELEASING.md +0 -109
@@ -21,6 +21,7 @@ module GrapeOAS
21
21
  sanitize_enum_against_type(schema_hash)
22
22
  apply_extensions_and_extra_properties(schema_hash)
23
23
  apply_all_constraints(schema_hash)
24
+ normalize_file_type!(schema_hash)
24
25
  schema_hash.compact
25
26
  end
26
27
 
@@ -37,29 +38,19 @@ module GrapeOAS
37
38
  if !schema_hash["description"] && @schema.items.respond_to?(:description) && @schema.items.description
38
39
  schema_hash["description"] = @schema.items.description.to_s
39
40
  end
40
- if @schema.items.respond_to?(:canonical_name) && @schema.items.canonical_name &&
41
- @schema.items.respond_to?(:nullable) && @schema.items.nullable
42
- case @nullable_strategy
43
- when Constants::NullableStrategy::KEYWORD
44
- schema_hash["nullable"] = true
45
- when Constants::NullableStrategy::EXTENSION
46
- schema_hash["x-nullable"] = true
47
- when Constants::NullableStrategy::TYPE_ARRAY
48
- schema_hash["type"] = (Array(schema_hash["type"]) | ["null"])
49
- end
50
- end
41
+
51
42
  end
52
43
  schema_hash["required"] = @schema.required if @schema.required && !@schema.required.empty?
53
- schema_hash["enum"] = normalize_enum(@schema.enum, schema_hash["type"]) if @schema.enum
44
+ schema_hash["enum"] = normalize_enum(@schema.enum, schema_hash["type"], nullable: nullable?) if @schema.enum
54
45
  schema_hash["default"] = @schema.default unless @schema.default.nil?
55
46
  schema_hash
56
47
  end
57
48
 
58
49
  def apply_examples(schema_hash)
59
- return unless @schema.examples
50
+ return if @schema.examples.nil?
60
51
 
61
- examples = Array(@schema.examples).map { |ex| coerce_example(ex, schema_hash["type"]) }
62
- schema_hash["example"] = examples.first
52
+ type = schema_hash["type"]
53
+ schema_hash["example"] = coerce_example(@schema.examples, type)
63
54
  end
64
55
 
65
56
  def apply_extensions_and_extra_properties(schema_hash)
@@ -79,8 +70,72 @@ module GrapeOAS
79
70
  apply_array_constraints(schema_hash, schema)
80
71
  end
81
72
 
73
+ def build_schema_or_ref(schema, include_metadata: true)
74
+ if schema.respond_to?(:canonical_name) && schema.canonical_name
75
+ @ref_tracker << schema.canonical_name if @ref_tracker
76
+ ref_name = GrapeOAS.schema_ref_name.call(schema.canonical_name)
77
+ ref_hash = { "$ref" => "#/components/schemas/#{ref_name}" }
78
+ unless include_metadata
79
+ return ref_hash unless schema_nullable?(schema)
80
+
81
+ return { "allOf" => [ref_hash] }.tap { |hash| apply_nullable_to_ref(hash, schema) }
82
+ end
83
+
84
+ result = {}
85
+ result["description"] = schema.description.to_s if schema.description
86
+ result["default"] = schema.default unless schema.default.nil?
87
+ enum_type = schema.type
88
+ if schema_nullable?(schema) && @nullable_strategy == Constants::NullableStrategy::TYPE_ARRAY
89
+ enum_type = Array(enum_type) | ["null"]
90
+ end
91
+ result["enum"] = normalize_enum(schema.enum, enum_type, nullable: schema_nullable?(schema)) if schema.enum
92
+ sanitize_enum_against_type(result, type: schema.type)
93
+ apply_all_constraints(result, schema)
94
+ result.merge!(schema.extensions) if schema.extensions
95
+ if result.empty? && !schema_nullable?(schema)
96
+ ref_hash
97
+ else
98
+ result["allOf"] = [ref_hash]
99
+ apply_nullable_to_ref(result, schema)
100
+ result
101
+ end
102
+ else
103
+ # self.class preserves the OAS version subclass (e.g. OAS31::Schema)
104
+ # so nested schemas get version-correct normalization.
105
+ built = self.class.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy).build
106
+ strip_items_metadata(built) unless include_metadata
107
+ built
108
+ end
109
+ end
110
+
82
111
  private
83
112
 
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
+ # Rewrites `type: file` (or `type: ["file", "null"]`) to the
120
+ # version-appropriate representation. Type detection lives here;
121
+ # version-specific attributes are set by `apply_file_schema_attributes!`.
122
+ def normalize_file_type!(hash)
123
+ type = hash["type"]
124
+ if type == Constants::SchemaTypes::FILE
125
+ hash["type"] = Constants::SchemaTypes::STRING
126
+ apply_file_schema_attributes!(hash)
127
+ elsif type.is_a?(Array) && type.include?(Constants::SchemaTypes::FILE)
128
+ hash["type"] = type.map { |t| t == Constants::SchemaTypes::FILE ? Constants::SchemaTypes::STRING : t }
129
+ apply_file_schema_attributes!(hash)
130
+ end
131
+ end
132
+
133
+ # OAS 3.0: files are `type: string, format: binary`.
134
+ # OAS 3.1 overrides this with content-* keywords.
135
+ def apply_file_schema_attributes!(hash)
136
+ hash["format"] = "binary"
137
+ end
138
+
84
139
  # Build allOf schema for inheritance
85
140
  def build_all_of_schema
86
141
  items = @schema.all_of.map { |item| build_schema_or_ref(item) }
@@ -115,10 +170,22 @@ module GrapeOAS
115
170
  result["format"] = @schema.format if @schema.format
116
171
  result["description"] = @schema.description.to_s if @schema.description
117
172
  result["default"] = @schema.default unless @schema.default.nil?
118
- result["enum"] = normalize_enum(@schema.enum, result["type"]) if @schema.enum
173
+ result["enum"] = normalize_enum(@schema.enum, result["type"], nullable: nullable?) if @schema.enum
119
174
  sanitize_enum_against_type(result)
120
175
  apply_all_constraints(result)
121
- result.merge!(@schema.extensions) if @schema.extensions
176
+ apply_composition_extensions(result)
177
+ normalize_file_type!(result)
178
+ result
179
+ end
180
+
181
+ def apply_composition_extensions(result)
182
+ return unless @schema.extensions
183
+
184
+ extensions = @schema.extensions.reject do |key, _value|
185
+ (key == "x-anyOf" && result.key?("anyOf")) ||
186
+ (key == "x-oneOf" && result.key?("oneOf"))
187
+ end
188
+ result.merge!(extensions)
122
189
  end
123
190
 
124
191
  # Build OAS3 discriminator object
@@ -137,8 +204,12 @@ module GrapeOAS
137
204
  end
138
205
  end
139
206
 
207
+ def schema_nullable?(schema)
208
+ schema.respond_to?(:nullable) && !!schema.nullable
209
+ end
210
+
140
211
  def nullable?
141
- @schema.respond_to?(:nullable) && @schema.nullable
212
+ schema_nullable?(@schema)
142
213
  end
143
214
 
144
215
  def nullable_type
@@ -151,14 +222,40 @@ module GrapeOAS
151
222
  def apply_nullable(schema_hash)
152
223
  return unless nullable?
153
224
 
225
+ composition_key = %w[allOf oneOf anyOf].find { |k| schema_hash.key?(k) }
154
226
  case @nullable_strategy
155
227
  when Constants::NullableStrategy::KEYWORD
156
- schema_hash["nullable"] = true
228
+ if composition_key
229
+ apply_keyword_null_union(schema_hash, composition_key)
230
+ elsif schema_hash["type"]
231
+ schema_hash["nullable"] = true
232
+ end
157
233
  when Constants::NullableStrategy::EXTENSION
158
234
  schema_hash["x-nullable"] = true
235
+ when Constants::NullableStrategy::TYPE_ARRAY
236
+ if composition_key
237
+ apply_null_union(schema_hash, composition_key, { "type" => "null" })
238
+ else
239
+ schema_hash["type"] = (Array(schema_hash["type"]) | ["null"])
240
+ end
159
241
  end
160
242
  end
161
243
 
244
+ # Nullable must accompany a type, and every composition constraint must
245
+ # also admit null. The enum keeps the extra branch from allowing objects.
246
+ def apply_keyword_null_union(hash, key)
247
+ hash["nullable"] = true if hash["type"]
248
+ apply_null_union(hash, key, keyword_null_only_branch)
249
+ end
250
+
251
+ def apply_null_union(hash, key, null_branch)
252
+ raise ArgumentError, "#{key} must be an Array of schemas" unless hash[key].is_a?(Array)
253
+
254
+ branch = { key => hash.delete(key) }
255
+ branch["discriminator"] = hash.delete("discriminator") if hash.key?("discriminator")
256
+ hash["anyOf"] = [branch, null_branch]
257
+ end
258
+
162
259
  def build_properties(properties)
163
260
  return nil unless properties
164
261
  return nil if properties.empty?
@@ -168,70 +265,80 @@ module GrapeOAS
168
265
  end
169
266
  end
170
267
 
171
- def build_schema_or_ref(schema, include_metadata: true)
172
- if schema.respond_to?(:canonical_name) && schema.canonical_name
173
- @ref_tracker << schema.canonical_name if @ref_tracker
174
- ref_name = schema.canonical_name.gsub("::", "_")
175
- ref_hash = { "$ref" => "#/components/schemas/#{ref_name}" }
176
- return ref_hash unless include_metadata
177
-
178
- result = {}
179
- result["description"] = schema.description.to_s if schema.description
180
- result["default"] = schema.default unless schema.default.nil?
181
- result["enum"] = normalize_enum(schema.enum, schema.type) if schema.enum
182
- sanitize_enum_against_type(result, type: schema.type)
183
- apply_all_constraints(result, schema)
184
- result.merge!(schema.extensions) if schema.extensions
185
- apply_nullable_to_ref(result, schema)
186
- if result.empty?
187
- ref_hash
188
- else
189
- result["allOf"] = [ref_hash]
190
- result
191
- end
192
- else
193
- built = Schema.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy).build
194
- strip_items_metadata(built) unless include_metadata
195
- built
196
- end
197
- end
198
-
199
268
  def strip_items_metadata(hash)
200
269
  hash.delete("description")
201
270
  end
202
271
 
203
272
  def apply_nullable_to_ref(result, schema)
204
- return unless schema.respond_to?(:nullable) && schema.nullable
273
+ return unless schema_nullable?(schema)
205
274
 
206
275
  case @nullable_strategy
207
276
  when Constants::NullableStrategy::KEYWORD
208
- result["nullable"] = true
277
+ apply_ref_null_union(result, keyword_null_only_branch, typed_nullable: true)
209
278
  when Constants::NullableStrategy::EXTENSION
210
279
  result["x-nullable"] = true
211
280
  when Constants::NullableStrategy::TYPE_ARRAY
212
- # TYPE_ARRAY encodes nullability via the "type" field, which cannot be
213
- # applied to a $ref schema. For refs we intentionally do nothing.
214
- nil
281
+ apply_ref_null_union(result, { "type" => "null" }, typed_nullable: false)
215
282
  end
216
283
  end
217
284
 
218
- def normalize_enum(enum_vals, type)
285
+ # Prefer a bare $ref as the non-null alternative when allOf would only
286
+ # wrap that single ref (OAS 3.0 needs allOf only for $ref siblings).
287
+ def apply_ref_null_union(result, null_branch, typed_nullable:)
288
+ allof = result["allOf"]
289
+ if bare_single_ref_allof?(allof)
290
+ ref = result.delete("allOf").first
291
+ result["nullable"] = true if typed_nullable && result["type"]
292
+ result["anyOf"] = [ref, null_branch]
293
+ elsif typed_nullable
294
+ apply_keyword_null_union(result, "allOf")
295
+ else
296
+ apply_null_union(result, "allOf", null_branch)
297
+ end
298
+ end
299
+
300
+ def bare_single_ref_allof?(allof)
301
+ allof.is_a?(Array) && allof.size == 1 && allof.first.is_a?(Hash) && allof.first.keys == ["$ref"]
302
+ end
303
+
304
+ def keyword_null_only_branch
305
+ { "type" => Constants::SchemaTypes::OBJECT, "nullable" => true, "enum" => [nil] }
306
+ end
307
+
308
+ def normalize_enum(enum_vals, type, nullable: false)
219
309
  return nil unless enum_vals.is_a?(Array)
220
310
 
221
- coerced = enum_vals.map do |v|
222
- case type
223
- when Constants::SchemaTypes::INTEGER then v.to_i if v.respond_to?(:to_i)
224
- when Constants::SchemaTypes::NUMBER then v.to_f if v.respond_to?(:to_f)
225
- else v
226
- end
227
- end.compact
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
+
317
+ result = enum_vals.each_with_object([]) do |v, acc|
318
+ next if v.nil?
228
319
 
229
- result = coerced.uniq
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?
326
+ end
327
+
328
+ result.uniq!
329
+ result.push(nil) if has_nil
230
330
  return nil if result.empty?
231
331
 
232
332
  result
233
333
  end
234
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
+ end
341
+
235
342
  def apply_numeric_constraints(hash, schema = @schema)
236
343
  hash["minimum"] = schema.minimum unless schema.minimum.nil?
237
344
  hash["maximum"] = schema.maximum unless schema.maximum.nil?
@@ -260,55 +367,96 @@ module GrapeOAS
260
367
  def apply_array_constraints(hash, schema = @schema)
261
368
  hash["minItems"] = schema.min_items unless schema.min_items.nil?
262
369
  hash["maxItems"] = schema.max_items unless schema.max_items.nil?
370
+ hash["uniqueItems"] = true if schema.unique_items
263
371
  end
264
372
 
265
- # Ensure enum values match the declared type; drop enum if incompatible to avoid invalid specs
373
+ # Ensure enum values match the declared type; drop enum if incompatible to avoid invalid specs.
266
374
  def sanitize_enum_against_type(hash, type: nil)
375
+ # A literal `"enum" => nil` (from a nil-only enum on a non-nullable schema) must not
376
+ # survive: composition/$ref callers never `.compact` their result hash, so an unguarded
377
+ # nil would leak as `enum: null` and, for $ref, would make `result.empty?` false and
378
+ # force an unwanted `allOf` wrapper.
379
+ return hash.delete("enum") if hash.key?("enum") && hash["enum"].nil?
380
+
267
381
  enum_vals = hash["enum"]
268
382
  type_val = type || hash["type"]
269
383
  return unless enum_vals && type_val
270
384
 
271
- base_type = if type_val.is_a?(Array)
272
- (type_val - ["null"]).first
273
- else
274
- type_val
275
- end
385
+ base = base_type_for(type_val)
386
+ return hash.delete("enum") if base.nil? || base == Constants::SchemaTypes::ARRAY || base == Constants::SchemaTypes::OBJECT
276
387
 
277
- # Remove enum for unsupported base types or mismatches
278
- case base_type
279
- when Constants::SchemaTypes::ARRAY, Constants::SchemaTypes::OBJECT, nil
280
- hash.delete("enum")
388
+ non_nil_vals = enum_vals.compact
389
+
390
+ case base
281
391
  when Constants::SchemaTypes::INTEGER
282
- hash.delete("enum") unless enum_vals.all?(Integer)
392
+ hash.delete("enum") unless non_nil_vals.all?(Integer)
283
393
  when Constants::SchemaTypes::NUMBER
284
- hash.delete("enum") unless enum_vals.all?(Numeric)
394
+ hash.delete("enum") unless non_nil_vals.all?(Numeric)
285
395
  when Constants::SchemaTypes::BOOLEAN
286
- hash.delete("enum") unless enum_vals.all? { |v| [true, false].include?(v) }
396
+ hash.delete("enum") unless non_nil_vals.all? { |v| v == true || v == false } # rubocop:disable Style/MultipleComparison
287
397
  else # string and fallback
288
- hash.delete("enum") unless enum_vals.all?(String)
398
+ hash.delete("enum") unless non_nil_vals.all?(String)
289
399
  end
290
400
  end
291
401
 
292
402
  def coerce_example(example, type_val)
293
- base_type = if type_val.is_a?(Array)
294
- (type_val - ["null"]).first
295
- else
296
- type_val
297
- end
403
+ return nil if example.nil?
404
+
405
+ base_type = base_type_for(type_val)
406
+ case base_type
407
+ when Constants::SchemaTypes::ARRAY
408
+ return example if example.is_a?(Array)
409
+
410
+ return nil
411
+ when Constants::SchemaTypes::OBJECT
412
+ return example if example.is_a?(Hash)
413
+
414
+ return nil
415
+ when nil
416
+ return example
417
+ else
418
+ return nil if example.is_a?(Hash)
419
+
420
+ if example.is_a?(Array)
421
+ return nil unless example.size == 1
422
+
423
+ example = example.first
424
+ end
425
+ end
298
426
 
299
427
  case base_type
300
428
  when Constants::SchemaTypes::INTEGER
301
- example.to_i
429
+ coerce_integer_example(example)
302
430
  when Constants::SchemaTypes::NUMBER
303
- example.to_f
431
+ coerce_number_example(example)
304
432
  when Constants::SchemaTypes::BOOLEAN
305
- example == true || example.to_s == "true"
306
- when Constants::SchemaTypes::STRING, nil
433
+ case example
434
+ when true, false
435
+ example
436
+ when String
437
+ return true if example.casecmp("true").zero?
438
+ return false if example.casecmp("false").zero?
439
+
440
+ nil
441
+ end
442
+ when Constants::SchemaTypes::STRING
307
443
  example.to_s
308
444
  else
309
445
  example
310
446
  end
311
447
  end
448
+
449
+ def coerce_integer_example(example)
450
+ example.is_a?(String) ? Integer(example, 10) : Integer(example)
451
+ rescue ArgumentError, TypeError
452
+ nil
453
+ end
454
+
455
+ def coerce_number_example(example)
456
+ Float(example)
457
+ rescue ArgumentError, TypeError
458
+ nil
459
+ end
312
460
  end
313
461
  end
314
462
  end
@@ -13,10 +13,12 @@ module GrapeOAS
13
13
  def build
14
14
  hash = super
15
15
 
16
- # swap example -> examples if present
16
+ # swap example -> examples if present. The OAS3 `example` is a single
17
+ # value, so wrap it as a one-element list (a value that is itself an
18
+ # array is one array-valued example, not many examples).
17
19
  if hash.key?("example")
18
20
  ex = hash.delete("example")
19
- hash["examples"] ||= ex
21
+ hash["examples"] = [ex] unless hash.key?("examples")
20
22
  end
21
23
  normalize_examples!(hash)
22
24
  hash
@@ -24,6 +26,14 @@ module GrapeOAS
24
26
 
25
27
  private
26
28
 
29
+ # OAS 3.1: files use JSON Schema content-* keywords instead of
30
+ # the OAS 3.0 `format: binary` convention.
31
+ def apply_file_schema_attributes!(hash)
32
+ hash.delete("format")
33
+ hash["contentMediaType"] = "application/octet-stream"
34
+ hash["contentEncoding"] = "binary"
35
+ end
36
+
27
37
  # Ensure examples is always an array and recurse into nested schemas
28
38
  def normalize_examples!(hash)
29
39
  hash["examples"] = [hash["examples"]].compact if hash.key?("examples") && !hash["examples"].is_a?(Array)
@@ -29,7 +29,7 @@ module GrapeOAS
29
29
  def nullable_strategy
30
30
  # OAS 3.1 always uses JSON Schema null unions ("type": ["string", "null"]).
31
31
  # The "nullable" keyword and "x-nullable" extension are not valid in OAS 3.1.
32
- Constants::NullableStrategy::TYPE_ARRAY
32
+ Constants::NullableStrategy::OAS31_DEFAULT
33
33
  end
34
34
  end
35
35
  end
@@ -20,6 +20,7 @@ module GrapeOAS
20
20
  "tags" => build_tags,
21
21
  "paths" => OAS3::Paths.new(@api, @ref_tracker,
22
22
  nullable_strategy: nullable_strategy,
23
+ schema_builder: schema_builder,
23
24
  suppress_default_error_response: @api.suppress_default_error_response,).build,
24
25
  "components" => build_components,
25
26
  "security" => build_security
@@ -88,7 +89,7 @@ module GrapeOAS
88
89
 
89
90
  processed << canonical_name
90
91
 
91
- ref_name = canonical_name.gsub("::", "_")
92
+ ref_name = GrapeOAS.schema_ref_name.call(canonical_name)
92
93
  schema = find_schema_by_canonical_name(canonical_name)
93
94
  if schema
94
95
  schemas[ref_name] =
@@ -120,7 +121,7 @@ module GrapeOAS
120
121
  end
121
122
 
122
123
  def nullable_strategy
123
- @api.nullable_strategy || Constants::NullableStrategy::KEYWORD
124
+ @api.nullable_strategy || Constants::NullableStrategy::OAS3_DEFAULT
124
125
  end
125
126
  end
126
127
  end
@@ -119,7 +119,8 @@ module GrapeOAS
119
119
  end
120
120
 
121
121
  def entity_doc
122
- @entity_class.respond_to?(:documentation) ? (@entity_class.documentation || {}) : {}
122
+ doc = @entity_class.respond_to?(:documentation) ? (@entity_class.documentation || {}) : {}
123
+ DocKeyNormalizer.normalize(doc)
123
124
  rescue NoMethodError
124
125
  {}
125
126
  end
@@ -19,8 +19,6 @@ module GrapeOAS
19
19
  # @param schema [ApiModel::Schema] the schema to populate
20
20
  def add_exposures_to_schema(schema)
21
21
  exposures.each do |exposure|
22
- next unless exposed?(exposure)
23
-
24
22
  add_exposure_to_schema(schema, exposure)
25
23
  end
26
24
  end
@@ -73,8 +71,11 @@ module GrapeOAS
73
71
  #
74
72
  # @param exposure the entity exposure
75
73
  # @return [Boolean] true if exposed
76
- def exposed?(_exposure)
77
- true
74
+ def exposed?(exposure)
75
+ doc = normalize_doc_keys(exposure.documentation || {})
76
+ hidden = doc[:hidden]
77
+ hidden = hidden.call if hidden.respond_to?(:call)
78
+ !hidden
78
79
  end
79
80
 
80
81
  # Checks if an exposure is conditional.
@@ -128,6 +129,8 @@ module GrapeOAS
128
129
  end
129
130
 
130
131
  def add_exposure_to_schema(schema, exposure)
132
+ return unless exposed?(exposure)
133
+
131
134
  doc = normalize_doc_keys(exposure.documentation || {})
132
135
  opts = exposure_options(exposure)
133
136
 
@@ -155,7 +158,9 @@ module GrapeOAS
155
158
  is_array = doc[:is_array]
156
159
  return prop_schema unless is_array
157
160
 
158
- ApiModel::Schema.new(type: Constants::SchemaTypes::ARRAY, items: prop_schema)
161
+ array_schema = ApiModel::Schema.new(type: Constants::SchemaTypes::ARRAY, items: prop_schema)
162
+ array_schema.examples = doc[:example] if array_valued_example?(doc)
163
+ array_schema
159
164
  end
160
165
 
161
166
  # Detects block-based nesting exposures (NestingExposure) that should become
@@ -178,6 +183,8 @@ module GrapeOAS
178
183
  nesting_accum = {}
179
184
  nesting_required = Hash.new { |h, k| h[k] = [] }
180
185
  Array(exposure.nested_exposures).each do |child_exposure|
186
+ next unless exposed?(child_exposure)
187
+
181
188
  if nesting_exposure?(child_exposure)
182
189
  key = child_exposure.key.to_s
183
190
  child_doc = normalize_doc_keys(child_exposure.documentation || {})
@@ -200,7 +207,16 @@ module GrapeOAS
200
207
  end
201
208
 
202
209
  def apply_exposure_properties(schema, doc)
203
- schema.nullable = doc[:nullable] || false
210
+ nullable = PropertyExtractor.extract_nullable(doc)
211
+ if nullable && schema.canonical_name
212
+ # Don't mutate the shared cached entity schema. Create a wrapper with
213
+ # all_of so the exporter emits { nullable: true, allOf: [{ $ref }] }.
214
+ # collect_refs traverses all_of and registers the original schema as a
215
+ # component, keeping its definition free of this property's nullable.
216
+ schema = ApiModel::Schema.new(nullable: true, all_of: [schema])
217
+ else
218
+ schema.nullable = nullable
219
+ end
204
220
  raw_values = doc[:values]
205
221
  if raw_values
206
222
  normalized = ValuesNormalizer.normalize(raw_values, context: "entity exposure values")
@@ -212,7 +228,7 @@ module GrapeOAS
212
228
  end
213
229
  schema.description = doc[:desc] if doc[:desc]
214
230
  schema.format = doc[:format] if doc[:format]
215
- schema.examples = doc[:example] if schema.respond_to?(:examples=) && doc[:example]
231
+ schema = apply_example_to_schema(schema, doc[:example]) if doc.key?(:example) && !array_valued_example?(doc)
216
232
  schema.additional_properties = doc[:additional_properties] if doc.key?(:additional_properties)
217
233
  schema.unevaluated_properties = doc[:unevaluated_properties] if doc.key?(:unevaluated_properties)
218
234
  defs = doc[:defs] || doc[:$defs]
@@ -223,17 +239,24 @@ module GrapeOAS
223
239
  end
224
240
 
225
241
  # Cached entity schemas (via using:) are shared across all exposures that
226
- # reference the same entity — dup before setting enum to avoid mutating
227
- # the shared schema; emit a warning so users know a dup occurred.
242
+ # reference the same entity — dup before mutating them (enum, example, ...)
243
+ # to avoid corrupting the shared schema; emit a warning so users know a
244
+ # dup occurred.
228
245
  #
246
+ # @return [ApiModel::Schema] a dup of schema with canonical_name cleared
247
+ def dup_cached_schema(schema, reason:, value:)
248
+ GrapeOAS.logger.warn(
249
+ "Duplicating cached schema '#{schema.canonical_name}' to apply #{reason} #{value.inspect}",
250
+ )
251
+ dup = schema.dup
252
+ dup.canonical_name = nil
253
+ dup
254
+ end
255
+
229
256
  # @return [ApiModel::Schema] the schema (or a dup) with enum applied
230
257
  def apply_enum_to_schema(schema, values)
231
258
  if schema.respond_to?(:canonical_name) && schema.canonical_name
232
- GrapeOAS.logger.warn(
233
- "Duplicating cached schema '#{schema.canonical_name}' to apply enum #{values.inspect}",
234
- )
235
- schema = schema.dup
236
- schema.canonical_name = nil
259
+ schema = dup_cached_schema(schema, reason: "enum", value: values)
237
260
  schema.enum = values
238
261
  return schema
239
262
  end
@@ -241,13 +264,9 @@ module GrapeOAS
241
264
  if schema.type == Constants::SchemaTypes::ARRAY &&
242
265
  schema.respond_to?(:items) && schema.items
243
266
  if schema.items.respond_to?(:canonical_name) && schema.items.canonical_name
244
- GrapeOAS.logger.warn(
245
- "Duplicating cached schema '#{schema.items.canonical_name}' to apply enum #{values.inspect}",
246
- )
247
- schema = schema.dup
248
- items_dup = schema.items.dup
249
- items_dup.canonical_name = nil
267
+ items_dup = dup_cached_schema(schema.items, reason: "enum", value: values)
250
268
  items_dup.enum = values
269
+ schema = schema.dup
251
270
  schema.items = items_dup
252
271
  else
253
272
  schema.items.enum = values
@@ -258,6 +277,22 @@ module GrapeOAS
258
277
  schema
259
278
  end
260
279
 
280
+ def apply_example_to_schema(schema, example)
281
+ return schema unless schema.respond_to?(:examples=)
282
+
283
+ if schema.respond_to?(:canonical_name) && schema.canonical_name
284
+ schema = dup_cached_schema(schema, reason: "example", value: example)
285
+ end
286
+ schema.examples = example
287
+ schema
288
+ end
289
+
290
+ # True when doc[:example] describes the whole array (not a single item),
291
+ # i.e. is_array: true and the example is itself an Array.
292
+ def array_valued_example?(doc)
293
+ doc[:is_array] && doc[:example].is_a?(Array)
294
+ end
295
+
261
296
  def normalize_doc_keys(doc)
262
297
  DocKeyNormalizer.normalize(doc)
263
298
  end