Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
/spec/reports/
/tmp/
/Gemfile.lock
.DS_Store

# rspec failure tracking
.rspec_status
Expand Down
76 changes: 70 additions & 6 deletions lib/rspec/openapi/schema_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -246,20 +246,84 @@

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)

Check notice

Code scanning / Rubocop

A calculated magnitude based on number of assignments, branches, and conditions. Note

Metrics/AbcSize: Assignment Branch Condition size for build_merged_schema_from_variations is too high. [<27, 62, 46> 81.79/46]

Check notice

Code scanning / Rubocop

A complexity metric that is strongly correlated to the number of test cases needed to validate a method. Note

Metrics/CyclomaticComplexity: Cyclomatic complexity for build_merged_schema_from_variations is too high. [33/13]

Check notice

Code scanning / Rubocop

A complexity metric geared towards measuring complexity for a human reader. Note

Metrics/PerceivedComplexity: Perceived complexity for build_merged_schema_from_variations is too high. [37/13]
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'
Expand Down
2 changes: 2 additions & 0 deletions lib/rspec/openapi/schema_merger.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions spec/apps/hanami/app/actions/array_hashes/empty_array.rb
Original file line number Diff line number Diff line change
@@ -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
33 changes: 33 additions & 0 deletions spec/apps/hanami/app/actions/array_hashes/mixed_types_nested.rb
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions spec/apps/hanami/app/actions/array_hashes/nested.rb
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions spec/apps/hanami/app/actions/array_hashes/nested_arrays.rb
Original file line number Diff line number Diff line change
@@ -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
39 changes: 39 additions & 0 deletions spec/apps/hanami/app/actions/array_hashes/nested_objects.rb
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions spec/apps/hanami/app/actions/array_hashes/non_hash_items.rb
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions spec/apps/hanami/app/actions/array_hashes/single_item.rb
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions spec/apps/hanami/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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']] }

Expand Down
Loading