rbdantic 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/.rspec +3 -0
- data/.rubocop.yml +245 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +852 -0
- data/README_CN.md +852 -0
- data/Rakefile +12 -0
- data/lib/rbdantic/base/access.rb +105 -0
- data/lib/rbdantic/base/dsl.rb +79 -0
- data/lib/rbdantic/base/validation.rb +152 -0
- data/lib/rbdantic/base.rb +30 -0
- data/lib/rbdantic/config.rb +60 -0
- data/lib/rbdantic/error_detail.rb +54 -0
- data/lib/rbdantic/field.rb +188 -0
- data/lib/rbdantic/json_schema/defs_registry.rb +79 -0
- data/lib/rbdantic/json_schema/generator.rb +148 -0
- data/lib/rbdantic/json_schema/types.rb +98 -0
- data/lib/rbdantic/serialization/dumper.rb +133 -0
- data/lib/rbdantic/serialization/json_serializer.rb +60 -0
- data/lib/rbdantic/validators/field_validator.rb +83 -0
- data/lib/rbdantic/validators/model_validator.rb +59 -0
- data/lib/rbdantic/validators/types/array.rb +77 -0
- data/lib/rbdantic/validators/types/base.rb +78 -0
- data/lib/rbdantic/validators/types/boolean.rb +37 -0
- data/lib/rbdantic/validators/types/float.rb +32 -0
- data/lib/rbdantic/validators/types/hash.rb +54 -0
- data/lib/rbdantic/validators/types/integer.rb +28 -0
- data/lib/rbdantic/validators/types/model.rb +75 -0
- data/lib/rbdantic/validators/types/number.rb +63 -0
- data/lib/rbdantic/validators/types/string.rb +70 -0
- data/lib/rbdantic/validators/types/symbol.rb +30 -0
- data/lib/rbdantic/validators/types/time.rb +33 -0
- data/lib/rbdantic/validators/types.rb +63 -0
- data/lib/rbdantic/validators/validator_context.rb +43 -0
- data/lib/rbdantic/version.rb +5 -0
- data/lib/rbdantic.rb +8 -0
- data/sig/rbdantic.rbs +4 -0
- metadata +84 -0
data/README.md
ADDED
|
@@ -0,0 +1,852 @@
|
|
|
1
|
+
# Rbdantic
|
|
2
|
+
|
|
3
|
+
**Ruby Data Validation and Settings Management** - A Pydantic-inspired data validation library for Ruby.
|
|
4
|
+
|
|
5
|
+
Rbdantic brings Pydantic's powerful data validation capabilities to Ruby, providing runtime data validation, serialization, and JSON Schema generation with an intuitive DSL.
|
|
6
|
+
|
|
7
|
+
[中文文档](README_CN.md)
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **Base Model Class** - Define data models with type-checked fields
|
|
12
|
+
- **Field Constraints** - Built-in constraints for strings, numbers, and arrays
|
|
13
|
+
- **Custom Validators** - Field-level and model-level validators with multiple modes
|
|
14
|
+
- **Type Coercion** - Automatic type conversion with configurable strictness
|
|
15
|
+
- **Nested Models** - Support for nested model validation
|
|
16
|
+
- **Model Inheritance** - Fields and validators are inherited by subclasses
|
|
17
|
+
- **Model Configuration** - Flexible configuration options (extra fields, frozen models, etc.)
|
|
18
|
+
- **Serialization** - Convert models to Hash or JSON with filtering options
|
|
19
|
+
- **JSON Schema Generation** - Automatic JSON Schema generation for API documentation
|
|
20
|
+
- **Detailed Error Reporting** - Structured validation errors with location paths
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
Add to your Gemfile:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
gem 'rbdantic'
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Or install directly:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
gem install rbdantic
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
require 'rbdantic'
|
|
40
|
+
|
|
41
|
+
class User < Rbdantic::BaseModel
|
|
42
|
+
field :name, String, min_length: 1, max_length: 100
|
|
43
|
+
field :email, String, pattern: /\A[^@\s]+@[^@\s]+\z/
|
|
44
|
+
field :age, Integer, gt: 0, le: 150
|
|
45
|
+
field :tags, [String], default_factory: -> { [] }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Create a valid user
|
|
49
|
+
user = User.new(
|
|
50
|
+
name: "Alice",
|
|
51
|
+
email: "alice@example.com",
|
|
52
|
+
age: 30
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
puts user.name # => "Alice"
|
|
56
|
+
puts user.age # => 30
|
|
57
|
+
puts user.tags # => []
|
|
58
|
+
|
|
59
|
+
# Serialize to Hash
|
|
60
|
+
puts user.model_dump
|
|
61
|
+
# => { name: "Alice", email: "alice@example.com", age: 30, tags: [] }
|
|
62
|
+
|
|
63
|
+
# Serialize to JSON
|
|
64
|
+
puts user.model_dump_json
|
|
65
|
+
# => {"name":"Alice","email":"alice@example.com","age":30,"tags":[]}
|
|
66
|
+
|
|
67
|
+
# Validation error
|
|
68
|
+
begin
|
|
69
|
+
User.new(name: "", email: "invalid", age: -1)
|
|
70
|
+
rescue Rbdantic::ValidationError => e
|
|
71
|
+
e.errors.each do |err|
|
|
72
|
+
puts "#{err.loc.join('.')}: #{err.msg}"
|
|
73
|
+
end
|
|
74
|
+
# name: String must be at least 1 characters
|
|
75
|
+
# email: String does not match pattern ...
|
|
76
|
+
# age: Value must be greater than 0
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Field Definition
|
|
81
|
+
|
|
82
|
+
### Basic Fields
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
class Product < Rbdantic::BaseModel
|
|
86
|
+
field :id, Integer
|
|
87
|
+
field :name, String
|
|
88
|
+
field :price, Float
|
|
89
|
+
field :active, Rbdantic::Boolean
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Default Values
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
class Config < Rbdantic::BaseModel
|
|
97
|
+
# Static default
|
|
98
|
+
field :timeout, Integer, default: 30
|
|
99
|
+
|
|
100
|
+
# Dynamic default (factory)
|
|
101
|
+
field :created_at, Time, default_factory: -> { Time.now }
|
|
102
|
+
|
|
103
|
+
# Optional field (can be nil)
|
|
104
|
+
field :nickname, String, optional: true
|
|
105
|
+
end
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Field Constraints
|
|
109
|
+
|
|
110
|
+
#### String Constraints
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
class User < Rbdantic::BaseModel
|
|
114
|
+
field :username, String,
|
|
115
|
+
min_length: 3,
|
|
116
|
+
max_length: 20,
|
|
117
|
+
pattern: /\A[a-zA-Z0-9_]+\z/
|
|
118
|
+
end
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
#### Numeric Constraints
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
class Product < Rbdantic::BaseModel
|
|
125
|
+
field :price, Float,
|
|
126
|
+
gt: 0, # greater than
|
|
127
|
+
le: 10000 # less than or equal
|
|
128
|
+
|
|
129
|
+
field :quantity, Integer,
|
|
130
|
+
ge: 0, # greater than or equal
|
|
131
|
+
multiple_of: 1
|
|
132
|
+
end
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
#### Array Constraints
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
class Order < Rbdantic::BaseModel
|
|
139
|
+
field :items, [String],
|
|
140
|
+
min_items: 1,
|
|
141
|
+
max_items: 100,
|
|
142
|
+
unique_items: true
|
|
143
|
+
|
|
144
|
+
end
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Custom Validators in Field
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
class User < Rbdantic::BaseModel
|
|
151
|
+
# Proc validator returning false on failure
|
|
152
|
+
field :email, String,
|
|
153
|
+
validators: [->(v) { v.include?("@") || false }]
|
|
154
|
+
|
|
155
|
+
# Proc validator returning error message
|
|
156
|
+
field :password, String,
|
|
157
|
+
validators: [->(v) { v.length >= 8 ? nil : "Password must be at least 8 characters" }]
|
|
158
|
+
end
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Model Configuration
|
|
162
|
+
|
|
163
|
+
Use `model_config` to configure model behavior:
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
class User < Rbdantic::BaseModel
|
|
167
|
+
model_config(
|
|
168
|
+
extra: :forbid, # reject extra fields
|
|
169
|
+
frozen: true, # immutable after creation
|
|
170
|
+
strict: true, # strict type checking
|
|
171
|
+
coerce_mode: :strict, # no type coercion
|
|
172
|
+
validate_assignment: true # validate on field assignment
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
field :name, String
|
|
176
|
+
end
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Configuration Options
|
|
180
|
+
|
|
181
|
+
| Option | Values | Description |
|
|
182
|
+
|--------|--------|-------------|
|
|
183
|
+
| `extra` | `:ignore`, `:forbid`, `:allow` | How to handle extra fields not defined |
|
|
184
|
+
| `frozen` | `true`, `false` | Make model immutable after initialization |
|
|
185
|
+
| `strict` | `true`, `false` | Strict type checking (no coercion) |
|
|
186
|
+
| `coerce_mode` | `:strict`, `:coerce` | Enable/disable type coercion |
|
|
187
|
+
| `validate_assignment` | `true`, `false` | Validate when assigning to fields |
|
|
188
|
+
|
|
189
|
+
### Extra Fields Behavior
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
# Ignore extra fields (default)
|
|
193
|
+
class ModelA < Rbdantic::BaseModel
|
|
194
|
+
model_config extra: :ignore
|
|
195
|
+
field :name, String
|
|
196
|
+
end
|
|
197
|
+
ModelA.new(name: "test", extra: "data") # extra field is dropped
|
|
198
|
+
|
|
199
|
+
# Forbid extra fields
|
|
200
|
+
class ModelB < Rbdantic::BaseModel
|
|
201
|
+
model_config extra: :forbid
|
|
202
|
+
field :name, String
|
|
203
|
+
end
|
|
204
|
+
ModelB.new(name: "test", extra: "data") # raises ValidationError
|
|
205
|
+
|
|
206
|
+
# Allow extra fields
|
|
207
|
+
class ModelC < Rbdantic::BaseModel
|
|
208
|
+
model_config extra: :allow
|
|
209
|
+
field :name, String
|
|
210
|
+
end
|
|
211
|
+
m = ModelC.new(name: "test", extra: "data")
|
|
212
|
+
m[:extra] # => "data"
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Validators
|
|
216
|
+
|
|
217
|
+
### Field Validators
|
|
218
|
+
|
|
219
|
+
Field validators run at different stages:
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
class User < Rbdantic::BaseModel
|
|
223
|
+
field :email, String
|
|
224
|
+
|
|
225
|
+
# Before validation - can transform value
|
|
226
|
+
field_validator :email, mode: :before do |value, ctx|
|
|
227
|
+
value&.downcase
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# After validation - validate transformed value
|
|
231
|
+
field_validator :email, mode: :after do |value, ctx|
|
|
232
|
+
raise "Invalid email format" unless value.include?("@")
|
|
233
|
+
value
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
#### Validator Modes
|
|
239
|
+
|
|
240
|
+
| Mode | Description |
|
|
241
|
+
|------|-------------|
|
|
242
|
+
| `:before` | Runs before type validation, can transform value |
|
|
243
|
+
| `:after` | Runs after type validation, validate the final value |
|
|
244
|
+
| `:plain` | Runs instead of type validation (skips type check) |
|
|
245
|
+
| `:wrap` | Runs after all other validators |
|
|
246
|
+
|
|
247
|
+
### Model Validators
|
|
248
|
+
|
|
249
|
+
Model validators validate the entire model:
|
|
250
|
+
|
|
251
|
+
```ruby
|
|
252
|
+
class Account < Rbdantic::BaseModel
|
|
253
|
+
field :password, String
|
|
254
|
+
field :confirm_password, String
|
|
255
|
+
|
|
256
|
+
# Before validator - preprocess input data
|
|
257
|
+
model_validator mode: :before do |data|
|
|
258
|
+
data[:password] = data[:password]&.strip
|
|
259
|
+
data
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# After validator - validate model state
|
|
263
|
+
model_validator mode: :after do |model|
|
|
264
|
+
if model.password != model.confirm_password
|
|
265
|
+
raise "Passwords do not match"
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
## Nested Models
|
|
272
|
+
|
|
273
|
+
Rbdantic supports nested models just like Pydantic, allowing you to build complex data structures with hierarchical validation.
|
|
274
|
+
|
|
275
|
+
### Single Nested Model
|
|
276
|
+
|
|
277
|
+
```ruby
|
|
278
|
+
class Address < Rbdantic::BaseModel
|
|
279
|
+
field :street, String, min_length: 1
|
|
280
|
+
field :city, String, min_length: 1
|
|
281
|
+
field :zip_code, String, pattern: /\A\d{5}\z/
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
class User < Rbdantic::BaseModel
|
|
285
|
+
field :name, String
|
|
286
|
+
field :address, Address # nested model type
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Create from hash - nested model is automatically validated
|
|
290
|
+
user = User.new(
|
|
291
|
+
name: "Alice",
|
|
292
|
+
address: {
|
|
293
|
+
street: "123 Main St",
|
|
294
|
+
city: "Boston",
|
|
295
|
+
zip_code: "02134"
|
|
296
|
+
}
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
puts user.address.class # => Address
|
|
300
|
+
puts user.address.city # => "Boston"
|
|
301
|
+
|
|
302
|
+
# Or pass a pre-built nested model instance
|
|
303
|
+
address = Address.new(street: "456 Oak Ave", city: "Cambridge", zip_code: "02139")
|
|
304
|
+
user = User.new(name: "Jane", address: address)
|
|
305
|
+
|
|
306
|
+
# Serialize - nested models are recursively dumped
|
|
307
|
+
user.model_dump
|
|
308
|
+
# => { name: "Jane", address: { street: "456 Oak Ave", city: "Cambridge", zip_code: "02139" } }
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
### Deeply Nested Models
|
|
312
|
+
|
|
313
|
+
You can nest models at any depth:
|
|
314
|
+
|
|
315
|
+
```ruby
|
|
316
|
+
class Country < Rbdantic::BaseModel
|
|
317
|
+
field :code, String, pattern: /\A[A-Z]{2}\z/
|
|
318
|
+
field :name, String
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
class City < Rbdantic::BaseModel
|
|
322
|
+
field :name, String
|
|
323
|
+
field :country, Country # nested within nested
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
class Person < Rbdantic::BaseModel
|
|
327
|
+
field :name, String
|
|
328
|
+
field :birthplace, City # two levels of nesting
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Create deeply nested structure
|
|
332
|
+
person = Person.new(
|
|
333
|
+
name: "Alice",
|
|
334
|
+
birthplace: {
|
|
335
|
+
name: "Paris",
|
|
336
|
+
country: {
|
|
337
|
+
code: "FR",
|
|
338
|
+
name: "France"
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
puts person.birthplace.country.code # => "FR"
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### Array of Nested Models
|
|
347
|
+
|
|
348
|
+
Use `[Type]` shorthand to validate arrays of nested models:
|
|
349
|
+
|
|
350
|
+
```ruby
|
|
351
|
+
class Item < Rbdantic::BaseModel
|
|
352
|
+
field :name, String, min_length: 1
|
|
353
|
+
field :quantity, Integer, gt: 0
|
|
354
|
+
field :price, Float, ge: 0
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
class Order < Rbdantic::BaseModel
|
|
358
|
+
field :order_id, String
|
|
359
|
+
field :items, [Item], min_items: 1
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Create order with multiple items
|
|
363
|
+
order = Order.new(
|
|
364
|
+
order_id: "ORD-001",
|
|
365
|
+
items: [
|
|
366
|
+
{ name: "Widget", quantity: 5, price: 9.99 },
|
|
367
|
+
{ name: "Gadget", quantity: 2, price: 19.99 }
|
|
368
|
+
]
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
puts order.items[0].class # => Item
|
|
372
|
+
puts order.items.length # => 2
|
|
373
|
+
|
|
374
|
+
# Serialize - array items are recursively dumped
|
|
375
|
+
order.model_dump
|
|
376
|
+
# => { order_id: "ORD-001", items: [{ name: "Widget", quantity: 5, price: 9.99 }, ...] }
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
### Optional Nested Models
|
|
380
|
+
|
|
381
|
+
```ruby
|
|
382
|
+
class Profile < Rbdantic::BaseModel
|
|
383
|
+
field :bio, String
|
|
384
|
+
field :avatar_url, String
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
class User < Rbdantic::BaseModel
|
|
388
|
+
field :name, String
|
|
389
|
+
field :profile, Profile, optional: true # can be nil
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Without profile
|
|
393
|
+
user = User.new(name: "Bob")
|
|
394
|
+
puts user.profile # => nil
|
|
395
|
+
|
|
396
|
+
# With profile
|
|
397
|
+
user = User.new(name: "Bob", profile: { bio: "Developer", avatar_url: "..." })
|
|
398
|
+
puts user.profile.bio # => "Developer"
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
### Nested Model Validation Errors
|
|
402
|
+
|
|
403
|
+
Errors in nested models include the full path:
|
|
404
|
+
|
|
405
|
+
```ruby
|
|
406
|
+
begin
|
|
407
|
+
User.new(
|
|
408
|
+
name: "Alice",
|
|
409
|
+
address: {
|
|
410
|
+
street: "", # invalid: too short
|
|
411
|
+
city: "Boston",
|
|
412
|
+
zip_code: "invalid" # invalid: pattern mismatch
|
|
413
|
+
}
|
|
414
|
+
)
|
|
415
|
+
rescue Rbdantic::ValidationError => e
|
|
416
|
+
e.errors.each do |err|
|
|
417
|
+
puts "#{err.loc.join('.')} - #{err.msg}"
|
|
418
|
+
end
|
|
419
|
+
# address.street - String must be at least 1 characters
|
|
420
|
+
# address.zip_code - String does not match pattern ...
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Deeply nested error path
|
|
424
|
+
begin
|
|
425
|
+
Person.new(
|
|
426
|
+
name: "Bob",
|
|
427
|
+
birthplace: {
|
|
428
|
+
name: "London",
|
|
429
|
+
country: { code: "invalid", name: "UK" }
|
|
430
|
+
}
|
|
431
|
+
)
|
|
432
|
+
rescue Rbdantic::ValidationError => e
|
|
433
|
+
puts e.errors.first.loc # => [:birthplace, :country, :code]
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Array item error path
|
|
437
|
+
begin
|
|
438
|
+
Order.new(
|
|
439
|
+
order_id: "ORD-001",
|
|
440
|
+
items: [
|
|
441
|
+
{ name: "Widget", quantity: 5, price: 9.99 },
|
|
442
|
+
{ name: "", quantity: 0, price: -1 } # invalid item at index 1
|
|
443
|
+
]
|
|
444
|
+
)
|
|
445
|
+
rescue Rbdantic::ValidationError => e
|
|
446
|
+
e.errors.each do |err|
|
|
447
|
+
puts "#{err.loc.join('.')} - #{err.msg}"
|
|
448
|
+
end
|
|
449
|
+
# items.1.name - String must be at least 1 characters
|
|
450
|
+
# items.1.quantity - Value must be greater than 0
|
|
451
|
+
# items.1.price - Value must be greater than or equal to 0
|
|
452
|
+
end
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
### Self-Referencing Models
|
|
456
|
+
|
|
457
|
+
Models can reference themselves for recursive structures:
|
|
458
|
+
|
|
459
|
+
```ruby
|
|
460
|
+
class TreeNode < Rbdantic::BaseModel
|
|
461
|
+
field :value, String
|
|
462
|
+
field :children, [TreeNode], default_factory: -> { [] }
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
tree = TreeNode.new(
|
|
466
|
+
value: "root",
|
|
467
|
+
children: [
|
|
468
|
+
{ value: "child1", children: [{ value: "grandchild1" }] },
|
|
469
|
+
{ value: "child2" }
|
|
470
|
+
]
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
puts tree.children[0].children[0].value # => "grandchild1"
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
## Inheritance
|
|
477
|
+
|
|
478
|
+
Fields, validators, and configuration are inherited:
|
|
479
|
+
|
|
480
|
+
```ruby
|
|
481
|
+
class Animal < Rbdantic::BaseModel
|
|
482
|
+
field :name, String
|
|
483
|
+
field :age, Integer, gt: 0
|
|
484
|
+
|
|
485
|
+
model_config extra: :ignore
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
class Dog < Animal
|
|
489
|
+
field :breed, String # inherits name and age
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
class Cat < Animal
|
|
493
|
+
model_config extra: :allow
|
|
494
|
+
end
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
**Note:** Child classes inherit parent `model_config` values and can override only the options they need.
|
|
498
|
+
|
|
499
|
+
## Serialization
|
|
500
|
+
|
|
501
|
+
### model_dump
|
|
502
|
+
|
|
503
|
+
Convert model to Hash with options:
|
|
504
|
+
|
|
505
|
+
```ruby
|
|
506
|
+
class User < Rbdantic::BaseModel
|
|
507
|
+
field :name, String
|
|
508
|
+
field :role, String, default: "user"
|
|
509
|
+
field :active, Rbdantic::Boolean, default: true
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
user = User.new(name: "Alice")
|
|
513
|
+
|
|
514
|
+
# Full dump
|
|
515
|
+
user.model_dump
|
|
516
|
+
# => { name: "Alice", role: "user", active: true }
|
|
517
|
+
|
|
518
|
+
# Exclude fields with default values
|
|
519
|
+
user.model_dump(exclude_defaults: true)
|
|
520
|
+
# => { name: "Alice" }
|
|
521
|
+
|
|
522
|
+
# Include specific fields
|
|
523
|
+
user.model_dump(include: [:name])
|
|
524
|
+
# => { name: "Alice" }
|
|
525
|
+
|
|
526
|
+
# Exclude specific fields
|
|
527
|
+
user.model_dump(exclude: [:active])
|
|
528
|
+
# => { name: "Alice", role: "user" }
|
|
529
|
+
|
|
530
|
+
# Exclude unset fields (not provided during initialization)
|
|
531
|
+
user.model_dump(exclude_unset: true)
|
|
532
|
+
# => { name: "Alice" }
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
### model_dump_json
|
|
536
|
+
|
|
537
|
+
Convert to JSON string:
|
|
538
|
+
|
|
539
|
+
```ruby
|
|
540
|
+
user.model_dump_json
|
|
541
|
+
# => {"name":"Alice","role":"user","active":true}
|
|
542
|
+
|
|
543
|
+
# With indentation
|
|
544
|
+
user.model_dump_json(indent: 2)
|
|
545
|
+
# => {
|
|
546
|
+
# "name": "Alice",
|
|
547
|
+
# "role": "user",
|
|
548
|
+
# "active": true
|
|
549
|
+
# }
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
## JSON Schema Generation
|
|
553
|
+
|
|
554
|
+
Generate JSON Schema for API documentation:
|
|
555
|
+
|
|
556
|
+
```ruby
|
|
557
|
+
class User < Rbdantic::BaseModel
|
|
558
|
+
field :id, Integer, gt: 0
|
|
559
|
+
field :name, String, min_length: 1, max_length: 100
|
|
560
|
+
field :email, String, pattern: /\A[^@\s]+@[^@\s]+\z/
|
|
561
|
+
field :age, Integer, optional: true, ge: 0, le: 150
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
schema = User.model_json_schema
|
|
565
|
+
# => {
|
|
566
|
+
# "$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
567
|
+
# "type": "object",
|
|
568
|
+
# "title": "User",
|
|
569
|
+
# "properties": {
|
|
570
|
+
# "id": { "type": "integer", "exclusiveMinimum": 0 },
|
|
571
|
+
# "name": { "type": "string", "minLength": 1, "maxLength": 100 },
|
|
572
|
+
# "email": { "type": "string", "pattern": "^[^@\\s]+@[^@\\s]+$" },
|
|
573
|
+
# "age": { "type": ["integer", "null"], "minimum": 0, "maximum": 150 }
|
|
574
|
+
# },
|
|
575
|
+
# "required": ["id", "name", "email"]
|
|
576
|
+
# }
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
## Type Coercion
|
|
580
|
+
|
|
581
|
+
Automatic type conversion when `coerce_mode: :coerce`:
|
|
582
|
+
|
|
583
|
+
```ruby
|
|
584
|
+
class Config < Rbdantic::BaseModel
|
|
585
|
+
model_config coerce_mode: :coerce
|
|
586
|
+
|
|
587
|
+
field :count, Integer
|
|
588
|
+
field :price, Float
|
|
589
|
+
field :enabled, Rbdantic::Boolean
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
config = Config.new(
|
|
593
|
+
count: "42", # coerced to 42
|
|
594
|
+
price: "19.99", # coerced to 19.99
|
|
595
|
+
enabled: "yes" # coerced to true
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
config.count # => 42 (Integer)
|
|
599
|
+
config.price # => 19.99 (Float)
|
|
600
|
+
config.enabled # => true
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
### Supported Coercions
|
|
604
|
+
|
|
605
|
+
| Target Type | Source Examples |
|
|
606
|
+
|-------------|-----------------|
|
|
607
|
+
| `String` | Any value with `to_s` |
|
|
608
|
+
| `Integer` | `"42"`, `42.0` |
|
|
609
|
+
| `Float` | `"3.14"`, `42` |
|
|
610
|
+
| `Rbdantic::Boolean` | `"true"`, `"yes"`, `"on"`, `"1"`, `1`, `"false"`, `"no"`, `"off"`, `"0"`, `0` |
|
|
611
|
+
| `Array` | String with `split`, any value with `to_a` |
|
|
612
|
+
| `Hash` | Array of pairs, any value with `to_h` |
|
|
613
|
+
|
|
614
|
+
## Validation Errors
|
|
615
|
+
|
|
616
|
+
ValidationError provides detailed error information:
|
|
617
|
+
|
|
618
|
+
```ruby
|
|
619
|
+
begin
|
|
620
|
+
User.new(name: "", age: -1)
|
|
621
|
+
rescue Rbdantic::ValidationError => e
|
|
622
|
+
e.error_count # => 2
|
|
623
|
+
e.errors # => Array of ErrorDetail
|
|
624
|
+
e.as_json # => { errors: [...], error_count: 2 }
|
|
625
|
+
e.to_h # => same as as_json
|
|
626
|
+
|
|
627
|
+
e.errors.each do |err|
|
|
628
|
+
err.type # => :string_too_short, :value_not_greater_than
|
|
629
|
+
err.loc # => [:name], [:age] (location path)
|
|
630
|
+
err.msg # => "String must be at least..."
|
|
631
|
+
err.input # => "" (original input value)
|
|
632
|
+
end
|
|
633
|
+
end
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
## Supported Types
|
|
637
|
+
|
|
638
|
+
| Type | Notes |
|
|
639
|
+
|------|-------|
|
|
640
|
+
| `String` | Built-in string type |
|
|
641
|
+
| `Integer` | Built-in integer type |
|
|
642
|
+
| `Float` | Built-in float type |
|
|
643
|
+
| `Rbdantic::Boolean` | Boolean field accepting true/false |
|
|
644
|
+
| `Symbol` | Ruby symbol, max length 256 chars (DoS protection) |
|
|
645
|
+
| `[Type]` | Array with per-item validation |
|
|
646
|
+
| `Hash` | Key-value hash type |
|
|
647
|
+
| `Time` | Ruby Time type |
|
|
648
|
+
| `Rbdantic::BaseModel` subclass | Nested model validation |
|
|
649
|
+
|
|
650
|
+
**Note:** Use `Rbdantic::Boolean` for public boolean fields.
|
|
651
|
+
|
|
652
|
+
```ruby
|
|
653
|
+
class Config < Rbdantic::BaseModel
|
|
654
|
+
field :enabled, Rbdantic::Boolean
|
|
655
|
+
field :active, Rbdantic::Boolean, optional: true
|
|
656
|
+
end
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
## Format Validation
|
|
660
|
+
|
|
661
|
+
Built-in format validators for common patterns:
|
|
662
|
+
|
|
663
|
+
```ruby
|
|
664
|
+
class User < Rbdantic::BaseModel
|
|
665
|
+
field :email, String, format: :email # Basic email validation
|
|
666
|
+
field :website, String, format: :uri # URI validation (http/https)
|
|
667
|
+
end
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
| Format | Pattern |
|
|
671
|
+
|--------|---------|
|
|
672
|
+
| `:email` | Basic email check (user@domain) |
|
|
673
|
+
| `:uri` | HTTP/HTTPS URI |
|
|
674
|
+
|
|
675
|
+
For complex validation, use custom `pattern` regex or `field_validator`.
|
|
676
|
+
|
|
677
|
+
## Limitations & Security
|
|
678
|
+
|
|
679
|
+
### Security Limits
|
|
680
|
+
|
|
681
|
+
| Limit | Value | Purpose |
|
|
682
|
+
|-------|-------|---------|
|
|
683
|
+
| Symbol max length | 256 chars | Prevent Symbol DoS attacks |
|
|
684
|
+
| Nested model depth | ~20 levels | Prevent stack overflow |
|
|
685
|
+
|
|
686
|
+
These limits protect against malicious input that could exhaust memory or cause stack overflow.
|
|
687
|
+
|
|
688
|
+
### Thread Safety
|
|
689
|
+
|
|
690
|
+
Models are thread-safe for read operations after initialization. However:
|
|
691
|
+
|
|
692
|
+
- Validation during initialization is not thread-safe (uses internal state)
|
|
693
|
+
- `validate_assignment` mode uses instance-level locking
|
|
694
|
+
- Avoid sharing model instances across threads during mutation
|
|
695
|
+
|
|
696
|
+
## Differences from Pydantic
|
|
697
|
+
|
|
698
|
+
| Feature | Pydantic | Rbdantic |
|
|
699
|
+
|---------|----------|----------|
|
|
700
|
+
| Field aliases | `Field(alias="name")` | `alias_name:` plus `by_alias: true` |
|
|
701
|
+
| Computed fields | `@computed_field` | Not supported |
|
|
702
|
+
| Generic models | `BaseModel[T]` | Not supported |
|
|
703
|
+
| Field serialization alias | `serialization_alias` | Uses `alias_name:` and dump/schema `by_alias:` |
|
|
704
|
+
| Model copy/update | `model.copy(update={})` | `copy(deep:)` and `update(**data)` helpers |
|
|
705
|
+
| Discriminated unions | `Annotated[Union, Field(discriminator)]` | Not supported |
|
|
706
|
+
| Custom type adapters | `TypeAdapter` | Use validators instead |
|
|
707
|
+
| Boolean type | `bool` | `Rbdantic::Boolean` |
|
|
708
|
+
| Config class | `BaseModelConfig` | `model_config` hash |
|
|
709
|
+
|
|
710
|
+
### API Naming Differences
|
|
711
|
+
|
|
712
|
+
| Pydantic | Rbdantic |
|
|
713
|
+
|----------|----------|
|
|
714
|
+
| `Field()` | `field :name, Type, **options` |
|
|
715
|
+
| `@field_validator` | `field_validator :name, mode: ...` |
|
|
716
|
+
| `@model_validator` | `model_validator mode: ...` |
|
|
717
|
+
| `model_config = ConfigDict(...)` | `model_config(...)` |
|
|
718
|
+
| `model_dump()` | `model_dump()` |
|
|
719
|
+
| `model_dump_json()` | `model_dump_json()` |
|
|
720
|
+
| `model_validate()` | `Model.model_validate(data)` |
|
|
721
|
+
|
|
722
|
+
## Requirements
|
|
723
|
+
|
|
724
|
+
- Ruby >= 2.7 (for keyword arguments and pattern matching)
|
|
725
|
+
- No external dependencies (pure Ruby implementation)
|
|
726
|
+
|
|
727
|
+
## Error Handling Best Practices
|
|
728
|
+
|
|
729
|
+
### Catching Specific Field Errors
|
|
730
|
+
|
|
731
|
+
```ruby
|
|
732
|
+
begin
|
|
733
|
+
User.new(name: "", email: "invalid")
|
|
734
|
+
rescue Rbdantic::ValidationError => e
|
|
735
|
+
# Find errors for specific field
|
|
736
|
+
name_errors = e.errors.select { |err| err.loc.first == :name }
|
|
737
|
+
puts "Name errors: #{name_errors.map(&:msg).join(', ')}"
|
|
738
|
+
|
|
739
|
+
# Group errors by field
|
|
740
|
+
errors_by_field = e.errors.group_by { |err| err.loc.first }
|
|
741
|
+
errors_by_field.each do |field, errs|
|
|
742
|
+
puts "#{field}: #{errs.map(&:msg).join(', ')}"
|
|
743
|
+
end
|
|
744
|
+
end
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
### Custom Error Messages
|
|
748
|
+
|
|
749
|
+
Use `field_validator` for custom messages:
|
|
750
|
+
|
|
751
|
+
```ruby
|
|
752
|
+
class User < Rbdantic::BaseModel
|
|
753
|
+
field :password, String
|
|
754
|
+
|
|
755
|
+
field_validator :password, mode: :after do |value, ctx|
|
|
756
|
+
if value.length < 8
|
|
757
|
+
raise Rbdantic::ValidationError::ErrorDetail.new(
|
|
758
|
+
type: :password_too_short,
|
|
759
|
+
loc: [:password],
|
|
760
|
+
msg: "Password must be at least 8 characters (got #{value.length})",
|
|
761
|
+
input: value
|
|
762
|
+
)
|
|
763
|
+
end
|
|
764
|
+
value
|
|
765
|
+
end
|
|
766
|
+
end
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
### Error JSON for APIs
|
|
770
|
+
|
|
771
|
+
```ruby
|
|
772
|
+
rescue Rbdantic::ValidationError => e
|
|
773
|
+
# Return as JSON for API responses
|
|
774
|
+
status 400
|
|
775
|
+
json e.as_json
|
|
776
|
+
# => { "errors": [...], "error_count": 2 }
|
|
777
|
+
```
|
|
778
|
+
|
|
779
|
+
## API Reference
|
|
780
|
+
|
|
781
|
+
### Rbdantic::BaseModel
|
|
782
|
+
|
|
783
|
+
| Method | Description |
|
|
784
|
+
|--------|-------------|
|
|
785
|
+
| `field(name, type, **options)` | Define a field with type and constraints |
|
|
786
|
+
| `model_config(**options)` | Configure model behavior |
|
|
787
|
+
| `field_validator(name, mode:, &block)` | Define a field-level validator |
|
|
788
|
+
| `model_validator(mode:, &block)` | Define a model-level validator |
|
|
789
|
+
| `model_json_schema(**options)` | Generate JSON Schema |
|
|
790
|
+
| `model_fields` | Returns hash of field definitions |
|
|
791
|
+
| `model_config` | Returns model configuration |
|
|
792
|
+
| `inherited(subclass)` | Hook for inheritance (internal) |
|
|
793
|
+
|
|
794
|
+
### Instance Methods
|
|
795
|
+
|
|
796
|
+
| Method | Description |
|
|
797
|
+
|--------|-------------|
|
|
798
|
+
| `initialize(data = {})` | Create model with validation |
|
|
799
|
+
| `model_dump(**options)` | Convert to Hash |
|
|
800
|
+
| `model_dump_json(indent: nil)` | Convert to JSON string |
|
|
801
|
+
| `[name]` | Bracket access for field value |
|
|
802
|
+
| `[name] = value` | Bracket assignment for field value |
|
|
803
|
+
|
|
804
|
+
### Field Options
|
|
805
|
+
|
|
806
|
+
| Option | Type | Description |
|
|
807
|
+
|--------|------|-------------|
|
|
808
|
+
| `default` | Any | Static default value |
|
|
809
|
+
| `default_factory` | Proc | Dynamic default value generator |
|
|
810
|
+
| `optional` | Boolean | Allow nil values |
|
|
811
|
+
| `required` | Boolean | Set to `false` to allow nil (same as `optional: true`) |
|
|
812
|
+
| `validators` | Array | Custom validator Procs |
|
|
813
|
+
| `alias_name` | Symbol | Alternative name for input/output (use with `by_alias: true`) |
|
|
814
|
+
| `format` | Symbol | Built-in format validator (`:email`, `:uri`, `:uuid`) |
|
|
815
|
+
| `min_length` | Integer | Minimum string length |
|
|
816
|
+
| `max_length` | Integer | Maximum string length |
|
|
817
|
+
| `pattern` | Regexp | String pattern match |
|
|
818
|
+
| `gt` | Numeric | Greater than |
|
|
819
|
+
| `ge` | Numeric | Greater than or equal |
|
|
820
|
+
| `lt` | Numeric | Less than |
|
|
821
|
+
| `le` | Numeric | Less than or equal |
|
|
822
|
+
| `multiple_of` | Numeric | Must be multiple of |
|
|
823
|
+
| `min_items` | Integer | Minimum array items |
|
|
824
|
+
| `max_items` | Integer | Maximum array items |
|
|
825
|
+
| `unique_items` | Boolean | Array items must be unique |
|
|
826
|
+
|
|
827
|
+
## Development
|
|
828
|
+
|
|
829
|
+
After checking out the repo:
|
|
830
|
+
|
|
831
|
+
```bash
|
|
832
|
+
bin/setup # Install dependencies
|
|
833
|
+
rake spec # Run tests
|
|
834
|
+
bin/console # Interactive prompt
|
|
835
|
+
bundle exec rake install # Install gem locally
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
## Contributing
|
|
839
|
+
|
|
840
|
+
Bug reports and pull requests are welcome on GitHub.
|
|
841
|
+
|
|
842
|
+
## License
|
|
843
|
+
|
|
844
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
845
|
+
|
|
846
|
+
## Inspiration
|
|
847
|
+
|
|
848
|
+
This library is inspired by [Pydantic](https://github.com/pydantic/pydantic) - the excellent Python data validation library.
|
|
849
|
+
|
|
850
|
+
## Development Notes
|
|
851
|
+
|
|
852
|
+
This library was primarily developed with AI assistance (Claude), demonstrating how AI tools can accelerate software development while maintaining code quality and comprehensive testing.
|