diff --git a/lib/literal.rb b/lib/literal.rb index 6237c229..f74fb519 100644 --- a/lib/literal.rb +++ b/lib/literal.rb @@ -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 diff --git a/lib/literal/json_schema/generator.rb b/lib/literal/json_schema/generator.rb new file mode 100644 index 00000000..e8438028 --- /dev/null +++ b/lib/literal/json_schema/generator.rb @@ -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 diff --git a/lib/literal/json_schema/type_mapper.rb b/lib/literal/json_schema/type_mapper.rb new file mode 100644 index 00000000..40486e38 --- /dev/null +++ b/lib/literal/json_schema/type_mapper.rb @@ -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 diff --git a/test/json_schema.test.rb b/test/json_schema.test.rb new file mode 100644 index 00000000..2f25ed6b --- /dev/null +++ b/test/json_schema.test.rb @@ -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