grape-oas 1.3.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -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 +6 -1
  7. data/lib/grape_oas/api_model/schema.rb +2 -2
  8. data/lib/grape_oas/api_model_builder.rb +3 -2
  9. data/lib/grape_oas/api_model_builders/concerns/content_type_resolver.rb +30 -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 +7 -8
  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 +29 -22
  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_location_resolver.rb +7 -2
  19. data/lib/grape_oas/api_model_builders/request_params_support/param_schema_builder.rb +4 -0
  20. data/lib/grape_oas/api_model_builders/request_params_support/schema_enhancer.rb +14 -9
  21. data/lib/grape_oas/api_model_builders/response.rb +28 -4
  22. data/lib/grape_oas/constants.rb +22 -1
  23. data/lib/grape_oas/documentation_extension.rb +7 -9
  24. data/lib/grape_oas/exporter/concerns/schema_indexer.rb +14 -3
  25. data/lib/grape_oas/exporter/oas2/operation.rb +4 -2
  26. data/lib/grape_oas/exporter/oas2/parameter.rb +74 -13
  27. data/lib/grape_oas/exporter/oas2/paths.rb +1 -0
  28. data/lib/grape_oas/exporter/oas2/response.rb +6 -4
  29. data/lib/grape_oas/exporter/oas2/schema.rb +85 -38
  30. data/lib/grape_oas/exporter/oas2_schema.rb +7 -5
  31. data/lib/grape_oas/exporter/oas3/operation.rb +5 -3
  32. data/lib/grape_oas/exporter/oas3/parameter.rb +8 -3
  33. data/lib/grape_oas/exporter/oas3/paths.rb +1 -0
  34. data/lib/grape_oas/exporter/oas3/request_body.rb +5 -8
  35. data/lib/grape_oas/exporter/oas3/response.rb +6 -9
  36. data/lib/grape_oas/exporter/oas3/schema.rb +273 -117
  37. data/lib/grape_oas/exporter/oas31/schema.rb +12 -2
  38. data/lib/grape_oas/exporter/oas31_schema.rb +1 -1
  39. data/lib/grape_oas/exporter/oas3_schema.rb +3 -2
  40. data/lib/grape_oas/introspectors/entity_introspector.rb +7 -2
  41. data/lib/grape_oas/introspectors/entity_introspector_support/exposure_processor.rb +55 -20
  42. data/lib/grape_oas/introspectors/entity_introspector_support/inheritance_builder.rb +1 -1
  43. data/lib/grape_oas/introspectors/entity_introspector_support/property_extractor.rb +2 -8
  44. data/lib/grape_oas/introspectors/entity_introspector_support/type_schema_resolver.rb +4 -20
  45. data/lib/grape_oas/introspectors/entity_introspector_support.rb +24 -0
  46. data/lib/grape_oas/range_utils.rb +25 -2
  47. data/lib/grape_oas/type_resolvers/array_resolver.rb +54 -22
  48. data/lib/grape_oas/type_resolvers/base.rb +2 -2
  49. data/lib/grape_oas/type_resolvers/default_resolver.rb +23 -0
  50. data/lib/grape_oas/type_resolvers/dry_type_resolver.rb +1 -1
  51. data/lib/grape_oas/type_resolvers/primitive_resolver.rb +29 -46
  52. data/lib/grape_oas/type_resolvers/registry.rb +31 -15
  53. data/lib/grape_oas/version.rb +1 -1
  54. data/lib/grape_oas.rb +37 -7
  55. metadata +5 -4
  56. data/CONTRIBUTING.md +0 -87
  57. 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,28 +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
45
+ schema_hash["default"] = @schema.default unless @schema.default.nil?
54
46
  schema_hash
55
47
  end
56
48
 
57
49
  def apply_examples(schema_hash)
58
- return unless @schema.examples
50
+ return if @schema.examples.nil?
59
51
 
60
- examples = Array(@schema.examples).map { |ex| coerce_example(ex, schema_hash["type"]) }
61
- schema_hash["example"] = examples.first
52
+ type = schema_hash["type"]
53
+ schema_hash["example"] = coerce_example(@schema.examples, type)
62
54
  end
63
55
 
64
56
  def apply_extensions_and_extra_properties(schema_hash)
@@ -72,34 +64,92 @@ module GrapeOAS
72
64
  schema_hash["discriminator"] = build_discriminator if @schema.discriminator
73
65
  end
74
66
 
75
- def apply_all_constraints(schema_hash)
76
- apply_numeric_constraints(schema_hash)
77
- apply_string_constraints(schema_hash)
78
- apply_array_constraints(schema_hash)
67
+ def apply_all_constraints(schema_hash, schema = @schema)
68
+ apply_numeric_constraints(schema_hash, schema)
69
+ apply_string_constraints(schema_hash, schema)
70
+ apply_array_constraints(schema_hash, schema)
71
+ end
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
79
109
  end
80
110
 
81
111
  private
82
112
 
83
- # Build allOf schema for inheritance
84
- def build_all_of_schema
85
- all_of_items = @schema.all_of.map do |item|
86
- build_schema_or_ref(item)
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)
87
130
  end
131
+ end
88
132
 
89
- result = { "allOf" => all_of_items }
90
- result["description"] = @schema.description.to_s if @schema.description
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
+
139
+ # Build allOf schema for inheritance
140
+ def build_all_of_schema
141
+ items = @schema.all_of.map { |item| build_schema_or_ref(item) }
142
+ result = { "allOf" => items }
143
+ apply_composition_attributes(result)
91
144
  apply_nullable(result)
92
145
  result
93
146
  end
94
147
 
95
148
  # Build oneOf schema for polymorphism
96
149
  def build_one_of_schema
97
- one_of_items = @schema.one_of.map do |item|
98
- build_schema_or_ref(item)
99
- end
100
-
101
- result = { "oneOf" => one_of_items }
102
- result["description"] = @schema.description.to_s if @schema.description
150
+ items = @schema.one_of.map { |item| build_schema_or_ref(item) }
151
+ result = { "oneOf" => items }
152
+ apply_composition_attributes(result)
103
153
  result["discriminator"] = build_discriminator if @schema.discriminator
104
154
  apply_nullable(result)
105
155
  result
@@ -107,17 +157,37 @@ module GrapeOAS
107
157
 
108
158
  # Build anyOf schema for polymorphism
109
159
  def build_any_of_schema
110
- any_of_items = @schema.any_of.map do |item|
111
- build_schema_or_ref(item)
112
- end
113
-
114
- result = { "anyOf" => any_of_items }
115
- result["description"] = @schema.description.to_s if @schema.description
160
+ items = @schema.any_of.map { |item| build_schema_or_ref(item) }
161
+ result = { "anyOf" => items }
162
+ apply_composition_attributes(result)
116
163
  result["discriminator"] = build_discriminator if @schema.discriminator
117
164
  apply_nullable(result)
118
165
  result
119
166
  end
120
167
 
168
+ def apply_composition_attributes(result)
169
+ result["type"] = nullable_type if @schema.type
170
+ result["format"] = @schema.format if @schema.format
171
+ result["description"] = @schema.description.to_s if @schema.description
172
+ result["default"] = @schema.default unless @schema.default.nil?
173
+ result["enum"] = normalize_enum(@schema.enum, result["type"], nullable: nullable?) if @schema.enum
174
+ sanitize_enum_against_type(result)
175
+ apply_all_constraints(result)
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)
189
+ end
190
+
121
191
  # Build OAS3 discriminator object
122
192
  def build_discriminator
123
193
  return nil unless @schema.discriminator
@@ -134,8 +204,12 @@ module GrapeOAS
134
204
  end
135
205
  end
136
206
 
207
+ def schema_nullable?(schema)
208
+ schema.respond_to?(:nullable) && !!schema.nullable
209
+ end
210
+
137
211
  def nullable?
138
- @schema.respond_to?(:nullable) && @schema.nullable
212
+ schema_nullable?(@schema)
139
213
  end
140
214
 
141
215
  def nullable_type
@@ -148,14 +222,40 @@ module GrapeOAS
148
222
  def apply_nullable(schema_hash)
149
223
  return unless nullable?
150
224
 
225
+ composition_key = %w[allOf oneOf anyOf].find { |k| schema_hash.key?(k) }
151
226
  case @nullable_strategy
152
227
  when Constants::NullableStrategy::KEYWORD
153
- 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
154
233
  when Constants::NullableStrategy::EXTENSION
155
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
156
241
  end
157
242
  end
158
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
+
159
259
  def build_properties(properties)
160
260
  return nil unless properties
161
261
  return nil if properties.empty?
@@ -165,142 +265,198 @@ module GrapeOAS
165
265
  end
166
266
  end
167
267
 
168
- def build_schema_or_ref(schema, include_metadata: true)
169
- if schema.respond_to?(:canonical_name) && schema.canonical_name
170
- @ref_tracker << schema.canonical_name if @ref_tracker
171
- ref_name = schema.canonical_name.gsub("::", "_")
172
- ref_hash = { "$ref" => "#/components/schemas/#{ref_name}" }
173
- return ref_hash unless include_metadata
174
-
175
- result = {}
176
- result["description"] = schema.description.to_s if schema.description
177
- apply_nullable_to_ref(result, schema)
178
- if result.empty?
179
- ref_hash
180
- else
181
- result["allOf"] = [ref_hash]
182
- result
183
- end
184
- else
185
- built = Schema.new(schema, @ref_tracker, nullable_strategy: @nullable_strategy).build
186
- strip_items_metadata(built) unless include_metadata
187
- built
188
- end
189
- end
190
-
191
268
  def strip_items_metadata(hash)
192
269
  hash.delete("description")
193
270
  end
194
271
 
195
272
  def apply_nullable_to_ref(result, schema)
196
- return unless schema.respond_to?(:nullable) && schema.nullable
273
+ return unless schema_nullable?(schema)
197
274
 
198
275
  case @nullable_strategy
199
276
  when Constants::NullableStrategy::KEYWORD
200
- result["nullable"] = true
277
+ apply_ref_null_union(result, keyword_null_only_branch, typed_nullable: true)
201
278
  when Constants::NullableStrategy::EXTENSION
202
279
  result["x-nullable"] = true
203
280
  when Constants::NullableStrategy::TYPE_ARRAY
204
- # TYPE_ARRAY encodes nullability via the "type" field, which cannot be
205
- # applied to a $ref schema. For refs we intentionally do nothing.
206
- nil
281
+ apply_ref_null_union(result, { "type" => "null" }, typed_nullable: false)
207
282
  end
208
283
  end
209
284
 
210
- 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)
211
309
  return nil unless enum_vals.is_a?(Array)
212
310
 
213
- coerced = enum_vals.map do |v|
214
- case type
215
- when Constants::SchemaTypes::INTEGER then v.to_i if v.respond_to?(:to_i)
216
- when Constants::SchemaTypes::NUMBER then v.to_f if v.respond_to?(:to_f)
217
- else v
218
- end
219
- 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?
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?
326
+ end
220
327
 
221
- result = coerced.uniq
328
+ result.uniq!
329
+ result.push(nil) if has_nil
222
330
  return nil if result.empty?
223
331
 
224
332
  result
225
333
  end
226
334
 
227
- def apply_numeric_constraints(hash)
228
- hash["minimum"] = @schema.minimum unless @schema.minimum.nil?
229
- hash["maximum"] = @schema.maximum unless @schema.maximum.nil?
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
+
342
+ def apply_numeric_constraints(hash, schema = @schema)
343
+ hash["minimum"] = schema.minimum unless schema.minimum.nil?
344
+ hash["maximum"] = schema.maximum unless schema.maximum.nil?
230
345
 
231
346
  if @nullable_strategy == Constants::NullableStrategy::TYPE_ARRAY
232
- if @schema.exclusive_minimum && !@schema.minimum.nil?
233
- hash["exclusiveMinimum"] = @schema.minimum
347
+ if schema.exclusive_minimum && !schema.minimum.nil?
348
+ hash["exclusiveMinimum"] = schema.minimum
234
349
  hash.delete("minimum")
235
350
  end
236
- if @schema.exclusive_maximum && !@schema.maximum.nil?
237
- hash["exclusiveMaximum"] = @schema.maximum
351
+ if schema.exclusive_maximum && !schema.maximum.nil?
352
+ hash["exclusiveMaximum"] = schema.maximum
238
353
  hash.delete("maximum")
239
354
  end
240
355
  else
241
- hash["exclusiveMinimum"] = @schema.exclusive_minimum if @schema.exclusive_minimum
242
- hash["exclusiveMaximum"] = @schema.exclusive_maximum if @schema.exclusive_maximum
356
+ hash["exclusiveMinimum"] = schema.exclusive_minimum if schema.exclusive_minimum
357
+ hash["exclusiveMaximum"] = schema.exclusive_maximum if schema.exclusive_maximum
243
358
  end
244
359
  end
245
360
 
246
- def apply_string_constraints(hash)
247
- hash["minLength"] = @schema.min_length unless @schema.min_length.nil?
248
- hash["maxLength"] = @schema.max_length unless @schema.max_length.nil?
249
- hash["pattern"] = @schema.pattern if @schema.pattern
361
+ def apply_string_constraints(hash, schema = @schema)
362
+ hash["minLength"] = schema.min_length unless schema.min_length.nil?
363
+ hash["maxLength"] = schema.max_length unless schema.max_length.nil?
364
+ hash["pattern"] = schema.pattern if schema.pattern
250
365
  end
251
366
 
252
- def apply_array_constraints(hash)
253
- hash["minItems"] = @schema.min_items unless @schema.min_items.nil?
254
- hash["maxItems"] = @schema.max_items unless @schema.max_items.nil?
367
+ def apply_array_constraints(hash, schema = @schema)
368
+ hash["minItems"] = schema.min_items unless schema.min_items.nil?
369
+ hash["maxItems"] = schema.max_items unless schema.max_items.nil?
370
+ hash["uniqueItems"] = true if schema.unique_items
255
371
  end
256
372
 
257
- # Ensure enum values match the declared type; drop enum if incompatible to avoid invalid specs
258
- def sanitize_enum_against_type(hash)
373
+ # Ensure enum values match the declared type; drop enum if incompatible to avoid invalid specs.
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
+
259
381
  enum_vals = hash["enum"]
260
- type_val = hash["type"]
382
+ type_val = type || hash["type"]
261
383
  return unless enum_vals && type_val
262
384
 
263
- base_type = if type_val.is_a?(Array)
264
- (type_val - ["null"]).first
265
- else
266
- type_val
267
- end
385
+ base = base_type_for(type_val)
386
+ return hash.delete("enum") if base.nil? || base == Constants::SchemaTypes::ARRAY || base == Constants::SchemaTypes::OBJECT
268
387
 
269
- # Remove enum for unsupported base types or mismatches
270
- case base_type
271
- when Constants::SchemaTypes::ARRAY, Constants::SchemaTypes::OBJECT, nil
272
- hash.delete("enum")
388
+ non_nil_vals = enum_vals.compact
389
+
390
+ case base
273
391
  when Constants::SchemaTypes::INTEGER
274
- hash.delete("enum") unless enum_vals.all?(Integer)
392
+ hash.delete("enum") unless non_nil_vals.all?(Integer)
275
393
  when Constants::SchemaTypes::NUMBER
276
- hash.delete("enum") unless enum_vals.all?(Numeric)
394
+ hash.delete("enum") unless non_nil_vals.all?(Numeric)
277
395
  when Constants::SchemaTypes::BOOLEAN
278
- 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
279
397
  else # string and fallback
280
- hash.delete("enum") unless enum_vals.all?(String)
398
+ hash.delete("enum") unless non_nil_vals.all?(String)
281
399
  end
282
400
  end
283
401
 
284
402
  def coerce_example(example, type_val)
285
- base_type = if type_val.is_a?(Array)
286
- (type_val - ["null"]).first
287
- else
288
- type_val
289
- 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
290
426
 
291
427
  case base_type
292
428
  when Constants::SchemaTypes::INTEGER
293
- example.to_i
429
+ coerce_integer_example(example)
294
430
  when Constants::SchemaTypes::NUMBER
295
- example.to_f
431
+ coerce_number_example(example)
296
432
  when Constants::SchemaTypes::BOOLEAN
297
- example == true || example.to_s == "true"
298
- 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
299
443
  example.to_s
300
444
  else
301
445
  example
302
446
  end
303
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
304
460
  end
305
461
  end
306
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
@@ -88,12 +88,16 @@ module GrapeOAS
88
88
  def initialize_or_reuse_schema
89
89
  @registry[@entity_class] ||= ApiModel::Schema.new(
90
90
  type: Constants::SchemaTypes::OBJECT,
91
- canonical_name: @entity_class.name,
91
+ canonical_name: resolve_canonical_name,
92
92
  description: nil,
93
93
  nullable: nil,
94
94
  )
95
95
  end
96
96
 
97
+ def resolve_canonical_name
98
+ EntityIntrospectorSupport.resolve_canonical_name(@entity_class)
99
+ end
100
+
97
101
  def populate_schema(schema)
98
102
  doc = entity_doc
99
103
  apply_schema_metadata(schema, doc)
@@ -115,7 +119,8 @@ module GrapeOAS
115
119
  end
116
120
 
117
121
  def entity_doc
118
- @entity_class.respond_to?(:documentation) ? (@entity_class.documentation || {}) : {}
122
+ doc = @entity_class.respond_to?(:documentation) ? (@entity_class.documentation || {}) : {}
123
+ DocKeyNormalizer.normalize(doc)
119
124
  rescue NoMethodError
120
125
  {}
121
126
  end