rspec-openapi 0.25.1 → 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,39 +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 = {
8
- description: record.description,
9
- }
10
-
11
- response_headers = build_response_headers(record)
12
- response[:headers] = response_headers unless response_headers.empty?
13
-
14
- if record.response_body
15
- disposition = normalize_content_disposition(record.response_content_disposition)
16
-
17
- has_content = !normalize_content_type(record.response_content_type).nil?
18
- response[:content] = build_content(disposition, record) if has_content
19
- end
20
-
21
- http_method = record.http_method.downcase
22
10
  {
23
11
  paths: {
24
12
  normalize_path(record.path) => {
25
- http_method => {
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: include_nil_request_body?(http_method) ? nil : build_request_body(record),
33
- responses: {
34
- record.status.to_s => response,
35
- },
36
- }.compact,
13
+ record.http_method.downcase => build_operation(record),
37
14
  },
38
15
  },
39
16
  }
@@ -41,49 +18,64 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
41
18
 
42
19
  private
43
20
 
44
- def include_nil_request_body?(http_method)
45
- %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
46
53
  end
47
54
 
48
- def build_content(disposition, record)
55
+ def build_content(record)
56
+ disposition = normalize_content_disposition(record.response_content_disposition)
49
57
  content_type = normalize_content_type(record.response_content_type)
50
- schema = build_property(record.response_body, disposition: disposition, record: record, context: :response)
51
-
52
- # If examples are globally disabled, always return schema-only content.
53
- return { content_type => { schema: schema }.compact } unless example_enabled?(record)
54
-
55
- case record.example_mode
56
- when :none
57
- # Only schema, no examples
58
- {
59
- content_type => {
60
- schema: schema,
61
- }.compact,
62
- }
63
- when :multiple
64
- # Multiple named examples
65
- {
66
- content_type => {
67
- schema: schema,
68
- examples: { record.example_key => build_example_object(record, disposition: disposition) },
69
- }.compact,
70
- }
71
- else # :single (default)
72
- # Single example + store name for possible merger conversion
73
- {
74
- content_type => {
75
- schema: schema,
76
- example: response_example(record, disposition: disposition),
77
- _example_key: record.example_key,
78
- _example_summary: example_summary(record),
79
- }.compact,
80
- }
81
- 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 }
82
64
  end
83
65
 
84
- def enrich_with_required_keys(obj)
85
- obj[:required] = obj[:properties]&.keys || []
86
- 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
87
79
  end
88
80
 
89
81
  def response_example(record, disposition:)
@@ -92,16 +84,17 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
92
84
  record.response_body
93
85
  end
94
86
 
95
- def build_example_object(record, disposition:)
87
+ def build_named_example(record, value)
96
88
  summary = example_summary(record)
97
- example = {}
98
- example[:summary] = summary if summary
99
- example[:value] = response_example(record, disposition: disposition)
100
- example
89
+ summary ? { summary: summary, value: value } : { value: value }
90
+ end
91
+
92
+ def example_metadata(record)
93
+ { _example_key: record.example_key, _example_summary: example_summary(record) }
101
94
  end
102
95
 
103
96
  def example_summary(record)
104
- return nil unless example_summary_enabled?
97
+ return nil unless RSpec::OpenAPI.enable_example_summary
105
98
  return nil if record.example_name.nil? || record.example_name.empty?
106
99
 
107
100
  record.example_name
@@ -111,58 +104,45 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
111
104
  record.example_enabled
112
105
  end
113
106
 
114
- def example_summary_enabled?
115
- RSpec::OpenAPI.enable_example_summary
116
- end
117
-
118
107
  def build_parameters(record)
119
- path_params = record.path_params.map do |key, value|
120
- {
121
- name: build_parameter_name(key, value),
122
- in: 'path',
123
- required: true,
124
- schema: build_property(try_cast(value), key: key, record: record, path: key.to_s, context: :request),
125
- example: (try_cast(value) if example_enabled?(record)),
126
- }.compact
108
+ parameters = record.path_params.map do |key, value|
109
+ build_parameter(key, value, location: 'path', record: record)
127
110
  end
128
111
 
129
- query_params = flatten_query_params(record.query_params).map do |key, value|
130
- {
131
- name: key,
132
- in: 'query',
133
- required: record.required_request_params.include?(key),
134
- schema: build_property(try_cast(value), key: key, record: record, path: key.to_s, context: :request),
135
- example: (try_cast(value) if example_enabled?(record)),
136
- }.compact
112
+ parameters += flatten_query_params(record.query_params).map do |key, value|
113
+ build_parameter(key, value, location: 'query', record: record)
137
114
  end
138
115
 
139
- header_params = record.request_headers.map do |key, value|
140
- {
141
- name: build_parameter_name(key, value),
142
- in: 'header',
143
- required: true,
144
- schema: build_property(try_cast(value), key: key, record: record, path: key.to_s, context: :request),
145
- example: (try_cast(value) if example_enabled?(record)),
146
- }.compact
116
+ parameters += record.request_headers.map do |key, value|
117
+ build_parameter(key, value, location: 'header', record: record)
147
118
  end
148
119
 
149
- parameters = path_params + query_params + header_params
150
-
151
- return nil if parameters.empty?
120
+ parameters&.empty? ? nil : parameters
121
+ end
152
122
 
153
- 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
154
139
  end
155
140
 
156
141
  def build_response_headers(record)
157
- headers = {}
158
-
159
- record.response_headers.each do |key, value|
160
- headers[key] = {
161
- schema: build_property(try_cast(value), key: key, record: record, path: key.to_s, context: :response),
162
- }.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) }]
163
145
  end
164
-
165
- headers
166
146
  end
167
147
 
168
148
  def build_parameter_name(key, value)
@@ -176,8 +156,7 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
176
156
  end
177
157
 
178
158
  def flatten_query_params(params, parent_key = nil)
179
- result = {}
180
- params.each do |key, value|
159
+ params.each_with_object({}) do |(key, value), result|
181
160
  full_key = parent_key ? "#{parent_key}[#{key}]" : key.to_s
182
161
 
183
162
  if value.is_a?(Hash)
@@ -186,71 +165,80 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
186
165
  result[full_key] = value
187
166
  end
188
167
  end
189
- result
190
168
  end
191
169
 
192
170
  def build_request_body(record)
193
171
  return nil if record.request_content_type.nil?
194
- return nil if record.status >= 400
172
+ return nil if record.status >= 400 && record.request_example_mode != :multiple
195
173
 
196
- {
197
- content: {
198
- normalize_content_type(record.request_content_type) => {
199
- schema: build_property(record.request_params, record: record, context: :request),
200
- example: (build_example(record.request_params) if example_enabled?(record)),
201
- }.compact,
202
- },
203
- }
204
- end
174
+ content_type = normalize_content_type(record.request_content_type)
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
205
178
 
206
- def build_property(value, disposition: nil, key: nil, record: nil, path: nil, context: nil)
207
- format = disposition ? 'binary' : infer_format(key, record)
208
- enum = infer_enum(path, record, context)
179
+ body = build_example_body(schema, record, mode: record.request_example_mode, example: example)
180
+ { content: { content_type => body } }
181
+ end
209
182
 
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)
210
186
  property = build_type(value, format: format, enum: enum)
211
187
 
212
188
  case value
213
189
  when Array
214
- property[:items] = if value.empty?
215
- {} # unknown
216
- else
217
- build_array_items_schema(value, record: record, path: path, context: context)
218
- end
190
+ property[:items] = value.empty? ? {} : build_array_items_schema(value, ctx.for_array_items)
219
191
  when Hash
220
- property[:properties] = {}.tap do |properties|
221
- value.each do |k, v|
222
- child_path = path ? "#{path}.#{k}" : k.to_s
223
- properties[k] = build_property(v, record: record, key: k, path: child_path, context: context)
224
- end
225
- end
226
- property = enrich_with_required_keys(property)
192
+ apply_object_schema(property, value, ctx)
227
193
  end
228
194
  property
229
195
  end
230
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
+
231
228
  def build_type(value, format: nil, enum: nil)
232
229
  result = if format
233
230
  { type: 'string', format: format }
234
231
  else
235
232
  case value
236
- when String
237
- { type: 'string' }
238
- when Integer
239
- { type: 'integer' }
240
- when Float
241
- { type: 'number', format: 'float' }
242
- when TrueClass, FalseClass
243
- { type: 'boolean' }
244
- when Array
245
- { type: 'array' }
246
- when Hash
247
- { type: 'object' }
248
- when ActionDispatch::Http::UploadedFile
249
- { type: 'string', format: 'binary' }
250
- when NilClass
251
- { nullable: true }
252
- else
253
- 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}"
254
242
  end
255
243
  end
256
244
 
@@ -259,19 +247,33 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
259
247
  end
260
248
 
261
249
  def infer_format(key, record)
262
- return nil if !key || !record || !record.formats
250
+ return nil unless key && record
263
251
 
264
- record.formats[key]
252
+ record.formats&.[](key)
265
253
  end
266
254
 
267
255
  def infer_enum(path, record, context)
268
256
  return nil if !path || !record
269
257
 
270
- enum_hash = context == :request ? record.request_enum : record.response_enum
271
- return nil unless enum_hash
272
-
273
258
  # Keys are already normalized to strings by SharedExtractor.normalize_enum
274
- enum_hash[path.to_s]
259
+ record.send("#{context}_enum")&.[](path.to_s)
260
+ end
261
+
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)
268
+ return nil unless record
269
+
270
+ overrides = record.send("#{context}_#{kind}")
271
+ return nil unless overrides
272
+
273
+ # path is nil at the body root; nil.to_s == '' lets users target it via { '' => ... }.
274
+ return nil if kind == :additional_properties && !overrides.key?(path.to_s)
275
+
276
+ overrides[path.to_s]
275
277
  end
276
278
 
277
279
  # Convert an always-String param to an appropriate type
@@ -284,30 +286,19 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
284
286
  def build_example(value)
285
287
  return nil if value.nil?
286
288
 
287
- value = value.dup
288
- adjust_params(value)
289
- end
290
-
291
- def adjust_params(value)
292
- value.each do |key, v|
293
- case v
294
- when ActionDispatch::Http::UploadedFile
295
- value[key] = v.original_filename
296
- when Hash
297
- adjust_params(v)
298
- when Array
299
- result = v.map do |item|
300
- case item
301
- when ActionDispatch::Http::UploadedFile
302
- item.original_filename
303
- when Hash
304
- adjust_params(item)
305
- else
306
- item
307
- end
308
- end
309
- value[key] = result
310
- 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
311
302
  end
312
303
  end
313
304
 
@@ -322,123 +313,134 @@ class << RSpec::OpenAPI::SchemaBuilder = Object.new
322
313
  # Same logic as normalize_content_type – strips header parameters after ';'
323
314
  alias normalize_content_disposition normalize_content_type
324
315
 
325
- def build_array_items_schema(array, record: nil, path: nil, context: nil)
316
+ def build_array_items_schema(array, ctx)
326
317
  return {} if array.empty?
327
- return build_property(array.first, record: record, path: path, context: context) if array.size == 1
328
- return build_property(array.first, record: record, path: path, context: context) unless array.all?(Hash)
329
318
 
330
- all_schemas = array.map { |item| build_property(item, record: record, path: path, context: context) }
331
- merged_schema = all_schemas.first.dup
332
- 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'
333
334
 
334
- 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
335
341
 
336
- all_keys.each do |key|
337
- 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
338
360
 
339
- nullable_only_schemas = all_property_schemas.select { |p| p && p.keys == [:nullable] }
340
- 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
341
364
 
342
- 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
343
373
 
344
- 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?
345
376
 
346
- if property_variations.empty? && has_nullable
347
- merged_schema[:properties][key] = { nullable: true }
348
- elsif property_variations.size == 1
349
- 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)
350
382
  else
351
- unique_types = property_variations.map { |p| p[:type] }.compact.uniq
352
-
353
- if unique_types.size > 1
354
- unique_props = property_variations.map { |p| p.reject { |k, _| k == :nullable } }.uniq
355
- merged_schema[:properties][key] = { oneOf: unique_props }
356
- else
357
- case unique_types.first
358
- when 'array'
359
- merged_schema[:properties][key] = { type: 'array' }
360
- items_variations = property_variations.map { |p| p[:items] }.compact
361
- merged_schema[:properties][key][:items] = build_merged_schema_from_variations(items_variations)
362
- when 'object'
363
- merged_schema[:properties][key] = build_merged_schema_from_variations(property_variations)
364
- else
365
- merged_schema[:properties][key] = property_variations.first.dup
366
- end
367
- end
383
+ merge_multi_array_items(prop_variations)
368
384
  end
369
385
 
370
- merged_schema[:properties][key][:nullable] = true if has_nullable && merged_schema[:properties][key].is_a?(Hash)
371
- end
386
+ return merged unless merged.is_a?(Hash)
372
387
 
373
- all_required_sets = all_schemas.map { |s| s[:required] || [] }
374
- 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
375
399
 
376
- 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
377
415
  end
378
416
 
379
- def build_merged_schema_from_variations(variations)
380
- return {} if variations.empty?
381
- 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) }
382
421
 
383
- 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
384
424
 
385
- if types.size == 1 && types.first == 'object'
386
- merged = { type: 'object', properties: {} }
387
- all_keys = variations.flat_map { |v| v[:properties]&.keys || [] }.uniq
388
-
389
- all_keys.each do |key|
390
- all_prop_variations = variations.map { |v| v[:properties]&.[](key) }
391
-
392
- nullable_only = all_prop_variations.select { |p| p && p.keys == [:nullable] }
393
- prop_variations = all_prop_variations.select { |p| p && p.keys != [:nullable] }.compact
394
-
395
- has_nullable = all_prop_variations.any? do |v|
396
- v.nil? || (v.is_a?(Hash) && v[:nullable] == true)
397
- end || nullable_only.any?
398
-
399
- if prop_variations.empty? && has_nullable
400
- merged[:properties][key] = { nullable: true }
401
- elsif prop_variations.size == 1
402
- merged[:properties][key] = prop_variations.first.dup
403
- merged[:properties][key][:nullable] = true if has_nullable
404
- elsif prop_variations.size > 1
405
- prop_types = prop_variations.map { |p| p[:type] }.compact.uniq
406
- has_one_of = prop_variations.any? { |p| p.key?(:oneOf) }
407
-
408
- if has_one_of
409
- all_options = []
410
- prop_variations.each do |prop|
411
- clean_prop = prop.reject { |k, _| k == :nullable }
412
- if clean_prop.key?(:oneOf)
413
- all_options.concat(clean_prop[:oneOf])
414
- else
415
- all_options << clean_prop unless clean_prop.empty?
416
- end
417
- end
418
- all_options.uniq!
419
- merged[:properties][key] = { oneOf: all_options }
420
- elsif prop_types.size == 1
421
- # Only recursively merge if it's an object type
422
- merged[:properties][key] = if prop_types.first == 'object'
423
- build_merged_schema_from_variations(prop_variations)
424
- else
425
- prop_variations.first.dup
426
- end
427
- else
428
- unique_props = prop_variations.map { |p| p.reject { |k, _| k == :nullable } }.uniq
429
- merged[:properties][key] = { oneOf: unique_props }
430
- end
431
-
432
- merged[:properties][key][:nullable] = true if has_nullable || prop_variations.size < variations.size
433
- 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
434
435
  end
436
+ end.uniq
437
+ end
435
438
 
436
- all_required = variations.map { |v| v[:required] || [] }
437
- merged[:required] = all_required.reduce(:&) || []
439
+ def without_nullable(prop)
440
+ prop.reject { |k, _| k == :nullable }
441
+ end
438
442
 
439
- merged
440
- else
441
- variations.first
442
- end
443
+ def one_of_schema(variations)
444
+ { oneOf: variations.map { |p| without_nullable(p) }.uniq }
443
445
  end
444
446
  end