skit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +469 -0
- data/exe/skit +31 -0
- data/lib/active_model/validations/skit_validator.rb +54 -0
- data/lib/skit/attribute.rb +63 -0
- data/lib/skit/json_schema/class_name_path.rb +67 -0
- data/lib/skit/json_schema/cli.rb +166 -0
- data/lib/skit/json_schema/code_generator.rb +132 -0
- data/lib/skit/json_schema/config.rb +67 -0
- data/lib/skit/json_schema/definitions/array_property_type.rb +36 -0
- data/lib/skit/json_schema/definitions/const_type.rb +68 -0
- data/lib/skit/json_schema/definitions/enum_type.rb +71 -0
- data/lib/skit/json_schema/definitions/hash_property_type.rb +36 -0
- data/lib/skit/json_schema/definitions/module.rb +54 -0
- data/lib/skit/json_schema/definitions/property_type.rb +39 -0
- data/lib/skit/json_schema/definitions/property_types.rb +13 -0
- data/lib/skit/json_schema/definitions/struct.rb +99 -0
- data/lib/skit/json_schema/definitions/struct_property.rb +75 -0
- data/lib/skit/json_schema/definitions/union_property_type.rb +40 -0
- data/lib/skit/json_schema/naming_utils.rb +25 -0
- data/lib/skit/json_schema/schema_analyzer.rb +407 -0
- data/lib/skit/json_schema/types/const.rb +69 -0
- data/lib/skit/json_schema.rb +77 -0
- data/lib/skit/serialization/errors.rb +23 -0
- data/lib/skit/serialization/path.rb +69 -0
- data/lib/skit/serialization/processor/array.rb +65 -0
- data/lib/skit/serialization/processor/base.rb +47 -0
- data/lib/skit/serialization/processor/boolean.rb +35 -0
- data/lib/skit/serialization/processor/date.rb +40 -0
- data/lib/skit/serialization/processor/enum.rb +54 -0
- data/lib/skit/serialization/processor/float.rb +36 -0
- data/lib/skit/serialization/processor/hash.rb +93 -0
- data/lib/skit/serialization/processor/integer.rb +31 -0
- data/lib/skit/serialization/processor/json_schema_const.rb +55 -0
- data/lib/skit/serialization/processor/nilable.rb +87 -0
- data/lib/skit/serialization/processor/simple_type.rb +51 -0
- data/lib/skit/serialization/processor/string.rb +31 -0
- data/lib/skit/serialization/processor/struct.rb +84 -0
- data/lib/skit/serialization/processor/symbol.rb +36 -0
- data/lib/skit/serialization/processor/time.rb +40 -0
- data/lib/skit/serialization/processor/union.rb +120 -0
- data/lib/skit/serialization/registry.rb +33 -0
- data/lib/skit/serialization.rb +60 -0
- data/lib/skit/version.rb +6 -0
- data/lib/skit.rb +46 -0
- data/lib/tapioca/dsl/compilers/skit.rb +105 -0
- metadata +135 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Skit
|
|
5
|
+
module JsonSchema
|
|
6
|
+
module Definitions
|
|
7
|
+
class Struct
|
|
8
|
+
extend T::Sig
|
|
9
|
+
|
|
10
|
+
sig { returns(String) }
|
|
11
|
+
attr_reader :class_name
|
|
12
|
+
|
|
13
|
+
sig { returns(T::Array[StructProperty]) }
|
|
14
|
+
attr_reader :properties
|
|
15
|
+
|
|
16
|
+
sig { returns(T.nilable(String)) }
|
|
17
|
+
attr_reader :description
|
|
18
|
+
|
|
19
|
+
sig do
|
|
20
|
+
params(
|
|
21
|
+
class_name: String,
|
|
22
|
+
properties: T::Array[StructProperty],
|
|
23
|
+
description: T.nilable(String)
|
|
24
|
+
).void
|
|
25
|
+
end
|
|
26
|
+
def initialize(class_name:, properties: [], description: nil)
|
|
27
|
+
@class_name = T.let(validate_class_name(class_name), String)
|
|
28
|
+
@properties = properties
|
|
29
|
+
@description = description
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
sig { returns(T::Array[String]) }
|
|
33
|
+
def referenced_types
|
|
34
|
+
types = []
|
|
35
|
+
@properties.each do |property|
|
|
36
|
+
types.concat(extract_types_from_property_type(property.type))
|
|
37
|
+
end
|
|
38
|
+
types.uniq
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
sig { returns(T::Array[StructProperty]) }
|
|
42
|
+
def required_properties
|
|
43
|
+
@properties.select(&:required?)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
sig { returns(T::Array[StructProperty]) }
|
|
47
|
+
def optional_properties
|
|
48
|
+
@properties.select(&:optional?)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
sig { params(property: StructProperty).void }
|
|
52
|
+
def add_property(property)
|
|
53
|
+
@properties << property
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
sig { params(class_name: String).returns(String) }
|
|
59
|
+
def validate_class_name(class_name)
|
|
60
|
+
unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/)
|
|
61
|
+
raise ArgumentError,
|
|
62
|
+
"Invalid class name: #{class_name.inspect}. Must start with uppercase letter " \
|
|
63
|
+
"and contain only alphanumeric characters and underscores."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
class_name
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
sig { params(property_type: PropertyTypes).returns(T::Array[String]) }
|
|
70
|
+
def extract_types_from_property_type(property_type)
|
|
71
|
+
types = []
|
|
72
|
+
|
|
73
|
+
case property_type
|
|
74
|
+
when ArrayPropertyType
|
|
75
|
+
types.concat(extract_types_from_property_type(property_type.item_type))
|
|
76
|
+
when HashPropertyType
|
|
77
|
+
types.concat(extract_types_from_property_type(property_type.value_type))
|
|
78
|
+
when UnionPropertyType
|
|
79
|
+
property_type.types.each do |union_type|
|
|
80
|
+
types.concat(extract_types_from_property_type(union_type))
|
|
81
|
+
end
|
|
82
|
+
when ConstType, EnumType
|
|
83
|
+
# ConstType/EnumType references itself as a custom type
|
|
84
|
+
types << property_type.class_name
|
|
85
|
+
when PropertyType
|
|
86
|
+
base_type = property_type.base_type
|
|
87
|
+
# Check if it's a custom class (inheriting from T::Struct)
|
|
88
|
+
types << base_type unless %w[String Integer Float T::Boolean Date Time
|
|
89
|
+
T.untyped].include?(base_type)
|
|
90
|
+
else
|
|
91
|
+
T.absurd(property_type)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
types
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Skit
|
|
5
|
+
module JsonSchema
|
|
6
|
+
module Definitions
|
|
7
|
+
class StructProperty
|
|
8
|
+
extend T::Sig
|
|
9
|
+
|
|
10
|
+
sig { returns(String) }
|
|
11
|
+
attr_reader :name
|
|
12
|
+
|
|
13
|
+
sig { returns(PropertyTypes) }
|
|
14
|
+
attr_reader :type
|
|
15
|
+
|
|
16
|
+
sig { returns(Symbol) }
|
|
17
|
+
attr_reader :mutability
|
|
18
|
+
|
|
19
|
+
sig { returns(T.nilable(String)) }
|
|
20
|
+
attr_reader :default_value
|
|
21
|
+
|
|
22
|
+
sig { returns(T.nilable(String)) }
|
|
23
|
+
attr_reader :comment
|
|
24
|
+
|
|
25
|
+
sig do
|
|
26
|
+
params(
|
|
27
|
+
name: String,
|
|
28
|
+
type: PropertyTypes,
|
|
29
|
+
mutability: Symbol,
|
|
30
|
+
default_value: T.nilable(String),
|
|
31
|
+
comment: T.nilable(String)
|
|
32
|
+
).void
|
|
33
|
+
end
|
|
34
|
+
def initialize(name:, type:, mutability: :prop, default_value: nil, comment: nil)
|
|
35
|
+
@name = name
|
|
36
|
+
@type = type
|
|
37
|
+
@mutability = T.let(validate_mutability(mutability), Symbol)
|
|
38
|
+
@default_value = default_value
|
|
39
|
+
@comment = comment
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
sig { returns(T::Boolean) }
|
|
43
|
+
def required?
|
|
44
|
+
!@type.nullable
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
sig { returns(T::Boolean) }
|
|
48
|
+
def optional?
|
|
49
|
+
@type.nullable
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
sig { returns(T::Boolean) }
|
|
53
|
+
def immutable?
|
|
54
|
+
@mutability == :const
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
sig { returns(T::Boolean) }
|
|
58
|
+
def mutable?
|
|
59
|
+
@mutability == :prop
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
sig { params(mutability: Symbol).returns(Symbol) }
|
|
65
|
+
def validate_mutability(mutability)
|
|
66
|
+
unless %i[prop const].include?(mutability)
|
|
67
|
+
raise ArgumentError, "mutability must be :prop or :const, got #{mutability.inspect}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
mutability
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Skit
|
|
5
|
+
module JsonSchema
|
|
6
|
+
module Definitions
|
|
7
|
+
class UnionPropertyType
|
|
8
|
+
extend T::Sig
|
|
9
|
+
|
|
10
|
+
sig { returns(T::Array[PropertyTypes]) }
|
|
11
|
+
attr_reader :types
|
|
12
|
+
|
|
13
|
+
sig { returns(T::Boolean) }
|
|
14
|
+
attr_reader :nullable
|
|
15
|
+
|
|
16
|
+
sig do
|
|
17
|
+
params(
|
|
18
|
+
types: T::Array[PropertyTypes],
|
|
19
|
+
nullable: T::Boolean
|
|
20
|
+
).void
|
|
21
|
+
end
|
|
22
|
+
def initialize(types:, nullable: false)
|
|
23
|
+
@types = types
|
|
24
|
+
@nullable = nullable
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
sig { returns(String) }
|
|
28
|
+
def to_sorbet_type
|
|
29
|
+
union_str = "T.any(#{@types.map(&:to_sorbet_type).join(", ")})"
|
|
30
|
+
@nullable ? "T.nilable(#{union_str})" : union_str
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
sig { returns(UnionPropertyType) }
|
|
34
|
+
def with_nullable
|
|
35
|
+
UnionPropertyType.new(types: @types, nullable: true)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Skit
|
|
5
|
+
module JsonSchema
|
|
6
|
+
module NamingUtils
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
sig { params(value: String).returns(String) }
|
|
10
|
+
def self.to_pascal_case(value)
|
|
11
|
+
value.gsub(/[^a-zA-Z0-9]+/, "_")
|
|
12
|
+
.gsub(/^_+|_+$/, "")
|
|
13
|
+
.split("_")
|
|
14
|
+
.map(&:capitalize)
|
|
15
|
+
.join
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
sig { params(value: T.any(Integer, Float)).returns(String) }
|
|
19
|
+
def self.number_to_name(value)
|
|
20
|
+
num_str = value.to_s.gsub("-", "Minus").gsub(".", "Dot")
|
|
21
|
+
"Val#{num_str}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json_schemer"
|
|
5
|
+
|
|
6
|
+
module Skit
|
|
7
|
+
module JsonSchema
|
|
8
|
+
class SchemaAnalyzer
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
sig { params(schema: T::Hash[String, T.untyped], config: Config).void }
|
|
12
|
+
def initialize(schema, config)
|
|
13
|
+
@schema = schema
|
|
14
|
+
@schemer = T.let(JSONSchemer.schema(@schema), T.untyped)
|
|
15
|
+
@nested_structs = T.let({}, T::Hash[String, Definitions::Struct])
|
|
16
|
+
@const_types = T.let({}, T::Hash[String, Definitions::ConstType])
|
|
17
|
+
@enum_types = T.let({}, T::Hash[String, Definitions::EnumType])
|
|
18
|
+
@config = config
|
|
19
|
+
@ref_stack = T.let([], T::Array[String])
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
sig { returns(Definitions::Module) }
|
|
23
|
+
def analyze
|
|
24
|
+
validate_schema
|
|
25
|
+
|
|
26
|
+
# Only object type is supported at the top level
|
|
27
|
+
raise Skit::Error, "Only object type schemas are supported at the top level" unless @schema["type"] == "object"
|
|
28
|
+
|
|
29
|
+
root_class_name_path = determine_root_class_name
|
|
30
|
+
root_struct = build_struct(@schema, root_class_name_path)
|
|
31
|
+
|
|
32
|
+
Definitions::Module.new(
|
|
33
|
+
root_struct: root_struct,
|
|
34
|
+
nested_structs: @nested_structs.values,
|
|
35
|
+
const_types: @const_types.values,
|
|
36
|
+
enum_types: @enum_types.values
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
sig { returns(ClassNamePath) }
|
|
43
|
+
def determine_root_class_name
|
|
44
|
+
# Determine root class name (in order of priority)
|
|
45
|
+
# 1. Class name specified by CLI option
|
|
46
|
+
# 2. Class name converted from title
|
|
47
|
+
# 3. Default class name
|
|
48
|
+
if (cli_class_name = @config.class_name)
|
|
49
|
+
ClassNamePath.new([cli_class_name])
|
|
50
|
+
elsif (title = extract_title(@schema))
|
|
51
|
+
ClassNamePath.title_to_class_name(title)
|
|
52
|
+
else
|
|
53
|
+
ClassNamePath.default
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
sig { params(schema: T::Hash[String, T.untyped]).returns(T.nilable(String)) }
|
|
58
|
+
def extract_title(schema)
|
|
59
|
+
title = schema["title"]
|
|
60
|
+
return nil unless title.is_a?(String) && !title.strip.empty?
|
|
61
|
+
|
|
62
|
+
title
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
sig { void }
|
|
66
|
+
def validate_schema
|
|
67
|
+
return if @schemer.valid_schema?
|
|
68
|
+
|
|
69
|
+
raise Skit::Error, "Invalid JSON Schema"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
sig { params(schema: T::Hash[String, T.untyped], class_name_path: ClassNamePath).returns(Definitions::Struct) }
|
|
73
|
+
def build_struct(schema, class_name_path)
|
|
74
|
+
unless schema["type"] == "object"
|
|
75
|
+
raise ArgumentError,
|
|
76
|
+
"Expected object type schema, got #{schema["type"].inspect}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
properties = []
|
|
80
|
+
|
|
81
|
+
if schema["properties"]
|
|
82
|
+
required_fields = T.cast(schema["required"] || [], T::Array[String])
|
|
83
|
+
|
|
84
|
+
schema["properties"].each do |prop_name, prop_schema|
|
|
85
|
+
prop_schema_typed = T.cast(prop_schema, T::Hash[String, T.untyped])
|
|
86
|
+
|
|
87
|
+
# Delegate all types to build_property_type (pass class name path for object types)
|
|
88
|
+
property_class_name_path = class_name_path.append(prop_name)
|
|
89
|
+
property_type = build_property_type(prop_schema_typed, property_class_name_path)
|
|
90
|
+
|
|
91
|
+
# Make nullable if not required
|
|
92
|
+
is_required = required_fields.include?(prop_name)
|
|
93
|
+
property_type = make_nullable(property_type) unless is_required
|
|
94
|
+
|
|
95
|
+
property = Definitions::StructProperty.new(
|
|
96
|
+
name: prop_name,
|
|
97
|
+
type: property_type,
|
|
98
|
+
comment: extract_comment(prop_schema_typed)
|
|
99
|
+
)
|
|
100
|
+
properties << property
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
Definitions::Struct.new(
|
|
105
|
+
class_name: class_name_path.to_class_name,
|
|
106
|
+
properties: properties,
|
|
107
|
+
description: schema["description"]
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
sig { params(schema: T::Hash[String, T.untyped], class_name_path: ClassNamePath).returns(Definitions::PropertyTypes) }
|
|
112
|
+
def build_property_type(schema, class_name_path)
|
|
113
|
+
# Resolve $ref if present, then process
|
|
114
|
+
if (ref_path = schema["$ref"])
|
|
115
|
+
# Circular reference check
|
|
116
|
+
if @ref_stack.include?(ref_path)
|
|
117
|
+
raise Skit::Error,
|
|
118
|
+
"Circular reference detected: #{ref_path} -> #{@ref_stack.join(" -> ")}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
@ref_stack.push(ref_path)
|
|
122
|
+
begin
|
|
123
|
+
resolved_schema = resolve_ref(ref_path)
|
|
124
|
+
result = build_property_type(resolved_schema, class_name_path)
|
|
125
|
+
ensure
|
|
126
|
+
@ref_stack.pop
|
|
127
|
+
end
|
|
128
|
+
return result
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Const type processing
|
|
132
|
+
return build_const_type(schema, class_name_path) if schema.key?("const")
|
|
133
|
+
|
|
134
|
+
# Enum type processing
|
|
135
|
+
return build_enum_type(schema, class_name_path) if schema.key?("enum")
|
|
136
|
+
|
|
137
|
+
# Union type processing
|
|
138
|
+
return build_union_type(schema, class_name_path) if schema["anyOf"] || schema["oneOf"]
|
|
139
|
+
|
|
140
|
+
case schema["type"]
|
|
141
|
+
when "string"
|
|
142
|
+
build_string_type(schema)
|
|
143
|
+
when "integer"
|
|
144
|
+
Definitions::PropertyType.new(base_type: "Integer")
|
|
145
|
+
when "number"
|
|
146
|
+
Definitions::PropertyType.new(base_type: "Float")
|
|
147
|
+
when "boolean"
|
|
148
|
+
Definitions::PropertyType.new(base_type: "T::Boolean")
|
|
149
|
+
when "array"
|
|
150
|
+
build_array_type(schema, class_name_path)
|
|
151
|
+
when "object"
|
|
152
|
+
build_object_type(schema, class_name_path)
|
|
153
|
+
else
|
|
154
|
+
# Fallback to T.untyped for unsupported types
|
|
155
|
+
Definitions::PropertyType.new(base_type: "T.untyped")
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
sig { params(schema: T::Hash[String, T.untyped], class_name_path: ClassNamePath).returns(Definitions::PropertyTypes) }
|
|
160
|
+
def build_object_type(schema, class_name_path)
|
|
161
|
+
if schema["properties"]
|
|
162
|
+
build_object_with_properties(schema, class_name_path)
|
|
163
|
+
else
|
|
164
|
+
# Generic hash when no properties are defined
|
|
165
|
+
untyped_value = Definitions::PropertyType.new(base_type: "T.untyped")
|
|
166
|
+
Definitions::HashPropertyType.new(value_type: untyped_value)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
sig { params(schema: T::Hash[String, T.untyped], class_name_path: ClassNamePath).returns(Definitions::PropertyType) }
|
|
171
|
+
def build_object_with_properties(schema, class_name_path)
|
|
172
|
+
# Use title when specified with priority
|
|
173
|
+
final_class_name_path = if (title = extract_title(schema))
|
|
174
|
+
# Use class name generated from title as-is when title is specified
|
|
175
|
+
ClassNamePath.title_to_class_name(title)
|
|
176
|
+
else
|
|
177
|
+
class_name_path
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
class_name = final_class_name_path.to_class_name
|
|
181
|
+
|
|
182
|
+
unless @nested_structs.key?(class_name)
|
|
183
|
+
struct_def = build_struct(schema, final_class_name_path)
|
|
184
|
+
@nested_structs[class_name] = struct_def
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
Definitions::PropertyType.new(base_type: class_name)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
sig { params(schema: T::Hash[String, T.untyped]).returns(Definitions::PropertyType) }
|
|
191
|
+
def build_string_type(schema)
|
|
192
|
+
case schema["format"]
|
|
193
|
+
when "date-time", "time"
|
|
194
|
+
Definitions::PropertyType.new(base_type: "Time")
|
|
195
|
+
when "date"
|
|
196
|
+
Definitions::PropertyType.new(base_type: "Date")
|
|
197
|
+
else
|
|
198
|
+
Definitions::PropertyType.new(base_type: "String")
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
sig { params(schema: T::Hash[String, T.untyped], class_name_path: ClassNamePath).returns(Definitions::ConstType) }
|
|
203
|
+
def build_const_type(schema, class_name_path)
|
|
204
|
+
const_value = schema["const"]
|
|
205
|
+
|
|
206
|
+
# Validate const value type (only string, integer, float, boolean are supported)
|
|
207
|
+
unless valid_const_value?(const_value)
|
|
208
|
+
raise Skit::Error, "Unsupported const value type: #{const_value.class}. " \
|
|
209
|
+
"Only String, Integer, Float, and Boolean are supported."
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Generate class name from property name and const value
|
|
213
|
+
class_name = generate_const_class_name(class_name_path, const_value)
|
|
214
|
+
|
|
215
|
+
const_type = Definitions::ConstType.new(
|
|
216
|
+
class_name: class_name,
|
|
217
|
+
value: const_value
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# Store const type definition (dedup by class name)
|
|
221
|
+
@const_types[class_name] = const_type unless @const_types.key?(class_name)
|
|
222
|
+
|
|
223
|
+
const_type
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
sig { params(value: T.untyped).returns(T::Boolean) }
|
|
227
|
+
def valid_const_value?(value)
|
|
228
|
+
case value
|
|
229
|
+
when String, Integer, Float, TrueClass, FalseClass
|
|
230
|
+
true
|
|
231
|
+
else
|
|
232
|
+
false
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
sig { params(class_name_path: ClassNamePath, const_value: T.untyped).returns(String) }
|
|
237
|
+
def generate_const_class_name(class_name_path, const_value)
|
|
238
|
+
# Generate class name from property name and const value
|
|
239
|
+
# e.g., "type" property with value "dog" -> "TypeDog"
|
|
240
|
+
property_name = class_name_path.property_name
|
|
241
|
+
|
|
242
|
+
value_suffix = case const_value
|
|
243
|
+
when String
|
|
244
|
+
NamingUtils.to_pascal_case(const_value)
|
|
245
|
+
when Integer, Float
|
|
246
|
+
NamingUtils.number_to_name(const_value)
|
|
247
|
+
when TrueClass
|
|
248
|
+
"True"
|
|
249
|
+
when FalseClass
|
|
250
|
+
"False"
|
|
251
|
+
else
|
|
252
|
+
"Value"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
"#{property_name}#{value_suffix}"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
sig { params(schema: T::Hash[String, T.untyped], class_name_path: ClassNamePath).returns(Definitions::PropertyTypes) }
|
|
259
|
+
def build_enum_type(schema, class_name_path)
|
|
260
|
+
enum_values = T.cast(schema["enum"], T::Array[T.untyped])
|
|
261
|
+
|
|
262
|
+
# Filter and validate enum values
|
|
263
|
+
valid_values = enum_values.select { |v| valid_enum_value?(v) }
|
|
264
|
+
|
|
265
|
+
# If no valid values or mixed types that can't be handled, fallback to T.untyped
|
|
266
|
+
return Definitions::PropertyType.new(base_type: "T.untyped") if valid_values.empty?
|
|
267
|
+
|
|
268
|
+
# Check if all values are of the same type category (all strings, all numbers, etc.)
|
|
269
|
+
return Definitions::PropertyType.new(base_type: "T.untyped") unless homogeneous_enum_values?(valid_values)
|
|
270
|
+
|
|
271
|
+
# Generate class name from property name
|
|
272
|
+
class_name = class_name_path.property_name
|
|
273
|
+
|
|
274
|
+
enum_type = Definitions::EnumType.new(
|
|
275
|
+
class_name: class_name,
|
|
276
|
+
values: valid_values
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# Store enum type definition (dedup by class name)
|
|
280
|
+
@enum_types[class_name] = enum_type unless @enum_types.key?(class_name)
|
|
281
|
+
|
|
282
|
+
enum_type
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
sig { params(value: T.untyped).returns(T::Boolean) }
|
|
286
|
+
def valid_enum_value?(value)
|
|
287
|
+
case value
|
|
288
|
+
when String, Integer, Float
|
|
289
|
+
true
|
|
290
|
+
else
|
|
291
|
+
false
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
sig { params(values: T::Array[T.untyped]).returns(T::Boolean) }
|
|
296
|
+
def homogeneous_enum_values?(values)
|
|
297
|
+
return true if values.empty?
|
|
298
|
+
|
|
299
|
+
first_type = value_type_category(values.first)
|
|
300
|
+
values.all? { |v| value_type_category(v) == first_type }
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
sig { params(value: T.untyped).returns(Symbol) }
|
|
304
|
+
def value_type_category(value)
|
|
305
|
+
case value
|
|
306
|
+
when String
|
|
307
|
+
:string
|
|
308
|
+
when Integer, Float
|
|
309
|
+
:number
|
|
310
|
+
else
|
|
311
|
+
:other
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
sig { params(schema: T::Hash[String, T.untyped], class_name_path: ClassNamePath).returns(Definitions::ArrayPropertyType) }
|
|
316
|
+
def build_array_type(schema, class_name_path)
|
|
317
|
+
if schema["items"]
|
|
318
|
+
item_schema = T.cast(schema["items"], T::Hash[String, T.untyped])
|
|
319
|
+
item_type = build_property_type(item_schema, class_name_path.append("item"))
|
|
320
|
+
Definitions::ArrayPropertyType.new(item_type: item_type)
|
|
321
|
+
else
|
|
322
|
+
# Array of T.untyped when items is not specified
|
|
323
|
+
untyped_item = Definitions::PropertyType.new(base_type: "T.untyped")
|
|
324
|
+
Definitions::ArrayPropertyType.new(item_type: untyped_item)
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
sig { params(schema: T::Hash[String, T.untyped], class_name_path: ClassNamePath).returns(Definitions::PropertyTypes) }
|
|
329
|
+
def build_union_type(schema, class_name_path)
|
|
330
|
+
union_schemas = T.cast(schema["anyOf"] || schema["oneOf"], T::Array[T.untyped])
|
|
331
|
+
|
|
332
|
+
# Handle null types: exclude null and make nullable at the end
|
|
333
|
+
has_null = T.let(false, T::Boolean)
|
|
334
|
+
non_null_schemas = T.let([], T::Array[T::Hash[String, T.untyped]])
|
|
335
|
+
|
|
336
|
+
union_schemas.each do |union_schema|
|
|
337
|
+
union_schema_typed = T.cast(union_schema, T::Hash[String, T.untyped])
|
|
338
|
+
if union_schema_typed["type"] == "null"
|
|
339
|
+
has_null = true
|
|
340
|
+
else
|
|
341
|
+
non_null_schemas << union_schema_typed
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Error if no non-null schemas exist
|
|
346
|
+
raise Skit::Error, "Union type with only null is not supported" if non_null_schemas.empty?
|
|
347
|
+
|
|
348
|
+
# Return nullable type when single type includes null
|
|
349
|
+
if non_null_schemas.length == 1
|
|
350
|
+
single_schema = T.must(non_null_schemas.first)
|
|
351
|
+
single_type = build_property_type(single_schema, class_name_path)
|
|
352
|
+
return has_null ? make_nullable(single_type) : single_type
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Analyze multiple types with unique class names for each member
|
|
356
|
+
types = non_null_schemas.each_with_index.map do |union_schema, index|
|
|
357
|
+
build_property_type(union_schema, class_name_path.append("Variant#{index}"))
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
union_type = Definitions::UnionPropertyType.new(types: types)
|
|
361
|
+
has_null ? make_nullable(union_type) : union_type
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
sig { params(property_type: Definitions::PropertyTypes).returns(Definitions::PropertyTypes) }
|
|
365
|
+
def make_nullable(property_type)
|
|
366
|
+
property_type.with_nullable
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
sig { params(schema: T::Hash[String, T.untyped]).returns(T.nilable(String)) }
|
|
370
|
+
def extract_comment(schema)
|
|
371
|
+
description = schema["description"]
|
|
372
|
+
examples = schema["examples"]
|
|
373
|
+
|
|
374
|
+
comment_parts = []
|
|
375
|
+
comment_parts << description if description
|
|
376
|
+
comment_parts << "Examples: #{examples.join(", ")}" if examples&.any?
|
|
377
|
+
|
|
378
|
+
comment_parts.empty? ? nil : comment_parts.join("\n")
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
sig { params(ref_path: String).returns(T::Hash[String, T.untyped]) }
|
|
382
|
+
def resolve_ref(ref_path)
|
|
383
|
+
# External references are not supported
|
|
384
|
+
raise Skit::Error, "External references not yet supported: #{ref_path}" unless ref_path.start_with?("#/")
|
|
385
|
+
|
|
386
|
+
# Parse JSON pointer and resolve reference
|
|
387
|
+
# #/$defs/Name -> ["$defs", "Name"]
|
|
388
|
+
path_parts = T.must(ref_path[2..]).split("/")
|
|
389
|
+
|
|
390
|
+
resolved = path_parts.reduce(@schema) do |current, part|
|
|
391
|
+
break nil unless current.is_a?(Hash)
|
|
392
|
+
|
|
393
|
+
current[part]
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
raise Skit::Error, "Cannot resolve reference: #{ref_path}" unless resolved
|
|
397
|
+
|
|
398
|
+
unless resolved.is_a?(Hash)
|
|
399
|
+
raise Skit::Error,
|
|
400
|
+
"Invalid reference target: #{ref_path} - expected object, got #{resolved.class}"
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
resolved
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
end
|