Class: Literal::Openapi::Adapters::BaseAdapter

Inherits:
Object
  • Object
show all
Defined in:
lib/literal/openapi/adapters/base_adapter.rb

Direct Known Subclasses

OpenAPI3_0

Constant Summary collapse

PRIMITIVE_MAP =
{
  ::String => { "type" => "string" },
  ::Integer => { "type" => "integer" },
  ::Float => { "type" => "number" },
  ::Numeric => { "type" => "number" },
  ::Symbol => { "type" => "string" },
  ::Date => { "type" => "string", "format" => "date" },
  ::Time => { "type" => "string", "format" => "date-time" },
  ::DateTime => { "type" => "string", "format" => "date-time" },
  ::TrueClass => { "type" => "boolean" },
  ::FalseClass => { "type" => "boolean" },
  ::NilClass => { "type" => "null" }
}.freeze
PROPERTY_KEY_ORDER =

Property-key order used by easy_talk’s YAML output. Puts type+content first, with nullable LAST. Any keys not in this list preserve their insertion order immediately before nullable. YAML diffing against existing schemas requires this exact sequence.

%w[
  type format items $ref
  properties additionalProperties required
  description enum
  minimum maximum exclusive_minimum exclusive_maximum multiple_of
  min_length max_length pattern
  min_items max_items unique_items
  min_properties max_properties
  default const examples
  nullable
].freeze

Instance Method Summary collapse

Instance Method Details

#build_schema(klass) ⇒ Object



27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/literal/openapi/adapters/base_adapter.rb', line 27

def build_schema(klass)
  properties = {}
  required = []

  # Walk the ancestor chain: child class props may inherit constraints from
  # a parent (e.g. ResponseSerializer::http_status). Class-level @ivars don't
  # inherit, so merge parent-first, overriding with child-specific values.
  extra_constraints = {}
  openapi_required = ::Set.new
  openapi_optional = ::Set.new
  shim_managed = ::Set.new
  chain = []
  cursor = klass
  while cursor
    chain.unshift(cursor) if cursor.respond_to?(:__openapi_constraints__)
    cursor = cursor.superclass
  end
  chain.each do |c|
    extra_constraints.merge!(c.__openapi_constraints__)
    if c.respond_to?(:__openapi_required__)
      openapi_required.merge(c.__openapi_required__)
      shim_managed.merge(c.__openapi_required__)
    end
    if c.respond_to?(:__openapi_optional__)
      openapi_optional.merge(c.__openapi_optional__)
      shim_managed.merge(c.__openapi_optional__)
    end
  end

  klass.literal_properties.each do |prop|
    schema = convert_type(prop.type)

    # Easy_talk drops siblings (description/format/etc.) on $ref-bearing
    # schemas. This is because OpenAPI 3.0 ignores them. Two forms count:
    #   1. Pure `{ "$ref" => "..." }`
    #   2. `{ "anyOf" => [{ "$ref" => "..." }, { "type" => "null" }] }`
    #      (our nilable-ref shape for 3.0)
    ref_shape = ref_schema?(schema)

    unless ref_shape
      schema["description"] = prop.description if prop.description
      schema["enum"] = prop.enum if prop.enum
      schema["format"] = prop.format if prop.format
      schema["items"] = schema["items"].merge("enum" => prop.items_enum) if prop.items_enum && schema["items"]
      # Merge shim-provided OpenAPI constraints (minimum/maximum/pattern/etc.).
      if (constraints = extra_constraints[prop.name])
        constraints.each { |k, v| schema[k.to_s] = v }
      end
      schema = reorder_property_keys(schema)
    end
    properties[prop.name.to_s] = schema

    # Required-set resolution:
    #   - If the shim marked it optional → not required (honors `optional: true`)
    #   - If the shim declared it at all → use the shim's required set (mirrors
    #     easy_talk: everything declared is required unless optional: true)
    #   - Else → fall back to literal's native prop.required? (defaults)
    is_required =
      if openapi_optional.include?(prop.name)
        false
      elsif shim_managed.include?(prop.name)
        openapi_required.include?(prop.name)
      else
        prop.required?
      end
    required << prop.name.to_s if is_required
  end

  result = { "type" => "object" }
  # Match easy_talk: omit empty `properties` key entirely.
  result["properties"] = properties unless properties.empty?
  result["additionalProperties"] = klass.openapi_additional_properties_value
  result["required"] = required unless required.empty?
  if klass.respond_to?(:openapi_description) && (desc = klass.openapi_description)
    result["description"] = desc
  end
  result
end

#convert_class_type(type) ⇒ Object



172
173
174
175
176
177
178
179
180
# File 'lib/literal/openapi/adapters/base_adapter.rb', line 172

def convert_class_type(type)
  # _Class(X) — back-compat with serializer classes: if X looks like a
  # Serializable, treat it as a ref. Otherwise emit a plain object.
  if type.type.respond_to?(:openapi_schema) || type.type.respond_to?(:literal_json_schema)
    { "$ref" => "#/components/schemas/#{schema_name(type.type)}" }
  else
    { "type" => "object" }
  end
end

#convert_nilable(_type) ⇒ Object

Version-specific: override in subclass.

Raises:

  • (NotImplementedError)


145
146
147
# File 'lib/literal/openapi/adapters/base_adapter.rb', line 145

def convert_nilable(_type)
  raise NotImplementedError, "#{self.class}#convert_nilable"
end

#convert_oneof(_types, include_null:) ⇒ Object

Version-specific: override in subclass.

Raises:

  • (NotImplementedError)


150
151
152
# File 'lib/literal/openapi/adapters/base_adapter.rb', line 150

def convert_oneof(_types, include_null:)
  raise NotImplementedError, "#{self.class}#convert_oneof"
end

#convert_primitive(klass) ⇒ Object



182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/literal/openapi/adapters/base_adapter.rb', line 182

def convert_primitive(klass)
  mapped = PRIMITIVE_MAP[klass]
  return mapped.dup if mapped

  # A bare serializer class (not wrapped in _Ref) inlines its schema. This
  # matches easy_talk's behavior for `T::Array[Foo]` where Foo is a class
  # that responds to schema generation. Use _Ref (in-gem) or T::Class[X]
  # (in-shim) to emit a `$ref` instead.
  if klass.respond_to?(:openapi_schema)
    inline_schema(klass)
  elsif klass.respond_to?(:literal_json_schema)
    klass.literal_json_schema
  else
    raise Literal::Openapi::UnknownTypeError,
          "Cannot convert #{klass.inspect} — not in PRIMITIVE_MAP and no openapi_schema method."
  end
end

#convert_ref(type) ⇒ Object



168
169
170
# File 'lib/literal/openapi/adapters/base_adapter.rb', line 168

def convert_ref(type)
  { "$ref" => "#/components/schemas/#{schema_name(type.type)}" }
end

#convert_type(type) ⇒ Object



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
# File 'lib/literal/openapi/adapters/base_adapter.rb', line 106

def convert_type(type)
  case type
  when Literal::Openapi::Types::RefType
    convert_ref(type)
  when Literal::Types::NilableType
    convert_nilable(type)
  when Literal::Types::ArrayType
    { "type" => "array", "items" => convert_type(type.type) }
  when Literal::Types::BooleanType
    { "type" => "boolean" }
  when Literal::Types::AnyType, Literal::Types::VoidType
    {}
  when Literal::Types::HashType
    { "type" => "object", "additionalProperties" => convert_type(type.value_type) }
  when Literal::Types::UnionType
    convert_union(type)
  when Literal::Types::ConstraintType
    # Strip the constraint and convert the underlying class (e.g. _String? → String).
    # Range/property constraints are not mapped to OpenAPI yet — see TODOS.
    convert_type(type.object_constraints.first)
  when Literal::Types::IntersectionType
    # Intersection is used by enum-wrapping; convert the first non-predicate member.
    non_predicate = type.types.reject { |t| t.is_a?(Literal::Types::PredicateType) }
    if non_predicate.empty?
      raise Literal::Openapi::UnknownTypeError,
            "IntersectionType must have a concrete member"
    end

    convert_type(non_predicate.first)
  when Literal::Types::ClassType
    convert_class_type(type)
  when Module
    convert_primitive(type)
  else
    raise Literal::Openapi::UnknownTypeError, "Cannot convert #{type.inspect} (#{type.class})"
  end
end

#convert_union(type) ⇒ Object



154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/literal/openapi/adapters/base_adapter.rb', line 154

def convert_union(type)
  members = type.to_a
  nil_members, non_nil = members.partition(&:nil?)

  if nil_members.any? && non_nil.length == 1
    # Single non-nil member with nil — same semantics as NilableType
    convert_nilable(Literal::Types::NilableType.new(non_nil.first))
  elsif nil_members.any?
    convert_oneof(non_nil, include_null: true)
  else
    convert_oneof(non_nil, include_null: false)
  end
end

#inline_schema(klass) ⇒ Object



200
201
202
# File 'lib/literal/openapi/adapters/base_adapter.rb', line 200

def inline_schema(klass)
  klass.openapi_schema(adapter: self)
end

#ref_schema?(schema) ⇒ Boolean

Detect whether a converted schema is a $ref (pure or wrapped in an anyOf-null). These drop sibling keys in easy_talk’s output.

Returns:

  • (Boolean)


213
214
215
216
217
218
219
220
221
# File 'lib/literal/openapi/adapters/base_adapter.rb', line 213

def ref_schema?(schema)
  return false unless schema.is_a?(::Hash)
  return true if schema.keys == ["$ref"]

  any_of = schema["anyOf"]
  return false unless any_of.is_a?(::Array)

  any_of.any? { |m| m.is_a?(::Hash) && m.key?("$ref") }
end

#reorder_property_keys(schema) ⇒ Object



239
240
241
242
243
244
245
246
247
248
249
250
251
252
# File 'lib/literal/openapi/adapters/base_adapter.rb', line 239

def reorder_property_keys(schema)
  return schema unless schema.is_a?(::Hash)

  result = {}
  PROPERTY_KEY_ORDER.each { |k| result[k] = schema[k] if schema.key?(k) }
  # Preserve any unknown keys in their original position, before nullable.
  schema.each_key do |k|
    next if PROPERTY_KEY_ORDER.include?(k)
    next if result.key?(k)

    result[k] = schema[k]
  end
  result
end

#schema_name(klass) ⇒ Object



204
205
206
207
208
209
# File 'lib/literal/openapi/adapters/base_adapter.rb', line 204

def schema_name(klass)
  name = klass.name
  raise Literal::Openapi::Error, "Anonymous class #{klass.inspect} cannot be ref'd" if name.nil?

  name.gsub("::", ".").delete_suffix("Serializer")
end