module SoberSwag class Compiler ## # A compiler for DRY-Struct data types, essentially. # It only consumes one type at a time. class Type # rubocop:disable Metrics/ClassLength class << self def get_ref(klass) "#/components/schemas/#{safe_name(klass)}" end def safe_name(klass) if klass.respond_to?(:identifier) klass.identifier else klass.to_s.gsub('::', '.') end end def primitive?(value) primitive_def(value) != nil end def primitive_def(value) return nil unless value.is_a?(Class) if (name = primitive_name(value)) { type: name } elsif value == Date { type: 'string', format: 'date' } elsif [Time, DateTime].any?(&value.ancestors.method(:include?)) { type: 'string', format: 'date-time' } end end def primitive_name(value) return 'null' if value == NilClass return 'integer' if value == Integer return 'number' if value == Float return 'string' if value == String return 'boolean' if [TrueClass, FalseClass].include?(value) end end class TooComplicatedError < ::SoberSwag::Compiler::Error; end class TooComplicatedForPathError < TooComplicatedError; end class TooComplicatedForQueryError < TooComplicatedError; end METADATA_KEYS = %i[description deprecated].freeze def initialize(type) @type = type end attr_reader :type ## # Is this type standalone, IE, worth serializing on its own # in the schemas section of our schema? def standalone? type.is_a?(Class) end def object_schema @object_schema ||= normalize(parsed_type).cata(&method(:to_object_schema)) end def schema_stub @schema_stub ||= generate_schema_stub end def path_schema path_schema_stub.map { |e| e.merge(in: :path) } rescue TooComplicatedError => e raise TooComplicatedForPathError, e.message end def query_schema path_schema_stub.map { |e| e.merge(in: :query) } rescue TooComplicatedError => e raise TooComplicatedForQueryError, e.message end def ref_name self.class.safe_name(type) end def found_types @found_types ||= begin (_, found_types) = parsed_result found_types end end def parsed_type @parsed_type ||= begin (parsed,) = parsed_result parsed end end def parsed_result @parsed_result ||= Parser.new(type_for_parser).run_parser end def eql?(other) other.class == self.class && other.type == type end def hash [self.class, type].hash end private def generate_schema_stub # rubocop:disable Metrics/MethodLength return self.class.primitive_def(type) if self.class.primitive?(type) case type when Class { :$ref => self.class.get_ref(type) } when Dry::Types::Constrained self.class.new(type.type).schema_stub when Dry::Types::Array::Member { type: :array, items: self.class.new(type.member).schema_stub } when Dry::Types::Sum { oneOf: normalize(parsed_type).elements.map { |t| self.class.new(t.value).schema_stub } } else raise ArgumentError, "Cannot generate a schema stub for #{type} (#{type.class})" end end def type_for_parser if type.is_a?(Class) type.schema.type else # Probably a constrained array type end end def normalize(object) object.cata { |e| rewrite_sums(e) }.cata { |e| flatten_one_ofs(e) } end def rewrite_sums(object) # rubocop:disable Metrics/MethodLength case object in Nodes::Sum[Nodes::OneOf[*lhs], Nodes::OneOf[*rhs]] Nodes::OneOf.new(lhs + rhs) in Nodes::Sum[Nodes::OneOf[*args], rhs] Nodes::OneOf.new(args + [rhs]) in Nodes::Sum[lhs, Nodes::OneOf[*args]] Nodes::OneOf.new([lhs] + args) in Nodes::Sum[lhs, rhs] Nodes::OneOf.new([lhs, rhs]) else object end end def flatten_one_ofs(object) case object in Nodes::OneOf[*args] Nodes::OneOf.new(args.uniq) else object end end def to_object_schema(object) # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity case object in Nodes::List[element] { type: :array, items: element } in Nodes::Enum[values] { type: :string, enum: values } in Nodes::OneOf[{ type: 'null' }, b] b.merge(nullable: true) in Nodes::OneOf[a, { type: 'null' }] a.merge(nullable: true) in Nodes::OneOf[*attrs] if attrs.include?(type: 'null') { oneOf: attrs.reject { |e| e[:type] == 'null' }, nullable: true } in Nodes::OneOf[*cases] { oneOf: cases } in Nodes::Object[*attrs] # openAPI requires that you give a list of required attributes # (which IMO is the *totally* wrong thing to do but whatever) # so we must do this garbage required = attrs.filter { |(_, b)| b[:required] }.map(&:first) { type: :object, properties: attrs.map { |(a, b)| [a, b.reject { |k, _| k == :required }] }.to_h, required: required } in Nodes::Attribute[name, true, value] [name, value.merge(required: true)] in Nodes::Attribute[name, false, value] [name, value] # can't match on value directly as ruby uses `===` to match, # and classes use `===` to mean `is an instance of`, as # opposed to direct equality lmao in Nodes::Primitive[value:, metadata:] if self.class.primitive?(value) md = self.class.primitive_def(value) METADATA_KEYS.select(&metadata.method(:key?)).reduce(md) do |definition, key| definition.merge(key => metadata[key]) end in Nodes::Primitive[value:] { '$ref': self.class.get_ref(value) } else raise ArgumentError, "Got confusing node #{object} (#{object.class})" end end def path_schema_stub @path_schema_stub ||= object_schema[:properties].map do |k, v| ensure_uncomplicated(k, v) { name: k, schema: v.reject { |key, _| %i[required nullable].include?(key) }, # rubocop:disable Style/DoubleNegation allowEmptyValue: !object_schema[:required].include?(k) || !!v[:nullable], # if it's required, no empties, but if *nullabe*, empties are okay # rubocop:enable Style/DoubleNegation required: object_schema[:required].include?(k) || false } end end def ensure_uncomplicated(key, value) return if value[:type] raise TooComplicatedError, <<~ERROR Property #{key} has object-schema #{value}, but this type of param should be simple (IE a primitive of some kind) ERROR end end end end