diff --git a/.gitignore b/.gitignore index 2e61384..c2fb226 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ /spec/reports/ /tmp/ /Gemfile.lock +.DS_Store # rspec failure tracking .rspec_status diff --git a/lib/rspec/openapi/schema_builder.rb b/lib/rspec/openapi/schema_builder.rb index bf5b8a7..dbc0b03 100644 --- a/lib/rspec/openapi/schema_builder.rb +++ b/lib/rspec/openapi/schema_builder.rb @@ -246,20 +246,84 @@ def normalize_content_disposition(content_disposition) def build_array_items_schema(array, record: nil) return {} if array.empty? + return build_property(array.first, record: record) if array.size == 1 + return build_property(array.first, record: record) unless array.all? { |item| item.is_a?(Hash) } - merged_schema = build_property(array.first, record: record) + all_schemas = array.map { |item| build_property(item, record: record) } + merged_schema = all_schemas.first.dup + merged_schema[:properties] = {} - # Future improvement - cover other types than just hashes - if array.size > 1 && array.all? { |item| item.is_a?(Hash) } - array[1..].each do |item| - item_schema = build_property(item, record: record) - merged_schema = merge_object_schemas(merged_schema, item_schema) + all_keys = all_schemas.flat_map { |s| s[:properties]&.keys || [] }.uniq + + all_keys.each do |key| + property_variations = all_schemas.map { |s| s[:properties]&.[](key) }.compact + + next if property_variations.empty? + + if property_variations.size == 1 + merged_schema[:properties][key] = make_property_nullable(property_variations.first) + else + unique_types = property_variations.map { |p| p[:type] }.compact.uniq + + 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 + + merged_schema[:properties][key][:nullable] = true if property_variations.size < all_schemas.size end end + all_required_sets = all_schemas.map { |s| s[:required] || [] } + merged_schema[:required] = all_required_sets.reduce(:&) || [] + merged_schema end + def build_merged_schema_from_variations(variations) + return {} if variations.empty? + return variations.first if variations.size == 1 + + types = variations.map { |v| v[:type] }.compact.uniq + + if types.size == 1 && types.first == 'object' + merged = { type: 'object', properties: {} } + all_keys = variations.flat_map { |v| v[:properties]&.keys || [] }.uniq + + all_keys.each do |key| + prop_variations = variations.map { |v| v[:properties]&.[](key) }.compact + + if prop_variations.size == 1 + merged[:properties][key] = make_property_nullable(prop_variations.first) + elsif prop_variations.size > 1 + prop_types = prop_variations.map { |p| p[:type] }.compact.uniq + + if prop_types.size == 1 + merged[:properties][key] = prop_variations.first.dup + else + unique_props = prop_variations.map { |p| p.reject { |k, _| k == :nullable } }.uniq + merged[:properties][key] = { oneOf: unique_props } + end + + merged[:properties][key][:nullable] = true if prop_variations.size < variations.size + end + end + + all_required = variations.map { |v| v[:required] || [] } + merged[:required] = all_required.reduce(:&) || [] + + merged + else + variations.first + end + end + def merge_object_schemas(schema1, schema2) return schema1 unless schema2.is_a?(Hash) && schema1.is_a?(Hash) return schema1 unless schema1[:type] == 'object' && schema2[:type] == 'object' diff --git a/lib/rspec/openapi/schema_merger.rb b/lib/rspec/openapi/schema_merger.rb index b411955..41db6d5 100644 --- a/lib/rspec/openapi/schema_merger.rb +++ b/lib/rspec/openapi/schema_merger.rb @@ -77,6 +77,8 @@ def merge_closest_match!(options, spec) return if option&.key?(:$ref) + return if spec[:oneOf] + if score.to_f > SIMILARITY_THRESHOLD merge_schema!(option, spec) else diff --git a/spec/apps/hanami/app/actions/array_hashes/empty_array.rb b/spec/apps/hanami/app/actions/array_hashes/empty_array.rb new file mode 100644 index 0000000..5c81ae9 --- /dev/null +++ b/spec/apps/hanami/app/actions/array_hashes/empty_array.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module HanamiTest + module Actions + module ArrayHashes + class EmptyArray < HanamiTest::Action + def handle(request, response) + response.format = :json + + response.body = { + "items" => [] + }.to_json + end + end + end + end +end 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 new file mode 100644 index 0000000..7157ee6 --- /dev/null +++ b/spec/apps/hanami/app/actions/array_hashes/mixed_types_nested.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module HanamiTest + module Actions + module ArrayHashes + class MixedTypesNested < HanamiTest::Action + def handle(request, response) + response.format = :json + + response.body = { + "items" => [ + { + "id" => 1, + "config" => { + "port" => 8080, + "host" => "localhost" + } + }, + { + "id" => 2, + "config" => { + "port" => "3000", + "host" => "example.com", + "ssl" => true + } + } + ] + }.to_json + end + end + end + end +end diff --git a/spec/apps/hanami/app/actions/array_hashes/nested.rb b/spec/apps/hanami/app/actions/array_hashes/nested.rb new file mode 100644 index 0000000..292e97e --- /dev/null +++ b/spec/apps/hanami/app/actions/array_hashes/nested.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module HanamiTest + module Actions + module ArrayHashes + class Nested < HanamiTest::Action + def handle(request, response) + response.format = :json + + response.body = { + "fields" => [ + { + "id" => "country_code", + "options" => [ + { + "id" => "us", + "label" => "United States" + }, + { + "id" => "ca", + "label" => "Canada" + } + ] + }, + { + "id" => "region_id", + "options" => [ + { + "id" => 1, + "label" => "New York" + }, + { + "id" => 2, + "label" => "California" + } + ] + } + ] + }.to_json + end + end + end + end +end diff --git a/spec/apps/hanami/app/actions/array_hashes/nested_arrays.rb b/spec/apps/hanami/app/actions/array_hashes/nested_arrays.rb new file mode 100644 index 0000000..6b6b304 --- /dev/null +++ b/spec/apps/hanami/app/actions/array_hashes/nested_arrays.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module HanamiTest + module Actions + module ArrayHashes + class NestedArrays < HanamiTest::Action + def handle(request, response) + response.format = :json + + response.body = { + "items" => [ + { + "id" => 1, + "tags" => ["ruby", "rails"] + }, + { + "id" => 2, + "tags" => ["python", "django"] + }, + { + "id" => 3, + "tags" => ["javascript"] + } + ] + }.to_json + end + end + end + end +end diff --git a/spec/apps/hanami/app/actions/array_hashes/nested_objects.rb b/spec/apps/hanami/app/actions/array_hashes/nested_objects.rb new file mode 100644 index 0000000..18aa822 --- /dev/null +++ b/spec/apps/hanami/app/actions/array_hashes/nested_objects.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module HanamiTest + module Actions + module ArrayHashes + class NestedObjects < HanamiTest::Action + def handle(request, response) + response.format = :json + + response.body = { + "items" => [ + { + "id" => 1, + "metadata" => { + "author" => "Alice", + "version" => "1.0" + } + }, + { + "id" => 2, + "metadata" => { + "author" => "Bob", + "version" => "2.0", + "reviewed" => true + } + }, + { + "id" => 3, + "metadata" => { + "author" => "Charlie" + } + } + ] + }.to_json + end + end + end + end +end diff --git a/spec/apps/hanami/app/actions/array_hashes/non_hash_items.rb b/spec/apps/hanami/app/actions/array_hashes/non_hash_items.rb new file mode 100644 index 0000000..5ba6686 --- /dev/null +++ b/spec/apps/hanami/app/actions/array_hashes/non_hash_items.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module HanamiTest + module Actions + module ArrayHashes + class NonHashItems < HanamiTest::Action + def handle(request, response) + response.format = :json + + response.body = { + "items" => ["string1", "string2", "string3"] + }.to_json + end + end + end + end +end diff --git a/spec/apps/hanami/app/actions/array_hashes/single_item.rb b/spec/apps/hanami/app/actions/array_hashes/single_item.rb new file mode 100644 index 0000000..aed7332 --- /dev/null +++ b/spec/apps/hanami/app/actions/array_hashes/single_item.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module HanamiTest + module Actions + module ArrayHashes + class SingleItem < HanamiTest::Action + def handle(request, response) + response.format = :json + + response.body = { + "items" => [ + { + "id" => 1, + "name" => "Item 1" + } + ] + }.to_json + end + end + end + end +end diff --git a/spec/apps/hanami/config/routes.rb b/spec/apps/hanami/config/routes.rb index a121eee..849de47 100644 --- a/spec/apps/hanami/config/routes.rb +++ b/spec/apps/hanami/config/routes.rb @@ -26,6 +26,13 @@ class Routes < Hanami::Routes get '/sites/:name', to: 'sites.show' get '/array_hashes/nullable', to: 'array_hashes.nullable' get '/array_hashes/non_nullable', to: 'array_hashes.non_nullable' + get '/array_hashes/nested', to: 'array_hashes.nested' + get '/array_hashes/empty_array', to: 'array_hashes.empty_array' + get '/array_hashes/single_item', to: 'array_hashes.single_item' + get '/array_hashes/non_hash_items', to: 'array_hashes.non_hash_items' + 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 '/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 7a54002..3656d03 100644 --- a/spec/apps/hanami/doc/openapi.json +++ b/spec/apps/hanami/doc/openapi.json @@ -15,6 +15,410 @@ } ], "paths": { + "/array_hashes/empty_array": { + "get": { + "summary": "empty_array", + "tags": [ + "ArrayHash" + ], + "responses": { + "200": { + "description": "returns empty items", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": {} + } + }, + "required": [ + "items" + ] + }, + "example": { + "items": [] + } + } + } + } + } + } + }, + "/array_hashes/mixed_types_nested": { + "get": { + "summary": "mixed_types_nested", + "tags": [ + "ArrayHash" + ], + "responses": { + "200": { + "description": "returns items with type conflicts in nested properties", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "config": { + "type": "object", + "properties": { + "port": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "host": { + "type": "string" + }, + "ssl": { + "type": "boolean", + "nullable": true + } + }, + "required": [ + "port", + "host" + ] + } + }, + "required": [ + "id", + "config" + ] + } + } + }, + "required": [ + "items" + ] + }, + "example": { + "items": [ + { + "id": 1, + "config": { + "port": 8080, + "host": "localhost" + } + }, + { + "id": 2, + "config": { + "port": "3000", + "host": "example.com", + "ssl": true + } + } + ] + } + } + } + } + } + } + }, + "/array_hashes/nested": { + "get": { + "summary": "nested", + "tags": [ + "ArrayHash" + ], + "responses": { + "200": { + "description": "returns some content", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "label": { + "type": "string" + } + }, + "required": [ + "id", + "label" + ] + } + } + }, + "required": [ + "id", + "options" + ] + } + } + }, + "required": [ + "fields" + ] + }, + "example": { + "fields": [ + { + "id": "country_code", + "options": [ + { + "id": "us", + "label": "United States" + }, + { + "id": "ca", + "label": "Canada" + } + ] + }, + { + "id": "region_id", + "options": [ + { + "id": 1, + "label": "New York" + }, + { + "id": 2, + "label": "California" + } + ] + } + ] + } + } + } + } + } + } + }, + "/array_hashes/nested_arrays": { + "get": { + "summary": "nested_arrays", + "tags": [ + "ArrayHash" + ], + "responses": { + "200": { + "description": "returns items with array properties", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "tags" + ] + } + } + }, + "required": [ + "items" + ] + }, + "example": { + "items": [ + { + "id": 1, + "tags": [ + "ruby", + "rails" + ] + }, + { + "id": 2, + "tags": [ + "python", + "django" + ] + }, + { + "id": 3, + "tags": [ + "javascript" + ] + } + ] + } + } + } + } + } + } + }, + "/array_hashes/nested_objects": { + "get": { + "summary": "nested_objects", + "tags": [ + "ArrayHash" + ], + "responses": { + "200": { + "description": "returns items with object properties", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "metadata": { + "type": "object", + "properties": { + "author": { + "type": "string" + }, + "version": { + "type": "string", + "nullable": true + }, + "reviewed": { + "type": "boolean", + "nullable": true + } + }, + "required": [ + "author" + ] + } + }, + "required": [ + "id", + "metadata" + ] + } + } + }, + "required": [ + "items" + ] + }, + "example": { + "items": [ + { + "id": 1, + "metadata": { + "author": "Alice", + "version": "1.0" + } + }, + { + "id": 2, + "metadata": { + "author": "Bob", + "version": "2.0", + "reviewed": true + } + }, + { + "id": 3, + "metadata": { + "author": "Charlie" + } + } + ] + } + } + } + } + } + } + }, + "/array_hashes/non_hash_items": { + "get": { + "summary": "non_hash_items", + "tags": [ + "ArrayHash" + ], + "responses": { + "200": { + "description": "returns array of strings", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "items" + ] + }, + "example": { + "items": [ + "string1", + "string2", + "string3" + ] + } + } + } + } + } + } + }, "/array_hashes/non_nullable": { "get": { "summary": "non_nullable", @@ -140,6 +544,57 @@ } } }, + "/array_hashes/single_item": { + "get": { + "summary": "single_item", + "tags": [ + "ArrayHash" + ], + "responses": { + "200": { + "description": "returns single item", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + } + } + }, + "required": [ + "items" + ] + }, + "example": { + "items": [ + { + "id": 1, + "name": "Item 1" + } + ] + } + } + } + } + } + } + }, "/images": { "get": { "summary": "index", diff --git a/spec/apps/hanami/doc/openapi.yaml b/spec/apps/hanami/doc/openapi.yaml index d62170e..32a98d3 100644 --- a/spec/apps/hanami/doc/openapi.yaml +++ b/spec/apps/hanami/doc/openapi.yaml @@ -14,6 +14,248 @@ info: servers: - url: http://localhost:3000 paths: + "/array_hashes/empty_array": + get: + summary: empty_array + tags: + - ArrayHash + responses: + '200': + description: returns empty items + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: {} + required: + - items + example: + items: [] + "/array_hashes/mixed_types_nested": + get: + summary: mixed_types_nested + tags: + - ArrayHash + responses: + '200': + description: returns items with type conflicts in nested properties + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + type: object + properties: + id: + type: integer + config: + type: object + properties: + port: + oneOf: + - type: integer + - type: string + host: + type: string + ssl: + type: boolean + nullable: true + required: + - port + - host + required: + - id + - config + required: + - items + example: + items: + - id: 1 + config: + port: 8080 + host: localhost + - id: 2 + config: + port: '3000' + host: example.com + ssl: true + "/array_hashes/nested": + get: + summary: nested + tags: + - ArrayHash + responses: + '200': + description: returns some content + content: + application/json: + schema: + type: object + properties: + fields: + type: array + items: + type: object + properties: + id: + type: string + options: + type: array + items: + type: object + properties: + id: + oneOf: + - type: string + - type: integer + label: + type: string + required: + - id + - label + required: + - id + - options + required: + - fields + example: + fields: + - id: country_code + options: + - id: us + label: United States + - id: ca + label: Canada + - id: region_id + options: + - id: 1 + label: New York + - id: 2 + label: California + "/array_hashes/nested_arrays": + get: + summary: nested_arrays + tags: + - ArrayHash + responses: + '200': + description: returns items with array properties + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + type: object + properties: + id: + type: integer + tags: + type: array + items: + type: string + required: + - id + - tags + required: + - items + example: + items: + - id: 1 + tags: + - ruby + - rails + - id: 2 + tags: + - python + - django + - id: 3 + tags: + - javascript + "/array_hashes/nested_objects": + get: + summary: nested_objects + tags: + - ArrayHash + responses: + '200': + description: returns items with object properties + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + type: object + properties: + id: + type: integer + metadata: + type: object + properties: + author: + type: string + version: + type: string + nullable: true + reviewed: + type: boolean + nullable: true + required: + - author + required: + - id + - metadata + required: + - items + example: + items: + - id: 1 + metadata: + author: Alice + version: '1.0' + - id: 2 + metadata: + author: Bob + version: '2.0' + reviewed: true + - id: 3 + metadata: + author: Charlie + "/array_hashes/non_hash_items": + get: + summary: non_hash_items + tags: + - ArrayHash + responses: + '200': + description: returns array of strings + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + type: string + required: + - items + example: + items: + - string1 + - string2 + - string3 "/array_hashes/non_nullable": get: summary: non_nullable @@ -91,6 +333,37 @@ paths: - label: value: unknown invited: true + "/array_hashes/single_item": + get: + summary: single_item + tags: + - ArrayHash + responses: + '200': + description: returns single item + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + required: + - id + - name + required: + - items + example: + items: + - id: 1 + name: Item 1 "/images": get: summary: index diff --git a/spec/apps/rails/app/controllers/array_hashes_controller.rb b/spec/apps/rails/app/controllers/array_hashes_controller.rb index 73a990f..e2ead03 100644 --- a/spec/apps/rails/app/controllers/array_hashes_controller.rb +++ b/spec/apps/rails/app/controllers/array_hashes_controller.rb @@ -36,4 +36,136 @@ def non_nullable } render json: response end + + def nested + response = { + "fields" => [ + { + "id" => "country_code", + "options" => [ + { + "id" => "us", + "label" => "United States" + }, + { + "id" => "ca", + "label" => "Canada" + } + ] + }, + { + "id" => "region_id", + "options" => [ + { + "id" => 1, + "label" => "New York" + }, + { + "id" => 2, + "label" => "California" + } + ] + } + ] + } + render json: response + end + + def empty_array + response = { + "items" => [] + } + render json: response + end + + def single_item + response = { + "items" => [ + { + "id" => 1, + "name" => "Item 1" + } + ] + } + render json: response + end + + def non_hash_items + response = { + "items" => ["string1", "string2", "string3"] + } + render json: response + end + + def nested_arrays + response = { + "items" => [ + { + "id" => 1, + "tags" => ["ruby", "rails"] + }, + { + "id" => 2, + "tags" => ["python", "django"] + }, + { + "id" => 3, + "tags" => ["javascript"] + } + ] + } + render json: response + end + + def nested_objects + response = { + "items" => [ + { + "id" => 1, + "metadata" => { + "author" => "Alice", + "version" => "1.0" + } + }, + { + "id" => 2, + "metadata" => { + "author" => "Bob", + "version" => "2.0", + "reviewed" => true + } + }, + { + "id" => 3, + "metadata" => { + "author" => "Charlie" + } + } + ] + } + render json: response + end + + def mixed_types_nested + response = { + "items" => [ + { + "id" => 1, + "config" => { + "port" => 8080, + "host" => "localhost" + } + }, + { + "id" => 2, + "config" => { + "port" => "3000", + "host" => "example.com", + "ssl" => true + } + } + ] + } + render json: response + end end diff --git a/spec/apps/rails/config/routes.rb b/spec/apps/rails/config/routes.rb index af4b438..636a493 100644 --- a/spec/apps/rails/config/routes.rb +++ b/spec/apps/rails/config/routes.rb @@ -31,6 +31,13 @@ resources :array_hashes, only: [] do get :nullable, on: :collection get :non_nullable, on: :collection + get :nested, on: :collection + get :empty_array, on: :collection + get :single_item, on: :collection + get :non_hash_items, on: :collection + get :nested_arrays, on: :collection + get :nested_objects, on: :collection + get :mixed_types_nested, 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 c591afb..7fb6c17 100644 --- a/spec/apps/rails/doc/minitest_openapi.json +++ b/spec/apps/rails/doc/minitest_openapi.json @@ -58,6 +58,410 @@ } } }, + "/array_hashes/empty_array": { + "get": { + "summary": "empty_array", + "tags": [ + "ArrayHash" + ], + "responses": { + "200": { + "description": "with empty array", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": {} + } + }, + "required": [ + "items" + ] + }, + "example": { + "items": [] + } + } + } + } + } + } + }, + "/array_hashes/mixed_types_nested": { + "get": { + "summary": "mixed_types_nested", + "tags": [ + "ArrayHash" + ], + "responses": { + "200": { + "description": "with mixed types in nested objects", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "config": { + "type": "object", + "properties": { + "port": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "host": { + "type": "string" + }, + "ssl": { + "type": "boolean", + "nullable": true + } + }, + "required": [ + "port", + "host" + ] + } + }, + "required": [ + "id", + "config" + ] + } + } + }, + "required": [ + "items" + ] + }, + "example": { + "items": [ + { + "id": 1, + "config": { + "port": 8080, + "host": "localhost" + } + }, + { + "id": 2, + "config": { + "port": "3000", + "host": "example.com", + "ssl": true + } + } + ] + } + } + } + } + } + } + }, + "/array_hashes/nested": { + "get": { + "summary": "nested", + "tags": [ + "ArrayHash" + ], + "responses": { + "200": { + "description": "with nested keys", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "label": { + "type": "string" + } + }, + "required": [ + "id", + "label" + ] + } + } + }, + "required": [ + "id", + "options" + ] + } + } + }, + "required": [ + "fields" + ] + }, + "example": { + "fields": [ + { + "id": "country_code", + "options": [ + { + "id": "us", + "label": "United States" + }, + { + "id": "ca", + "label": "Canada" + } + ] + }, + { + "id": "region_id", + "options": [ + { + "id": 1, + "label": "New York" + }, + { + "id": 2, + "label": "California" + } + ] + } + ] + } + } + } + } + } + } + }, + "/array_hashes/nested_arrays": { + "get": { + "summary": "nested_arrays", + "tags": [ + "ArrayHash" + ], + "responses": { + "200": { + "description": "with nested arrays", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "tags" + ] + } + } + }, + "required": [ + "items" + ] + }, + "example": { + "items": [ + { + "id": 1, + "tags": [ + "ruby", + "rails" + ] + }, + { + "id": 2, + "tags": [ + "python", + "django" + ] + }, + { + "id": 3, + "tags": [ + "javascript" + ] + } + ] + } + } + } + } + } + } + }, + "/array_hashes/nested_objects": { + "get": { + "summary": "nested_objects", + "tags": [ + "ArrayHash" + ], + "responses": { + "200": { + "description": "with nested objects", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "metadata": { + "type": "object", + "properties": { + "author": { + "type": "string" + }, + "version": { + "type": "string", + "nullable": true + }, + "reviewed": { + "type": "boolean", + "nullable": true + } + }, + "required": [ + "author" + ] + } + }, + "required": [ + "id", + "metadata" + ] + } + } + }, + "required": [ + "items" + ] + }, + "example": { + "items": [ + { + "id": 1, + "metadata": { + "author": "Alice", + "version": "1.0" + } + }, + { + "id": 2, + "metadata": { + "author": "Bob", + "version": "2.0", + "reviewed": true + } + }, + { + "id": 3, + "metadata": { + "author": "Charlie" + } + } + ] + } + } + } + } + } + } + }, + "/array_hashes/non_hash_items": { + "get": { + "summary": "non_hash_items", + "tags": [ + "ArrayHash" + ], + "responses": { + "200": { + "description": "with non-hash items", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "items" + ] + }, + "example": { + "items": [ + "string1", + "string2", + "string3" + ] + } + } + } + } + } + } + }, "/array_hashes/non_nullable": { "get": { "summary": "non_nullable", @@ -183,6 +587,57 @@ } } }, + "/array_hashes/single_item": { + "get": { + "summary": "single_item", + "tags": [ + "ArrayHash" + ], + "responses": { + "200": { + "description": "with single item", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + } + } + }, + "required": [ + "items" + ] + }, + "example": { + "items": [ + { + "id": 1, + "name": "Item 1" + } + ] + } + } + } + } + } + } + }, "/images": { "get": { "summary": "index", diff --git a/spec/apps/rails/doc/minitest_openapi.yaml b/spec/apps/rails/doc/minitest_openapi.yaml index 1221fd5..6f36f20 100644 --- a/spec/apps/rails/doc/minitest_openapi.yaml +++ b/spec/apps/rails/doc/minitest_openapi.yaml @@ -42,6 +42,248 @@ paths: gold: 1 silver: 2 bronze: 3 + "/array_hashes/empty_array": + get: + summary: empty_array + tags: + - ArrayHash + responses: + '200': + description: with empty array + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: {} + required: + - items + example: + items: [] + "/array_hashes/mixed_types_nested": + get: + summary: mixed_types_nested + tags: + - ArrayHash + responses: + '200': + description: with mixed types in nested objects + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + type: object + properties: + id: + type: integer + config: + type: object + properties: + port: + oneOf: + - type: integer + - type: string + host: + type: string + ssl: + type: boolean + nullable: true + required: + - port + - host + required: + - id + - config + required: + - items + example: + items: + - id: 1 + config: + port: 8080 + host: localhost + - id: 2 + config: + port: '3000' + host: example.com + ssl: true + "/array_hashes/nested": + get: + summary: nested + tags: + - ArrayHash + responses: + '200': + description: with nested keys + content: + application/json: + schema: + type: object + properties: + fields: + type: array + items: + type: object + properties: + id: + type: string + options: + type: array + items: + type: object + properties: + id: + oneOf: + - type: string + - type: integer + label: + type: string + required: + - id + - label + required: + - id + - options + required: + - fields + example: + fields: + - id: country_code + options: + - id: us + label: United States + - id: ca + label: Canada + - id: region_id + options: + - id: 1 + label: New York + - id: 2 + label: California + "/array_hashes/nested_arrays": + get: + summary: nested_arrays + tags: + - ArrayHash + responses: + '200': + description: with nested arrays + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + type: object + properties: + id: + type: integer + tags: + type: array + items: + type: string + required: + - id + - tags + required: + - items + example: + items: + - id: 1 + tags: + - ruby + - rails + - id: 2 + tags: + - python + - django + - id: 3 + tags: + - javascript + "/array_hashes/nested_objects": + get: + summary: nested_objects + tags: + - ArrayHash + responses: + '200': + description: with nested objects + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + type: object + properties: + id: + type: integer + metadata: + type: object + properties: + author: + type: string + version: + type: string + nullable: true + reviewed: + type: boolean + nullable: true + required: + - author + required: + - id + - metadata + required: + - items + example: + items: + - id: 1 + metadata: + author: Alice + version: '1.0' + - id: 2 + metadata: + author: Bob + version: '2.0' + reviewed: true + - id: 3 + metadata: + author: Charlie + "/array_hashes/non_hash_items": + get: + summary: non_hash_items + tags: + - ArrayHash + responses: + '200': + description: with non-hash items + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + type: string + required: + - items + example: + items: + - string1 + - string2 + - string3 "/array_hashes/non_nullable": get: summary: non_nullable @@ -119,6 +361,37 @@ paths: - label: value: invited invited: true + "/array_hashes/single_item": + get: + summary: single_item + tags: + - ArrayHash + responses: + '200': + description: with single item + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + required: + - id + - name + required: + - items + example: + items: + - id: 1 + name: Item 1 "/images": get: summary: index diff --git a/spec/apps/rails/doc/rspec_openapi.json b/spec/apps/rails/doc/rspec_openapi.json index 399e612..824ba85 100644 --- a/spec/apps/rails/doc/rspec_openapi.json +++ b/spec/apps/rails/doc/rspec_openapi.json @@ -58,6 +58,410 @@ } } }, + "/array_hashes/empty_array": { + "get": { + "summary": "empty_array", + "tags": [ + "ArrayHash" + ], + "responses": { + "200": { + "description": "returns empty items", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": {} + } + }, + "required": [ + "items" + ] + }, + "example": { + "items": [] + } + } + } + } + } + } + }, + "/array_hashes/mixed_types_nested": { + "get": { + "summary": "mixed_types_nested", + "tags": [ + "ArrayHash" + ], + "responses": { + "200": { + "description": "returns items with type conflicts in nested properties", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "config": { + "type": "object", + "properties": { + "port": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "host": { + "type": "string" + }, + "ssl": { + "type": "boolean", + "nullable": true + } + }, + "required": [ + "port", + "host" + ] + } + }, + "required": [ + "id", + "config" + ] + } + } + }, + "required": [ + "items" + ] + }, + "example": { + "items": [ + { + "id": 1, + "config": { + "port": 8080, + "host": "localhost" + } + }, + { + "id": 2, + "config": { + "port": "3000", + "host": "example.com", + "ssl": true + } + } + ] + } + } + } + } + } + } + }, + "/array_hashes/nested": { + "get": { + "summary": "nested", + "tags": [ + "ArrayHash" + ], + "responses": { + "200": { + "description": "returns some content", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "label": { + "type": "string" + } + }, + "required": [ + "id", + "label" + ] + } + } + }, + "required": [ + "id", + "options" + ] + } + } + }, + "required": [ + "fields" + ] + }, + "example": { + "fields": [ + { + "id": "country_code", + "options": [ + { + "id": "us", + "label": "United States" + }, + { + "id": "ca", + "label": "Canada" + } + ] + }, + { + "id": "region_id", + "options": [ + { + "id": 1, + "label": "New York" + }, + { + "id": 2, + "label": "California" + } + ] + } + ] + } + } + } + } + } + } + }, + "/array_hashes/nested_arrays": { + "get": { + "summary": "nested_arrays", + "tags": [ + "ArrayHash" + ], + "responses": { + "200": { + "description": "returns items with array properties", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "tags" + ] + } + } + }, + "required": [ + "items" + ] + }, + "example": { + "items": [ + { + "id": 1, + "tags": [ + "ruby", + "rails" + ] + }, + { + "id": 2, + "tags": [ + "python", + "django" + ] + }, + { + "id": 3, + "tags": [ + "javascript" + ] + } + ] + } + } + } + } + } + } + }, + "/array_hashes/nested_objects": { + "get": { + "summary": "nested_objects", + "tags": [ + "ArrayHash" + ], + "responses": { + "200": { + "description": "returns items with object properties", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "metadata": { + "type": "object", + "properties": { + "author": { + "type": "string" + }, + "version": { + "type": "string", + "nullable": true + }, + "reviewed": { + "type": "boolean", + "nullable": true + } + }, + "required": [ + "author" + ] + } + }, + "required": [ + "id", + "metadata" + ] + } + } + }, + "required": [ + "items" + ] + }, + "example": { + "items": [ + { + "id": 1, + "metadata": { + "author": "Alice", + "version": "1.0" + } + }, + { + "id": 2, + "metadata": { + "author": "Bob", + "version": "2.0", + "reviewed": true + } + }, + { + "id": 3, + "metadata": { + "author": "Charlie" + } + } + ] + } + } + } + } + } + } + }, + "/array_hashes/non_hash_items": { + "get": { + "summary": "non_hash_items", + "tags": [ + "ArrayHash" + ], + "responses": { + "200": { + "description": "returns array of strings", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "items" + ] + }, + "example": { + "items": [ + "string1", + "string2", + "string3" + ] + } + } + } + } + } + } + }, "/array_hashes/non_nullable": { "get": { "summary": "non_nullable", @@ -183,6 +587,57 @@ } } }, + "/array_hashes/single_item": { + "get": { + "summary": "single_item", + "tags": [ + "ArrayHash" + ], + "responses": { + "200": { + "description": "returns single item", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + } + } + }, + "required": [ + "items" + ] + }, + "example": { + "items": [ + { + "id": 1, + "name": "Item 1" + } + ] + } + } + } + } + } + } + }, "/images": { "get": { "summary": "index", diff --git a/spec/apps/rails/doc/rspec_openapi.yaml b/spec/apps/rails/doc/rspec_openapi.yaml index bd47e18..942890d 100644 --- a/spec/apps/rails/doc/rspec_openapi.yaml +++ b/spec/apps/rails/doc/rspec_openapi.yaml @@ -42,6 +42,248 @@ paths: gold: 1 silver: 2 bronze: 3 + "/array_hashes/empty_array": + get: + summary: empty_array + tags: + - ArrayHash + responses: + '200': + description: returns empty items + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: {} + required: + - items + example: + items: [] + "/array_hashes/mixed_types_nested": + get: + summary: mixed_types_nested + tags: + - ArrayHash + responses: + '200': + description: returns items with type conflicts in nested properties + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + type: object + properties: + id: + type: integer + config: + type: object + properties: + port: + oneOf: + - type: integer + - type: string + host: + type: string + ssl: + type: boolean + nullable: true + required: + - port + - host + required: + - id + - config + required: + - items + example: + items: + - id: 1 + config: + port: 8080 + host: localhost + - id: 2 + config: + port: '3000' + host: example.com + ssl: true + "/array_hashes/nested": + get: + summary: nested + tags: + - ArrayHash + responses: + '200': + description: returns some content + content: + application/json: + schema: + type: object + properties: + fields: + type: array + items: + type: object + properties: + id: + type: string + options: + type: array + items: + type: object + properties: + id: + oneOf: + - type: string + - type: integer + label: + type: string + required: + - id + - label + required: + - id + - options + required: + - fields + example: + fields: + - id: country_code + options: + - id: us + label: United States + - id: ca + label: Canada + - id: region_id + options: + - id: 1 + label: New York + - id: 2 + label: California + "/array_hashes/nested_arrays": + get: + summary: nested_arrays + tags: + - ArrayHash + responses: + '200': + description: returns items with array properties + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + type: object + properties: + id: + type: integer + tags: + type: array + items: + type: string + required: + - id + - tags + required: + - items + example: + items: + - id: 1 + tags: + - ruby + - rails + - id: 2 + tags: + - python + - django + - id: 3 + tags: + - javascript + "/array_hashes/nested_objects": + get: + summary: nested_objects + tags: + - ArrayHash + responses: + '200': + description: returns items with object properties + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + type: object + properties: + id: + type: integer + metadata: + type: object + properties: + author: + type: string + version: + type: string + nullable: true + reviewed: + type: boolean + nullable: true + required: + - author + required: + - id + - metadata + required: + - items + example: + items: + - id: 1 + metadata: + author: Alice + version: '1.0' + - id: 2 + metadata: + author: Bob + version: '2.0' + reviewed: true + - id: 3 + metadata: + author: Charlie + "/array_hashes/non_hash_items": + get: + summary: non_hash_items + tags: + - ArrayHash + responses: + '200': + description: returns array of strings + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + type: string + required: + - items + example: + items: + - string1 + - string2 + - string3 "/array_hashes/non_nullable": get: summary: non_nullable @@ -119,6 +361,37 @@ paths: - label: value: invited invited: true + "/array_hashes/single_item": + get: + summary: single_item + tags: + - ArrayHash + responses: + '200': + description: returns single item + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + required: + - id + - name + required: + - items + example: + items: + - id: 1 + name: Item 1 "/images": get: summary: index diff --git a/spec/integration_tests/rails_test.rb b/spec/integration_tests/rails_test.rb index 27cd7a7..397a308 100644 --- a/spec/integration_tests/rails_test.rb +++ b/spec/integration_tests/rails_test.rb @@ -318,4 +318,39 @@ class ArrayOfHashesTest < ActionDispatch::IntegrationTest get '/array_hashes/non_nullable' assert_response 200 end + + test 'with nested keys' do + get '/array_hashes/nested' + assert_response 200 + end + + test 'with empty array' do + get '/array_hashes/empty_array' + assert_response 200 + end + + test 'with single item' do + get '/array_hashes/single_item' + assert_response 200 + end + + test 'with non-hash items' do + get '/array_hashes/non_hash_items' + assert_response 200 + end + + test 'with nested arrays' do + get '/array_hashes/nested_arrays' + assert_response 200 + end + + test 'with nested objects' do + get '/array_hashes/nested_objects' + assert_response 200 + end + + test 'with mixed types in nested objects' do + get '/array_hashes/mixed_types_nested' + assert_response 200 + end end diff --git a/spec/requests/hanami_spec.rb b/spec/requests/hanami_spec.rb index f9f1bba..1926925 100644 --- a/spec/requests/hanami_spec.rb +++ b/spec/requests/hanami_spec.rb @@ -331,4 +331,53 @@ expect(last_response.status).to eq(200) end end + + describe 'with nested keys with type conflicts' do + it 'returns some content' do + get '/array_hashes/nested' + expect(last_response.status).to eq(200) + end + end + + describe 'with empty array' do + it 'returns empty items' do + get '/array_hashes/empty_array' + expect(last_response.status).to eq(200) + end + end + + describe 'with single item' do + it 'returns single item' do + get '/array_hashes/single_item' + expect(last_response.status).to eq(200) + end + end + + describe 'with non-hash items' do + it 'returns array of strings' do + get '/array_hashes/non_hash_items' + expect(last_response.status).to eq(200) + end + end + + describe 'with nested arrays' do + it 'returns items with array properties' do + get '/array_hashes/nested_arrays' + expect(last_response.status).to eq(200) + end + end + + describe 'with nested objects' do + it 'returns items with object properties' do + get '/array_hashes/nested_objects' + expect(last_response.status).to eq(200) + end + end + + describe 'with mixed types in nested objects' do + it 'returns items with type conflicts in nested properties' do + get '/array_hashes/mixed_types_nested' + 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 ecfb85f..7a1355c 100644 --- a/spec/requests/rails_spec.rb +++ b/spec/requests/rails_spec.rb @@ -315,4 +315,53 @@ expect(response.status).to eq(200) end end + + describe 'with nested keys with type conflicts' do + it 'returns some content' do + get '/array_hashes/nested' + expect(response.status).to eq(200) + end + end + + describe 'with empty array' do + it 'returns empty items' do + get '/array_hashes/empty_array' + expect(response.status).to eq(200) + end + end + + describe 'with single item' do + it 'returns single item' do + get '/array_hashes/single_item' + expect(response.status).to eq(200) + end + end + + describe 'with non-hash items' do + it 'returns array of strings' do + get '/array_hashes/non_hash_items' + expect(response.status).to eq(200) + end + end + + describe 'with nested arrays' do + it 'returns items with array properties' do + get '/array_hashes/nested_arrays' + expect(response.status).to eq(200) + end + end + + describe 'with nested objects' do + it 'returns items with object properties' do + get '/array_hashes/nested_objects' + expect(response.status).to eq(200) + end + end + + describe 'with mixed types in nested objects' do + it 'returns items with type conflicts in nested properties' do + get '/array_hashes/mixed_types_nested' + expect(response.status).to eq(200) + end + end end