rspec-openapi 0.26.0 → 0.27.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.
@@ -1,45 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class << RSpec::OpenAPI::SchemaBuilder = Object.new
3
+ RSpec::OpenAPI::SchemaBuilder = Object.new
4
+ require_relative 'schema_builder/build_context'
5
+
6
+ class << RSpec::OpenAPI::SchemaBuilder
4
7
  # @param [RSpec::OpenAPI::Record] record
5
8
  # @return [Hash]
6
9
  def build(record)
7
- response = if record.response_example_mode == :none
8
- # `:none` opts out of recording, so the description is provisional.
9
- # Stash it under a fallback key; SchemaCleaner promotes it to
10
- # `description` only if no documented test has set one. This makes
11
- # the merge result independent of RSpec's random execution order.
12
- { _fallback_description: record.description }
13
- else
14
- { description: record.description }
15
- end
16
-
17
- response_headers = build_response_headers(record)
18
- response[:headers] = response_headers unless response_headers.empty?
19
-
20
- if record.response_body
21
- disposition = normalize_content_disposition(record.response_content_disposition)
22
-
23
- has_content = !normalize_content_type(record.response_content_type).nil?
24
- response[:content] = build_content(disposition, record) if has_content
25
- end
26
-
27
- http_method = record.http_method.downcase
28
10
  {
29
11
  paths: {
30
12
  normalize_path(record.path) => {
31
- http_method => {
32
- summary: record.summary,
33
- tags: record.tags,
34
- operationId: record.operation_id,
35
- security: record.security,
36
- deprecated: record.deprecated ? true : nil,
37
- parameters: build_parameters(record),
38
- requestBody: include_nil_request_body?(http_method) ? nil : build_request_body(record),
39
- responses: {
40
- record.status.to_s => response,
41
- },
42
- }.compact,
13
+ record.http_method.downcase => build_operation(record),
43
14
  },
44
15
  },
45
16
  }
@@ -47,48 +18,64 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
47
18
 
48
19
  private
49
20
 
50
- def include_nil_request_body?(http_method)
51
- %w[delete get].include?(http_method)
21
+ def build_operation(record)
22
+ http_method = record.http_method.downcase
23
+ # GET and DELETE never have a request body in OpenAPI.
24
+ request_body = ['delete', 'get'].include?(http_method) ? nil : build_request_body(record)
25
+ {
26
+ summary: record.summary,
27
+ tags: record.tags,
28
+ operationId: record.operation_id,
29
+ security: record.security,
30
+ deprecated: record.deprecated ? true : nil,
31
+ parameters: build_parameters(record),
32
+ requestBody: request_body,
33
+ responses: { record.status.to_s => build_response(record) },
34
+ }.compact
35
+ end
36
+
37
+ def build_response(record)
38
+ # `:none` opts out of recording, so the description is provisional. Stash
39
+ # it under a fallback key; SchemaCleaner promotes it to `description` only
40
+ # if no documented test has set one. This makes the merge result
41
+ # independent of RSpec's random execution order.
42
+ desc_key = record.response_example_mode == :none ? :_fallback_description : :description
43
+ response = { desc_key => record.description }
44
+
45
+ response_headers = build_response_headers(record)
46
+ response[:headers] = response_headers unless response_headers.empty?
47
+
48
+ if record.response_body && !normalize_content_type(record.response_content_type).nil?
49
+ response[:content] = build_content(record)
50
+ end
51
+
52
+ response
52
53
  end
53
54
 
54
- def build_content(disposition, record)
55
+ def build_content(record)
56
+ disposition = normalize_content_disposition(record.response_content_disposition)
55
57
  content_type = normalize_content_type(record.response_content_type)
56
- schema = build_property(record.response_body, disposition: disposition, record: record, context: :response)
57
-
58
- # If examples are globally disabled, always return schema-only content.
59
- return { content_type => { schema: schema }.compact } unless example_enabled?(record)
60
-
61
- case record.response_example_mode
62
- when :none
63
- # Only schema, no examples
64
- {
65
- content_type => {
66
- schema: schema,
67
- }.compact,
68
- }
69
- when :multiple
70
- # Multiple named examples
71
- {
72
- content_type => {
73
- schema: schema,
74
- examples: { record.example_key => build_example_object(record, disposition: disposition) },
75
- }.compact,
76
- }
77
- else # :single (default)
78
- # Single example + store name for possible merger conversion
79
- {
80
- content_type => {
81
- schema: schema,
82
- example: response_example(record, disposition: disposition),
83
- **example_metadata(record),
84
- }.compact,
85
- }
86
- end
58
+ ctx = BuildContext.new(record: record, context: :response)
59
+ schema = build_property(record.response_body, ctx, disposition: disposition)
60
+ example = response_example(record, disposition: disposition)
61
+
62
+ body = build_example_body(schema, record, mode: record.response_example_mode, example: example)
63
+ { content_type => body }
87
64
  end
88
65
 
89
- def enrich_with_required_keys(obj)
90
- obj[:required] = obj[:properties]&.keys || []
91
- obj
66
+ # Returns the per-content-type body (schema + optional example/examples).
67
+ # Shared by response content and request body to keep example_mode handling in one place.
68
+ def build_example_body(schema, record, mode:, example:)
69
+ return { schema: schema } if !example_enabled?(record) || mode == :none
70
+
71
+ case mode
72
+ when :multiple
73
+ { schema: schema, examples: { record.example_key => build_named_example(record, example) } }
74
+ else
75
+ # :single (default)
76
+ # :single may emit nil example or nil _example_summary; compact strips them.
77
+ { schema: schema, example: example, **example_metadata(record) }.compact
78
+ end
92
79
  end
93
80
 
94
81
  def response_example(record, disposition:)
@@ -97,16 +84,9 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
97
84
  record.response_body
98
85
  end
99
86
 
100
- def build_example_object(record, disposition:)
101
- build_named_example(record, response_example(record, disposition: disposition))
102
- end
103
-
104
87
  def build_named_example(record, value)
105
88
  summary = example_summary(record)
106
- example = {}
107
- example[:summary] = summary if summary
108
- example[:value] = value
109
- example
89
+ summary ? { summary: summary, value: value } : { value: value }
110
90
  end
111
91
 
112
92
  def example_metadata(record)
@@ -114,7 +94,7 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
114
94
  end
115
95
 
116
96
  def example_summary(record)
117
- return nil unless example_summary_enabled?
97
+ return nil unless RSpec::OpenAPI.enable_example_summary
118
98
  return nil if record.example_name.nil? || record.example_name.empty?
119
99
 
120
100
  record.example_name
@@ -124,58 +104,45 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
124
104
  record.example_enabled
125
105
  end
126
106
 
127
- def example_summary_enabled?
128
- RSpec::OpenAPI.enable_example_summary
129
- end
130
-
131
107
  def build_parameters(record)
132
- path_params = record.path_params.map do |key, value|
133
- {
134
- name: build_parameter_name(key, value),
135
- in: 'path',
136
- required: true,
137
- schema: build_property(try_cast(value), key: key, record: record, path: key.to_s, context: :request),
138
- example: (try_cast(value) if example_enabled?(record)),
139
- }.compact
108
+ parameters = record.path_params.map do |key, value|
109
+ build_parameter(key, value, location: 'path', record: record)
140
110
  end
141
111
 
142
- query_params = flatten_query_params(record.query_params).map do |key, value|
143
- {
144
- name: key,
145
- in: 'query',
146
- required: record.required_request_params.include?(key),
147
- schema: build_property(try_cast(value), key: key, record: record, path: key.to_s, context: :request),
148
- example: (try_cast(value) if example_enabled?(record)),
149
- }.compact
112
+ parameters += flatten_query_params(record.query_params).map do |key, value|
113
+ build_parameter(key, value, location: 'query', record: record)
150
114
  end
151
115
 
152
- header_params = record.request_headers.map do |key, value|
153
- {
154
- name: build_parameter_name(key, value),
155
- in: 'header',
156
- required: true,
157
- schema: build_property(try_cast(value), key: key, record: record, path: key.to_s, context: :request),
158
- example: (try_cast(value) if example_enabled?(record)),
159
- }.compact
116
+ parameters += record.request_headers.map do |key, value|
117
+ build_parameter(key, value, location: 'header', record: record)
160
118
  end
161
119
 
162
- parameters = path_params + query_params + header_params
163
-
164
- return nil if parameters.empty?
120
+ parameters&.empty? ? nil : parameters
121
+ end
165
122
 
166
- parameters
123
+ # `compound_name` and `required` follow from `location`:
124
+ # path/header params are always required and use bracketed names like
125
+ # `key[subkey]`; query params are pre-flattened and may be optional.
126
+ def build_parameter(key, value, location:, record:)
127
+ is_query = location == 'query'
128
+ compound_name = !is_query
129
+ required = is_query ? record.required_request_params.include?(key) : true
130
+ cast = try_cast(value)
131
+ ctx = BuildContext.new(record: record, context: :request, key: key, path: key.to_s)
132
+ {
133
+ name: compound_name ? build_parameter_name(key, value) : key,
134
+ in: location,
135
+ required: required,
136
+ schema: build_property(cast, ctx),
137
+ example: (cast if example_enabled?(record)),
138
+ }.compact
167
139
  end
168
140
 
169
141
  def build_response_headers(record)
170
- headers = {}
171
-
172
- record.response_headers.each do |key, value|
173
- headers[key] = {
174
- schema: build_property(try_cast(value), key: key, record: record, path: key.to_s, context: :response),
175
- }.compact
142
+ record.response_headers.to_h do |key, value|
143
+ ctx = BuildContext.new(record: record, context: :response, key: key, path: key.to_s)
144
+ [key, { schema: build_property(try_cast(value), ctx) }]
176
145
  end
177
-
178
- headers
179
146
  end
180
147
 
181
148
  def build_parameter_name(key, value)
@@ -189,8 +156,7 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
189
156
  end
190
157
 
191
158
  def flatten_query_params(params, parent_key = nil)
192
- result = {}
193
- params.each do |key, value|
159
+ params.each_with_object({}) do |(key, value), result|
194
160
  full_key = parent_key ? "#{parent_key}[#{key}]" : key.to_s
195
161
 
196
162
  if value.is_a?(Hash)
@@ -199,7 +165,6 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
199
165
  result[full_key] = value
200
166
  end
201
167
  end
202
- result
203
168
  end
204
169
 
205
170
  def build_request_body(record)
@@ -207,99 +172,73 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
207
172
  return nil if record.status >= 400 && record.request_example_mode != :multiple
208
173
 
209
174
  content_type = normalize_content_type(record.request_content_type)
210
- schema = build_property(record.request_params, record: record, context: :request)
211
-
212
- return { content: { content_type => { schema: schema }.compact } } unless example_enabled?(record)
213
-
214
- example = build_example(record.request_params)
215
-
216
- body =
217
- case record.request_example_mode
218
- when :none
219
- { schema: schema }
220
- when :multiple
221
- {
222
- schema: schema,
223
- examples: { record.example_key => build_named_example(record, example) },
224
- }
225
- else # :single (default)
226
- {
227
- schema: schema,
228
- example: example,
229
- **example_metadata(record),
230
- }
231
- end
175
+ ctx = BuildContext.new(record: record, context: :request)
176
+ schema = build_property(record.request_params, ctx)
177
+ example = example_enabled?(record) ? build_example(record.request_params) : nil
232
178
 
233
- { content: { content_type => body.compact } }
179
+ body = build_example_body(schema, record, mode: record.request_example_mode, example: example)
180
+ { content: { content_type => body } }
234
181
  end
235
182
 
236
- def build_property(value, disposition: nil, key: nil, record: nil, path: nil, context: nil)
237
- format = disposition ? 'binary' : infer_format(key, record)
238
- enum = infer_enum(path, record, context)
239
-
183
+ def build_property(value, ctx, disposition: nil)
184
+ format = disposition ? 'binary' : infer_format(ctx.key, ctx.record)
185
+ enum = infer_enum(ctx.path, ctx.record, ctx.context)
240
186
  property = build_type(value, format: format, enum: enum)
241
187
 
242
188
  case value
243
189
  when Array
244
- property[:items] = if value.empty?
245
- {} # unknown
246
- else
247
- build_array_items_schema(value, record: record, path: path, context: context)
248
- end
190
+ property[:items] = value.empty? ? {} : build_array_items_schema(value, ctx.for_array_items)
249
191
  when Hash
250
- override = infer_additional_properties(path, record, context)
251
- hybrid_override = infer_hybrid_additional_properties(path, record, context)
252
- if override.is_a?(Hash) && !override.empty?
253
- # Schema override: the object's keys are dynamic — replace captured
254
- # `properties` / `required` with the supplied dictionary value schema.
255
- property[:additionalProperties] = override
256
- else
257
- property[:properties] = {}.tap do |properties|
258
- value.each do |k, v|
259
- child_path = path ? "#{path}.#{k}" : k.to_s
260
- properties[k] = build_property(v, record: record, key: k, path: child_path, context: context)
261
- end
262
- end
263
- property = enrich_with_required_keys(property)
264
- # Hybrid: keep the observed `properties` / `required` and attach
265
- # `additionalProperties` alongside.
266
- # - Boolean values are constraints (`false` forbids extras, `true`
267
- # explicitly allows them).
268
- # - Hash schema values come from the dedicated `hybrid_additional_properties`
269
- # metadata, expressing "known keys + extras of this type".
270
- if override == true || override == false
271
- property[:additionalProperties] = override
272
- elsif hybrid_override.is_a?(Hash) && !hybrid_override.empty?
273
- property[:additionalProperties] = hybrid_override
274
- end
275
- end
192
+ apply_object_schema(property, value, ctx)
276
193
  end
277
194
  property
278
195
  end
279
196
 
197
+ def apply_object_schema(property, value, ctx)
198
+ override = infer_override(ctx.path, ctx.record, ctx.context, :additional_properties)
199
+
200
+ if override.is_a?(Hash) && !override.empty?
201
+ # Schema override: the object's keys are dynamic — replace captured
202
+ # `properties` / `required` with the supplied dictionary value schema.
203
+ property[:additionalProperties] = override
204
+ return
205
+ end
206
+
207
+ property[:properties] = value.to_h do |k, v|
208
+ [k, build_property(v, ctx.descend(k))]
209
+ end
210
+ property[:required] = property[:properties].keys
211
+ apply_additional_properties(property, override,
212
+ infer_override(ctx.path, ctx.record, ctx.context, :hybrid_additional_properties),)
213
+ end
214
+
215
+ # Hybrid: keep the observed `properties` / `required` and attach
216
+ # `additionalProperties` alongside.
217
+ # - Boolean values are constraints (`false` forbids extras, `true` explicitly allows them).
218
+ # - Hash schema values come from the dedicated `hybrid_additional_properties`
219
+ # metadata, expressing "known keys + extras of this type".
220
+ def apply_additional_properties(property, override, hybrid_override)
221
+ if [true, false].include?(override)
222
+ property[:additionalProperties] = override
223
+ elsif hybrid_override.is_a?(Hash) && !hybrid_override.empty?
224
+ property[:additionalProperties] = hybrid_override
225
+ end
226
+ end
227
+
280
228
  def build_type(value, format: nil, enum: nil)
281
229
  result = if format
282
230
  { type: 'string', format: format }
283
231
  else
284
232
  case value
285
- when String
286
- { type: 'string' }
287
- when Integer
288
- { type: 'integer' }
289
- when Float
290
- { type: 'number', format: 'float' }
291
- when TrueClass, FalseClass
292
- { type: 'boolean' }
293
- when Array
294
- { type: 'array' }
295
- when Hash
296
- { type: 'object' }
297
- when ActionDispatch::Http::UploadedFile
298
- { type: 'string', format: 'binary' }
299
- when NilClass
300
- { nullable: true }
301
- else
302
- raise NotImplementedError, "type detection is not implemented for: #{value.inspect}"
233
+ when String then { type: 'string' }
234
+ when Integer then { type: 'integer' }
235
+ when Float then { type: 'number', format: 'float' }
236
+ when TrueClass, FalseClass then { type: 'boolean' }
237
+ when Array then { type: 'array' }
238
+ when Hash then { type: 'object' }
239
+ when ActionDispatch::Http::UploadedFile then { type: 'string', format: 'binary' }
240
+ when NilClass then { nullable: true }
241
+ else raise NotImplementedError, "type detection is not implemented for: #{value.inspect}"
303
242
  end
304
243
  end
305
244
 
@@ -308,47 +247,31 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
308
247
  end
309
248
 
310
249
  def infer_format(key, record)
311
- return nil if !key || !record || !record.formats
250
+ return nil unless key && record
312
251
 
313
- record.formats[key]
252
+ record.formats&.[](key)
314
253
  end
315
254
 
316
255
  def infer_enum(path, record, context)
317
256
  return nil if !path || !record
318
257
 
319
- enum_hash = context == :request ? record.request_enum : record.response_enum
320
- return nil unless enum_hash
321
-
322
258
  # Keys are already normalized to strings by SharedExtractor.normalize_enum
323
- enum_hash[path.to_s]
259
+ record.send("#{context}_enum")&.[](path.to_s)
324
260
  end
325
261
 
326
- def infer_additional_properties(path, record, context)
262
+ # Looks up an override for the current path under one of the per-context
263
+ # override maps on the record (e.g. request_additional_properties).
264
+ # For :additional_properties we use `key?` so a literal `false` is
265
+ # distinguishable from "no override"; for :hybrid_additional_properties
266
+ # plain lookup is enough because only Hash values are meaningful.
267
+ def infer_override(path, record, context, kind)
327
268
  return nil unless record
328
269
 
329
- overrides = if context == :request
330
- record.request_additional_properties
331
- else
332
- record.response_additional_properties
333
- end
270
+ overrides = record.send("#{context}_#{kind}")
334
271
  return nil unless overrides
335
272
 
336
273
  # path is nil at the body root; nil.to_s == '' lets users target it via { '' => ... }.
337
- # Use `key?` so a literal `false` override is distinguishable from "no override".
338
- return nil unless overrides.key?(path.to_s)
339
-
340
- overrides[path.to_s]
341
- end
342
-
343
- def infer_hybrid_additional_properties(path, record, context)
344
- return nil unless record
345
-
346
- overrides = if context == :request
347
- record.request_hybrid_additional_properties
348
- else
349
- record.response_hybrid_additional_properties
350
- end
351
- return nil unless overrides
274
+ return nil if kind == :additional_properties && !overrides.key?(path.to_s)
352
275
 
353
276
  overrides[path.to_s]
354
277
  end
@@ -363,30 +286,19 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
363
286
  def build_example(value)
364
287
  return nil if value.nil?
365
288
 
366
- value = value.dup
367
- adjust_params(value)
368
- end
369
-
370
- def adjust_params(value)
371
- value.each do |key, v|
372
- case v
373
- when ActionDispatch::Http::UploadedFile
374
- value[key] = v.original_filename
375
- when Hash
376
- adjust_params(v)
377
- when Array
378
- result = v.map do |item|
379
- case item
380
- when ActionDispatch::Http::UploadedFile
381
- item.original_filename
382
- when Hash
383
- adjust_params(item)
384
- else
385
- item
386
- end
387
- end
388
- value[key] = result
389
- end
289
+ adjust_params(value.dup)
290
+ end
291
+
292
+ def adjust_params(hash)
293
+ hash.transform_values! { |v| adjust_value(v) }
294
+ end
295
+
296
+ def adjust_value(value)
297
+ case value
298
+ when ActionDispatch::Http::UploadedFile then value.original_filename
299
+ when Hash then adjust_params(value)
300
+ when Array then value.map { |item| adjust_value(item) }
301
+ else value
390
302
  end
391
303
  end
392
304
 
@@ -401,123 +313,134 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
401
313
  # Same logic as normalize_content_type – strips header parameters after ';'
402
314
  alias normalize_content_disposition normalize_content_type
403
315
 
404
- def build_array_items_schema(array, record: nil, path: nil, context: nil)
316
+ def build_array_items_schema(array, ctx)
405
317
  return {} if array.empty?
406
- return build_property(array.first, record: record, path: path, context: context) if array.size == 1
407
- return build_property(array.first, record: record, path: path, context: context) unless array.all?(Hash)
408
318
 
409
- all_schemas = array.map { |item| build_property(item, record: record, path: path, context: context) }
410
- merged_schema = all_schemas.first.dup
411
- merged_schema[:properties] = {}
319
+ schemas = array.map { |item| build_property(item, ctx) }
320
+ return schemas.first if schemas.size == 1 || !array.all?(Hash)
321
+
322
+ merged = schemas.first.dup
323
+ merged[:properties] = merge_property_variations(schemas, allow_recursive_merge: false)
324
+ merged[:required] = schemas.map { |s| s[:required] || [] }.reduce(:&) || []
325
+ merged
326
+ end
327
+
328
+ def build_merged_schema_from_variations(variations)
329
+ return {} if variations.empty?
330
+ return variations.first if variations.size == 1
331
+
332
+ types = variations.map { |v| v[:type] }.compact.uniq
333
+ return variations.first unless types.size == 1 && types.first == 'object'
412
334
 
413
- all_keys = all_schemas.flat_map { |s| s[:properties]&.keys || [] }.uniq
335
+ {
336
+ type: 'object',
337
+ properties: merge_property_variations(variations, allow_recursive_merge: true),
338
+ required: variations.map { |v| v[:required] || [] }.reduce(:&) || [],
339
+ }
340
+ end
414
341
 
415
- all_keys.each do |key|
416
- all_property_schemas = all_schemas.map { |s| s[:properties]&.[](key) }
342
+ # Merge the per-key property schemas of multiple object variations.
343
+ # When `allow_recursive_merge` is true, objects are recursively merged via
344
+ # build_merged_schema_from_variations and existing oneOf entries are flattened.
345
+ # When false (callsite: array-items merging), divergent property variations
346
+ # become oneOf without recursive descent.
347
+ def merge_property_variations(variations, allow_recursive_merge:)
348
+ property_keys(variations).each_with_object({}) do |key, merged_props|
349
+ all = variations.map { |v| v[:properties]&.[](key) }
350
+ prop_variations = all.reject { |p| p.nil? || p.keys == [:nullable] }
351
+ has_nullable = nullable_present?(all, recursive: allow_recursive_merge)
352
+
353
+ next if prop_variations.empty? && !has_nullable
354
+
355
+ merged_props[key] = merge_single_property(prop_variations, has_nullable,
356
+ variations_total: variations.size,
357
+ allow_recursive_merge: allow_recursive_merge,)
358
+ end
359
+ end
417
360
 
418
- nullable_only_schemas = all_property_schemas.select { |p| p && p.keys == [:nullable] }
419
- property_variations = all_property_schemas.select { |p| p && p.keys != [:nullable] }
361
+ def property_keys(variations)
362
+ variations.flat_map { |v| v[:properties]&.keys || [] }.uniq
363
+ end
420
364
 
421
- has_nullable = all_property_schemas.any?(&:nil?) || nullable_only_schemas.any?
365
+ # `recursive` mirrors build_merged_schema_from_variations' original rule that
366
+ # also treats `{ ..., nullable: true }` as a nullable signal. Array-items
367
+ # merging only looks at outright nil or `{ nullable: true }` markers.
368
+ def nullable_present?(all_props, recursive:)
369
+ all_props.any? do |p|
370
+ p.nil? || (p.is_a?(Hash) && (p.keys == [:nullable] || (recursive && p[:nullable] == true)))
371
+ end
372
+ end
422
373
 
423
- next if property_variations.empty? && !has_nullable
374
+ def merge_single_property(prop_variations, has_nullable, variations_total:, allow_recursive_merge:)
375
+ return { nullable: true } if prop_variations.empty?
424
376
 
425
- if property_variations.empty? && has_nullable
426
- merged_schema[:properties][key] = { nullable: true }
427
- elsif property_variations.size == 1
428
- merged_schema[:properties][key] = property_variations.first.dup
377
+ merged =
378
+ if prop_variations.size == 1
379
+ prop_variations.first.dup
380
+ elsif allow_recursive_merge
381
+ merge_multi_recursive(prop_variations)
429
382
  else
430
- unique_types = property_variations.map { |p| p[:type] }.compact.uniq
431
-
432
- if unique_types.size > 1
433
- unique_props = property_variations.map { |p| p.reject { |k, _| k == :nullable } }.uniq
434
- merged_schema[:properties][key] = { oneOf: unique_props }
435
- else
436
- case unique_types.first
437
- when 'array'
438
- merged_schema[:properties][key] = { type: 'array' }
439
- items_variations = property_variations.map { |p| p[:items] }.compact
440
- merged_schema[:properties][key][:items] = build_merged_schema_from_variations(items_variations)
441
- when 'object'
442
- merged_schema[:properties][key] = build_merged_schema_from_variations(property_variations)
443
- else
444
- merged_schema[:properties][key] = property_variations.first.dup
445
- end
446
- end
383
+ merge_multi_array_items(prop_variations)
447
384
  end
448
385
 
449
- merged_schema[:properties][key][:nullable] = true if has_nullable && merged_schema[:properties][key].is_a?(Hash)
450
- end
386
+ return merged unless merged.is_a?(Hash)
451
387
 
452
- all_required_sets = all_schemas.map { |s| s[:required] || [] }
453
- merged_schema[:required] = all_required_sets.reduce(:&) || []
388
+ # In recursive mode, multi-variation merges also flag nullable when the key
389
+ # only appeared in some of the source variations.
390
+ needs_nullable =
391
+ if allow_recursive_merge && prop_variations.size > 1
392
+ has_nullable || prop_variations.size < variations_total
393
+ else
394
+ has_nullable
395
+ end
396
+ merged[:nullable] = true if needs_nullable
397
+ merged
398
+ end
454
399
 
455
- merged_schema
400
+ # Array-items mode: combine multiple variations of the same property without
401
+ # recursing into nested objects/arrays beyond one level.
402
+ def merge_multi_array_items(prop_variations)
403
+ unique_types = prop_variations.map { |p| p[:type] }.compact.uniq
404
+ return one_of_schema(prop_variations) if unique_types.size > 1
405
+
406
+ case unique_types.first
407
+ when 'array'
408
+ items_variations = prop_variations.map { |p| p[:items] }.compact
409
+ { type: 'array', items: build_merged_schema_from_variations(items_variations) }
410
+ when 'object'
411
+ build_merged_schema_from_variations(prop_variations)
412
+ else
413
+ prop_variations.first.dup
414
+ end
456
415
  end
457
416
 
458
- def build_merged_schema_from_variations(variations)
459
- return {} if variations.empty?
460
- return variations.first if variations.size == 1
417
+ # Recursive-merge mode (used inside build_merged_schema_from_variations):
418
+ # additionally flattens existing oneOf entries and recurses into objects.
419
+ def merge_multi_recursive(prop_variations)
420
+ return { oneOf: flatten_one_of(prop_variations) } if prop_variations.any? { |p| p.key?(:oneOf) }
461
421
 
462
- types = variations.map { |v| v[:type] }.compact.uniq
422
+ prop_types = prop_variations.map { |p| p[:type] }.compact.uniq
423
+ return one_of_schema(prop_variations) if prop_types.size > 1
463
424
 
464
- if types.size == 1 && types.first == 'object'
465
- merged = { type: 'object', properties: {} }
466
- all_keys = variations.flat_map { |v| v[:properties]&.keys || [] }.uniq
467
-
468
- all_keys.each do |key|
469
- all_prop_variations = variations.map { |v| v[:properties]&.[](key) }
470
-
471
- nullable_only = all_prop_variations.select { |p| p && p.keys == [:nullable] }
472
- prop_variations = all_prop_variations.select { |p| p && p.keys != [:nullable] }.compact
473
-
474
- has_nullable = all_prop_variations.any? do |v|
475
- v.nil? || (v.is_a?(Hash) && v[:nullable] == true)
476
- end || nullable_only.any?
477
-
478
- if prop_variations.empty? && has_nullable
479
- merged[:properties][key] = { nullable: true }
480
- elsif prop_variations.size == 1
481
- merged[:properties][key] = prop_variations.first.dup
482
- merged[:properties][key][:nullable] = true if has_nullable
483
- elsif prop_variations.size > 1
484
- prop_types = prop_variations.map { |p| p[:type] }.compact.uniq
485
- has_one_of = prop_variations.any? { |p| p.key?(:oneOf) }
486
-
487
- if has_one_of
488
- all_options = []
489
- prop_variations.each do |prop|
490
- clean_prop = prop.reject { |k, _| k == :nullable }
491
- if clean_prop.key?(:oneOf)
492
- all_options.concat(clean_prop[:oneOf])
493
- else
494
- all_options << clean_prop unless clean_prop.empty?
495
- end
496
- end
497
- all_options.uniq!
498
- merged[:properties][key] = { oneOf: all_options }
499
- elsif prop_types.size == 1
500
- # Only recursively merge if it's an object type
501
- merged[:properties][key] = if prop_types.first == 'object'
502
- build_merged_schema_from_variations(prop_variations)
503
- else
504
- prop_variations.first.dup
505
- end
506
- else
507
- unique_props = prop_variations.map { |p| p.reject { |k, _| k == :nullable } }.uniq
508
- merged[:properties][key] = { oneOf: unique_props }
509
- end
510
-
511
- merged[:properties][key][:nullable] = true if has_nullable || prop_variations.size < variations.size
512
- end
425
+ prop_types.first == 'object' ? build_merged_schema_from_variations(prop_variations) : prop_variations.first.dup
426
+ end
427
+
428
+ def flatten_one_of(prop_variations)
429
+ prop_variations.each_with_object([]) do |prop, options|
430
+ clean = without_nullable(prop)
431
+ if clean.key?(:oneOf)
432
+ options.concat(clean[:oneOf])
433
+ elsif !clean.empty?
434
+ options << clean
513
435
  end
436
+ end.uniq
437
+ end
514
438
 
515
- all_required = variations.map { |v| v[:required] || [] }
516
- merged[:required] = all_required.reduce(:&) || []
439
+ def without_nullable(prop)
440
+ prop.reject { |k, _| k == :nullable }
441
+ end
517
442
 
518
- merged
519
- else
520
- variations.first
521
- end
443
+ def one_of_schema(variations)
444
+ { oneOf: variations.map { |p| without_nullable(p) }.uniq }
522
445
  end
523
446
  end