# frozen_string_literal: true
require "dry/schema/constants"
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) }
return unless key
target_info = opts[:member] ? {items: target.to_h} : target.to_h
type = opts[:member] ? "array" : "object"
keys.update(key => {**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
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"
raise_unknown_conversion_error!(:type, :array) unless loose?
{not: {type: "null"}}
else
{not: {type: "null"}}
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]
end
orig_opts.merge!(new_opts)
end
# @api private
def root?
@root
end
# @api private
def loose?
@loose
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
end
end
end
end
end