From af842ff0c18b4770b98a396cc58653386dbdb55e Mon Sep 17 00:00:00 2001 From: Adrian Gonzalez Date: Mon, 7 Apr 2025 21:39:54 +0200 Subject: [PATCH 1/4] Scaffold basic functionality --- lib/literal.rb | 6 + lib/literal/json_schema/generator.rb | 36 +++++ lib/literal/json_schema/type_mapper.rb | 66 +++++++++ test/json_schema.test.rb | 192 +++++++++++++++++++++++++ 4 files changed, 300 insertions(+) create mode 100644 lib/literal/json_schema/generator.rb create mode 100644 lib/literal/json_schema/type_mapper.rb create mode 100644 test/json_schema.test.rb 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..404b42a0 --- /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] = TypeMapper.map_type(prop.type) + schema[prop.name][:nullable] = true if prop.default? + 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..e2fad98a --- /dev/null +++ b/lib/literal/json_schema/type_mapper.rb @@ -0,0 +1,66 @@ +# 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 == 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) + 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..8644e6d4 --- /dev/null +++ b/test/json_schema.test.rb @@ -0,0 +1,192 @@ +# 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 + +# Hash type tests +test "hash with symbol keys and string values" do + assert_schema Literal::Hash(Symbol, String), { + type: "object", + additionalProperties: { + type: "string", + }, + } +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, default: nil + 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 :data, Literal::Hash.new({}, key_type: Symbol, value_type: String) + prop :created_at, Time + end + + expected_schema = { + type: "object", + properties: { + id: { type: "integer" }, + status: { type: "string" }, + data: { + type: "object", + additionalProperties: { type: "string" }, + }, + created_at: { + type: "string", + format: "date-time", + }, + }, + required: ["id", "status", "data", "created_at"], + 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 From 188d57bbe900940c46e5b0c2ee82b47c5f78e3ab Mon Sep 17 00:00:00 2001 From: Adrian Gonzalez Date: Mon, 7 Apr 2025 21:43:17 +0200 Subject: [PATCH 2/4] Recursive generate --- lib/literal/json_schema/generator.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/literal/json_schema/generator.rb b/lib/literal/json_schema/generator.rb index 404b42a0..8de762c0 100644 --- a/lib/literal/json_schema/generator.rb +++ b/lib/literal/json_schema/generator.rb @@ -18,7 +18,7 @@ def generate(type) def generate_data_structure(type) schema = {} type.literal_properties.each do |prop| - schema[prop.name] = TypeMapper.map_type(prop.type) + schema[prop.name] = generate(prop.type) schema[prop.name][:nullable] = true if prop.default? end From 520c9640f6d344a481c350fd810cb2b8f2d65c19 Mon Sep 17 00:00:00 2001 From: Adrian Gonzalez Date: Mon, 7 Apr 2025 21:49:58 +0200 Subject: [PATCH 3/4] Account for nilable types --- lib/literal/json_schema/generator.rb | 2 +- lib/literal/json_schema/type_mapper.rb | 2 ++ test/json_schema.test.rb | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/literal/json_schema/generator.rb b/lib/literal/json_schema/generator.rb index 8de762c0..e8438028 100644 --- a/lib/literal/json_schema/generator.rb +++ b/lib/literal/json_schema/generator.rb @@ -19,7 +19,7 @@ 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.default? + schema[prop.name][:nullable] = true if prop.optional? end required = type.literal_properties.select(&:required?).reject(&:default?).map(&:name).map(&:to_s) diff --git a/lib/literal/json_schema/type_mapper.rb b/lib/literal/json_schema/type_mapper.rb index e2fad98a..0de67aca 100644 --- a/lib/literal/json_schema/type_mapper.rb +++ b/lib/literal/json_schema/type_mapper.rb @@ -8,6 +8,8 @@ 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 diff --git a/test/json_schema.test.rb b/test/json_schema.test.rb index 8644e6d4..d5a59b72 100644 --- a/test/json_schema.test.rb +++ b/test/json_schema.test.rb @@ -99,7 +99,7 @@ class Colors < Literal::Enum(String) class Person < Literal::Data prop :name, String prop :age, Integer - prop :email, String, default: nil + prop :email, _String? end expected_schema = { From 4b258cec3ca108f2cc442c0b4e4890d26d361626 Mon Sep 17 00:00:00 2001 From: Adrian Gonzalez Date: Tue, 8 Apr 2025 09:20:25 +0200 Subject: [PATCH 4/4] Add some tests for different date --- lib/literal/json_schema/type_mapper.rb | 4 +++ test/json_schema.test.rb | 37 +++++++++++++++----------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/lib/literal/json_schema/type_mapper.rb b/lib/literal/json_schema/type_mapper.rb index 0de67aca..40486e38 100644 --- a/lib/literal/json_schema/type_mapper.rb +++ b/lib/literal/json_schema/type_mapper.rb @@ -26,6 +26,10 @@ def map_type(type) { 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 diff --git a/test/json_schema.test.rb b/test/json_schema.test.rb index d5a59b72..2f25ed6b 100644 --- a/test/json_schema.test.rb +++ b/test/json_schema.test.rb @@ -70,16 +70,6 @@ } end -# Hash type tests -test "hash with symbol keys and string values" do - assert_schema Literal::Hash(Symbol, String), { - type: "object", - additionalProperties: { - type: "string", - }, - } -end - # Enum type tests test "enum type mapping" do class Colors < Literal::Enum(String) @@ -159,8 +149,10 @@ class Contact < Literal::Data class ApiResponse < Literal::Data prop :id, Integer prop :status, String - prop :data, Literal::Hash.new({}, key_type: Symbol, value_type: String) prop :created_at, Time + prop :created_at_const, _Time? + prop :date_added, Date + prop :date_added_const, _Date? end expected_schema = { @@ -168,16 +160,29 @@ class ApiResponse < Literal::Data properties: { id: { type: "integer" }, status: { type: "string" }, - data: { - type: "object", - additionalProperties: { 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", "data", "created_at"], + required: ["id", "status", "created_at", "date_added"], additionalProperties: false, }