Skip to content

FEAT: add open api schema extension #486

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

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
2 changes: 2 additions & 0 deletions docsite/source/extensions.html.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ sections:
- info
- monads
- json_schema
- open_api_schema
---

`dry-schema` can be extended with extension. Those extensions are loaded with `Dry::Schema.load_extensions`.
Expand All @@ -17,3 +18,4 @@ Available extensions:
- [Info](docs::extensions/info)
- [Monads](docs::extensions/monads)
- [JSON Schema](docs::extensions/json_schema)
- [OpenAPI Schema](docs::extensions/open_api_schema)
44 changes: 44 additions & 0 deletions docsite/source/extensions/open_api_schema.html.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
title: OpenAPI Schema
layout: gem-single
name: dry-schema
---

The `:open_api_schema` extension allows you to generate a valid [OpenAPI Schema](https://swagger.io/specification/v3/) from a `Dry::Schema`. This makes it straightforward to leverage tools like Swagger, which is popular for API documentation and testing.

```ruby
Dry::Schema.load_extensions(:open_api_schema)

UserSchema = Dry::Schema.JSON do
required(:email).filled(:str?, min_size?: 8)
optional(:favorite_color).filled(:str?, included_in?: %w[red green blue pink])
optional(:age).filled(:int?)
end

UserSchema.open_api_schema
# {
# type: "object",
# properties: {
# email: {
# type: "string",
# minLength: 8
# },
# favorite_color: {
# type: "string",
# minLength: 1,
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, this seems to be not necessary for "filled" enums, will add more tests for cases like this and adjust the implementation

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also add an additional null enum along with the nullable: true flag for optional enums.

# enum: %w[red green blue pink]
# },
# age: {
# type: "integer"
# }
# },
# required: ["email"]
# }
```

### Learn more

- [Official OpenAPI docs](https://spec.openapis.org/)
- [Swagger](https://swagger.io/docs/)
- [Integrate Swagger with your Rails app](https://github.com/rswag/rswag)

4 changes: 4 additions & 0 deletions lib/dry/schema/extensions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,7 @@
Dry::Schema.register_extension(:json_schema) do
require "dry/schema/extensions/json_schema"
end

Dry::Schema.register_extension(:open_api_schema) do
require "dry/schema/extensions/open_api_schema"
end
256 changes: 46 additions & 210 deletions lib/dry/schema/extensions/json_schema/schema_compiler.rb
Original file line number Diff line number Diff line change
@@ -1,201 +1,52 @@
# frozen_string_literal: true

require "dry/schema/constants"
require "dry/schema/extensions/schema_compiler_base"

module Dry
module Schema
# @api private
module JSONSchema
# @api private
class SchemaCompiler
# An error raised when a predicate cannot be converted
UnknownConversionError = ::Class.new(::StandardError)

IDENTITY = ->(v, _) { v }.freeze
TO_INTEGER = ->(v, _) { v.to_i }.freeze

PREDICATE_TO_TYPE = {
array?: {type: "array"},
bool?: {type: "boolean"},
date?: {type: "string", format: "date"},
date_time?: {type: "string", format: "date-time"},
decimal?: {type: "number"},
float?: {type: "number"},
hash?: {type: "object"},
int?: {type: "integer"},
nil?: {type: "null"},
str?: {type: "string"},
time?: {type: "string", format: "time"},
min_size?: {minLength: TO_INTEGER},
max_size?: {maxLength: TO_INTEGER},
included_in?: {enum: ->(v, _) { v.to_a }},
filled?: EMPTY_HASH,
uri?: {format: "uri"},
uuid_v1?: {
pattern: "^[0-9A-F]{8}-[0-9A-F]{4}-1[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$"
},
uuid_v2?: {
pattern: "^[0-9A-F]{8}-[0-9A-F]{4}-2[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$"
},
uuid_v3?: {
pattern: "^[0-9A-F]{8}-[0-9A-F]{4}-3[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$"
},
uuid_v4?: {
pattern: "^[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}$"
},
uuid_v5?: {
pattern: "^[0-9A-F]{8}-[0-9A-F]{4}-5[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$"
},
gt?: {exclusiveMinimum: IDENTITY},
gteq?: {minimum: IDENTITY},
lt?: {exclusiveMaximum: IDENTITY},
lteq?: {maximum: IDENTITY},
odd?: {type: "integer", not: {multipleOf: 2}},
even?: {type: "integer", multipleOf: 2}
}.freeze

# @api private
attr_reader :keys, :required

# @api private
def initialize(root: false, loose: false)
@keys = EMPTY_HASH.dup
@required = Set.new
@root = root
@loose = loose
end

# @api private
def to_hash
result = {}
result[:$schema] = "http://json-schema.org/draft-06/schema#" if root?
result.merge!(type: "object", properties: keys, required: required.to_a)
result
end

alias_method :to_h, :to_hash

# @api private
def call(ast)
visit(ast)
end

# @api private
def visit(node, opts = EMPTY_HASH)
meth, rest = node
public_send(:"visit_#{meth}", rest, opts)
end

# @api private
def visit_set(node, opts = EMPTY_HASH)
target = (key = opts[:key]) ? self.class.new(loose: loose?) : self

node.map { |child| target.visit(child, opts.except(:member)) }

return unless key

target_info = opts[:member] ? {items: target.to_h} : target.to_h
type = opts[:member] ? "array" : "object"

merge_opts!(keys[key], {type: type, **target_info})
end

# @api private
def visit_and(node, opts = EMPTY_HASH)
left, right = node

# We need to know the type first to apply filled macro
if left[1][0] == :filled?
visit(right, opts)
visit(left, opts)
else
visit(left, opts)
visit(right, opts)
end
class SchemaCompiler < SchemaCompilerBase::Base
def predicate_to_type
{
array?: {type: "array"},
bool?: {type: "boolean"},
date?: {type: "string", format: "date"},
date_time?: {type: "string", format: "date-time"},
decimal?: {type: "number"},
float?: {type: "number"},
hash?: {type: "object"},
int?: {type: "integer"},
nil?: {type: "null"},
str?: {type: "string"},
time?: {type: "string", format: "time"},
min_size?: {minLength: SchemaCompilerBase::TO_INTEGER},
max_size?: {maxLength: SchemaCompilerBase::TO_INTEGER},
included_in?: {enum: ->(v, _) { v.to_a }},
filled?: EMPTY_HASH,
uri?: {format: "uri"},
uuid_v1?: {pattern: "^[0-9A-F]{8}-[0-9A-F]{4}-1[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$"},
uuid_v2?: {pattern: "^[0-9A-F]{8}-[0-9A-F]{4}-2[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$"},
uuid_v3?: {pattern: "^[0-9A-F]{8}-[0-9A-F]{4}-3[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$"},
uuid_v4?: {pattern: "^[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}$"},
uuid_v5?: {pattern: "^[0-9A-F]{8}-[0-9A-F]{4}-5[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$"},
gt?: {exclusiveMinimum: SchemaCompilerBase::IDENTITY},
gteq?: {minimum: SchemaCompilerBase::IDENTITY},
lt?: {exclusiveMaximum: SchemaCompilerBase::IDENTITY},
lteq?: {maximum: SchemaCompilerBase::IDENTITY},
odd?: {type: "integer", not: {multipleOf: 2}},
even?: {type: "integer", multipleOf: 2}
}
end

# @api private
def visit_or(node, opts = EMPTY_HASH)
node.each do |child|
c = self.class.new(loose: loose?)
c.keys.update(subschema: {})
c.visit(child, opts.merge(key: :subschema))

any_of = (keys[opts[:key]][:anyOf] ||= [])
any_of << c.keys[:subschema]
end
end

# @api private
def visit_implication(node, opts = EMPTY_HASH)
node.each do |el|
visit(el, **opts, required: false)
end
end

# @api private
def visit_each(node, opts = EMPTY_HASH)
visit(node, opts.merge(member: true))
end

# @api private
def visit_key(node, opts = EMPTY_HASH)
name, rest = node

if opts.fetch(:required, :true)
required << name.to_s
else
opts.delete(:required)
end

visit(rest, opts.merge(key: name))
end

# @api private
def visit_not(node, opts = EMPTY_HASH)
_name, rest = node

visit_predicate(rest, opts)
end

# @api private
def visit_predicate(node, opts = EMPTY_HASH)
name, rest = node

if name.equal?(:key?)
prop_name = rest[0][1]
keys[prop_name] = {}
else
target = keys[opts[:key]]
type_opts = fetch_type_opts_for_predicate(name, rest, target)

if target[:type]&.include?("array")
target[:items] ||= {}
merge_opts!(target[:items], type_opts)
else
merge_opts!(target, type_opts)
end
end
end

# @api private
def fetch_type_opts_for_predicate(name, rest, target)
type_opts = PREDICATE_TO_TYPE.fetch(name) do
raise_unknown_conversion_error!(:predicate, name) unless loose?

EMPTY_HASH
end.dup
type_opts.transform_values! { |v| v.respond_to?(:call) ? v.call(rest[0][1], target) : v }
type_opts.merge!(fetch_filled_options(target[:type], target)) if name == :filled?
type_opts
end

# @api private
def fetch_filled_options(type, _target)
case type
when "string"
{minLength: 1}
when "array"
# If we are in strict mode, raise an error if we haven't
# explicitly handled "filled?" with array
raise_unknown_conversion_error!(:type, :array) unless loose?

{not: {type: "null"}}
Expand All @@ -204,39 +55,24 @@ def fetch_filled_options(type, _target)
end
end

# @api private
def merge_opts!(orig_opts, new_opts)
new_type = new_opts[:type]
orig_type = orig_opts[:type]

if orig_type && new_type && orig_type != new_type
new_opts[:type] = [orig_type, new_type].flatten.uniq
end

orig_opts.merge!(new_opts)
# In JSON Schema, we handle an OR branch with "anyOf"
def merge_or!(target, new_schema)
(target[:anyOf] ||= []) << new_schema
end

# @api private
def root?
@root
# Info to inject at the root level for JSON Schema
def schema_info
{"$schema": "http://json-schema.org/draft-06/schema#"}
end

# @api private
def loose?
@loose
# Useful in error messages
def schema_type
"JSON"
end

def raise_unknown_conversion_error!(type, name)
message = <<~MSG
Could not find an equivalent conversion for #{type} #{name.inspect}.

This means that your generated JSON schema may be missing this validation.

You can ignore this by generating the schema in "loose" mode, i.e.:
my_schema.json_schema(loose: true)
MSG

raise UnknownConversionError, message.chomp
# Used in the unknown_conversion_message to show users how to call json_schema(loose: true)
def schema_method
"json_schema"
end
end
end
Expand Down
29 changes: 29 additions & 0 deletions lib/dry/schema/extensions/open_api_schema.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# frozen_string_literal: true

require "dry/schema/extensions/open_api_schema/schema_compiler"

module Dry
module Schema
# OpenAPISchema extension
#
# @api public
module OpenAPISchema
module SchemaMethods
# Convert the schema into a OpenAPI schema hash
#
# @param [Symbol] loose Compile the schema in "loose" mode
#
# @return [Hash<Symbol=>Hash>]
#
# @api public
def open_api_schema(loose: false)
compiler = SchemaCompiler.new(root: false, loose: loose)
compiler.call(to_ast)
compiler.to_hash
end
end
end

Processor.include(OpenAPISchema::SchemaMethods)
end
end
Loading