validrb 0.5.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/CHANGELOG.md +99 -0
- data/CLAUDE.md +434 -0
- data/LICENSE +21 -0
- data/README.md +654 -0
- data/Rakefile +10 -0
- data/lib/validrb/constraints/base.rb +59 -0
- data/lib/validrb/constraints/enum.rb +33 -0
- data/lib/validrb/constraints/format.rb +63 -0
- data/lib/validrb/constraints/length.rb +72 -0
- data/lib/validrb/constraints/max.rb +43 -0
- data/lib/validrb/constraints/min.rb +43 -0
- data/lib/validrb/context.rb +41 -0
- data/lib/validrb/custom_type.rb +95 -0
- data/lib/validrb/errors.rb +122 -0
- data/lib/validrb/field.rb +346 -0
- data/lib/validrb/i18n.rb +88 -0
- data/lib/validrb/introspection.rb +206 -0
- data/lib/validrb/openapi.rb +642 -0
- data/lib/validrb/result.rb +89 -0
- data/lib/validrb/schema.rb +303 -0
- data/lib/validrb/serializer.rb +113 -0
- data/lib/validrb/types/array.rb +91 -0
- data/lib/validrb/types/base.rb +90 -0
- data/lib/validrb/types/boolean.rb +37 -0
- data/lib/validrb/types/date.rb +70 -0
- data/lib/validrb/types/datetime.rb +71 -0
- data/lib/validrb/types/decimal.rb +57 -0
- data/lib/validrb/types/discriminated_union.rb +74 -0
- data/lib/validrb/types/float.rb +46 -0
- data/lib/validrb/types/integer.rb +53 -0
- data/lib/validrb/types/literal.rb +43 -0
- data/lib/validrb/types/object.rb +52 -0
- data/lib/validrb/types/string.rb +29 -0
- data/lib/validrb/types/time.rb +69 -0
- data/lib/validrb/types/union.rb +75 -0
- data/lib/validrb/version.rb +5 -0
- data/lib/validrb.rb +55 -0
- data/validrb.gemspec +43 -0
- metadata +91 -0
data/README.md
ADDED
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
# Validrb
|
|
2
|
+
|
|
3
|
+
A powerful Ruby schema validation library with type coercion, inspired by Pydantic and Zod. Define schemas once, validate data with automatic type coercion, generate JSON Schema, and serialize results.
|
|
4
|
+
|
|
5
|
+
[](https://www.ruby-lang.org/)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
|
|
8
|
+
## Features
|
|
9
|
+
|
|
10
|
+
- **Type Coercion** - Automatic conversion of strings to integers, booleans, dates, etc.
|
|
11
|
+
- **Rich Constraints** - min/max, length, format, enum, and custom validations
|
|
12
|
+
- **Schema Composition** - Extend, merge, pick, omit, and partial schemas
|
|
13
|
+
- **Nested Validation** - Deep validation of objects and arrays
|
|
14
|
+
- **Union Types** - Accept multiple types for a single field
|
|
15
|
+
- **Discriminated Unions** - Polymorphic data with type discriminators
|
|
16
|
+
- **Conditional Validation** - Validate fields based on other field values
|
|
17
|
+
- **Custom Types** - Define your own types with custom coercion
|
|
18
|
+
- **I18n Support** - Internationalized error messages
|
|
19
|
+
- **JSON Schema Generation** - Export schemas to JSON Schema format
|
|
20
|
+
- **Serialization** - Convert validated data to JSON-ready primitives
|
|
21
|
+
- **Zero Dependencies** - Pure Ruby, no external runtime dependencies
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
Add to your Gemfile:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
gem 'validrb'
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or install directly:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
gem install validrb
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick Start
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
require 'validrb'
|
|
41
|
+
|
|
42
|
+
# Define a schema
|
|
43
|
+
UserSchema = Validrb.schema do
|
|
44
|
+
field :name, :string, min: 1, max: 100
|
|
45
|
+
field :email, :string, format: :email
|
|
46
|
+
field :age, :integer, min: 0, optional: true
|
|
47
|
+
field :role, :string, enum: %w[admin user guest], default: "user"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Parse with automatic coercion
|
|
51
|
+
result = UserSchema.safe_parse({
|
|
52
|
+
name: "John Doe",
|
|
53
|
+
email: "john@example.com",
|
|
54
|
+
age: "25" # String automatically coerced to integer
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
if result.success?
|
|
58
|
+
puts result.data # => { name: "John Doe", email: "john@example.com", age: 25, role: "user" }
|
|
59
|
+
else
|
|
60
|
+
puts result.errors.full_messages
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Or raise on failure
|
|
64
|
+
user = UserSchema.parse(params) # Raises Validrb::ValidationError on failure
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Table of Contents
|
|
68
|
+
|
|
69
|
+
- [Types](#types)
|
|
70
|
+
- [Constraints](#constraints)
|
|
71
|
+
- [Field Options](#field-options)
|
|
72
|
+
- [Schema Options](#schema-options)
|
|
73
|
+
- [Schema Composition](#schema-composition)
|
|
74
|
+
- [Custom Validators](#custom-validators)
|
|
75
|
+
- [Conditional Validation](#conditional-validation)
|
|
76
|
+
- [Union Types](#union-types)
|
|
77
|
+
- [Discriminated Unions](#discriminated-unions)
|
|
78
|
+
- [Refinements](#refinements)
|
|
79
|
+
- [Validation Context](#validation-context)
|
|
80
|
+
- [Custom Types](#custom-types)
|
|
81
|
+
- [Serialization](#serialization)
|
|
82
|
+
- [JSON Schema Generation](#json-schema-generation)
|
|
83
|
+
- [Schema Introspection](#schema-introspection)
|
|
84
|
+
- [I18n Support](#i18n-support)
|
|
85
|
+
- [Error Handling](#error-handling)
|
|
86
|
+
|
|
87
|
+
## Types
|
|
88
|
+
|
|
89
|
+
### Built-in Types
|
|
90
|
+
|
|
91
|
+
| Type | Ruby Class | Coerces From |
|
|
92
|
+
|------|------------|--------------|
|
|
93
|
+
| `:string` | String | Symbol, Numeric |
|
|
94
|
+
| `:integer` | Integer | String, Float (whole numbers) |
|
|
95
|
+
| `:float` | Float | String, Integer |
|
|
96
|
+
| `:boolean` | TrueClass/FalseClass | "true"/"false", "yes"/"no", "1"/"0", 1/0 |
|
|
97
|
+
| `:decimal` | BigDecimal | String, Integer, Float |
|
|
98
|
+
| `:date` | Date | ISO8601 String, DateTime, Time, Unix timestamp |
|
|
99
|
+
| `:datetime` | DateTime | ISO8601 String, Date, Time, Unix timestamp |
|
|
100
|
+
| `:time` | Time | ISO8601 String, DateTime, Date, Unix timestamp |
|
|
101
|
+
| `:array` | Array | (validates items with `of:` option) |
|
|
102
|
+
| `:object` | Hash | (validates with nested `schema:`) |
|
|
103
|
+
|
|
104
|
+
### Type Examples
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
schema = Validrb.schema do
|
|
108
|
+
# Basic types
|
|
109
|
+
field :name, :string
|
|
110
|
+
field :count, :integer
|
|
111
|
+
field :price, :float
|
|
112
|
+
field :active, :boolean
|
|
113
|
+
|
|
114
|
+
# Precise decimals
|
|
115
|
+
field :amount, :decimal
|
|
116
|
+
|
|
117
|
+
# Date/time types
|
|
118
|
+
field :birth_date, :date
|
|
119
|
+
field :created_at, :datetime
|
|
120
|
+
field :timestamp, :time
|
|
121
|
+
|
|
122
|
+
# Arrays with typed items
|
|
123
|
+
field :tags, :array, of: :string
|
|
124
|
+
field :scores, :array, of: :integer
|
|
125
|
+
|
|
126
|
+
# Nested objects
|
|
127
|
+
field :address, :object, schema: AddressSchema
|
|
128
|
+
end
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Constraints
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
schema = Validrb.schema do
|
|
135
|
+
# Numeric min/max
|
|
136
|
+
field :age, :integer, min: 0, max: 150
|
|
137
|
+
field :price, :float, min: 0.01
|
|
138
|
+
|
|
139
|
+
# String length (min/max applied to length)
|
|
140
|
+
field :username, :string, min: 3, max: 20
|
|
141
|
+
|
|
142
|
+
# Exact length
|
|
143
|
+
field :pin, :string, length: 4
|
|
144
|
+
|
|
145
|
+
# Length range
|
|
146
|
+
field :password, :string, length: 8..128
|
|
147
|
+
|
|
148
|
+
# Length with options
|
|
149
|
+
field :bio, :string, length: { min: 10, max: 500 }
|
|
150
|
+
|
|
151
|
+
# Named formats
|
|
152
|
+
field :email, :string, format: :email
|
|
153
|
+
field :website, :string, format: :url
|
|
154
|
+
field :id, :string, format: :uuid
|
|
155
|
+
|
|
156
|
+
# Custom regex
|
|
157
|
+
field :code, :string, format: /\A[A-Z]{2}-\d{4}\z/
|
|
158
|
+
|
|
159
|
+
# Enum (allowed values)
|
|
160
|
+
field :status, :string, enum: %w[pending active completed]
|
|
161
|
+
field :priority, :integer, enum: [1, 2, 3]
|
|
162
|
+
end
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Available Formats
|
|
166
|
+
|
|
167
|
+
`:email`, `:url`, `:uuid`, `:phone`, `:alphanumeric`, `:alpha`, `:numeric`, `:hex`, `:slug`
|
|
168
|
+
|
|
169
|
+
## Field Options
|
|
170
|
+
|
|
171
|
+
| Option | Type | Description |
|
|
172
|
+
|--------|------|-------------|
|
|
173
|
+
| `optional` | Boolean | Field can be missing (default: false) |
|
|
174
|
+
| `nullable` | Boolean | Field accepts nil value (default: false) |
|
|
175
|
+
| `default` | Any/Proc | Default value when missing |
|
|
176
|
+
| `message` | String | Custom error message |
|
|
177
|
+
| `preprocess` | Proc | Transform input BEFORE validation |
|
|
178
|
+
| `transform` | Proc | Transform value AFTER validation |
|
|
179
|
+
| `coerce` | Boolean | Enable type coercion (default: true) |
|
|
180
|
+
| `when` | Proc/Symbol | Only validate if condition is true |
|
|
181
|
+
| `unless` | Proc/Symbol | Only validate if condition is false |
|
|
182
|
+
| `union` | Array | Accept any of these types |
|
|
183
|
+
| `literal` | Array | Accept only exact values |
|
|
184
|
+
| `refine` | Proc/Array | Custom validation predicates |
|
|
185
|
+
|
|
186
|
+
### Examples
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
schema = Validrb.schema do
|
|
190
|
+
# Optional field
|
|
191
|
+
field :nickname, :string, optional: true
|
|
192
|
+
|
|
193
|
+
# Nullable field (accepts nil)
|
|
194
|
+
field :deleted_at, :datetime, nullable: true
|
|
195
|
+
|
|
196
|
+
# Default values
|
|
197
|
+
field :role, :string, default: "user"
|
|
198
|
+
field :created_at, :datetime, default: -> { DateTime.now }
|
|
199
|
+
|
|
200
|
+
# Preprocessing (runs BEFORE validation)
|
|
201
|
+
field :email, :string, format: :email,
|
|
202
|
+
preprocess: ->(v) { v.to_s.strip.downcase }
|
|
203
|
+
|
|
204
|
+
# Transform (runs AFTER validation)
|
|
205
|
+
field :tags, :string, transform: ->(v) { v.split(",").map(&:strip) }
|
|
206
|
+
|
|
207
|
+
# Disable coercion (strict type checking)
|
|
208
|
+
field :count, :integer, coerce: false
|
|
209
|
+
|
|
210
|
+
# Custom error message
|
|
211
|
+
field :age, :integer, min: 18, message: "Must be 18 or older"
|
|
212
|
+
end
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Schema Options
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
# Strict mode - reject unknown keys
|
|
219
|
+
schema = Validrb.schema(strict: true) do
|
|
220
|
+
field :name, :string
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
schema.safe_parse({ name: "John", extra: "rejected" })
|
|
224
|
+
# => Failure with error on :extra
|
|
225
|
+
|
|
226
|
+
# Passthrough mode - keep unknown keys
|
|
227
|
+
schema = Validrb.schema(passthrough: true) do
|
|
228
|
+
field :name, :string
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
schema.parse({ name: "John", extra: "kept" })
|
|
232
|
+
# => { name: "John", extra: "kept" }
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## Schema Composition
|
|
236
|
+
|
|
237
|
+
```ruby
|
|
238
|
+
BaseSchema = Validrb.schema do
|
|
239
|
+
field :id, :integer
|
|
240
|
+
field :created_at, :datetime, default: -> { DateTime.now }
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Extend with additional fields
|
|
244
|
+
UserSchema = BaseSchema.extend do
|
|
245
|
+
field :name, :string
|
|
246
|
+
field :email, :string, format: :email
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Pick specific fields
|
|
250
|
+
PublicUserSchema = UserSchema.pick(:id, :name)
|
|
251
|
+
|
|
252
|
+
# Omit specific fields
|
|
253
|
+
SafeUserSchema = UserSchema.omit(:password)
|
|
254
|
+
|
|
255
|
+
# Merge two schemas (second takes precedence)
|
|
256
|
+
MergedSchema = Schema1.merge(Schema2)
|
|
257
|
+
|
|
258
|
+
# Make all fields optional (useful for PATCH updates)
|
|
259
|
+
UpdateSchema = UserSchema.partial
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Custom Validators
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
schema = Validrb.schema do
|
|
266
|
+
field :password, :string, min: 8
|
|
267
|
+
field :password_confirmation, :string
|
|
268
|
+
|
|
269
|
+
# Cross-field validation
|
|
270
|
+
validate do |data|
|
|
271
|
+
if data[:password] != data[:password_confirmation]
|
|
272
|
+
error(:password_confirmation, "doesn't match password")
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Base-level errors (not tied to a field)
|
|
277
|
+
validate do |data|
|
|
278
|
+
if data[:items]&.empty?
|
|
279
|
+
base_error("At least one item is required")
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## Conditional Validation
|
|
286
|
+
|
|
287
|
+
```ruby
|
|
288
|
+
schema = Validrb.schema do
|
|
289
|
+
field :account_type, :string, enum: %w[personal business]
|
|
290
|
+
|
|
291
|
+
# Validate only when condition is true
|
|
292
|
+
field :company_name, :string,
|
|
293
|
+
when: ->(data) { data[:account_type] == "business" }
|
|
294
|
+
|
|
295
|
+
# Validate unless condition is true
|
|
296
|
+
field :personal_id, :string,
|
|
297
|
+
unless: ->(data) { data[:account_type] == "business" }
|
|
298
|
+
|
|
299
|
+
# Symbol shorthand (checks if field is truthy)
|
|
300
|
+
field :subscribe, :boolean, default: false
|
|
301
|
+
field :email, :string, format: :email, when: :subscribe
|
|
302
|
+
end
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
## Union Types
|
|
306
|
+
|
|
307
|
+
```ruby
|
|
308
|
+
schema = Validrb.schema do
|
|
309
|
+
# Accept multiple types (tries in order, put specific types first)
|
|
310
|
+
field :id, :string, union: [:integer, :string]
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
schema.parse({ id: 123 }) # => { id: 123 }
|
|
314
|
+
schema.parse({ id: "abc-123" }) # => { id: "abc-123" }
|
|
315
|
+
schema.parse({ id: "456" }) # => { id: 456 } (coerced to integer)
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
## Discriminated Unions
|
|
319
|
+
|
|
320
|
+
For polymorphic data, use discriminated unions to select the right schema based on a discriminator field:
|
|
321
|
+
|
|
322
|
+
```ruby
|
|
323
|
+
CreditCardSchema = Validrb.schema do
|
|
324
|
+
field :type, :string
|
|
325
|
+
field :card_number, :string
|
|
326
|
+
field :expiry, :string
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
PayPalSchema = Validrb.schema do
|
|
330
|
+
field :type, :string
|
|
331
|
+
field :email, :string, format: :email
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
PaymentSchema = Validrb.schema do
|
|
335
|
+
field :payment, :discriminated_union,
|
|
336
|
+
discriminator: :type,
|
|
337
|
+
mapping: {
|
|
338
|
+
"credit_card" => CreditCardSchema,
|
|
339
|
+
"paypal" => PayPalSchema
|
|
340
|
+
}
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
PaymentSchema.parse({
|
|
344
|
+
payment: { type: "credit_card", card_number: "4111...", expiry: "12/25" }
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
PaymentSchema.parse({
|
|
348
|
+
payment: { type: "paypal", email: "user@example.com" }
|
|
349
|
+
})
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
## Refinements
|
|
353
|
+
|
|
354
|
+
Add custom validation predicates beyond built-in constraints:
|
|
355
|
+
|
|
356
|
+
```ruby
|
|
357
|
+
schema = Validrb.schema do
|
|
358
|
+
# Simple refinement
|
|
359
|
+
field :age, :integer, refine: ->(v) { v >= 18 }
|
|
360
|
+
|
|
361
|
+
# With custom message
|
|
362
|
+
field :password, :string,
|
|
363
|
+
refine: {
|
|
364
|
+
check: ->(v) { v.match?(/[A-Z]/) },
|
|
365
|
+
message: "must contain an uppercase letter"
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
# Multiple refinements
|
|
369
|
+
field :code, :string,
|
|
370
|
+
refine: [
|
|
371
|
+
{ check: ->(v) { v.length >= 8 }, message: "too short" },
|
|
372
|
+
{ check: ->(v) { v.match?(/\d/) }, message: "needs a digit" },
|
|
373
|
+
{ check: ->(v) { v.match?(/[A-Z]/) }, message: "needs uppercase" }
|
|
374
|
+
]
|
|
375
|
+
end
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
## Validation Context
|
|
379
|
+
|
|
380
|
+
Pass request-level data through the validation pipeline:
|
|
381
|
+
|
|
382
|
+
```ruby
|
|
383
|
+
schema = Validrb.schema do
|
|
384
|
+
field :amount, :decimal,
|
|
385
|
+
refine: ->(value, ctx) {
|
|
386
|
+
ctx.nil? || value <= ctx[:max_amount]
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
field :admin_only, :string,
|
|
390
|
+
when: ->(data, ctx) { ctx && ctx[:is_admin] }
|
|
391
|
+
|
|
392
|
+
validate do |data, ctx|
|
|
393
|
+
if ctx && ctx[:restricted] && data[:amount] > 100
|
|
394
|
+
error(:amount, "exceeds limit in restricted mode")
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Create and pass context
|
|
400
|
+
ctx = Validrb.context(max_amount: 1000, is_admin: true)
|
|
401
|
+
result = schema.safe_parse(data, context: ctx)
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
## Custom Types
|
|
405
|
+
|
|
406
|
+
Define your own types with custom coercion and validation:
|
|
407
|
+
|
|
408
|
+
```ruby
|
|
409
|
+
Validrb.define_type(:money) do
|
|
410
|
+
coerce { |v| BigDecimal(v.to_s.gsub(/[$,]/, "")) }
|
|
411
|
+
validate { |v| v >= 0 }
|
|
412
|
+
error_message { "must be a valid money amount" }
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
Validrb.define_type(:slug) do
|
|
416
|
+
coerce { |v| v.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/^-|-$/, "") }
|
|
417
|
+
validate { |v| v.match?(/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/) }
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
schema = Validrb.schema do
|
|
421
|
+
field :price, :money
|
|
422
|
+
field :url_slug, :slug
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
schema.parse({ price: "$1,234.56", url_slug: "Hello World!" })
|
|
426
|
+
# => { price: #<BigDecimal:1234.56>, url_slug: "hello-world" }
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
## Serialization
|
|
430
|
+
|
|
431
|
+
Convert validated data to JSON-ready primitives:
|
|
432
|
+
|
|
433
|
+
```ruby
|
|
434
|
+
schema = Validrb.schema do
|
|
435
|
+
field :name, :string
|
|
436
|
+
field :created_at, :date
|
|
437
|
+
field :amount, :decimal
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
result = schema.safe_parse({
|
|
441
|
+
name: "Test",
|
|
442
|
+
created_at: "2024-01-15",
|
|
443
|
+
amount: "99.99"
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
# Serialize to hash with primitives
|
|
447
|
+
result.dump
|
|
448
|
+
# => { "name" => "Test", "created_at" => "2024-01-15", "amount" => "99.99" }
|
|
449
|
+
|
|
450
|
+
# Serialize to JSON
|
|
451
|
+
result.to_json
|
|
452
|
+
# => '{"name":"Test","created_at":"2024-01-15","amount":"99.99"}'
|
|
453
|
+
|
|
454
|
+
# Schema-level dump (parse + serialize)
|
|
455
|
+
schema.dump(data) # Raises on validation error
|
|
456
|
+
schema.safe_dump(data) # Returns Result
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
## JSON Schema Generation
|
|
460
|
+
|
|
461
|
+
Generate JSON Schema from your Validrb schemas:
|
|
462
|
+
|
|
463
|
+
```ruby
|
|
464
|
+
schema = Validrb.schema do
|
|
465
|
+
field :id, :integer
|
|
466
|
+
field :name, :string, min: 1, max: 100
|
|
467
|
+
field :email, :string, format: :email
|
|
468
|
+
field :age, :integer, optional: true, min: 0
|
|
469
|
+
field :role, :string, enum: %w[admin user], default: "user"
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
json_schema = schema.to_json_schema
|
|
473
|
+
# => {
|
|
474
|
+
# "$schema" => "https://json-schema.org/draft-07/schema#",
|
|
475
|
+
# "type" => "object",
|
|
476
|
+
# "required" => ["id", "name", "email"],
|
|
477
|
+
# "properties" => {
|
|
478
|
+
# "id" => { "type" => "integer" },
|
|
479
|
+
# "name" => { "type" => "string", "minLength" => 1, "maxLength" => 100 },
|
|
480
|
+
# "email" => { "type" => "string" },
|
|
481
|
+
# "age" => { "type" => "integer", "minimum" => 0 },
|
|
482
|
+
# "role" => { "type" => "string", "enum" => ["admin", "user"], "default" => "user" }
|
|
483
|
+
# }
|
|
484
|
+
# }
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
## OpenAPI 3.0 Generation
|
|
488
|
+
|
|
489
|
+
Generate complete OpenAPI 3.0 specifications from your schemas:
|
|
490
|
+
|
|
491
|
+
```ruby
|
|
492
|
+
# Create an OpenAPI generator
|
|
493
|
+
generator = Validrb::OpenAPI.generator
|
|
494
|
+
|
|
495
|
+
# Register schemas
|
|
496
|
+
generator.register("User", UserSchema)
|
|
497
|
+
generator.register("CreateUser", CreateUserSchema)
|
|
498
|
+
|
|
499
|
+
# Build paths
|
|
500
|
+
paths = Validrb::OpenAPI::PathBuilder.new(generator)
|
|
501
|
+
.get("/users", summary: "List users")
|
|
502
|
+
.post("/users", schema: CreateUserSchema, summary: "Create user")
|
|
503
|
+
.get("/users/{id}", summary: "Get user")
|
|
504
|
+
.put("/users/{id}", schema: UpdateUserSchema, summary: "Update user")
|
|
505
|
+
.to_h
|
|
506
|
+
|
|
507
|
+
# Generate the OpenAPI document
|
|
508
|
+
doc = generator.generate(
|
|
509
|
+
info: {
|
|
510
|
+
title: "My API",
|
|
511
|
+
version: "1.0.0",
|
|
512
|
+
description: "API documentation"
|
|
513
|
+
},
|
|
514
|
+
servers: ["https://api.example.com"],
|
|
515
|
+
paths: paths
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
# Export as JSON or YAML
|
|
519
|
+
puts generator.to_json(info: { title: "My API", version: "1.0.0" })
|
|
520
|
+
puts generator.to_yaml(info: { title: "My API", version: "1.0.0" })
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
### Import from OpenAPI/JSON Schema
|
|
524
|
+
|
|
525
|
+
Create Validrb schemas from existing OpenAPI or JSON Schema definitions:
|
|
526
|
+
|
|
527
|
+
```ruby
|
|
528
|
+
# Import from OpenAPI document
|
|
529
|
+
openapi_doc = JSON.parse(File.read("openapi.json"))
|
|
530
|
+
importer = Validrb::OpenAPI.import(openapi_doc)
|
|
531
|
+
|
|
532
|
+
# Access imported schemas
|
|
533
|
+
user_schema = importer["User"]
|
|
534
|
+
post_schema = importer["Post"]
|
|
535
|
+
|
|
536
|
+
# Use for validation
|
|
537
|
+
result = user_schema.safe_parse(params)
|
|
538
|
+
|
|
539
|
+
# Import a single JSON Schema
|
|
540
|
+
json_schema = {
|
|
541
|
+
"type" => "object",
|
|
542
|
+
"properties" => {
|
|
543
|
+
"name" => { "type" => "string", "minLength" => 1 },
|
|
544
|
+
"age" => { "type" => "integer", "minimum" => 0 }
|
|
545
|
+
},
|
|
546
|
+
"required" => ["name"]
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
schema = Validrb::OpenAPI.import_schema(json_schema)
|
|
550
|
+
schema.parse({ name: "John", age: 25 })
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
## Schema Introspection
|
|
554
|
+
|
|
555
|
+
Inspect schema structure programmatically:
|
|
556
|
+
|
|
557
|
+
```ruby
|
|
558
|
+
schema.field_names # => [:id, :name, :email, :age, :role]
|
|
559
|
+
schema.required_fields # => [:id, :name, :email]
|
|
560
|
+
schema.optional_fields # => [:age]
|
|
561
|
+
schema.fields_with_defaults # => [:role]
|
|
562
|
+
schema.conditional_fields # => []
|
|
563
|
+
|
|
564
|
+
# Get field details
|
|
565
|
+
field = schema.field(:name)
|
|
566
|
+
field.type.type_name # => "string"
|
|
567
|
+
field.constraint_values # => { min: 1, max: 100 }
|
|
568
|
+
field.optional? # => false
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
## I18n Support
|
|
572
|
+
|
|
573
|
+
Customize error messages with internationalization:
|
|
574
|
+
|
|
575
|
+
```ruby
|
|
576
|
+
# Add custom translations
|
|
577
|
+
Validrb::I18n.add_translations(:en,
|
|
578
|
+
required: "cannot be blank",
|
|
579
|
+
min: "must be at least %{value}"
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
# Switch locale
|
|
583
|
+
Validrb::I18n.add_translations(:es,
|
|
584
|
+
required: "es requerido",
|
|
585
|
+
min: "debe ser al menos %{value}"
|
|
586
|
+
)
|
|
587
|
+
Validrb::I18n.locale = :es
|
|
588
|
+
|
|
589
|
+
# Reset to defaults
|
|
590
|
+
Validrb::I18n.reset!
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
## Error Handling
|
|
594
|
+
|
|
595
|
+
```ruby
|
|
596
|
+
# safe_parse returns a Result object
|
|
597
|
+
result = schema.safe_parse(data)
|
|
598
|
+
|
|
599
|
+
result.success? # => true/false
|
|
600
|
+
result.failure? # => true/false
|
|
601
|
+
result.data # => validated data (if success)
|
|
602
|
+
result.errors # => ErrorCollection (if failure)
|
|
603
|
+
|
|
604
|
+
# Error details
|
|
605
|
+
result.errors.each do |error|
|
|
606
|
+
error.path # => [:user, :email]
|
|
607
|
+
error.message # => "must be a valid email"
|
|
608
|
+
error.code # => :format
|
|
609
|
+
error.to_s # => "user.email must be a valid email"
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
# Error collection methods
|
|
613
|
+
result.errors.messages # => ["must be a valid email", ...]
|
|
614
|
+
result.errors.full_messages # => ["user.email must be a valid email", ...]
|
|
615
|
+
result.errors.to_h # => { [:user, :email] => ["must be a valid email"] }
|
|
616
|
+
|
|
617
|
+
# parse raises on failure
|
|
618
|
+
begin
|
|
619
|
+
schema.parse(invalid_data)
|
|
620
|
+
rescue Validrb::ValidationError => e
|
|
621
|
+
e.errors # => ErrorCollection
|
|
622
|
+
e.message # => Summary of errors
|
|
623
|
+
end
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
## Requirements
|
|
627
|
+
|
|
628
|
+
- Ruby >= 3.0
|
|
629
|
+
- No runtime dependencies
|
|
630
|
+
|
|
631
|
+
## Development
|
|
632
|
+
|
|
633
|
+
```bash
|
|
634
|
+
# Install dependencies
|
|
635
|
+
bundle install
|
|
636
|
+
|
|
637
|
+
# Run tests
|
|
638
|
+
bundle exec rspec
|
|
639
|
+
|
|
640
|
+
# Run demo
|
|
641
|
+
bundle exec ruby demo.rb
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
## Contributing
|
|
645
|
+
|
|
646
|
+
Bug reports and pull requests are welcome on GitHub.
|
|
647
|
+
|
|
648
|
+
## License
|
|
649
|
+
|
|
650
|
+
MIT License. See [LICENSE](LICENSE) for details.
|
|
651
|
+
|
|
652
|
+
## Credits
|
|
653
|
+
|
|
654
|
+
Inspired by [Pydantic](https://pydantic.dev/) (Python) and [Zod](https://zod.dev/) (TypeScript).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Validrb
|
|
4
|
+
module Constraints
|
|
5
|
+
# Registry for constraint types
|
|
6
|
+
@registry = {}
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
attr_reader :registry
|
|
10
|
+
|
|
11
|
+
def register(name, klass)
|
|
12
|
+
@registry[name.to_sym] = klass
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def lookup(name)
|
|
16
|
+
@registry[name.to_sym]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def build(name, *args, **kwargs)
|
|
20
|
+
klass = lookup(name)
|
|
21
|
+
raise ArgumentError, "Unknown constraint: #{name}" unless klass
|
|
22
|
+
|
|
23
|
+
klass.new(*args, **kwargs)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Base class for all constraints
|
|
28
|
+
class Base
|
|
29
|
+
attr_reader :options
|
|
30
|
+
|
|
31
|
+
def initialize(**options)
|
|
32
|
+
@options = options.freeze
|
|
33
|
+
freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Validate a value and return an array of Error objects (empty if valid)
|
|
37
|
+
def call(value, path: [])
|
|
38
|
+
return [] if valid?(value)
|
|
39
|
+
|
|
40
|
+
[Error.new(path: path, message: error_message(value), code: error_code)]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Override in subclasses to implement validation logic
|
|
44
|
+
def valid?(_value)
|
|
45
|
+
raise NotImplementedError, "#{self.class}#valid? must be implemented"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Override in subclasses to provide error message
|
|
49
|
+
def error_message(_value)
|
|
50
|
+
raise NotImplementedError, "#{self.class}#error_message must be implemented"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Override in subclasses to provide error code
|
|
54
|
+
def error_code
|
|
55
|
+
:constraint_error
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|