diff --git a/docsite/source/extensions.html.md b/docsite/source/extensions.html.md
index 328f8b87..7ae203d6 100644
--- a/docsite/source/extensions.html.md
+++ b/docsite/source/extensions.html.md
@@ -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`.
@@ -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)
diff --git a/docsite/source/extensions/open_api_schema.html.md b/docsite/source/extensions/open_api_schema.html.md
new file mode 100644
index 00000000..31d6a80e
--- /dev/null
+++ b/docsite/source/extensions/open_api_schema.html.md
@@ -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,
+# 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)
+
diff --git a/lib/dry/schema/extensions.rb b/lib/dry/schema/extensions.rb
index 951bf5e8..e7f82046 100644
--- a/lib/dry/schema/extensions.rb
+++ b/lib/dry/schema/extensions.rb
@@ -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
diff --git a/lib/dry/schema/extensions/json_schema/schema_compiler.rb b/lib/dry/schema/extensions/json_schema/schema_compiler.rb
index f4383693..2d12036d 100644
--- a/lib/dry/schema/extensions/json_schema/schema_compiler.rb
+++ b/lib/dry/schema/extensions/json_schema/schema_compiler.rb
@@ -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"}}
@@ -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
diff --git a/lib/dry/schema/extensions/open_api_schema.rb b/lib/dry/schema/extensions/open_api_schema.rb
new file mode 100644
index 00000000..aa419be8
--- /dev/null
+++ b/lib/dry/schema/extensions/open_api_schema.rb
@@ -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 [HashHash>]
+ #
+ # @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
diff --git a/lib/dry/schema/extensions/open_api_schema/schema_compiler.rb b/lib/dry/schema/extensions/open_api_schema/schema_compiler.rb
new file mode 100644
index 00000000..4a2298a3
--- /dev/null
+++ b/lib/dry/schema/extensions/open_api_schema/schema_compiler.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+require "dry/schema/extensions/schema_compiler_base"
+
+module Dry
+ module Schema
+ # @api private
+ module OpenAPISchema
+ # @api private
+ 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?: {nullable: true},
+ str?: {type: "string"},
+ time?: {type: "string", format: "date-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?: {minimum: SchemaCompilerBase::IDENTITY, exclusiveMinimum: true},
+ gteq?: {minimum: SchemaCompilerBase::IDENTITY},
+ lt?: {maximum: SchemaCompilerBase::IDENTITY, exclusiveMaximum: true},
+ lteq?: {maximum: SchemaCompilerBase::IDENTITY},
+ odd?: {type: "integer", not: {multipleOf: 2}},
+ even?: {type: "integer", multipleOf: 2}
+ }
+ end
+
+ def fetch_filled_options(type, target)
+ case type
+ when "string"
+ {minLength: 1}
+ when "array"
+ target[:minItems] = 1
+ {}
+ else
+ {}
+ end
+ end
+
+ # In OpenAPI, we want to use "oneOf" instead of "anyOf"
+ def merge_or!(target, new_schema)
+ (target[:oneOf] ||= []) << new_schema
+ end
+
+ # We do not support the `root`. If we did we'd have to include
+ # all required OpenAPI root properties that require additional info.
+ def schema_info
+ {}
+ end
+
+ # Useful in error messages
+ def schema_type
+ "OpenAPI"
+ end
+
+ # Used in the unknown_conversion_message to show users how to call json_schema(loose: true)
+ def schema_method
+ "open_api_schema"
+ end
+ end
+ end
+ end
+end
diff --git a/lib/dry/schema/extensions/schema_compiler_base.rb b/lib/dry/schema/extensions/schema_compiler_base.rb
new file mode 100644
index 00000000..e6064971
--- /dev/null
+++ b/lib/dry/schema/extensions/schema_compiler_base.rb
@@ -0,0 +1,222 @@
+# frozen_string_literal: true
+
+# This file defines an abstract base class for compiling a Dry::Schema AST
+# into some output format (e.g., JSON Schema, OpenAPI Schema).
+# Each subclass should override certain "abstract" methods to adapt the output.
+
+require "dry/schema/constants"
+require "set"
+
+module Dry
+ module Schema
+ module SchemaCompilerBase
+ IDENTITY = ->(v, _) { v }.freeze
+ TO_INTEGER = ->(v, _) { v.to_i }.freeze
+
+ # This base class contains all the common logic for compiling a schema AST.
+ # Subclasses must implement the abstract methods:
+ # - predicate_to_type
+ # - fetch_filled_options
+ # - merge_or!
+ # - schema_info
+ # - schema_type
+ # - schema_method
+ #
+ # The shape of AST nodes (roughly):
+ # [:set, [ ... ]]
+ # [:key, [name, [ ... ]]]
+ # [:predicate, [predicate_name, [arg_name, arg_val], ...]]
+ # etc.
+ class Base
+ UnknownConversionError = ::Class.new(::StandardError)
+
+ attr_reader :keys, :required
+
+ def initialize(root: false, loose: false)
+ @keys = EMPTY_HASH.dup
+ @required = Set.new
+ @root = root
+ @loose = loose
+ end
+
+ def to_hash
+ result = {}
+ result.merge!(schema_info) if root?
+ result.merge!(type: "object", properties: keys, required: required.to_a)
+ result
+ end
+ alias_method :to_h, :to_hash
+
+ def call(ast)
+ visit(ast)
+ end
+
+ def visit(node, opts = EMPTY_HASH)
+ meth, rest = node
+ public_send(:"visit_#{meth}", rest, opts)
+ end
+
+ def visit_set(node, opts = EMPTY_HASH)
+ # If a key is present we want to build a nested schema
+ target = opts[:key] ? self.class.new(root: false, loose: loose?) : self
+
+ node.each { |child| target.visit(child, opts.except(:member)) }
+
+ if opts[:key]
+ target_info = opts[:member] ? {items: target.to_h} : target.to_h
+ type = opts[:member] ? "array" : "object"
+ merge_opts!(keys[opts[:key]], {type: type, **target_info})
+ end
+ end
+
+ def visit_and(node, opts = EMPTY_HASH)
+ left, right = node
+ # We reorder if left starts with :filled? so we know the type first
+ if left[1][0] == :filled?
+ visit(right, opts)
+ visit(left, opts)
+ else
+ visit(left, opts)
+ visit(right, opts)
+ end
+ end
+
+ def visit_or(node, opts = EMPTY_HASH)
+ # Process each alternative and merge using a custom "or" merger (anyOf vs oneOf)
+ node.each do |child|
+ c = self.class.new(root: false, loose: loose?)
+ c.keys.update(subschema: {})
+ c.visit(child, opts.merge(key: :subschema))
+ merge_or!(keys[opts[:key]], c.keys[:subschema])
+ end
+ end
+
+ # :implication means "if left, then right." But for schema compilation,
+ # we simply visit both with required: false so that it doesn't always enforce them.
+ def visit_implication(node, opts = EMPTY_HASH)
+ node.each { |el| visit(el, **opts, required: false) }
+ end
+
+ def visit_each(node, opts = EMPTY_HASH)
+ visit(node, opts.merge(member: true))
+ end
+
+ def visit_key(node, opts = EMPTY_HASH)
+ name, rest = node
+ if opts.fetch(:required, true)
+ required << name.to_s
+ else
+ # If not required, remove it from opts so sub-rules won't re-add it
+ opts.delete(:required)
+ end
+ visit(rest, opts.merge(key: name))
+ end
+
+ def visit_not(node, opts = EMPTY_HASH)
+ _name, rest = node
+ visit_predicate(rest, opts)
+ end
+
+ def visit_predicate(node, opts = EMPTY_HASH)
+ name, rest = node
+
+ if name.equal?(:key?)
+ keys[rest[0][1]] = {}
+ 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
+
+ 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! do |v|
+ v.respond_to?(:call) ? v.call(rest[0][1], target) : v
+ end
+ type_opts.merge!(fetch_filled_options(target[:type], target)) if name == :filled?
+ type_opts
+ end
+
+ 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)
+ end
+
+ def raise_unknown_conversion_error!(type, name)
+ # Build a helpful message explaining that we couldn’t convert this type/predicate
+ # and they can use loose: true to ignore unknowns.
+ # This error is particularly helpful if new predicates are added to Dry::Schema
+ # that aren't yet handled in the compiler.
+
+ message = unknown_conversion_message(type, name)
+ raise UnknownConversionError, message.chomp
+ end
+
+ def root?
+ @root
+ end
+
+ def loose?
+ @loose
+ end
+
+ # === Abstract methods to be implemented by subclasses ===
+
+ # Returns a hash mapping predicate names to schema options.
+ def predicate_to_type
+ raise NotImplementedError, "#{self.class.name} must implement `predicate_to_type`"
+ end
+
+ # Returns extra options for the :filled? predicate based on the type.
+ def fetch_filled_options(_type, _target)
+ {}
+ end
+
+ # Merges an “or” branch into the parent schema.
+ # By default, JSON Schema uses "anyOf", but OpenAPI might use "oneOf".
+ def merge_or!(target, new_schema)
+ (target[:anyOf] ||= []) << new_schema
+ end
+
+ # Additional information to merge at the root level (e.g. $schema for JSON Schema).
+ def schema_info
+ {}
+ end
+
+ # Returns the name of the schema type (e.g. "JSON" or "OpenAPI") for error messages.
+ def schema_type
+ raise NotImplementedError, "#{self.class.name} must implement `schema_type`"
+ end
+
+ # Returns the schema method name (e.g. "json_schema" or "open_api_schema") for error messages.
+ def schema_method
+ raise NotImplementedError, "#{self.class.name} must implement `schema_method`"
+ end
+
+ def unknown_conversion_message(type, name)
+ <<~MSG
+ Could not find an equivalent conversion for #{type} #{name.inspect}.
+
+ This means that your generated #{schema_type} schema may be missing this validation.
+
+ You can ignore this by generating the schema in "loose" mode, i.e.:
+ my_schema.#{schema_method}(loose: true)
+ MSG
+ end
+ end
+ end
+ end
+end
diff --git a/spec/extensions/open_api_schema/schema_spec.rb b/spec/extensions/open_api_schema/schema_spec.rb
new file mode 100644
index 00000000..049f5d7b
--- /dev/null
+++ b/spec/extensions/open_api_schema/schema_spec.rb
@@ -0,0 +1,410 @@
+# frozen_string_literal: true
+
+require "json_schemer"
+
+RSpec.describe Dry::Schema::JSON, "#open_api_schema" do
+ before do
+ Dry::Schema.load_extensions(:open_api_schema)
+ end
+
+ shared_examples "metaschema validation" do
+ describe "validating against the metaschema" do
+ it "produces a valid open api schema document for draft6" do
+ input = schema.respond_to?(:open_api_schema) ? schema.open_api_schema : schema
+
+ schema_root = {
+ "openapi" => "3.0.1",
+ "info" => {
+ "title" => "example"
+ },
+ "components" => {
+ "schemas" => {"example" => input}
+ }
+ }
+
+ expect(JSONSchemer.validate_schema(schema_root).to_a).to be_empty
+ end
+ end
+ end
+
+ context "when using a realistic schema with nested data" do
+ subject(:schema) do
+ Dry::Schema.JSON do
+ required(:email).value(:string)
+
+ optional(:age).value(:integer)
+
+ required(:roles).array(:hash) do
+ required(:name).value(:string, min_size?: 12, max_size?: 36)
+
+ required(:metadata).hash do
+ required(:assigned_at).value(:time)
+ end
+ end
+
+ optional(:address).hash do
+ optional(:street).value(:string)
+ end
+
+ required(:id) { str? | int? }
+ end
+ end
+
+ include_examples "metaschema validation"
+
+ it "returns the correct open api schema" do
+ expect(schema.open_api_schema).to eql(
+ type: "object",
+ properties: {
+ email: {
+ type: "string"
+ },
+ age: {
+ type: "integer"
+ },
+ roles: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ name: {
+ type: "string",
+ minLength: 12,
+ maxLength: 36
+ },
+ metadata: {
+ type: "object",
+ properties: {
+ assigned_at: {
+ format: "date-time",
+ type: "string"
+ }
+ },
+ required: %w[assigned_at]
+ }
+ },
+ required: %w[name metadata]
+ }
+ },
+ address: {
+ type: "object",
+ properties: {
+ street: {
+ type: "string"
+ }
+ },
+ required: []
+ },
+ id: {
+ oneOf: [
+ {type: "string"},
+ {type: "integer"}
+ ]
+ }
+ },
+ required: %w[email roles id]
+ )
+ end
+ end
+
+ context "when using maybe types" do
+ include_examples "metaschema validation"
+
+ subject(:schema) do
+ Dry::Schema.JSON do
+ required(:email).maybe(:string)
+ end
+ end
+
+ it "returns the correct open api schema" do
+ expect(schema.open_api_schema).to eql(
+ type: "object",
+ properties: {
+ email: {
+ type: "string",
+ nullable: true
+ }
+ },
+ required: %w[email]
+ )
+ end
+ end
+
+ context "when using maybe array types" do
+ include_examples "metaschema validation"
+
+ subject(:schema) do
+ Dry::Schema.JSON do
+ required(:list).maybe(:array).each(:str?)
+ end
+ end
+
+ it "returns the correct open api schema" do
+ expect(schema.open_api_schema).to eql(
+ type: "object",
+ properties: {
+ list: {
+ type: "array",
+ nullable: true,
+ items: {
+ type: "string"
+ }
+ }
+ },
+ required: %w[list]
+ )
+ end
+ end
+
+ context "when using maybe array types with nested properties" do
+ include_examples "metaschema validation"
+
+ subject(:schema) do
+ Dry::Schema.JSON do
+ required(:list).maybe(:array).each do
+ hash do
+ required(:name).value(:string)
+ end
+ end
+ end
+ end
+
+ it "returns the correct open api schema" do
+ expect(schema.open_api_schema).to eql(
+ type: "object",
+ properties: {
+ list: {
+ type: "array",
+ nullable: true,
+ items: {
+ type: "object",
+ properties: {
+ name: {
+ type: "string"
+ }
+ },
+ required: ["name"]
+ }
+ }
+ },
+ required: %w[list]
+ )
+ end
+ end
+
+ describe "filled macro" do
+ context "when there is no type" do
+ include_examples "metaschema validation"
+
+ subject(:schema) do
+ Dry::Schema.JSON do
+ required(:email).filled
+ end
+ end
+
+ it "returns the correct open api schema" do
+ expect(schema.open_api_schema).to include(
+ properties: {
+ email: {}
+ }
+ )
+ end
+ end
+
+ context "when its a string type" do
+ include_examples "metaschema validation"
+
+ subject(:schema) do
+ Dry::Schema.JSON do
+ required(:email).filled(:str?)
+ end
+ end
+
+ it "returns the correct open api schema" do
+ expect(schema.open_api_schema).to include(
+ properties: {
+ email: {
+ type: "string",
+ minLength: 1
+ }
+ }
+ )
+ end
+ end
+
+ context "when its an array type" do
+ subject(:schema) do
+ Dry::Schema.JSON do
+ required(:tags).filled(:array)
+ end
+ end
+
+ it "returns the correct open api schema" do
+ expect(schema.open_api_schema).to include(
+ {
+ type: "object",
+ properties: {
+ tags: {
+ type: "array",
+ minItems: 1,
+ items: {}
+ }
+ },
+ required: ["tags"]
+ }
+ )
+ end
+ end
+ end
+
+ context "when using non-convertible types" do
+ unsupported_cases = [
+ Types.Constructor(Struct.new(:name)),
+ {excluded_from?: ["foo"]},
+ {format?: /something/},
+ {bytesize?: 2}
+ ]
+
+ unsupported_cases.each do |predicate|
+ subject(:schema) do
+ Dry::Schema.JSON do
+ required(:nested).hash do
+ if predicate.is_a?(Hash)
+ required(:key).filled(**predicate)
+ else
+ required(:key).filled(predicate)
+ end
+ end
+ end
+ end
+
+ it "raises an unknown type conversion error by default" do
+ expect { schema.open_api_schema }.to raise_error(
+ Dry::Schema::OpenAPISchema::SchemaCompiler::UnknownConversionError, /predicate/
+ )
+ end
+
+ it "allows for the schema to be generated loosely" do
+ expect { schema.open_api_schema(loose: true) }.not_to raise_error
+ end
+ end
+ end
+
+ context "when using enums" do
+ include_examples "metaschema validation"
+
+ subject(:schema) do
+ Dry::Schema.JSON do
+ required(:color).value(:str?, included_in?: %w[red blue])
+ required(:shade).maybe(array[Types::String.enum("light", "medium", "dark")])
+ end
+ end
+
+ it "returns the correct open api schema" do
+ expect(schema.open_api_schema).to eql(
+ type: "object",
+ properties: {
+ color: {
+ type: "string",
+ enum: %w[red blue]
+ },
+ shade: {
+ type: "array",
+ nullable: true,
+ items: {
+ type: "string",
+ enum: %w[light medium dark]
+ }
+ }
+ },
+ required: %w[color shade]
+ )
+ end
+ end
+
+ describe "inferring types" do
+ {
+ 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"},
+ integer: {type: "integer"},
+ nil: {nullable: true},
+ string: {type: "string"},
+ time: {type: "string", format: "date-time"},
+ 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}$"}
+ }.each do |type_spec, type_opts|
+ describe "type: #{type_spec.inspect}" do
+ subject(:schema) do
+ Dry::Schema.define { required(:key).value(type_spec) }.open_api_schema
+ end
+
+ include_examples "metaschema validation"
+
+ it "infers with correct default options - #{type_opts.to_json}" do
+ expect(schema).to include(
+ type: "object",
+ properties: {key: type_opts},
+ required: ["key"]
+ )
+ end
+ end
+ end
+ end
+
+ describe "special string predictes" do
+ {
+ {uri?: "https"} => {type: "string", format: "uri"}
+ }.each do |type_spec, type_opts|
+ describe "type: #{type_spec.inspect}" do
+ subject(:schema) do
+ Dry::Schema.define { required(:key).value(:string, **type_spec) }.open_api_schema
+ end
+
+ include_examples "metaschema validation"
+
+ it "infers with correct default options - #{type_opts.to_json}" do
+ expect(schema).to include(
+ properties: {key: type_opts}
+ )
+ end
+ end
+ end
+ end
+
+ describe "special number predictes" do
+ {
+ {gt?: 5} => {type: "integer", exclusiveMinimum: true, minimum: 5},
+ {gteq?: 5} => {type: "integer", minimum: 5},
+ {lt?: 5} => {type: "integer", exclusiveMaximum: true, maximum: 5},
+ {lteq?: 5} => {type: "integer", maximum: 5},
+ odd?: {type: "integer", not: {multipleOf: 2}},
+ even?: {type: "integer", multipleOf: 2}
+ }.each do |type_spec, type_opts|
+ describe "type: #{type_spec.inspect}" do
+ subject(:schema) do
+ if type_spec.is_a?(Hash)
+ Dry::Schema.define { required(:key).value(:int?, **type_spec) }.open_api_schema
+ else
+ Dry::Schema.define { required(:key).value(type_spec) }.open_api_schema
+ end
+ end
+
+ include_examples "metaschema validation"
+
+ it "infers with correct default options - #{type_opts.to_json}" do
+ expect(schema).to include(
+ properties: {key: type_opts}
+ )
+ end
+ end
+ end
+ end
+end