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 for $ref #67

Merged
merged 11 commits into from
Aug 24, 2022
105 changes: 105 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,111 @@ RSpec::OpenAPI.description_builder = -> (example) { example.description }
RSpec::OpenAPI.example_types = %i[request]
```

### Can I use rspec-openapi with `$ref` to minimize duplication of schema?

Yes, rspec-openapi v0.7.0+ supports [`$ref` mechanism](https://swagger.io/docs/specification/using-ref/) and generates
schemas under `#/components/schemas` with some manual steps.

1. First, generate plain OpenAPI file.
2. Then, manually replace the duplications with `$ref`.

```yaml
paths:
"/users":
get:
responses:
'200':
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/User"
"/users/{id}":
get:
responses:
'200':
content:
application/json:
schema:
$ref: "#/components/schemas/User"
# Note) #/components/schamas is not needed to be defined.
```

3. Then, re-run rspec-openapi. It will generate `#/components/schemas` with the referenced schema (`User` for example) newly-generated or updated.

```yaml
paths:
"/users":
get:
responses:
'200':
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/User"
"/users/{id}":
get:
responses:
'200':
content:
application/json:
schema:
$ref: "#/components/schemas/User"
components:
schemas:
User:
type: object
properties:
id:
type: string
name:
type: string
role:
type: array
items:
type: string
```

rspec-openapi also supports `$ref` in `properties` of schemas. Example)

```yaml
paths:
"/locations":
get:
responses:
'200':
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Location"
components:
schemas:
Location:
type: object
properties:
id:
type: string
name:
type: string
Coordinate:
"$ref": "#/components/schemas/Coordinate"
Coordinate:
type: object
properties:
lat:
type: string
lon:
type: string
```

Note that automatic `schemas` update feature is still new and may not work in complex scenario.
If you find a room for improvement, open an issue.

### How can I add information which can't be generated from RSpec?

rspec-openapi tries to keep manual modifications as much as possible when generating specs.
Expand Down
65 changes: 65 additions & 0 deletions lib/rspec/openapi/components_updater.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
require_relative 'hash_helper'

class << RSpec::OpenAPI::ComponentsUpdater = Object.new
# @param [Hash] base
# @param [Hash] fresh
def update!(base, fresh)
# Top-level schema: Used as the body of request or response
top_level_refs = paths_to_top_level_refs(base)
return if top_level_refs.empty?
fresh_schemas = build_fresh_schemas(top_level_refs, base, fresh)

# Nested schema: References in Top-level schemas. May contain some top-level schema.
generated_schema_names = fresh_schemas.keys
nested_refs = find_non_top_level_nested_refs(base, generated_schema_names)
nested_refs.each do |paths|
parent_name = paths[-4]
property_name = paths[-2]
nested_schema = fresh_schemas.dig(parent_name, 'properties', property_name)

# Skip if the property using $ref is not found in the parent schema. The property may be removed.
next if nested_schema.nil?

schema_name = base.dig(*paths)&.gsub('#/components/schemas/', '')
fresh_schemas[schema_name] ||= {}
RSpec::OpenAPI::SchemaMerger.merge!(fresh_schemas[schema_name], nested_schema)
end

RSpec::OpenAPI::SchemaMerger.merge!(base, { 'components' => { 'schemas' => fresh_schemas }})
RSpec::OpenAPI::SchemaCleaner.cleanup_components_schemas!(base, { 'components' => { 'schemas' => fresh_schemas } })
end

private

def build_fresh_schemas(references, base, fresh)
references.inject({}) do |acc, paths|
ref_link = dig_schema(base, paths).dig('$ref')
schema_name = ref_link.gsub('#/components/schemas/', '')
schema_body = dig_schema(fresh, paths)
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')
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/')
end
end

def find_non_top_level_nested_refs(base, generated_names)
nested_refs = RSpec::OpenAPI::HashHelper::matched_paths(base, 'components.schemas.*.properties.*.$ref')

# Reject already-generated schemas to reduce unnecessary loop
nested_refs.reject do |paths|
ref_link = base.dig(*paths)
schema_name = ref_link.gsub('#/components/schemas/', '')
generated_names.include?(schema_name)
end
end
end
23 changes: 23 additions & 0 deletions lib/rspec/openapi/hash_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
class << RSpec::OpenAPI::HashHelper = Object.new
def paths_to_all_fields(obj)
case obj
when Hash
obj.each.flat_map do |k, v|
k = k.to_s
[[k]] + paths_to_all_fields(v).map { |x| [k, *x] }
end
else
[]
end
end

def matched_paths(obj, selector)
selector_parts = selector.split('.').map(&:to_s)
selectors = paths_to_all_fields(obj).select do |key_parts|
key_parts.size == selector_parts.size && key_parts.zip(selector_parts).all? do |kp, sp|
kp == sp || (sp == '*' && kp != nil)
end
end
selectors
end
end
2 changes: 2 additions & 0 deletions lib/rspec/openapi/hooks.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require 'rspec'
require 'rspec/openapi/components_updater'
require 'rspec/openapi/default_schema'
require 'rspec/openapi/record_builder'
require 'rspec/openapi/schema_builder'
Expand Down Expand Up @@ -35,6 +36,7 @@
end
end
RSpec::OpenAPI::SchemaCleaner.cleanup!(spec, new_from_zero)
RSpec::OpenAPI::ComponentsUpdater.update!(spec, new_from_zero)
end
end
if error_records.any?
Expand Down
37 changes: 13 additions & 24 deletions lib/rspec/openapi/schema_cleaner.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
# For Ruby 3.0+
require 'set'

require_relative 'hash_helper'

class << RSpec::OpenAPI::SchemaCleaner = Object.new
# Cleanup the properties, of component schemas, that exists in the base but not in the spec.
#
# @param [Hash] base
# @param [Hash] spec
def cleanup_components_schemas!(base, spec)
cleanup_hash!(base, spec, 'components.schemas.*')
cleanup_hash!(base, spec, 'components.schemas.*.properties.*')
end

# Cleanup specific elements that exists in the base but not in the spec
#
# @param [Hash] base
Expand All @@ -28,34 +39,12 @@ def cleanup!(base, spec)

private

def paths_to_all_fields(obj)
case obj
when Hash
obj.each.flat_map do |k,v|
k = k.to_s
[[k]] + paths_to_all_fields(v).map { |x| [k, *x] }
end
else
[]
end
end

def matched_paths(obj, selector)
selector_parts = selector.split('.').map(&:to_s)
selectors = paths_to_all_fields(obj).select do |key_parts|
key_parts.size == selector_parts.size && key_parts.zip(selector_parts).all? do |kp, sp|
kp == sp || (sp == '*' && kp != nil)
end
end
selectors
end

def cleanup_array!(base, spec, selector, fields_for_identity = [])
marshal = lambda do |obj|
Marshal.dump(slice(obj, fields_for_identity))
end

matched_paths(base, selector).each do |paths|
RSpec::OpenAPI::HashHelper::matched_paths(base, selector).each do |paths|
target_array = base.dig(*paths)
spec_array = spec.dig(*paths)
unless target_array.is_a?(Array) && spec_array.is_a?(Array)
Expand All @@ -72,7 +61,7 @@ def cleanup_array!(base, spec, selector, fields_for_identity = [])
end

def cleanup_hash!(base, spec, selector)
matched_paths(base, selector).each do |paths|
RSpec::OpenAPI::HashHelper::matched_paths(base, selector).each do |paths|
exist_in_base = !base.dig(*paths).nil?
not_in_spec = spec.dig(*paths).nil?
if exist_in_base && not_in_spec
Expand Down
16 changes: 10 additions & 6 deletions spec/rails/doc/smart/expected.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -136,12 +136,7 @@ components:
description:
type: string
database:
type: object
properties:
id:
type: integer
name:
type: string
"$ref": "#/components/schemas/Database"
null_sample:
nullable: true
storage_size:
Expand All @@ -151,3 +146,12 @@ components:
type: string
updated_at:
type: string
Database:
type: object
description: 'this should be preserved'
properties:
id:
type: integer
description: 'this should be preserved'
name:
type: string
32 changes: 25 additions & 7 deletions spec/rails/doc/smart/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -205,17 +205,15 @@ components:
properties:
id:
type: integer
name:
# This field should exists in expected
# name:
# type: string
this_field_should_not_exist_in_expected:
type: string
description:
type: string
database:
type: object
properties:
id:
type: integer
name:
type: string
"$ref": "#/components/schemas/Database"
null_sample:
nullable: true
storage_size:
Expand All @@ -225,3 +223,23 @@ components:
type: string
updated_at:
type: string
Database:
type: object
description: 'this should be preserved'
properties:
id:
type: integer
description: 'this should be preserved'
# This filed should exists in expected
# name:
# type: string
this_field_should_not_exist_in_expected:
type: string
this_field_should_not_exist_in_expected_2:
"$ref": "#/components/schemas/NoSuchSchemaFound"
SchemaNotInUse:
type: object
description: 'should be deleted'
properties:
id:
type: integer