ask-schema 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 +350 -0
- data/lib/ask/schema/dsl/complex_types.rb +72 -0
- data/lib/ask/schema/dsl/conditionals.rb +243 -0
- data/lib/ask/schema/dsl/primitive_types.rb +60 -0
- data/lib/ask/schema/dsl/schema_builders.rb +254 -0
- data/lib/ask/schema/dsl/utilities.rb +99 -0
- data/lib/ask/schema/dsl.rb +24 -0
- data/lib/ask/schema/errors.rb +31 -0
- data/lib/ask/schema/helpers.rb +19 -0
- data/lib/ask/schema/json_output.rb +49 -0
- data/lib/ask/schema/validator.rb +100 -0
- data/lib/ask/schema/version.rb +7 -0
- data/lib/ask-schema.rb +176 -0
- metadata +97 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f5a9c41e5de3b5e4c3bdaad787059efa89197dc88048cb24b4f545a34adfd060
|
|
4
|
+
data.tar.gz: 2f3dc5d00239bf5364ffd0894feb138a060cca73359310aee1b576ac2f6a77bb
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e05cfa19bd6d8160fb98c14b46a39dd5cc69970dacd1e571cb813c27b1f73454d73ce7bf74dfaa7e14ad1399ec0fd3f93bfe193aaf4e0939f6ccae59beeb0636
|
|
7
|
+
data.tar.gz: bc91c29e8bcea693ed199cccea34a6581f4289b2ddf1420634fc1b0b32e7828b591a7bc1addc28cd028a99fbd888b2e5ebf5e6a97a432e7985ac88b67e16ee4f
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kaka Ruto
|
|
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,350 @@
|
|
|
1
|
+
# ask-schema
|
|
2
|
+
|
|
3
|
+
A compact Ruby DSL for building standards-compliant JSON Schema documents. Zero dependencies.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
gem "ask-schema"
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
require "ask-schema"
|
|
11
|
+
|
|
12
|
+
schema = Ask::Schema.create do
|
|
13
|
+
string :name, description: "Full name"
|
|
14
|
+
integer :age, description: "Age in years", minimum: 0
|
|
15
|
+
boolean :active, required: false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
schema.new("user", description: "A user profile").to_json
|
|
19
|
+
# => {
|
|
20
|
+
# "name": "user",
|
|
21
|
+
# "description": "A user profile",
|
|
22
|
+
# "schema": {
|
|
23
|
+
# "type": "object",
|
|
24
|
+
# "properties": {
|
|
25
|
+
# "name": { "type": "string", "description": "Full name" },
|
|
26
|
+
# "age": { "type": "integer", "description": "Age in years", "minimum": 0 },
|
|
27
|
+
# "active": { "type": "boolean" }
|
|
28
|
+
# },
|
|
29
|
+
# "required": ["name", "age"],
|
|
30
|
+
# "additionalProperties": false,
|
|
31
|
+
# "strict": true
|
|
32
|
+
# }
|
|
33
|
+
# }
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
### Block-based DSL
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
schema = Ask::Schema.create do
|
|
42
|
+
string :name, description: "The user's name"
|
|
43
|
+
integer :age, description: "Age in years"
|
|
44
|
+
boolean :active, required: false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
instance = schema.new("user_profile", description: "A user profile")
|
|
48
|
+
instance.to_json_schema
|
|
49
|
+
# => { name: "user_profile", description: "A user profile", schema: { ... } }
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Class-based DSL
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
class Address < Ask::Schema
|
|
56
|
+
string :street
|
|
57
|
+
string :city
|
|
58
|
+
string :zip
|
|
59
|
+
string :country, required: false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
class User < Ask::Schema
|
|
63
|
+
string :name, description: "Full name"
|
|
64
|
+
string :email, format: "email"
|
|
65
|
+
integer :age
|
|
66
|
+
object :address, of: Address
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
User.new("user").to_json_schema
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Primitive Types
|
|
73
|
+
|
|
74
|
+
Each primitive type supports standard JSON Schema constraints.
|
|
75
|
+
|
|
76
|
+
### String
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
string :username,
|
|
80
|
+
description: "Username",
|
|
81
|
+
enum: %w[admin user guest],
|
|
82
|
+
min_length: 3,
|
|
83
|
+
max_length: 50,
|
|
84
|
+
pattern: "^[a-zA-Z0-9_]+$",
|
|
85
|
+
format: "email"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Number
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
number :price,
|
|
92
|
+
description: "Price in USD",
|
|
93
|
+
minimum: 0,
|
|
94
|
+
maximum: 999999.99,
|
|
95
|
+
multiple_of: 0.01
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Integer
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
integer :age,
|
|
102
|
+
minimum: 0,
|
|
103
|
+
maximum: 150
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Boolean
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
boolean :active, description: "Is the user active?"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Null
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
null :deleted_at, description: "When the record was deleted"
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Complex Types
|
|
119
|
+
|
|
120
|
+
### Object
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
# Inline object
|
|
124
|
+
object :address do
|
|
125
|
+
string :street
|
|
126
|
+
string :city
|
|
127
|
+
string :zip
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Reference to a defined schema
|
|
131
|
+
define(:address) do
|
|
132
|
+
string :street
|
|
133
|
+
string :city
|
|
134
|
+
end
|
|
135
|
+
object :billing, of: :address
|
|
136
|
+
|
|
137
|
+
# Reference to a Schema class
|
|
138
|
+
object :shipping, of: Address
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Array
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
# Array of primitive type
|
|
145
|
+
array :tags, of: :string, description: "List of tags"
|
|
146
|
+
|
|
147
|
+
# Array with min/max items
|
|
148
|
+
array :prices, of: :number, min_items: 1, max_items: 100
|
|
149
|
+
|
|
150
|
+
# Array with complex items (block)
|
|
151
|
+
array :contacts do
|
|
152
|
+
object do
|
|
153
|
+
string :name
|
|
154
|
+
string :email
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Array with any_of items
|
|
159
|
+
array :identifiers do
|
|
160
|
+
any_of do
|
|
161
|
+
string
|
|
162
|
+
integer
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### any_of / one_of
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
any_of :contact do
|
|
171
|
+
string description: "Phone number"
|
|
172
|
+
object do
|
|
173
|
+
string :email
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
one_of :payment_method do
|
|
178
|
+
string :credit_card
|
|
179
|
+
string :paypal
|
|
180
|
+
end
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Optional (nullable)
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
optional :nickname do
|
|
187
|
+
string
|
|
188
|
+
end
|
|
189
|
+
# Produces: anyOf: [{ type: "string" }, { type: "null" }]
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Named Definitions and References
|
|
193
|
+
|
|
194
|
+
Use `define` to create reusable named sub-schemas and `reference` (or `of:`) to reference them:
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
class User < Ask::Schema
|
|
198
|
+
define(:address) do
|
|
199
|
+
string :street
|
|
200
|
+
string :city
|
|
201
|
+
string :zip
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
string :name
|
|
205
|
+
object :home_address, of: :address
|
|
206
|
+
object :work_address, of: :address
|
|
207
|
+
end
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Output includes proper `$defs` and `$ref`:
|
|
211
|
+
|
|
212
|
+
```json
|
|
213
|
+
{
|
|
214
|
+
"type": "object",
|
|
215
|
+
"properties": {
|
|
216
|
+
"name": { "type": "string" },
|
|
217
|
+
"home_address": { "$ref": "#/$defs/address" },
|
|
218
|
+
"work_address": { "$ref": "#/$defs/address" }
|
|
219
|
+
},
|
|
220
|
+
"$defs": {
|
|
221
|
+
"address": {
|
|
222
|
+
"type": "object",
|
|
223
|
+
"properties": { "street": { "type": "string" }, ... }
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## Conditionals
|
|
230
|
+
|
|
231
|
+
### If/Then/Else
|
|
232
|
+
|
|
233
|
+
```ruby
|
|
234
|
+
schema = Ask::Schema.create do
|
|
235
|
+
integer :age
|
|
236
|
+
string :country
|
|
237
|
+
|
|
238
|
+
given(age: 18, country: "US") do
|
|
239
|
+
requires :license_number
|
|
240
|
+
validates :license_number, type: :string, pattern: /^[A-Z]{2}\d{6}$/
|
|
241
|
+
otherwise do
|
|
242
|
+
requires :country_name
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### Dependent Required
|
|
249
|
+
|
|
250
|
+
```ruby
|
|
251
|
+
dependent :shipping_address do
|
|
252
|
+
requires :name, :street, :city
|
|
253
|
+
end
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Coercion rules
|
|
257
|
+
|
|
258
|
+
| Ruby value | JSON Schema |
|
|
259
|
+
|---|---|
|
|
260
|
+
| `18` (scalar) | `{ const: 18 }` |
|
|
261
|
+
| `["admin", "user"]` (Array) | `{ enum: ["admin", "user"] }` |
|
|
262
|
+
| `/^[A-Z]+$/` (Regexp) | `{ pattern: "^[A-Z]+$" }` |
|
|
263
|
+
| `{ minimum: 0 }` (Hash) | Passed through as-is |
|
|
264
|
+
|
|
265
|
+
## Validation
|
|
266
|
+
|
|
267
|
+
```ruby
|
|
268
|
+
schema = Ask::Schema.create { string :name }
|
|
269
|
+
schema.valid? # => true
|
|
270
|
+
schema.validate! # => nil (or raises Ask::Schema::ValidationError)
|
|
271
|
+
|
|
272
|
+
# Circular reference detection
|
|
273
|
+
schema = Ask::Schema.create do
|
|
274
|
+
define(:a) { object :b, of: :b }
|
|
275
|
+
define(:b) { object :a, of: :a }
|
|
276
|
+
end
|
|
277
|
+
schema.valid? # => false
|
|
278
|
+
schema.validate! # => raises Ask::Schema::ValidationError
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
## Output Formats
|
|
282
|
+
|
|
283
|
+
```ruby
|
|
284
|
+
instance.to_json_schema
|
|
285
|
+
# => Hash with :name, :description, :schema keys
|
|
286
|
+
|
|
287
|
+
instance.to_json
|
|
288
|
+
# => Pretty-printed JSON string
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
## Configuration
|
|
292
|
+
|
|
293
|
+
```ruby
|
|
294
|
+
class StrictSchema < Ask::Schema
|
|
295
|
+
string :name
|
|
296
|
+
strict true # defaults to true
|
|
297
|
+
additional_properties false # defaults to false
|
|
298
|
+
end
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
## Integration with ask-tools
|
|
302
|
+
|
|
303
|
+
`ask-schema` powers tool parameter schemas in `ask-tools`:
|
|
304
|
+
|
|
305
|
+
```ruby
|
|
306
|
+
class WeatherTool < Ask::Tool
|
|
307
|
+
description "Get weather for a location"
|
|
308
|
+
|
|
309
|
+
params do
|
|
310
|
+
string :location, description: "City name"
|
|
311
|
+
string :unit, enum: %w[celsius fahrenheit]
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def execute(location:, unit: "celsius")
|
|
315
|
+
# ...
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Under the hood, `Ask::Schema.create` is used to build the JSON Schema for tool parameters.
|
|
321
|
+
|
|
322
|
+
## Error Types
|
|
323
|
+
|
|
324
|
+
| Error | When |
|
|
325
|
+
|---|---|
|
|
326
|
+
| `Ask::Schema::InvalidArrayTypeError` | Invalid type for array `:of` |
|
|
327
|
+
| `Ask::Schema::InvalidObjectTypeError` | Invalid type for object `:of` |
|
|
328
|
+
| `Ask::Schema::ValidationError` | Schema validation fails (e.g., circular refs) |
|
|
329
|
+
| `Ask::Schema::InvalidSchemaTypeError` | Unknown schema type specified |
|
|
330
|
+
| `Ask::Schema::InvalidSchemaError` | Schema definition is invalid |
|
|
331
|
+
| `Ask::Schema::LimitExceededError` | Maximum limits exceeded |
|
|
332
|
+
|
|
333
|
+
## Development
|
|
334
|
+
|
|
335
|
+
```
|
|
336
|
+
bundle install
|
|
337
|
+
bundle exec rake test
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
## Status
|
|
341
|
+
|
|
342
|
+
**Phase 3** of the ask-rb ecosystem migration. This gem replaces `ruby_llm-schema`
|
|
343
|
+
in the ask-rb stack. It should be built after `ask-core` and `ask-llm-providers`
|
|
344
|
+
are stable.
|
|
345
|
+
|
|
346
|
+
Current state: v0.1.0 — initial port complete with full feature parity.
|
|
347
|
+
|
|
348
|
+
## License
|
|
349
|
+
|
|
350
|
+
MIT
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
class Schema
|
|
5
|
+
module DSL
|
|
6
|
+
# DSL methods for declaring complex (non-primitive) property types.
|
|
7
|
+
module ComplexTypes
|
|
8
|
+
# Declare an object property with inline or referenced sub-schema.
|
|
9
|
+
# @param name [Symbol] Property name
|
|
10
|
+
# @param description [String, nil] Property description
|
|
11
|
+
# @param required [Boolean] Whether the property is required (default: true)
|
|
12
|
+
# @param requires [Symbol, Array<Symbol>, nil] Dependent property requirements
|
|
13
|
+
# @param options [Hash] Additional options (of:, reference:)
|
|
14
|
+
# @param block [Proc] Inline property definitions
|
|
15
|
+
def object(name, description: nil, required: true, requires: nil, **options, &block)
|
|
16
|
+
add_property(name, object_schema(description: description, **options, &block), required: required, requires: requires)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Declare an array property.
|
|
20
|
+
# @param name [Symbol] Property name
|
|
21
|
+
# @param description [String, nil] Property description
|
|
22
|
+
# @param required [Boolean] Whether the property is required (default: true)
|
|
23
|
+
# @param requires [Symbol, Array<Symbol>, nil] Dependent property requirements
|
|
24
|
+
# @param options [Hash] Additional options (of:, min_items:, max_items:)
|
|
25
|
+
# @param block [Proc] Block for complex item definitions
|
|
26
|
+
def array(name, description: nil, required: true, requires: nil, **options, &block)
|
|
27
|
+
add_property(name, array_schema(description: description, **options, &block), required: required, requires: requires)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Declare a property accepting any of the listed schemas.
|
|
31
|
+
# @param name [Symbol] Property name
|
|
32
|
+
# @param description [String, nil] Property description
|
|
33
|
+
# @param required [Boolean] Whether the property is required (default: true)
|
|
34
|
+
# @param requires [Symbol, Array<Symbol>, nil] Dependent property requirements
|
|
35
|
+
# @param options [Hash] Additional options
|
|
36
|
+
# @param block [Proc] Block listing alternative schemas
|
|
37
|
+
def any_of(name, description: nil, required: true, requires: nil, **options, &block)
|
|
38
|
+
add_property(name, any_of_schema(description: description, **options, &block), required: required, requires: requires)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Declare a property accepting exactly one of the listed schemas.
|
|
42
|
+
# @param name [Symbol] Property name
|
|
43
|
+
# @param description [String, nil] Property description
|
|
44
|
+
# @param required [Boolean] Whether the property is required (default: true)
|
|
45
|
+
# @param requires [Symbol, Array<Symbol>, nil] Dependent property requirements
|
|
46
|
+
# @param options [Hash] Additional options
|
|
47
|
+
# @param block [Proc] Block listing alternative schemas
|
|
48
|
+
def one_of(name, description: nil, required: true, requires: nil, **options, &block)
|
|
49
|
+
add_property(name, one_of_schema(description: description, **options, &block), required: required, requires: requires)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Declare an optional (nullable) property using +anyOf+ with +null+.
|
|
53
|
+
#
|
|
54
|
+
# @example
|
|
55
|
+
# optional :nickname do
|
|
56
|
+
# string
|
|
57
|
+
# end
|
|
58
|
+
# # Produces: anyOf: [{ type: "string" }, { type: "null" }]
|
|
59
|
+
#
|
|
60
|
+
# @param name [Symbol] Property name
|
|
61
|
+
# @param description [String, nil] Property description
|
|
62
|
+
# @param block [Proc] Block defining the non-null type
|
|
63
|
+
def optional(name, description: nil, &block)
|
|
64
|
+
any_of(name, description: description) do
|
|
65
|
+
instance_eval(&block)
|
|
66
|
+
null
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
class Schema
|
|
5
|
+
module DSL
|
|
6
|
+
# Conditional schema features: +if/then/else+, +dependentRequired+,
|
|
7
|
+
# and +dependentSchemas+ for JSON Schema conditional validation.
|
|
8
|
+
module Conditionals
|
|
9
|
+
# Collection of conditionals (if/then/else) defined on this schema.
|
|
10
|
+
# @return [Array<Hash>] The conditions
|
|
11
|
+
def conditions
|
|
12
|
+
@conditions ||= []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Collection of dependencies (dependentRequired/dependentSchemas) defined.
|
|
16
|
+
# @return [Hash{String => ConditionalBuilder}] The dependencies
|
|
17
|
+
def dependencies
|
|
18
|
+
@dependencies ||= {}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Declare that a property has dependencies on other properties.
|
|
22
|
+
#
|
|
23
|
+
# @example
|
|
24
|
+
# dependent :shipping_address do
|
|
25
|
+
# requires :name, :street, :city
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# @param property [Symbol] The property that has dependencies
|
|
29
|
+
# @param block [Proc] Block declaring requirements via +requires+ and +validates+
|
|
30
|
+
def dependent(property, &block)
|
|
31
|
+
builder = ConditionalBuilder.new
|
|
32
|
+
builder.instance_eval(&block)
|
|
33
|
+
|
|
34
|
+
dependencies[property.to_s] = builder
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Declare a conditional (if/then/else) constraint.
|
|
38
|
+
#
|
|
39
|
+
# Values are automatically coerced: scalars become +const+, arrays become
|
|
40
|
+
# +enum+, and Regexps become +pattern+.
|
|
41
|
+
#
|
|
42
|
+
# @example
|
|
43
|
+
# given(age: 18) do
|
|
44
|
+
# requires :license_number
|
|
45
|
+
# otherwise do
|
|
46
|
+
# requires :guardian_name
|
|
47
|
+
# end
|
|
48
|
+
# end
|
|
49
|
+
#
|
|
50
|
+
# @param properties [Hash{Symbol => Object}] Property conditions
|
|
51
|
+
# @param block [Proc] Block declaring then/else requirements
|
|
52
|
+
# @raise [ArgumentError] If no conditions are provided
|
|
53
|
+
def given(**properties, &block)
|
|
54
|
+
raise ArgumentError, "given requires at least one property condition" if properties.empty?
|
|
55
|
+
|
|
56
|
+
if_schema = {
|
|
57
|
+
properties: properties.transform_keys(&:to_s).transform_values { |v| coerce_condition(v) },
|
|
58
|
+
required: properties.keys.map(&:to_s)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
then_builder = ConditionalBuilder.new
|
|
62
|
+
else_builder = ConditionalBuilder.new
|
|
63
|
+
|
|
64
|
+
context = ConditionalContext.new(then_builder, else_builder)
|
|
65
|
+
context.instance_eval(&block)
|
|
66
|
+
|
|
67
|
+
condition = {if: if_schema, then: then_builder.to_schema}
|
|
68
|
+
condition[:else] = else_builder.to_schema unless else_builder.empty?
|
|
69
|
+
|
|
70
|
+
conditions << condition
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# Merge any conditions and dependencies from a sub-schema into the schema hash.
|
|
76
|
+
def merge_conditions(schema, schema_class)
|
|
77
|
+
if schema_class.respond_to?(:conditions) && schema_class.conditions.any?
|
|
78
|
+
if schema_class.conditions.length == 1
|
|
79
|
+
schema.merge!(schema_class.conditions.first)
|
|
80
|
+
else
|
|
81
|
+
schema[:allOf] = schema_class.conditions
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
if schema_class.respond_to?(:dependencies) && schema_class.dependencies.any?
|
|
86
|
+
dependent_required = {}
|
|
87
|
+
dependent_schemas = {}
|
|
88
|
+
|
|
89
|
+
schema_class.dependencies.each do |property, builder|
|
|
90
|
+
if builder.validations_empty?
|
|
91
|
+
dependent_required[property] = builder.required_fields
|
|
92
|
+
else
|
|
93
|
+
dependent_schemas[property] = builder.to_schema
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
schema[:dependentRequired] = dependent_required if dependent_required.any?
|
|
98
|
+
schema[:dependentSchemas] = dependent_schemas if dependent_schemas.any?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
schema
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Coerce a Ruby value into a JSON Schema condition.
|
|
105
|
+
# @param value [Object] The condition value
|
|
106
|
+
# @return [Hash] JSON Schema condition fragment
|
|
107
|
+
def coerce_condition(value)
|
|
108
|
+
case value
|
|
109
|
+
when Array then {enum: value}
|
|
110
|
+
when Regexp then {pattern: value.source}
|
|
111
|
+
when Hash then value
|
|
112
|
+
else {const: value}
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Execution context for +given+ blocks, providing +requires+, +validates+,
|
|
118
|
+
# and +otherwise+ DSL methods.
|
|
119
|
+
class ConditionalContext
|
|
120
|
+
# @param then_builder [ConditionalBuilder] Builder for the "then" clause
|
|
121
|
+
# @param else_builder [ConditionalBuilder] Builder for the "else" clause
|
|
122
|
+
def initialize(then_builder, else_builder)
|
|
123
|
+
@then_builder = then_builder
|
|
124
|
+
@else_builder = else_builder
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Mark fields as required when the condition is met.
|
|
128
|
+
# @param fields [Array<Symbol>] Field names to require
|
|
129
|
+
def requires(*fields)
|
|
130
|
+
@then_builder.requires(*fields)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Add validation constraints for a field.
|
|
134
|
+
# @param field [Symbol] Field name
|
|
135
|
+
# @param options [Hash] Validation constraints
|
|
136
|
+
# @option options [Symbol] :type Expected JSON type
|
|
137
|
+
# @option options [Object] :const Expected constant value
|
|
138
|
+
# @option options [Array] :enum Allowed values
|
|
139
|
+
# @option options [Object] :not_value Disallowed value (maps to +not+)
|
|
140
|
+
# @option options [Integer] :min_length Minimum string length
|
|
141
|
+
# @option options [Integer] :max_length Maximum string length
|
|
142
|
+
# @option options [String, Regexp] :pattern Regex pattern
|
|
143
|
+
# @option options [Numeric] :minimum Minimum number
|
|
144
|
+
# @option options [Numeric] :maximum Maximum number
|
|
145
|
+
def validates(field, **options)
|
|
146
|
+
@then_builder.validates(field, **options)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Define the "else" clause for when the condition is not met.
|
|
150
|
+
# @param block [Proc] Block declaring requirements
|
|
151
|
+
def otherwise(&block)
|
|
152
|
+
@else_builder.instance_eval(&block)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Builder for collecting requirements and validations within a conditional clause.
|
|
157
|
+
class ConditionalBuilder
|
|
158
|
+
# Mark fields as required.
|
|
159
|
+
# @param fields [Array<Symbol, String>] Field names
|
|
160
|
+
def requires(*fields)
|
|
161
|
+
required.concat(fields.map(&:to_s))
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Map of validated option names to JSON Schema key names.
|
|
165
|
+
VALIDATES_KEY_MAP = {
|
|
166
|
+
type: :type,
|
|
167
|
+
const: :const,
|
|
168
|
+
enum: :enum,
|
|
169
|
+
not_value: :not,
|
|
170
|
+
min_length: :minLength,
|
|
171
|
+
max_length: :maxLength,
|
|
172
|
+
pattern: :pattern,
|
|
173
|
+
minimum: :minimum,
|
|
174
|
+
maximum: :maximum
|
|
175
|
+
}.freeze
|
|
176
|
+
|
|
177
|
+
# Add validation constraints for a field.
|
|
178
|
+
#
|
|
179
|
+
# @param field [Symbol] Field name
|
|
180
|
+
# @param options [Hash] Validation constraints (see {ConditionalContext#validates})
|
|
181
|
+
# @raise [ArgumentError] If an unknown option is provided
|
|
182
|
+
def validates(field, **options)
|
|
183
|
+
constraints = {}
|
|
184
|
+
|
|
185
|
+
options.each do |key, value|
|
|
186
|
+
schema_key = VALIDATES_KEY_MAP[key]
|
|
187
|
+
raise ArgumentError, "unknown validates option: #{key.inspect}" unless schema_key
|
|
188
|
+
|
|
189
|
+
case key
|
|
190
|
+
when :type then constraints[:type] = value.to_s
|
|
191
|
+
when :not_value then constraints[:not] = {const: value}
|
|
192
|
+
when :pattern then constraints[:pattern] = value.is_a?(Regexp) ? value.source : value
|
|
193
|
+
else constraints[schema_key] = value
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
validations[field.to_s] = constraints
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Convert to a JSON Schema fragment.
|
|
201
|
+
# @return [Hash] Schema fragment with +required+ and +properties+
|
|
202
|
+
def to_schema
|
|
203
|
+
schema = {}
|
|
204
|
+
|
|
205
|
+
schema[:required] = required if required.any?
|
|
206
|
+
schema[:properties] = validations if validations.any?
|
|
207
|
+
|
|
208
|
+
schema
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Check if the builder has no requirements or validations.
|
|
212
|
+
# @return [Boolean]
|
|
213
|
+
def empty?
|
|
214
|
+
required.empty? && validations.empty?
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Get the required field names (duped).
|
|
218
|
+
# @return [Array<String>]
|
|
219
|
+
def required_fields
|
|
220
|
+
required.dup
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Check if no validations have been defined.
|
|
224
|
+
# @return [Boolean]
|
|
225
|
+
def validations_empty?
|
|
226
|
+
validations.empty?
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
private
|
|
230
|
+
|
|
231
|
+
# @return [Array<String>] Accumulated required fields
|
|
232
|
+
def required
|
|
233
|
+
@required ||= []
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# @return [Hash{String => Hash}] Accumulated field validations
|
|
237
|
+
def validations
|
|
238
|
+
@validations ||= {}
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|