class StrongJSON module Type module Match def =~(value) coerce(value) true rescue false end def ===(value) self =~ value end end module WithAlias def alias @alias end def with_alias(name) _ = dup.tap do |copy| copy.instance_eval do @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 :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) # @type var other: 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) # @type var other: 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) # @type var other: 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) # @type var other: Array other.type == type end end __skip__ = begin alias eql? == end end class Object include Match include WithAlias # @dynamic fields, ignored_attributes, prohibited_attributes attr_reader :fields, :ignored_attributes, :prohibited_attributes def initialize(fields, ignored_attributes:, prohibited_attributes:) @fields = fields @ignored_attributes = ignored_attributes @prohibited_attributes = prohibited_attributes end def coerce(object, path: ErrorPath.root(self)) unless object.is_a?(::Hash) raise TypeError.new(path: path, value: object) end unless (intersection = Set.new(object.keys).intersection(prohibited_attributes)).empty? raise UnexpectedAttributeError.new(path: path, attribute: intersection.to_a.first) end case attrs = ignored_attributes when :any object = object.dup extra_keys = Set.new(object.keys) - Set.new(fields.keys) extra_keys.each do |key| object.delete(key) end when Set object = object.dup attrs.each do |key| object.delete(key) end end # @type var result: ::Hash result = {} object.each do |key, _| unless fields.key?(key) raise UnexpectedAttributeError.new(path: path, attribute: key) end end fields.each do |key, type| result[key] = type.coerce(object[key], path: path.dig(key: key, type: type)) end _ = result end def ignore(attrs) Object.new(fields, ignored_attributes: attrs, prohibited_attributes: prohibited_attributes) end def ignore!(attrs) @ignored_attributes = attrs self end def prohibit(attrs) Object.new(fields, ignored_attributes: ignored_attributes, prohibited_attributes: attrs) end def prohibit!(attrs) @prohibited_attributes = attrs self end def update_fields fields.dup.yield_self do |fields| yield fields Object.new(fields, ignored_attributes: ignored_attributes, prohibited_attributes: prohibited_attributes) 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) # @type var other: Object other.fields == fields && other.ignored_attributes == ignored_attributes && other.prohibited_attributes == prohibited_attributes 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) # @type var other: 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) # @type var other: 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