Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions lib/literal.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ module Literal

autoload :TRANSFORMS, "literal/transforms"

# JSON Schema support
module JsonSchema
autoload :TypeMapper, "literal/json_schema/type_mapper"
autoload :Generator, "literal/json_schema/generator"
end

def self.Enum(type)
Class.new(Literal::Enum) do
prop :value, type, :positional, reader: :public
Expand Down
36 changes: 36 additions & 0 deletions lib/literal/json_schema/generator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# frozen_string_literal: true

module Literal
module JsonSchema
# Generates JSON Schema from Literal types and structures
class Generator
class << self
def generate(type)
if type.is_a?(Class) && type < Literal::DataStructure
generate_data_structure(type)
else
TypeMapper.map_type(type)
end
end

private

def generate_data_structure(type)
schema = {}
type.literal_properties.each do |prop|
schema[prop.name] = generate(prop.type)
schema[prop.name][:nullable] = true if prop.optional?
end

required = type.literal_properties.select(&:required?).reject(&:default?).map(&:name).map(&:to_s)
{
type: "object",
properties: schema,
required:,
additionalProperties: false,
}
end
end
end
end
end
72 changes: 72 additions & 0 deletions lib/literal/json_schema/type_mapper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# frozen_string_literal: true

module Literal
module JsonSchema
# Handles mapping between Literal types and JSON Schema types
class TypeMapper
class << self
def map_type(type)
if type == String || type == Symbol
{ type: "string" }
elsif type.is_a?(Types::NilableType)
map_type(type.type)
elsif type == Integer
{ type: "integer" }
elsif type == Float || type == Numeric
{ type: "number" }
elsif type == NilClass
{ type: "null" }
elsif type.is_a? Literal::Array::Generic
map_array(type)
elsif type.is_a? Literal::Hash::Generic
map_object(type)
elsif type.is_a? Range
map_range(type)
elsif type == TrueClass || type == TrueClass || type.is_a?(Literal::Types::BooleanType)
{ type: "boolean" }
elsif type.is_a?(Literal::Enum) || type <= Literal::Enum
map_enum(type)
elsif Time >= type
{ type: "string", format: "date-time" }
elsif Date >= type
{ type: "string", format: "date" }
else
{ type: "object" }
end
end

private

def map_range(range)
schema = { type: range.begin.is_a?(Integer) ? "integer" : "number" }
schema[:minimum] = range.begin unless range.begin.nil?
schema[:maximum] = range.end unless range.end.nil?
schema[:exclusiveMaximum] = true if range.exclude_end?
schema
end

def map_array(array_type)
{
type: "array",
items: map_type(array_type.type),
}
end

def map_object(hash_type)
{
type: "object",
additionalProperties: map_type(hash_type.value_type),
}
end

def map_enum(enum_type)
values = enum_type.values
{
type: "string",
enum: values.map(&:to_s),
}
end
end
end
end
end
197 changes: 197 additions & 0 deletions test/json_schema.test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
# frozen_string_literal: true

# Basic primitive type tests
test "string type mapping" do
assert_schema String, {
type: "string",
}
end

test "integer type mapping" do
assert_schema Integer, {
type: "integer",
}
end

test "float type mapping" do
assert_schema Float, {
type: "number",
}
end

test "boolean type mapping" do
assert_schema Literal::Types::_Boolean, {
type: "boolean",
}
end

# Range type tests
test "integer range mapping" do
assert_schema 1..10, {
type: "integer",
minimum: 1,
maximum: 10,
}
end

test "exclusive integer range mapping" do
assert_schema 1...10, {
type: "integer",
minimum: 1,
maximum: 10,
exclusiveMaximum: true,
}
end

test "float range mapping" do
assert_schema 1.0..10.0, {
type: "number",
minimum: 1.0,
maximum: 10.0,
}
end

# Array type tests
test "string array mapping" do
assert_schema Literal::Array(String), {
type: "array",
items: {
type: "string",
},
}
end

test "integer array mapping" do
assert_schema Literal::Array(Integer), {
type: "array",
items: {
type: "integer",
},
}
end

# Enum type tests
test "enum type mapping" do
class Colors < Literal::Enum(String)
Red = new("Red")
Blue = new("Blue")
Green = new("Green")
end

assert_schema Colors, {
type: "string",
enum: ["Red", "Blue", "Green"],
}
end

# Data structure tests
test "basic data structure mapping" do
class Person < Literal::Data
prop :name, String
prop :age, Integer
prop :email, _String?
end

expected_schema = {
type: "object",
properties: {
name: { type: "string" },
age: { type: "integer" },
email: { type: "string", nullable: true },
},
required: ["name", "age"],
additionalProperties: false,
}

assert_equal Literal::JsonSchema::Generator.generate(Person), expected_schema
end

test "nested data structure mapping" do
class Address < Literal::Data
prop :street, String
prop :city, String
prop :country, String
end

class Contact < Literal::Data
prop :name, String
prop :address, Address
prop :phone_numbers, Literal::Array(String)
end

expected_schema = {
type: "object",
properties: {
name: { type: "string" },
address: {
type: "object",
properties: {
street: { type: "string" },
city: { type: "string" },
country: { type: "string" },
},
required: ["street", "city", "country"],
additionalProperties: false,
},
phone_numbers: {
type: "array",
items: { type: "string" },
},
},
required: ["name", "address", "phone_numbers"],
additionalProperties: false,
}

assert_equal expected_schema, Literal::JsonSchema::Generator.generate(Contact)
end

test "openapi compatibility with date-time format" do
class ApiResponse < Literal::Data
prop :id, Integer
prop :status, String
prop :created_at, Time
prop :created_at_const, _Time?
prop :date_added, Date
prop :date_added_const, _Date?
end

expected_schema = {
type: "object",
properties: {
id: { type: "integer" },
status: { type: "string" },
created_at: {
type: "string",
format: "date-time",
},

created_at_const: {
type: "string",
format: "date-time",
nullable: true,
},

date_added: {
type: "string",
format: "date",
},

date_added_const: {
type: "string",
format: "date",
nullable: true,
},
},
required: ["id", "status", "created_at", "date_added"],
additionalProperties: false,
}

assert_equal expected_schema, Literal::JsonSchema::Generator.generate(ApiResponse)
end

private

def assert_schema(type, expected)
actual = Literal::JsonSchema::TypeMapper.map_type(type)
assert_equal actual, expected
end