Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial support of complex schema with manually-added oneOf #174

Merged
merged 7 commits into from
Feb 17, 2024
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
30 changes: 25 additions & 5 deletions lib/rspec/openapi/components_updater.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ def update!(base, fresh)
# 0 1 2 ^...............................^
# ["components", "schema", "Table", "properties", "owner", "properties", "company", "$ref"]
# 0 1 2 ^...........................................^
needle = paths.slice(2, paths.size - 3)
needle = paths.reject { |path| path.is_a?(Integer) || path == 'oneOf' }
needle = needle.slice(2, needle.size - 3)
nested_schema = fresh_schemas.dig(*needle)

# Skip if the property using $ref is not found in the parent schema. The property may be removed.
Expand All @@ -44,27 +45,36 @@ def build_fresh_schemas(references, base, fresh)
references.inject({}) do |acc, paths|
ref_link = dig_schema(base, paths)['$ref']
schema_name = ref_link.gsub('#/components/schemas/', '')
schema_body = dig_schema(fresh, paths)
schema_body = dig_schema(fresh, paths.reject { |path| path.is_a?(Integer) })

RSpec::OpenAPI::SchemaMerger.merge!(acc, { schema_name => schema_body })
end
end

def dig_schema(obj, paths)
obj.dig(*paths, 'schema', 'items') || obj.dig(*paths, 'schema')
item_schema = obj.dig(*paths, 'schema', 'items')
object_schema = obj.dig(*paths, 'schema')
one_of_schema = obj.dig(*paths.take(paths.size - 1), 'schema', 'oneOf', paths.last)

item_schema || object_schema || one_of_schema
end

def paths_to_top_level_refs(base)
request_bodies = RSpec::OpenAPI::HashHelper.matched_paths(base, 'paths.*.*.requestBody.content.application/json')
responses = RSpec::OpenAPI::HashHelper.matched_paths(base, 'paths.*.*.responses.*.content.application/json')
(request_bodies + responses).select do |paths|
dig_schema(base, paths)&.dig('$ref')&.start_with?('#/components/schemas/')
(request_bodies + responses).flat_map do |paths|
object_paths = find_object_refs(base, paths)
one_of_paths = find_one_of_refs(base, paths)

object_paths || one_of_paths || []
end
end

def find_non_top_level_nested_refs(base, generated_names)
nested_refs = [
*RSpec::OpenAPI::HashHelper.matched_paths_deeply_nested(base, 'components.schemas', 'properties.*.$ref'),
*RSpec::OpenAPI::HashHelper.matched_paths_deeply_nested(base, 'components.schemas', 'properties.*.items.$ref'),
*RSpec::OpenAPI::HashHelper.matched_paths_deeply_nested(base, 'components.schemas', 'oneOf.*.$ref'),
]
# Reject already-generated schemas to reduce unnecessary loop
nested_refs.reject do |paths|
Expand All @@ -73,4 +83,14 @@ def find_non_top_level_nested_refs(base, generated_names)
generated_names.include?(schema_name)
end
end

def find_one_of_refs(base, paths)
dig_schema(base, paths)&.dig('oneOf')&.map&.with_index do |schema, index|
paths + [index] if schema&.dig('$ref')&.start_with?('#/components/schemas/')
end&.compact
end

def find_object_refs(base, paths)
[paths] if dig_schema(base, paths)&.dig('$ref')&.start_with?('#/components/schemas/')
end
end
4 changes: 4 additions & 0 deletions lib/rspec/openapi/hash_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ def paths_to_all_fields(obj)
k = k.to_s
[[k]] + paths_to_all_fields(v).map { |x| [k, *x] }
end
when Array
obj.flat_map.with_index do |value, i|
[[i]] + paths_to_all_fields(value).map { |x| [i, *x] }
end
else
[]
end
Expand Down
41 changes: 41 additions & 0 deletions lib/rspec/openapi/schema_merger.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ def normalize_keys(spec)
#
# TODO: Should we probably force-merge `summary` regardless of manual modifications?
def merge_schema!(base, spec)
if (options = base['oneOf'])
merge_closest_match!(options, spec)

return base
end

spec.each do |key, value|
if base[key].is_a?(Hash) && value.is_a?(Hash)
merge_schema!(base[key], value) unless base[key].key?('$ref')
Expand Down Expand Up @@ -67,4 +73,39 @@ def merge_parameters(base, key, value)
all_parameters.uniq! { |param| param.slice('name', 'in') }
base[key] = all_parameters
end

SIMILARITY_THRESHOLD = 0.5

def merge_closest_match!(options, spec)
score, option = options.map { |option| [similarity(option, spec), option] }.max_by(&:first)

return if option&.key?('$ref')

if score.to_f > SIMILARITY_THRESHOLD
merge_schema!(option, spec)
else
options.push(spec)
end
end

def similarity(first, second)
return 1 if first == second

score =
case [first.class, second.class]
when [Array, Array]
(first & second).size / [first.size, second.size].max.to_f
when [Hash, Hash]
return 1 if first.merge(second).key?('$ref')

intersection = first.keys & second.keys
total_size = [first.size, second.size].max.to_f

intersection.sum { |key| similarity(first[key], second[key]) } / total_size
else
0
end

score.finite? ? score : 0
end
end
100 changes: 74 additions & 26 deletions spec/rails/doc/smart/expected.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -114,15 +114,46 @@ paths:
description:
type: string
database:
type: object
properties:
id:
type: integer
name:
type: string
required:
- id
- name
discriminator:
propertyName: name
oneOf:
- type: array
items:
type: object
properties:
name:
type: string
host:
type: string
port:
type: integer
user:
type: string
schema:
type: string
- type: object
properties:
host:
type: string
port:
type: integer
user:
type: string
schema:
type: string
required:
- host
- port
- schema
- type: object
properties:
id:
type: integer
name:
type: string
required:
- id
- name
null_sample:
nullable: true
storage_size:
Expand Down Expand Up @@ -161,7 +192,9 @@ paths:
content:
application/json:
schema:
"$ref": "#/components/schemas/PostUsersRequest"
oneOf:
- "$ref": "#/components/schemas/PostUsersRequest"
- type: string
example:
name: alice
avatar_url: "https://example.com/avatar.png"
Expand Down Expand Up @@ -293,22 +326,37 @@ components:
- name
- column_type
User:
type: object
properties:
name:
type: string
relations:
type: object
properties:
avatar:
"$ref": "#/components/schemas/Avatar"
pets:
type: array
items:
"$ref": "#/components/schemas/Pet"
required:
- avatar
- pets
discriminator:
propertyName: name
oneOf:
- type: object
properties:
name:
type: string
foo:
type: string
bar:
type: string
baz:
type: string
quux:
type: string
- type: object
properties:
name:
type: string
relations:
type: object
properties:
avatar:
"$ref": "#/components/schemas/Avatar"
pets:
type: array
items:
"$ref": "#/components/schemas/Pet"
required:
- avatar
- pets
Avatar:
type: object
properties:
Expand Down
82 changes: 62 additions & 20 deletions spec/rails/doc/smart/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -174,12 +174,37 @@ paths:
description:
type: string
database:
type: object
properties:
id:
type: integer
name:
type: string
discriminator:
propertyName: name
oneOf:
- type: array
items:
type: object
properties:
name:
type: string
host:
type: string
port:
type: integer
user:
type: string
schema:
type: string
- type: object
properties:
host:
type: string
port:
type: integer
user:
type: string
schema:
type: string
required:
- host
- port
- schema
null_sample:
nullable: true
storage_size:
Expand Down Expand Up @@ -212,7 +237,9 @@ paths:
content:
application/json:
schema:
"$ref": "#/components/schemas/PostUsersRequest"
oneOf:
- "$ref": "#/components/schemas/PostUsersRequest"
- type: string
responses:
'201':
description: returns a user
Expand Down Expand Up @@ -279,16 +306,31 @@ components:
id:
type: integer
User:
type: object
properties:
name:
type: string
relations:
type: object
properties:
avatar:
"$ref": "#/components/schemas/Avatar"
pets:
type: array
items:
"$ref": "#/components/schemas/Pet"
discriminator:
propertyName: name
oneOf:
- type: object
properties:
name:
type: string
foo:
type: string
bar:
type: string
baz:
type: string
quux:
type: string
- type: object
properties:
name:
type: string
relations:
type: object
properties:
avatar:
"$ref": "#/components/schemas/Avatar"
pets:
type: array
items:
"$ref": "#/components/schemas/Pet"
Loading