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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e8cc8c9595a7c97a181d67e3c183726e25dc6d5aea07510bcb09fd325ed6aae9
|
|
4
|
+
data.tar.gz: 8c6b40877f2949364e61015b761fc0d00376498917f28331f0d44f9154283685
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 868a602fc15e934970fa4259eb6eccc19a91a0633ed7539671a490b7af0fa6e67ed4c78710acb6500c99cfbe64e28a347acc1fcd08bc23cc995d6ca58f24793c
|
|
7
|
+
data.tar.gz: 8cc83bc6d79f59ff448b0c113b53f100a8291a6f305c335976baaded471a1360cb877f6143ae99a4e6f63e9b2d55909f92f047c6d05703a8bc03d6d30bd206fc
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Speria, inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
# Skit
|
|
2
|
+
|
|
3
|
+
A Ruby gem that integrates JSON Schema with Sorbet T::Struct. Generate type-safe Ruby code from JSON Schema, serialize/deserialize JSON data to T::Struct, and store complex objects in ActiveRecord JSON/JSONB columns.
|
|
4
|
+
|
|
5
|
+
## Key Features
|
|
6
|
+
|
|
7
|
+
- **JSON Schema to Code**: Generate Sorbet T::Struct definitions from JSON Schema
|
|
8
|
+
- **Type-Safe Serialization**: Seamless conversion between T::Struct and JSON
|
|
9
|
+
- **ActiveRecord Integration**: Store T::Struct in JSON/JSONB columns with full type safety
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Add this line to your application's Gemfile:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem "skit"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
And then execute:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
bundle install
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
### 1. Generate T::Struct from JSON Schema
|
|
28
|
+
|
|
29
|
+
#### CLI Tool
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Basic usage
|
|
33
|
+
skit generate schema.json
|
|
34
|
+
|
|
35
|
+
# Specify class name
|
|
36
|
+
skit generate -c User user_schema.json
|
|
37
|
+
|
|
38
|
+
# Specify module name
|
|
39
|
+
skit generate -m MyModule user_schema.json
|
|
40
|
+
|
|
41
|
+
# Output to file
|
|
42
|
+
skit generate -o lib/types/user.rb user_schema.json
|
|
43
|
+
|
|
44
|
+
# Combine options
|
|
45
|
+
skit generate -m MyApp::Types -c User -o user.rb user_schema.json
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
#### Programmatic API
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
require "skit"
|
|
52
|
+
|
|
53
|
+
schema = {
|
|
54
|
+
"type" => "object",
|
|
55
|
+
"properties" => {
|
|
56
|
+
"name" => { "type" => "string" },
|
|
57
|
+
"age" => { "type" => "integer" }
|
|
58
|
+
},
|
|
59
|
+
"required" => ["name"]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
code = Skit::JsonSchema.generate(schema, class_name: "User", module_name: "MyApp")
|
|
63
|
+
puts code
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Output:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
# typed: strict
|
|
70
|
+
# frozen_string_literal: true
|
|
71
|
+
|
|
72
|
+
require "sorbet-runtime"
|
|
73
|
+
|
|
74
|
+
module MyApp
|
|
75
|
+
class User < T::Struct
|
|
76
|
+
prop :name, String
|
|
77
|
+
prop :age, T.nilable(Integer)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
#### Enum Support
|
|
83
|
+
|
|
84
|
+
JSON Schema `enum` generates `T::Enum` classes:
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{
|
|
88
|
+
"type": "object",
|
|
89
|
+
"properties": {
|
|
90
|
+
"status": {
|
|
91
|
+
"type": "string",
|
|
92
|
+
"enum": ["pending", "active", "completed"]
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Generates:
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
class Status < T::Enum
|
|
102
|
+
enums do
|
|
103
|
+
Pending = new("pending")
|
|
104
|
+
Active = new("active")
|
|
105
|
+
Completed = new("completed")
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
class Root < T::Struct
|
|
110
|
+
prop :status, T.nilable(Status)
|
|
111
|
+
end
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
#### Const Support
|
|
115
|
+
|
|
116
|
+
JSON Schema `const` generates type-safe constant classes for discriminated unions:
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
{
|
|
120
|
+
"type": "object",
|
|
121
|
+
"properties": {
|
|
122
|
+
"type": { "const": "dog" },
|
|
123
|
+
"breed": { "type": "string" }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Generates:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
class TypeDog < Skit::JsonSchema::Types::Const
|
|
132
|
+
VALUE = "dog"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
class Root < T::Struct
|
|
136
|
+
prop :type, T.nilable(TypeDog)
|
|
137
|
+
prop :breed, T.nilable(String)
|
|
138
|
+
end
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
#### Discriminated Unions (oneOf with objects)
|
|
142
|
+
|
|
143
|
+
JSON Schema `oneOf` with object types generates union types:
|
|
144
|
+
|
|
145
|
+
```json
|
|
146
|
+
{
|
|
147
|
+
"properties": {
|
|
148
|
+
"animal": {
|
|
149
|
+
"oneOf": [
|
|
150
|
+
{ "type": "object", "properties": { "type": { "const": "dog" }, "breed": { "type": "string" } } },
|
|
151
|
+
{ "type": "object", "properties": { "type": { "const": "cat" }, "color": { "type": "string" } } }
|
|
152
|
+
]
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Generates:
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
class AnimalVariant0 < T::Struct
|
|
162
|
+
prop :type, T.nilable(TypeDog)
|
|
163
|
+
prop :breed, T.nilable(String)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
class AnimalVariant1 < T::Struct
|
|
167
|
+
prop :type, T.nilable(TypeCat)
|
|
168
|
+
prop :color, T.nilable(String)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
class Root < T::Struct
|
|
172
|
+
prop :animal, T.any(AnimalVariant0, AnimalVariant1)
|
|
173
|
+
end
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### 2. Serialize/Deserialize T::Struct
|
|
177
|
+
|
|
178
|
+
Use your own T::Struct definitions directly:
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
class Product < T::Struct
|
|
182
|
+
const :name, String
|
|
183
|
+
const :price, Integer
|
|
184
|
+
const :tags, T::Array[String], default: []
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Deserialize: Hash -> T::Struct
|
|
188
|
+
data = { "name" => "Ruby Book", "price" => 3000, "tags" => ["programming", "ruby"] }
|
|
189
|
+
product = Skit.deserialize(data, Product)
|
|
190
|
+
|
|
191
|
+
product.name # => "Ruby Book"
|
|
192
|
+
product.price # => 3000
|
|
193
|
+
product.tags # => ["programming", "ruby"]
|
|
194
|
+
|
|
195
|
+
# Serialize: T::Struct -> Hash
|
|
196
|
+
hash = Skit.serialize(product)
|
|
197
|
+
# => {"name" => "Ruby Book", "price" => 3000, "tags" => ["programming", "ruby"]}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
#### Union Types (T.any)
|
|
201
|
+
|
|
202
|
+
Union types with T::Struct variants are automatically resolved during deserialization:
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
class TypeDog < Skit::JsonSchema::Types::Const
|
|
206
|
+
VALUE = "dog"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
class TypeCat < Skit::JsonSchema::Types::Const
|
|
210
|
+
VALUE = "cat"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
class Dog < T::Struct
|
|
214
|
+
const :type, TypeDog
|
|
215
|
+
const :breed, String
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
class Cat < T::Struct
|
|
219
|
+
const :type, TypeCat
|
|
220
|
+
const :color, String
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
class Pet < T::Struct
|
|
224
|
+
const :animal, T.any(Dog, Cat)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Deserialize: tries each variant, Const values discriminate the match
|
|
228
|
+
data = { "animal" => { "type" => "dog", "breed" => "Shiba" } }
|
|
229
|
+
pet = Skit.deserialize(data, Pet)
|
|
230
|
+
pet.animal # => Dog instance
|
|
231
|
+
|
|
232
|
+
# Serialize: detects the actual struct class
|
|
233
|
+
hash = Skit.serialize(pet)
|
|
234
|
+
# => {"animal" => {"type" => "dog", "breed" => "Shiba"}}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### 3. ActiveRecord JSONB Integration
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
class Address < T::Struct
|
|
241
|
+
const :city, String
|
|
242
|
+
const :zip, T.nilable(String)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
class Customer < ActiveRecord::Base
|
|
246
|
+
attribute :address, Skit::Attribute[Address]
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Assign with Hash
|
|
250
|
+
customer = Customer.new
|
|
251
|
+
customer.address = { city: "Tokyo", zip: "100-0001" }
|
|
252
|
+
|
|
253
|
+
# Assign with T::Struct
|
|
254
|
+
customer.address = Address.new(city: "Tokyo", zip: "100-0001")
|
|
255
|
+
|
|
256
|
+
# Access as T::Struct
|
|
257
|
+
customer.address.city # => "Tokyo"
|
|
258
|
+
customer.address.zip # => "100-0001"
|
|
259
|
+
|
|
260
|
+
# Save to database (stored as json)
|
|
261
|
+
customer.save
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### Array Type
|
|
265
|
+
|
|
266
|
+
```ruby
|
|
267
|
+
class Tag < T::Struct
|
|
268
|
+
const :name, String
|
|
269
|
+
const :color, String
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
class Article < ActiveRecord::Base
|
|
273
|
+
attribute :tags, Skit::Attribute[T::Array[Tag]]
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
article = Article.new
|
|
277
|
+
article.tags = [
|
|
278
|
+
{ name: "Ruby", color: "red" },
|
|
279
|
+
{ name: "Rails", color: "red" }
|
|
280
|
+
]
|
|
281
|
+
|
|
282
|
+
article.tags[0].name # => "Ruby"
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Hash Type
|
|
286
|
+
|
|
287
|
+
```ruby
|
|
288
|
+
class BoxSize < T::Struct
|
|
289
|
+
const :width, Integer
|
|
290
|
+
const :height, Integer
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
class Layout < ActiveRecord::Base
|
|
294
|
+
attribute :sizes, Skit::Attribute[T::Hash[String, BoxSize]]
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
layout = Layout.new
|
|
298
|
+
layout.sizes = {
|
|
299
|
+
"small" => { width: 100, height: 50 },
|
|
300
|
+
"large" => { width: 200, height: 100 }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
layout.sizes["small"].width # => 100
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### Nested Structs
|
|
307
|
+
|
|
308
|
+
```ruby
|
|
309
|
+
class Address < T::Struct
|
|
310
|
+
const :street, String
|
|
311
|
+
const :city, String
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
class Company < T::Struct
|
|
315
|
+
const :name, String
|
|
316
|
+
const :address, Address
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
class Employee < ActiveRecord::Base
|
|
320
|
+
attribute :company, Skit::Attribute[Company]
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
employee = Employee.new
|
|
324
|
+
employee.company = {
|
|
325
|
+
name: "Acme Corp",
|
|
326
|
+
address: { street: "123 Main St", city: "Springfield" }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
employee.company.address.city # => "Springfield"
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
### Validation
|
|
333
|
+
|
|
334
|
+
Skit integrates with ActiveModel::Validations:
|
|
335
|
+
|
|
336
|
+
```ruby
|
|
337
|
+
class Product < T::Struct
|
|
338
|
+
include ActiveModel::Validations
|
|
339
|
+
|
|
340
|
+
const :name, String
|
|
341
|
+
const :price, Integer
|
|
342
|
+
|
|
343
|
+
validates :name, presence: true
|
|
344
|
+
validates :price, numericality: { greater_than: 0 }
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
class Order < ActiveRecord::Base
|
|
348
|
+
attribute :product, Skit::Attribute[Product]
|
|
349
|
+
validates :product, skit: true
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
order = Order.new
|
|
353
|
+
order.product = { name: "", price: -100 }
|
|
354
|
+
order.valid? # => false
|
|
355
|
+
order.errors[:"product.name"] # => ["can't be blank"]
|
|
356
|
+
order.errors[:"product.price"] # => ["must be greater than 0"]
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
Array elements are validated with indexed error keys:
|
|
360
|
+
|
|
361
|
+
```ruby
|
|
362
|
+
class Item < T::Struct
|
|
363
|
+
include ActiveModel::Validations
|
|
364
|
+
|
|
365
|
+
const :name, String
|
|
366
|
+
validates :name, presence: true
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
class Cart < ActiveRecord::Base
|
|
370
|
+
attribute :items, Skit::Attribute[T::Array[Item]]
|
|
371
|
+
validates :items, skit: true
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
cart = Cart.new
|
|
375
|
+
cart.items = [{ name: "Book" }, { name: "" }]
|
|
376
|
+
cart.valid? # => false
|
|
377
|
+
cart.errors[:"items.[1].name"] # => ["can't be blank"]
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
## Type Mapping
|
|
381
|
+
|
|
382
|
+
### JSON Schema to Sorbet
|
|
383
|
+
|
|
384
|
+
| JSON Schema | Sorbet Type |
|
|
385
|
+
|-------------|-------------|
|
|
386
|
+
| `string` | `String` |
|
|
387
|
+
| `string` (format: date) | `Date` |
|
|
388
|
+
| `string` (format: date-time) | `Time` |
|
|
389
|
+
| `string` (format: time) | `Time` |
|
|
390
|
+
| `integer` | `Integer` |
|
|
391
|
+
| `number` | `Float` |
|
|
392
|
+
| `boolean` | `T::Boolean` |
|
|
393
|
+
| `array` | `T::Array[ElementType]` |
|
|
394
|
+
| `object` (with properties) | Custom T::Struct |
|
|
395
|
+
| `object` (no properties) | `T::Hash[String, T.untyped]` |
|
|
396
|
+
| `anyOf`/`oneOf` | `T.any(...)` or `T.nilable(...)` |
|
|
397
|
+
| `anyOf`/`oneOf` (objects) | `T.any(Struct1, Struct2, ...)` |
|
|
398
|
+
| `enum` | `T::Enum` |
|
|
399
|
+
| `const` | `Skit::JsonSchema::Types::Const` subclass |
|
|
400
|
+
|
|
401
|
+
### Sorbet to JSON (Serialization)
|
|
402
|
+
|
|
403
|
+
| Sorbet Type | JSON Type |
|
|
404
|
+
|-------------|-----------|
|
|
405
|
+
| `String` | `string` |
|
|
406
|
+
| `Integer`, `Float` | `number` |
|
|
407
|
+
| `T::Boolean` | `boolean` |
|
|
408
|
+
| `Symbol` | `string` |
|
|
409
|
+
| `Date` | `string` (ISO 8601: `"2025-01-15"`) |
|
|
410
|
+
| `Time` | `string` (ISO 8601: `"2025-01-15T10:30:00+09:00"`) |
|
|
411
|
+
| `T::Struct` | `object` |
|
|
412
|
+
| `T::Array[T]` | `array` |
|
|
413
|
+
| `T::Hash[String, T]` | `object` |
|
|
414
|
+
| `T.nilable(T)` | type or `null` |
|
|
415
|
+
| `T.any(Struct1, Struct2)` | `object` (resolved by matching variant) |
|
|
416
|
+
| `T::Enum` | serialized value (e.g. `"active"`) |
|
|
417
|
+
| `Skit::JsonSchema::Types::Const` | constant value (e.g. `"dog"`) |
|
|
418
|
+
|
|
419
|
+
## CLI Reference
|
|
420
|
+
|
|
421
|
+
```bash
|
|
422
|
+
skit generate [OPTIONS] SCHEMA_FILE
|
|
423
|
+
|
|
424
|
+
Options:
|
|
425
|
+
-c, --class-name NAME Root class name (default: from schema title or "GeneratedClass")
|
|
426
|
+
-m, --module-name NAME Module name to wrap generated classes
|
|
427
|
+
-o, --output FILE Output file path (default: stdout)
|
|
428
|
+
--typed LEVEL Sorbet strictness level (default: "strict")
|
|
429
|
+
-h, --help Show help message
|
|
430
|
+
-v, --version Show version
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
## Development
|
|
434
|
+
|
|
435
|
+
After checking out the repo, run `bundle install` to install dependencies.
|
|
436
|
+
|
|
437
|
+
### Running Tests
|
|
438
|
+
|
|
439
|
+
```bash
|
|
440
|
+
# Run all tests and linters (default task)
|
|
441
|
+
bundle exec rake
|
|
442
|
+
|
|
443
|
+
# Run tests only
|
|
444
|
+
bundle exec rspec
|
|
445
|
+
|
|
446
|
+
# Run unit tests only
|
|
447
|
+
bundle exec rspec --tag type:unit
|
|
448
|
+
|
|
449
|
+
# Run integration tests only
|
|
450
|
+
bundle exec rspec --tag type:integration
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
### Code Quality
|
|
454
|
+
|
|
455
|
+
```bash
|
|
456
|
+
# Run RuboCop (linter)
|
|
457
|
+
bundle exec rubocop
|
|
458
|
+
bundle exec rubocop -a # Auto-fix
|
|
459
|
+
|
|
460
|
+
# Run Sorbet type checker
|
|
461
|
+
bundle exec srb tc
|
|
462
|
+
|
|
463
|
+
# Update RBI files (Tapioca)
|
|
464
|
+
bundle exec rake sorbet:update
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
## License
|
|
468
|
+
|
|
469
|
+
MIT License. See LICENSE file for details.
|
data/exe/skit
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "skit"
|
|
5
|
+
|
|
6
|
+
if ARGV.empty? || ARGV[0] == "--help" || ARGV[0] == "-h"
|
|
7
|
+
puts "Usage: skit <command> [options]"
|
|
8
|
+
puts ""
|
|
9
|
+
puts "Commands:"
|
|
10
|
+
puts " generate Generate Sorbet T::Struct from JSON Schema"
|
|
11
|
+
puts ""
|
|
12
|
+
puts "Run 'skit <command> --help' for more information on a command."
|
|
13
|
+
exit 0
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
if ["--version", "-v"].include?(ARGV[0])
|
|
17
|
+
puts "skit #{Skit::VERSION}"
|
|
18
|
+
exit 0
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
command = ARGV.shift
|
|
22
|
+
|
|
23
|
+
case command
|
|
24
|
+
when "generate"
|
|
25
|
+
cli = Skit::JsonSchema::CLI.new
|
|
26
|
+
exit cli.run(ARGV)
|
|
27
|
+
else
|
|
28
|
+
warn "Unknown command: #{command}"
|
|
29
|
+
warn "Run 'skit --help' for usage information."
|
|
30
|
+
exit 1
|
|
31
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveModel
|
|
5
|
+
module Validations
|
|
6
|
+
class SkitValidator < ActiveModel::EachValidator
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
sig { params(record: T.untyped, attribute: T.any(String, Symbol), value: T.untyped).void }
|
|
10
|
+
def validate_each(record, attribute, value)
|
|
11
|
+
return if value.nil?
|
|
12
|
+
|
|
13
|
+
attribute_type = get_skit_attribute_type(record, attribute)
|
|
14
|
+
return unless attribute_type
|
|
15
|
+
|
|
16
|
+
processor = attribute_type.processor
|
|
17
|
+
processor.traverse(value) do |_type_spec, node, path|
|
|
18
|
+
next unless node.respond_to?(:valid?)
|
|
19
|
+
next if node.valid?
|
|
20
|
+
|
|
21
|
+
node.errors.each do |error|
|
|
22
|
+
error_key = build_error_key(attribute, path, error.attribute)
|
|
23
|
+
record.errors.add(error_key, error.message)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
sig do
|
|
31
|
+
params(
|
|
32
|
+
attribute: T.any(::String, Symbol),
|
|
33
|
+
path: Skit::Serialization::Path,
|
|
34
|
+
error_attribute: Symbol
|
|
35
|
+
).returns(::String)
|
|
36
|
+
end
|
|
37
|
+
def build_error_key(attribute, path, error_attribute)
|
|
38
|
+
if path.empty?
|
|
39
|
+
"#{attribute}.#{error_attribute}"
|
|
40
|
+
else
|
|
41
|
+
"#{attribute}.#{path}.#{error_attribute}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
sig { params(record: T.untyped, attribute: T.any(String, Symbol)).returns(T.nilable(Skit::Attribute)) }
|
|
46
|
+
def get_skit_attribute_type(record, attribute)
|
|
47
|
+
return nil unless record.class.respond_to?(:attribute_types)
|
|
48
|
+
|
|
49
|
+
attribute_type = record.class.attribute_types[attribute.to_s]
|
|
50
|
+
attribute_type.is_a?(Skit::Attribute) ? attribute_type : nil
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "active_model"
|
|
5
|
+
require "active_support/json"
|
|
6
|
+
|
|
7
|
+
module Skit
|
|
8
|
+
class Attribute < ActiveModel::Type::Value
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
sig { params(type_spec: T.untyped).returns(Attribute) }
|
|
12
|
+
def self.[](type_spec)
|
|
13
|
+
new(type_spec)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
sig { params(type_spec: T.untyped).void }
|
|
17
|
+
def initialize(type_spec)
|
|
18
|
+
super()
|
|
19
|
+
@type_spec = type_spec
|
|
20
|
+
@processor = T.let(
|
|
21
|
+
Serialization.default_registry.processor_for(type_spec),
|
|
22
|
+
Serialization::Processor::Base
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
sig { returns(Serialization::Processor::Base) }
|
|
27
|
+
attr_reader :processor
|
|
28
|
+
|
|
29
|
+
# Cast is called when assigning a value to the attribute
|
|
30
|
+
# e.g., record.data = { width: 100, height: 200 }
|
|
31
|
+
sig { params(value: T.untyped).returns(T.untyped) }
|
|
32
|
+
def cast(value)
|
|
33
|
+
return nil if value.nil?
|
|
34
|
+
|
|
35
|
+
@processor.deserialize(value)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Serialize is called before saving to the database
|
|
39
|
+
# Returns JSON string for storage
|
|
40
|
+
sig { params(value: T.untyped).returns(T.nilable(String)) }
|
|
41
|
+
def serialize(value)
|
|
42
|
+
return nil if value.nil?
|
|
43
|
+
|
|
44
|
+
serialized = @processor.serialize(value)
|
|
45
|
+
ActiveSupport::JSON.encode(serialized)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Deserialize is called when loading from the database
|
|
49
|
+
# Receives JSON string or Hash (depending on database adapter)
|
|
50
|
+
sig { params(value: T.untyped).returns(T.untyped) }
|
|
51
|
+
def deserialize(value)
|
|
52
|
+
return nil if value.nil?
|
|
53
|
+
|
|
54
|
+
data = if value.is_a?(String)
|
|
55
|
+
ActiveSupport::JSON.decode(value)
|
|
56
|
+
else
|
|
57
|
+
value
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
@processor.deserialize(data)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Skit
|
|
5
|
+
module JsonSchema
|
|
6
|
+
class ClassNamePath
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
sig { params(parts: T::Array[String]).void }
|
|
10
|
+
def initialize(parts)
|
|
11
|
+
@parts = T.let(parts.dup, T::Array[String])
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
sig { params(title: String).returns(ClassNamePath) }
|
|
15
|
+
def self.title_to_class_name(title)
|
|
16
|
+
# Split existing PascalCase before conversion (APIResponseData -> API_Response_Data)
|
|
17
|
+
with_underscores = title.gsub(/([a-z])([A-Z])/, '\1_\2')
|
|
18
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
19
|
+
|
|
20
|
+
class_name = NamingUtils.to_pascal_case(with_underscores)
|
|
21
|
+
|
|
22
|
+
return default if class_name.empty?
|
|
23
|
+
|
|
24
|
+
ClassNamePath.new([class_name])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
sig { params(file_path: T.nilable(String)).returns(ClassNamePath) }
|
|
28
|
+
def self.from_file_path(file_path)
|
|
29
|
+
return default unless file_path
|
|
30
|
+
|
|
31
|
+
basename = File.basename(file_path, ".*")
|
|
32
|
+
ClassNamePath.new([NamingUtils.to_pascal_case(basename)])
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
sig { returns(ClassNamePath) }
|
|
36
|
+
def self.default
|
|
37
|
+
ClassNamePath.new(["GeneratedClass"])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
sig { returns(T::Array[String]) }
|
|
41
|
+
attr_reader :parts
|
|
42
|
+
|
|
43
|
+
sig { params(suffix: String).returns(ClassNamePath) }
|
|
44
|
+
def append(suffix)
|
|
45
|
+
ClassNamePath.new(@parts + [NamingUtils.to_pascal_case(suffix)])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
sig { returns(T.nilable(String)) }
|
|
49
|
+
def parent_class
|
|
50
|
+
return nil if @parts.length < 2
|
|
51
|
+
|
|
52
|
+
@parts[0]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
sig { returns(String) }
|
|
56
|
+
def property_name
|
|
57
|
+
T.must(@parts.last)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
sig { returns(String) }
|
|
61
|
+
def to_class_name
|
|
62
|
+
# Generate class name by converting each part to PascalCase (TestUser + address -> TestUserAddress)
|
|
63
|
+
@parts.join
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|