diff --git a/lib/rspec/openapi/schema_builder.rb b/lib/rspec/openapi/schema_builder.rb index 5a1c006c..b86fca81 100644 --- a/lib/rspec/openapi/schema_builder.rb +++ b/lib/rspec/openapi/schema_builder.rb @@ -270,22 +270,38 @@ def build_array_items_schema(array, record: nil) elsif property_variations.size == 1 merged_schema[:properties][key] = property_variations.first.dup else - unique_types = property_variations.map { |p| p[:type] }.compact.uniq + has_one_of = property_variations.any? { |p| p.key?(:oneOf) } + + if has_one_of + all_options = [] + property_variations.each do |prop| + clean_prop = prop.reject { |k, _| k == :nullable } + if clean_prop.key?(:oneOf) + all_options.concat(clean_prop[:oneOf]) + else + all_options << clean_prop unless clean_prop.empty? + end + end - if unique_types.size > 1 - # Different types detected - create oneOf - unique_props = property_variations.map { |p| p.reject { |k, _| k == :nullable } }.uniq - merged_schema[:properties][key] = { oneOf: unique_props } + all_options.uniq! + merged_schema[:properties][key] = { oneOf: all_options } else - case unique_types.first - when 'array' - merged_schema[:properties][key] = { type: 'array' } - items_variations = property_variations.map { |p| p[:items] }.compact - merged_schema[:properties][key][:items] = build_merged_schema_from_variations(items_variations) - when 'object' - merged_schema[:properties][key] = build_merged_schema_from_variations(property_variations) + unique_types = property_variations.map { |p| p[:type] }.compact.uniq + + if unique_types.size > 1 + unique_props = property_variations.map { |p| p.reject { |k, _| k == :nullable } }.uniq + merged_schema[:properties][key] = { oneOf: unique_props } else - merged_schema[:properties][key] = property_variations.first.dup + case unique_types.first + when 'array' + merged_schema[:properties][key] = { type: 'array' } + items_variations = property_variations.map { |p| p[:items] }.compact + merged_schema[:properties][key][:items] = build_merged_schema_from_variations(items_variations) + when 'object' + merged_schema[:properties][key] = build_merged_schema_from_variations(property_variations) + else + merged_schema[:properties][key] = property_variations.first.dup + end end end end @@ -324,8 +340,21 @@ def build_merged_schema_from_variations(variations) merged[:properties][key][:nullable] = true if has_nullable elsif prop_variations.size > 1 prop_types = prop_variations.map { |p| p[:type] }.compact.uniq - - if prop_types.size == 1 + has_one_of = prop_variations.any? { |p| p.key?(:oneOf) } + + if has_one_of + all_options = [] + prop_variations.each do |prop| + clean_prop = prop.reject { |k, _| k == :nullable } + if clean_prop.key?(:oneOf) + all_options.concat(clean_prop[:oneOf]) + else + all_options << clean_prop unless clean_prop.empty? + end + end + all_options.uniq! + merged[:properties][key] = { oneOf: all_options } + elsif prop_types.size == 1 # Only recursively merge if it's an object type merged[:properties][key] = if prop_types.first == 'object' build_merged_schema_from_variations(prop_variations) diff --git a/spec/apps/hanami/app/actions/array_hashes/mixed_types_nested.rb b/spec/apps/hanami/app/actions/array_hashes/mixed_types_nested.rb index 43ed6d83..3ade7746 100644 --- a/spec/apps/hanami/app/actions/array_hashes/mixed_types_nested.rb +++ b/spec/apps/hanami/app/actions/array_hashes/mixed_types_nested.rb @@ -43,7 +43,26 @@ def handle(request, response) "ssl" => true }, "form" => nil - } + }, + { + "id" => 3, + "config" => { + "port" => "9010", + "host" => "foo.example.com", + "ssl" => true + }, + "form" => [ + { + "value" => false, + }, + { + "value" => ['First', 'Second'], + }, + { + "value" => 3, + }, + ] + }, ] }.to_json end diff --git a/spec/apps/hanami/app/actions/array_hashes/multiple_one_of_test.rb b/spec/apps/hanami/app/actions/array_hashes/multiple_one_of_test.rb new file mode 100644 index 00000000..b3db04a6 --- /dev/null +++ b/spec/apps/hanami/app/actions/array_hashes/multiple_one_of_test.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +module HanamiTest + module Actions + module ArrayHashes + class MultipleOneOfTest < HanamiTest::Action + def handle(request, response) + response.format = :json + + response.body = { + "data" => { + "form" => [ + { + "inputs" => [ + { + "value" => 'John Doe', + }, + { + "value" => + 'some_email_123@someone.com', + }, + { + "value" => 'In progress', + }, + { + "value" => '2025-12-11T06:25:20.770+00:00', + }, + ], + }, + { + "inputs" => [ + { + "value" => nil, + }, + { + "value" => 'user_1', + }, + { + "value" => 'user_2', + }, + ], + }, + { + "inputs" => [ + { + "value" => false, + }, + { + "value" => 'Some organisation', + }, + { + "value" => 'organisation_1', + }, + { + "value" => [ + 'organisation_1', + 'organisation_2', + 'organisation_3', + ], + }, + ], + }, + { + "inputs" => [ + { + "value" => 'Initialized', + }, + { + "value" => 'Initialized', + }, + { + "value" => 'Initialized', + }, + { + "value" => 'Initialized', + }, + { + "value" => nil, + }, + ], + }, + { + "inputs" => [ + { + "value" => nil, + }, + { + "value" => nil, + }, + { + "value" => nil, + }, + { + "value" => nil, + }, + ], + }, + ], + }, + }.to_json + end + end + end + end +end diff --git a/spec/apps/hanami/config/routes.rb b/spec/apps/hanami/config/routes.rb index 849de477..f8bf072c 100644 --- a/spec/apps/hanami/config/routes.rb +++ b/spec/apps/hanami/config/routes.rb @@ -33,6 +33,7 @@ class Routes < Hanami::Routes get '/array_hashes/nested_arrays', to: 'array_hashes.nested_arrays' get '/array_hashes/nested_objects', to: 'array_hashes.nested_objects' get '/array_hashes/mixed_types_nested', to: 'array_hashes.mixed_types_nested' + get '/array_hashes/multiple_one_of_test', to: 'array_hashes.multiple_one_of_test' get '/test_block', to: ->(_env) { [200, { 'Content-Type' => 'text/plain' }, ['A TEST']] } diff --git a/spec/apps/hanami/doc/openapi.json b/spec/apps/hanami/doc/openapi.json index d7f7cf76..c7ff19b0 100644 --- a/spec/apps/hanami/doc/openapi.json +++ b/spec/apps/hanami/doc/openapi.json @@ -108,9 +108,20 @@ { "type": "array", "items": {} + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "integer" } - ], - "nullable": true + ] }, "options": { "oneOf": [ @@ -148,8 +159,7 @@ } }, "required": [ - "value", - "options" + "value" ] }, "nullable": true @@ -208,6 +218,28 @@ "ssl": true }, "form": null + }, + { + "id": 3, + "config": { + "port": "9010", + "host": "foo.example.com", + "ssl": true + }, + "form": [ + { + "value": false + }, + { + "value": [ + "First", + "Second" + ] + }, + { + "value": 3 + } + ] } ] } @@ -217,6 +249,168 @@ } } }, + "/array_hashes/multiple_one_of_test": { + "get": { + "summary": "multiple_one_of_test", + "tags": [ + "ArrayHash" + ], + "responses": { + "200": { + "description": "returns items that would produce different oneOf types", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "form": { + "type": "array", + "items": { + "type": "object", + "properties": { + "inputs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "nullable": true + } + }, + "required": [ + "value" + ] + } + } + }, + "required": [ + "inputs" + ] + } + } + }, + "required": [ + "form" + ] + } + }, + "required": [ + "data" + ] + }, + "example": { + "data": { + "form": [ + { + "inputs": [ + { + "value": "John Doe" + }, + { + "value": "some_email_123@someone.com" + }, + { + "value": "In progress" + }, + { + "value": "2025-12-11T06:25:20.770+00:00" + } + ] + }, + { + "inputs": [ + { + "value": null + }, + { + "value": "user_1" + }, + { + "value": "user_2" + } + ] + }, + { + "inputs": [ + { + "value": false + }, + { + "value": "Some organisation" + }, + { + "value": "organisation_1" + }, + { + "value": [ + "organisation_1", + "organisation_2", + "organisation_3" + ] + } + ] + }, + { + "inputs": [ + { + "value": "Initialized" + }, + { + "value": "Initialized" + }, + { + "value": "Initialized" + }, + { + "value": "Initialized" + }, + { + "value": null + } + ] + }, + { + "inputs": [ + { + "value": null + }, + { + "value": null + }, + { + "value": null + }, + { + "value": null + } + ] + } + ] + } + } + } + } + } + } + } + }, "/array_hashes/nested": { "get": { "summary": "nested", diff --git a/spec/apps/hanami/doc/openapi.yaml b/spec/apps/hanami/doc/openapi.yaml index ea541da7..da0327dc 100644 --- a/spec/apps/hanami/doc/openapi.yaml +++ b/spec/apps/hanami/doc/openapi.yaml @@ -79,7 +79,11 @@ paths: - type: string - type: array items: {} - nullable: true + - type: boolean + - type: array + items: + type: string + - type: integer options: oneOf: - type: array @@ -102,7 +106,6 @@ paths: nullable: true required: - value - - options nullable: true required: - id @@ -133,6 +136,90 @@ paths: host: example.com ssl: true form: + - id: 3 + config: + port: '9010' + host: foo.example.com + ssl: true + form: + - value: false + - value: + - First + - Second + - value: 3 + "/array_hashes/multiple_one_of_test": + get: + summary: multiple_one_of_test + tags: + - ArrayHash + responses: + '200': + description: returns items that would produce different oneOf types + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + form: + type: array + items: + type: object + properties: + inputs: + type: array + items: + type: object + properties: + value: + oneOf: + - type: string + - type: boolean + - type: array + items: + type: string + nullable: true + required: + - value + required: + - inputs + required: + - form + required: + - data + example: + data: + form: + - inputs: + - value: John Doe + - value: some_email_123@someone.com + - value: In progress + - value: '2025-12-11T06:25:20.770+00:00' + - inputs: + - value: + - value: user_1 + - value: user_2 + - inputs: + - value: false + - value: Some organisation + - value: organisation_1 + - value: + - organisation_1 + - organisation_2 + - organisation_3 + - inputs: + - value: Initialized + - value: Initialized + - value: Initialized + - value: Initialized + - value: + - inputs: + - value: + - value: + - value: + - value: "/array_hashes/nested": get: summary: nested diff --git a/spec/apps/rails/app/controllers/array_hashes_controller.rb b/spec/apps/rails/app/controllers/array_hashes_controller.rb index edd09bde..01f414a6 100644 --- a/spec/apps/rails/app/controllers/array_hashes_controller.rb +++ b/spec/apps/rails/app/controllers/array_hashes_controller.rb @@ -236,9 +236,112 @@ def mixed_types_nested "ssl" => true }, "form" => nil - } + }, + { + "id" => 3, + "config" => { + "port" => "9010", + "host" => "foo.example.com", + "ssl" => true + } + }, ] } render json: response end + + def multiple_one_of_test + response = { + "data" => { + "form" => [ + { + "inputs" => [ + { + "value" => 'John Doe', + }, + { + "value" => + 'some_email_123@someone.com', + }, + { + "value" => 'In progress', + }, + { + "value" => '2025-12-11T06:25:20.770+00:00', + }, + ], + }, + { + "inputs" => [ + { + "value" => nil, + }, + { + "value" => 'user_1', + }, + { + "value" => 'user_2', + }, + ], + }, + { + "inputs" => [ + { + "value" => false, + }, + { + "value" => 'Some organisation', + }, + { + "value" => 'organisation_1', + }, + { + "value" => [ + 'organisation_1', + 'organisation_2', + 'organisation_3', + ], + }, + ], + }, + { + "inputs" => [ + { + "value" => 'Initialized', + }, + { + "value" => 'Initialized', + }, + { + "value" => 'Initialized', + }, + { + "value" => 'Initialized', + }, + { + "value" => nil, + }, + ], + }, + { + "inputs" => [ + { + "value" => nil, + }, + { + "value" => nil, + }, + { + "value" => nil, + }, + { + "value" => nil, + }, + ], + }, + ], + }, + } + render json: response + end end diff --git a/spec/apps/rails/config/routes.rb b/spec/apps/rails/config/routes.rb index 45462639..daa7c3df 100644 --- a/spec/apps/rails/config/routes.rb +++ b/spec/apps/rails/config/routes.rb @@ -39,6 +39,7 @@ get :nested_arrays, on: :collection get :nested_objects, on: :collection get :mixed_types_nested, on: :collection + get :multiple_one_of_test, on: :collection end scope :admin do diff --git a/spec/apps/rails/doc/minitest_openapi.json b/spec/apps/rails/doc/minitest_openapi.json index 698a7bcc..7181cf65 100644 --- a/spec/apps/rails/doc/minitest_openapi.json +++ b/spec/apps/rails/doc/minitest_openapi.json @@ -191,8 +191,7 @@ } }, "required": [ - "value", - "options" + "value" ] }, "nullable": true @@ -251,6 +250,14 @@ "ssl": true }, "form": null + }, + { + "id": 3, + "config": { + "port": "9010", + "host": "foo.example.com", + "ssl": true + } } ] } @@ -260,6 +267,168 @@ } } }, + "/array_hashes/multiple_one_of_test": { + "get": { + "summary": "multiple_one_of_test", + "tags": [ + "ArrayHash" + ], + "responses": { + "200": { + "description": "with mixed types in nested objects in nested array", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "form": { + "type": "array", + "items": { + "type": "object", + "properties": { + "inputs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "nullable": true + } + }, + "required": [ + "value" + ] + } + } + }, + "required": [ + "inputs" + ] + } + } + }, + "required": [ + "form" + ] + } + }, + "required": [ + "data" + ] + }, + "example": { + "data": { + "form": [ + { + "inputs": [ + { + "value": "John Doe" + }, + { + "value": "some_email_123@someone.com" + }, + { + "value": "In progress" + }, + { + "value": "2025-12-11T06:25:20.770+00:00" + } + ] + }, + { + "inputs": [ + { + "value": null + }, + { + "value": "user_1" + }, + { + "value": "user_2" + } + ] + }, + { + "inputs": [ + { + "value": false + }, + { + "value": "Some organisation" + }, + { + "value": "organisation_1" + }, + { + "value": [ + "organisation_1", + "organisation_2", + "organisation_3" + ] + } + ] + }, + { + "inputs": [ + { + "value": "Initialized" + }, + { + "value": "Initialized" + }, + { + "value": "Initialized" + }, + { + "value": "Initialized" + }, + { + "value": null + } + ] + }, + { + "inputs": [ + { + "value": null + }, + { + "value": null + }, + { + "value": null + }, + { + "value": null + } + ] + } + ] + } + } + } + } + } + } + } + }, "/array_hashes/nested": { "get": { "summary": "nested", diff --git a/spec/apps/rails/doc/minitest_openapi.yaml b/spec/apps/rails/doc/minitest_openapi.yaml index a7b30b8a..cf33274a 100644 --- a/spec/apps/rails/doc/minitest_openapi.yaml +++ b/spec/apps/rails/doc/minitest_openapi.yaml @@ -130,7 +130,6 @@ paths: nullable: true required: - value - - options nullable: true required: - id @@ -161,6 +160,84 @@ paths: host: example.com ssl: true form: + - id: 3 + config: + port: '9010' + host: foo.example.com + ssl: true + "/array_hashes/multiple_one_of_test": + get: + summary: multiple_one_of_test + tags: + - ArrayHash + responses: + '200': + description: with mixed types in nested objects in nested array + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + form: + type: array + items: + type: object + properties: + inputs: + type: array + items: + type: object + properties: + value: + oneOf: + - type: string + - type: boolean + - type: array + items: + type: string + nullable: true + required: + - value + required: + - inputs + required: + - form + required: + - data + example: + data: + form: + - inputs: + - value: John Doe + - value: some_email_123@someone.com + - value: In progress + - value: '2025-12-11T06:25:20.770+00:00' + - inputs: + - value: + - value: user_1 + - value: user_2 + - inputs: + - value: false + - value: Some organisation + - value: organisation_1 + - value: + - organisation_1 + - organisation_2 + - organisation_3 + - inputs: + - value: Initialized + - value: Initialized + - value: Initialized + - value: Initialized + - value: + - inputs: + - value: + - value: + - value: + - value: "/array_hashes/nested": get: summary: nested diff --git a/spec/apps/rails/doc/rspec_openapi.json b/spec/apps/rails/doc/rspec_openapi.json index 5e96420b..1888312f 100644 --- a/spec/apps/rails/doc/rspec_openapi.json +++ b/spec/apps/rails/doc/rspec_openapi.json @@ -191,8 +191,7 @@ } }, "required": [ - "value", - "options" + "value" ] }, "nullable": true @@ -251,6 +250,14 @@ "ssl": true }, "form": null + }, + { + "id": 3, + "config": { + "port": "9010", + "host": "foo.example.com", + "ssl": true + } } ] } @@ -260,6 +267,168 @@ } } }, + "/array_hashes/multiple_one_of_test": { + "get": { + "summary": "multiple_one_of_test", + "tags": [ + "ArrayHash" + ], + "responses": { + "200": { + "description": "returns items that would produce different oneOf types", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "form": { + "type": "array", + "items": { + "type": "object", + "properties": { + "inputs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "nullable": true + } + }, + "required": [ + "value" + ] + } + } + }, + "required": [ + "inputs" + ] + } + } + }, + "required": [ + "form" + ] + } + }, + "required": [ + "data" + ] + }, + "example": { + "data": { + "form": [ + { + "inputs": [ + { + "value": "John Doe" + }, + { + "value": "some_email_123@someone.com" + }, + { + "value": "In progress" + }, + { + "value": "2025-12-11T06:25:20.770+00:00" + } + ] + }, + { + "inputs": [ + { + "value": null + }, + { + "value": "user_1" + }, + { + "value": "user_2" + } + ] + }, + { + "inputs": [ + { + "value": false + }, + { + "value": "Some organisation" + }, + { + "value": "organisation_1" + }, + { + "value": [ + "organisation_1", + "organisation_2", + "organisation_3" + ] + } + ] + }, + { + "inputs": [ + { + "value": "Initialized" + }, + { + "value": "Initialized" + }, + { + "value": "Initialized" + }, + { + "value": "Initialized" + }, + { + "value": null + } + ] + }, + { + "inputs": [ + { + "value": null + }, + { + "value": null + }, + { + "value": null + }, + { + "value": null + } + ] + } + ] + } + } + } + } + } + } + } + }, "/array_hashes/nested": { "get": { "summary": "nested", diff --git a/spec/apps/rails/doc/rspec_openapi.yaml b/spec/apps/rails/doc/rspec_openapi.yaml index 76790869..f0f5a9d0 100644 --- a/spec/apps/rails/doc/rspec_openapi.yaml +++ b/spec/apps/rails/doc/rspec_openapi.yaml @@ -130,7 +130,6 @@ paths: nullable: true required: - value - - options nullable: true required: - id @@ -161,6 +160,84 @@ paths: host: example.com ssl: true form: + - id: 3 + config: + port: '9010' + host: foo.example.com + ssl: true + "/array_hashes/multiple_one_of_test": + get: + summary: multiple_one_of_test + tags: + - ArrayHash + responses: + '200': + description: returns items that would produce different oneOf types + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + form: + type: array + items: + type: object + properties: + inputs: + type: array + items: + type: object + properties: + value: + oneOf: + - type: string + - type: boolean + - type: array + items: + type: string + nullable: true + required: + - value + required: + - inputs + required: + - form + required: + - data + example: + data: + form: + - inputs: + - value: John Doe + - value: some_email_123@someone.com + - value: In progress + - value: '2025-12-11T06:25:20.770+00:00' + - inputs: + - value: + - value: user_1 + - value: user_2 + - inputs: + - value: false + - value: Some organisation + - value: organisation_1 + - value: + - organisation_1 + - organisation_2 + - organisation_3 + - inputs: + - value: Initialized + - value: Initialized + - value: Initialized + - value: Initialized + - value: + - inputs: + - value: + - value: + - value: + - value: "/array_hashes/nested": get: summary: nested diff --git a/spec/integration_tests/rails_test.rb b/spec/integration_tests/rails_test.rb index 900699a2..6c20e5d8 100644 --- a/spec/integration_tests/rails_test.rb +++ b/spec/integration_tests/rails_test.rb @@ -356,6 +356,11 @@ class ArrayOfHashesTest < ActionDispatch::IntegrationTest get '/array_hashes/mixed_types_nested' assert_response 200 end + + test 'with mixed types in nested objects in nested array' do + get '/array_hashes/multiple_one_of_test' + assert_response 200 + end end class RSpecHooksAfterSuiteTest < Minitest::Test diff --git a/spec/requests/hanami_spec.rb b/spec/requests/hanami_spec.rb index 1926925b..d49c4218 100644 --- a/spec/requests/hanami_spec.rb +++ b/spec/requests/hanami_spec.rb @@ -380,4 +380,11 @@ expect(last_response.status).to eq(200) end end + + describe 'with mixed types in nested objects in nested array' do + it 'returns items that would produce different oneOf types' do + get '/array_hashes/multiple_one_of_test' + expect(last_response.status).to eq(200) + end + end end diff --git a/spec/requests/rails_spec.rb b/spec/requests/rails_spec.rb index 7a1355c1..e809a93c 100644 --- a/spec/requests/rails_spec.rb +++ b/spec/requests/rails_spec.rb @@ -364,4 +364,11 @@ expect(response.status).to eq(200) end end + + describe 'with mixed types in nested objects in nested array' do + it 'returns items that would produce different oneOf types' do + get '/array_hashes/multiple_one_of_test' + expect(response.status).to eq(200) + end + end end