From 8a937ddf1adaddfac2323ee77d4036c9bfd5619c Mon Sep 17 00:00:00 2001 From: Volodymyr Radchenko Date: Wed, 19 Feb 2025 16:49:42 +0100 Subject: [PATCH 1/3] REFACT: extract json schema compiler logic to a base class --- .../extensions/json_schema/schema_compiler.rb | 256 ++++-------------- .../schema/extensions/schema_compiler_base.rb | 222 +++++++++++++++ 2 files changed, 268 insertions(+), 210 deletions(-) create mode 100644 lib/dry/schema/extensions/schema_compiler_base.rb 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/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 From 293fbb5235c95aa3bc198bbf4f90fdae196c76d2 Mon Sep 17 00:00:00 2001 From: Volodymyr Radchenko Date: Wed, 19 Feb 2025 16:50:05 +0100 Subject: [PATCH 2/3] FEAT: implement open_api_schema extension --- lib/dry/schema/extensions.rb | 4 + lib/dry/schema/extensions/open_api_schema.rb | 29 ++ .../open_api_schema/schema_compiler.rb | 78 ++++ .../extensions/open_api_schema/schema_spec.rb | 410 ++++++++++++++++++ 4 files changed, 521 insertions(+) create mode 100644 lib/dry/schema/extensions/open_api_schema.rb create mode 100644 lib/dry/schema/extensions/open_api_schema/schema_compiler.rb create mode 100644 spec/extensions/open_api_schema/schema_spec.rb 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/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/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 From 70d0b9bc0aef4f23de03fd100b4724e46be3435f Mon Sep 17 00:00:00 2001 From: Volodymyr Radchenko Date: Wed, 19 Feb 2025 19:56:22 +0100 Subject: [PATCH 3/3] DOC: reference open_api_schema extension in the documentation --- docsite/source/extensions.html.md | 2 + .../source/extensions/open_api_schema.html.md | 44 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 docsite/source/extensions/open_api_schema.html.md 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) +