class StrongJSON module Type module Match def =~(value) coerce(value) true rescue false end def ===(value) self =~ value end end module WithAlias def alias defined?(@alias) ? @alias : nil end def with_alias(name) _ = dup.tap do |copy| copy.instance_eval do |x| @alias = name end end end end class Base include Match include WithAlias # @dynamic type attr_reader :type def initialize(type) @type = type end def test(value) case @type when :any true when :number value.is_a?(Numeric) when :integer value.is_a?(Integer) when :string value.is_a?(String) when :boolean value == true || value == false when :numeric value.is_a?(Numeric) || value.is_a?(String) && /\A[\-\+]?[\d.]+\Z/ =~ value when :symbol value.is_a?(String) || value.is_a?(Symbol) else false end end def coerce(value, path: ErrorPath.root(self)) raise TypeError.new(value: value, path: path) unless test(value) case type when :symbol value.to_sym else value end end def to_s self.alias&.to_s || @type.to_s end def ==(other) if other.is_a?(Base) other.type == type end end __skip__ = begin alias eql? == end end class Optional include Match include WithAlias # @dynamic type attr_reader :type def initialize(type) @type = type end def coerce(value, path: ErrorPath.root(self)) unless value == nil @type.coerce(value, path: path.expand(type: @type)) else nil end end def to_s self.alias&.to_s || "optional(#{@type})" end def ==(other) if other.is_a?(Optional) other.type == type end end __skip__ = begin alias eql? == end end class Literal include Match include WithAlias # @dynamic value attr_reader :value def initialize(value) @value = value end def to_s self.alias&.to_s || (_ = @value).inspect end def coerce(value, path: ErrorPath.root(self)) raise TypeError.new(path: path, value: value) unless (_ = self.value) == value value end def ==(other) if other.is_a?(Literal) other.value == value end end __skip__ = begin alias eql? == end end class Array include Match include WithAlias # @dynamic type attr_reader :type def initialize(type) @type = type end def coerce(value, path: ErrorPath.root(self)) if value.is_a?(::Array) value.map.with_index do |v, i| @type.coerce(v, path: path.dig(key: i, type: @type)) end else raise TypeError.new(path: path, value: value) end end def to_s self.alias&.to_s || "array(#{@type})" end def ==(other) if other.is_a?(Array) other.type == type end end __skip__ = begin alias eql? == end end class Object include Match include WithAlias # @dynamic fields, on_unknown, exceptions attr_reader :fields, :on_unknown, :exceptions def initialize(fields, on_unknown:, exceptions:) @fields = fields @on_unknown = on_unknown @exceptions = exceptions end def coerce(object, path: ErrorPath.root(self)) unless object.is_a?(::Hash) raise TypeError.new(path: path, value: object) end object = object.dup unknown_attributes = Set.new(object.keys) - fields.keys case on_unknown when :reject unknown_attributes.each do |attr| if exceptions.member?(attr) object.delete(attr) else raise UnexpectedAttributeError.new(path: path, attribute: attr) end end when :ignore unknown_attributes.each do |attr| if exceptions.member?(attr) raise UnexpectedAttributeError.new(path: path, attribute: attr) else object.delete(attr) end end end # @type var result: ::Hash[Symbol, untyped] result = {} fields.each do |key, type| result[key] = type.coerce(object[key], path: path.dig(key: key, type: type)) end _ = result end def ignore(*ignores, except: nil) if ignores.empty? && !except Object.new(fields, on_unknown: :ignore, exceptions: Set[]) else if except Object.new(fields, on_unknown: :ignore, exceptions: except) else Object.new(fields, on_unknown: :reject, exceptions: Set.new(ignores)) end end end def reject(*rejecteds, except: nil) if rejecteds.empty? && !except Object.new(fields, on_unknown: :reject, exceptions: Set[]) else if except Object.new(fields, on_unknown: :reject, exceptions: except) else Object.new(fields, on_unknown: :ignore, exceptions: Set.new(rejecteds)) end end end def update_fields fields.dup.yield_self do |fields| yield fields Object.new(fields, on_unknown: on_unknown, exceptions: exceptions) end end def to_s fields = @fields.map do |name, type| "#{name}: #{type}" end self.alias&.to_s || "object(#{fields.join(', ')})" end def ==(other) if other.is_a?(Object) other.fields == fields && other.on_unknown == on_unknown && other.exceptions == exceptions end end __skip__ = begin alias eql? == end end class Enum include Match include WithAlias # @dynamic types, detector attr_reader :types attr_reader :detector def initialize(types, detector = nil) @types = types @detector = detector end def to_s self.alias&.to_s || "enum(#{types.map(&:to_s).join(", ")})" end def coerce(value, path: ErrorPath.root(self)) if d = detector type = d[value] if type && types.include?(type) return type.coerce(value, path: path.expand(type: type)) end end types.each do |ty| begin return ty.coerce(value, path: path.expand(type: ty)) rescue UnexpectedAttributeError, TypeError # rubocop:disable Lint/HandleExceptions end end raise TypeError.new(path: path, value: value) end def ==(other) if other.is_a?(Enum) other.types == types && other.detector == detector end end __skip__ = begin alias eql? == end end class Hash include Match include WithAlias # @dynamic type attr_reader :type def initialize(type) @type = type end def coerce(value, path: ErrorPath.root(self)) if value.is_a?(::Hash) (_ = {}).tap do |result| value.each do |k, v| result[k] = type.coerce(v, path: path.dig(key: k, type: type)) end end else raise TypeError.new(path: path, value: value) end end def ==(other) if other.is_a?(Hash) other.type == type end end end class UnexpectedAttributeError < StandardError # @dynamic path, attribute attr_reader :path, :attribute def initialize(path:, attribute:) @path = path @attribute = attribute super "UnexpectedAttributeError at #{path.to_s}: attribute=#{attribute}" end def type path.type end end class TypeError < StandardError # @dynamic path, value attr_reader :path, :value def initialize(path:, value:) @path = path @value = value type = path.type s = type.alias || type super "TypeError at #{path.to_s}: expected=#{s}, value=#{value.inspect}" end def type path.type end end class ErrorPath # @dynamic type, parent attr_reader :type, :parent def initialize(type:, parent:) @type = type @parent = parent end def dig(key:, type:) # @type var parent: [Integer | Symbol | nil, ErrorPath] parent = [key, self] self.class.new(type: type, parent: parent) end def expand(type:) # @type var parent: [Integer | Symbol | nil, ErrorPath] parent = [nil, self] self.class.new(type: type, parent: parent) end def self.root(type) self.new(type: type, parent: nil) end def root? !parent end def to_s if pa = parent if key = pa[0] pa[1].to_s + case key when Integer "[#{key}]" when Symbol ".#{key}" end else pa[1].to_s end else "$" end end end end end