Skip to content

Commit

Permalink
Merge pull request #67 from exoego/update-top-level-components
Browse files Browse the repository at this point in the history
Initial support for $ref
  • Loading branch information
exoego committed Aug 24, 2022
2 parents e5c1ada + 07ca178 commit 9ebac10
Show file tree
Hide file tree
Showing 7 changed files with 243 additions and 37 deletions.
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

0 comments on commit 9ebac10

Please sign in to comment.