dynamicschema 1.0.0.beta01
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +548 -0
- data/dynamicschema.gemspec +39 -0
- data/lib/dynamic_schema/builder.rb +38 -0
- data/lib/dynamic_schema/builder_methods/conversion.rb +54 -0
- data/lib/dynamic_schema/builder_methods/validation.rb +109 -0
- data/lib/dynamic_schema/definition.rb +23 -0
- data/lib/dynamic_schema/errors.rb +50 -0
- data/lib/dynamic_schema/receiver.rb +222 -0
- data/lib/dynamic_schema/resolver.rb +126 -0
- data/lib/dynamic_schema.rb +9 -0
- metadata +92 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 32cb8398626d7ac822a228c89778e566ef62182b054ab267b8edf45bc718eda3
|
4
|
+
data.tar.gz: 3475b1a090f57e4702076677b426300c892d91a06ac55841cd6e48657f69d7aa
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: a39f196fc0f7e3a657a6e776163ac5d1d90d42ff8cba556b99691074dead341d42bc9e8230cfab4ece6fd81617540a09462278ce4d5fbca97eadec218ee09902
|
7
|
+
data.tar.gz: bd2c0f880a7c088ccecd5df2574acf32e6242dacac8b730d44141d9b95f334828c0b39c860a24f7abd7e815eaf82c97499119c95664f565b557f80688ad955ac
|
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2024 Endless International
|
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,548 @@
|
|
1
|
+
# DynamicSchema
|
2
|
+
|
3
|
+
The **DynamicSchema** gem provides a elegant and expressive way to define a domain-specific
|
4
|
+
language (DSL) schemas, making it effortless to build and validate complex Ruby `Hash`
|
5
|
+
constructs.
|
6
|
+
|
7
|
+
This is particularly useful when dealing with intricate configuration or interfacing with
|
8
|
+
external APIs, where data structures need to adhere to specific formats and validations.
|
9
|
+
By allowing default values, type constraints, nested schemas, and transformations,
|
10
|
+
DynamicSchema ensures that your data structures are both robust and flexible.
|
11
|
+
|
12
|
+
You can trivially define a custom schema:
|
13
|
+
|
14
|
+
```ruby
|
15
|
+
openai_request_schema = DynamicSchema.define do
|
16
|
+
model String, default: 'gpt-4o'
|
17
|
+
max_tokens Integer, default: 1024
|
18
|
+
temperature Float, in: 0..1
|
19
|
+
|
20
|
+
message arguments: [ :role ], as: :messages, array: true do
|
21
|
+
role Symbol, in: [ :system, :user, :assistant ]
|
22
|
+
content array: true do
|
23
|
+
type Symbol, default: :text
|
24
|
+
text String
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
```
|
29
|
+
|
30
|
+
And then repetedly use that schema to elegantly build a schema conformant `Hash`:
|
31
|
+
```ruby
|
32
|
+
request = openai_request_schema.build {
|
33
|
+
message :system do
|
34
|
+
content text: "You are a helpful assistant that talks like a pirate."
|
35
|
+
end
|
36
|
+
message :user do
|
37
|
+
content text: ARGV[0] || "say hello!"
|
38
|
+
end
|
39
|
+
}
|
40
|
+
```
|
41
|
+
|
42
|
+
You can find a full OpenAI request example in the `/examples` folder of this repository.
|
43
|
+
|
44
|
+
---
|
45
|
+
|
46
|
+
## Table of Contents
|
47
|
+
|
48
|
+
- [Installation](#installation)
|
49
|
+
- [Usage](#usage)
|
50
|
+
- [Values](#values)
|
51
|
+
- [Objects](#objects)
|
52
|
+
- [Types](#types)
|
53
|
+
- [Options](#options)
|
54
|
+
- [default Option](#default-option)
|
55
|
+
- [required Option](#required-option)
|
56
|
+
- [array Option](#array-option)
|
57
|
+
- [as Option](#as-option)
|
58
|
+
- [in Option (Values Only)](#in-option)
|
59
|
+
- [arguments Option](#arguments-option)
|
60
|
+
- [Validation Methods](#validation-methods)
|
61
|
+
- [Validation Rules](#validation-rules)
|
62
|
+
- [validate!](#validate)
|
63
|
+
- [validate](#validate-1)
|
64
|
+
- [valid?](#valid)
|
65
|
+
- [Error Types](#error-types)
|
66
|
+
- [Contributing](#contributing)
|
67
|
+
- [License](#license)
|
68
|
+
|
69
|
+
---
|
70
|
+
|
71
|
+
## Installation
|
72
|
+
|
73
|
+
Add this line to your application's Gemfile:
|
74
|
+
|
75
|
+
```ruby
|
76
|
+
gem 'dynamicschema'
|
77
|
+
```
|
78
|
+
|
79
|
+
And then execute:
|
80
|
+
|
81
|
+
```bash
|
82
|
+
bundle install
|
83
|
+
```
|
84
|
+
|
85
|
+
Or install it yourself as:
|
86
|
+
|
87
|
+
```bash
|
88
|
+
gem install dynamicschema
|
89
|
+
```
|
90
|
+
|
91
|
+
## Usage
|
92
|
+
|
93
|
+
### Requiring the Gem
|
94
|
+
|
95
|
+
To start using the `dynamic_schema` gem, simply require it in your Ruby file:
|
96
|
+
|
97
|
+
```ruby
|
98
|
+
require 'dynamic_schema'
|
99
|
+
```
|
100
|
+
|
101
|
+
### Defining Schemas with **DynamicSchema**
|
102
|
+
|
103
|
+
DynamicSchema permits the caller to define a domain specific language (DSL) schema with *values*,
|
104
|
+
*objects* and related *options*. You can use the `DynamicSchema.define` convenience method, or
|
105
|
+
instantiate `DynamicSchema::Builder`, then call it's `define` method, to prepare a builder.
|
106
|
+
|
107
|
+
In all cases the `define` methods require a block where the names of schema components as well as
|
108
|
+
their options are specified.
|
109
|
+
|
110
|
+
Once a schema is defined you may repeatedly use the `Builder` instance to 'build' a Hash of values
|
111
|
+
using the DSL you've defined. The builder has a 'build' method which will construct a Hash without
|
112
|
+
validating the values. If you've specified that a value should be of a specific type and an
|
113
|
+
incompatible type was given that type will be in the Hash with no indication of that violation.
|
114
|
+
Alterativelly, you can call the `build!` method which will validate the Hash, raising an exception
|
115
|
+
if any of the schema criteria is violated.
|
116
|
+
|
117
|
+
Finally, you can use a builder to validate a given Hash against the schema you've defined using
|
118
|
+
the `validate`, `validate!` and `valid?` `Builder` instance methods.
|
119
|
+
|
120
|
+
---
|
121
|
+
|
122
|
+
## Values
|
123
|
+
|
124
|
+
A *value* is the basic building blocks of your schema. Values represent individual settings,
|
125
|
+
options or API paramters that you can define with specific types, defaults, and other options.
|
126
|
+
|
127
|
+
When defining a value, you provide the name as though you were calling a Ruby method, with
|
128
|
+
arguments that include an optional type (which can be a `Class`, `Module` or an `Array` of these )
|
129
|
+
as well as a `Hash` of options, all of which are optional:
|
130
|
+
|
131
|
+
`name {type} default: {true|false}, required: {true|false}, array: {true|false}, as: {name}, in: {Array|Range}`
|
132
|
+
|
133
|
+
#### example:
|
134
|
+
|
135
|
+
```ruby
|
136
|
+
require 'dynamic_schema'
|
137
|
+
|
138
|
+
# define a schema structure with values
|
139
|
+
schema = DynamicSchema::Builder.new.define do
|
140
|
+
api_key
|
141
|
+
version, String, default: '1.0'
|
142
|
+
end
|
143
|
+
|
144
|
+
# build the schema and set values
|
145
|
+
result = schema.build! do
|
146
|
+
api_key 'your-api-key'
|
147
|
+
end
|
148
|
+
|
149
|
+
# access the schema values
|
150
|
+
puts result[:api_key] # => "your-api-key"
|
151
|
+
puts result[:version] # => "1.0"
|
152
|
+
```
|
153
|
+
|
154
|
+
- defining
|
155
|
+
- `api_key` defines a value named `api_key`. Any type can be used to assign the value.
|
156
|
+
- `version, String, default: '1.0'` defines a value with a default.
|
157
|
+
- building
|
158
|
+
- `schema.build!` build accepts both a Hash and a block where you can set the values.
|
159
|
+
- Inside the block, `api_key 'your-api-key'` sets the value of `api_key`.
|
160
|
+
- accessing
|
161
|
+
- `result[:api_key]` retrieves the value of `api_key`.
|
162
|
+
- If a value has a default and you don't set it, the default value will be included in
|
163
|
+
resulting hash.
|
164
|
+
|
165
|
+
---
|
166
|
+
|
167
|
+
## Objects
|
168
|
+
|
169
|
+
A schema may be organized hierarchically, by creating collections of related values and
|
170
|
+
even other collections. These collections are called objects.
|
171
|
+
|
172
|
+
An *object* is defined in a similar manner to a value. Simply provide the name as though
|
173
|
+
calling a Ruby method, with a Hash of options and a block which encloses the child values
|
174
|
+
and objects:
|
175
|
+
|
176
|
+
```
|
177
|
+
name arguments: [ {argument} ], default: {true|false}, required: {true|false}, array: {true|false}, as: {name} do
|
178
|
+
# child values and objects can be defined here
|
179
|
+
end
|
180
|
+
```
|
181
|
+
|
182
|
+
Notice an *object* does not accept a type as it is always of type `Object`.
|
183
|
+
|
184
|
+
#### example:
|
185
|
+
|
186
|
+
```ruby
|
187
|
+
require 'dynamic_schema'
|
188
|
+
|
189
|
+
schema = DynamicSchema::Builder.new do
|
190
|
+
api_key, String
|
191
|
+
chat_options do
|
192
|
+
model String, default: 'claude-3'
|
193
|
+
max_tokens Integer, default: 1024
|
194
|
+
temperature, Float, default: 0.5, in: 0..1
|
195
|
+
stream [ TrueClass, FalseClass ]
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
result = schema.build! do
|
200
|
+
api_key 'your-api-key'
|
201
|
+
chat_options do
|
202
|
+
temperature 0.8
|
203
|
+
stream true
|
204
|
+
end
|
205
|
+
end
|
206
|
+
|
207
|
+
# Accessing values
|
208
|
+
puts result[:api_key] # => "your-api-key"
|
209
|
+
puts result[:chat_options][:model] # => "claude-3"
|
210
|
+
puts result[:chat_options][:temperature] # => 0.8
|
211
|
+
puts result[:chat_options][:stream] # => true
|
212
|
+
```
|
213
|
+
|
214
|
+
- defining
|
215
|
+
- `chat_options do ... end` defines an object named `chat_options`.
|
216
|
+
- Inside the object you can define values that belong to that object.
|
217
|
+
- building
|
218
|
+
- In the build block, you can set values for values within objects by nesting blocks.
|
219
|
+
- `chat_options do ... end` allows you to set values inside the `chat_options` object.
|
220
|
+
- accessing
|
221
|
+
- You access values by chaining the keys: `result[:chat_options][:model]`.
|
222
|
+
|
223
|
+
---
|
224
|
+
|
225
|
+
## Types
|
226
|
+
|
227
|
+
An *object* is always of type `Object`. A *value* can have no type or it can be of one or
|
228
|
+
more types. You specify the value type by providing an instance of a `Class` when defining
|
229
|
+
the value. If you want to specify multiple types simply provide an array of types.
|
230
|
+
|
231
|
+
#### example:
|
232
|
+
|
233
|
+
```ruby
|
234
|
+
require 'dynamic_schema'
|
235
|
+
|
236
|
+
schema = DynamicSchema::Builder.new do
|
237
|
+
typeless_value
|
238
|
+
symbol_value Symbol
|
239
|
+
boolean_value [ TrueClass, FalseClass ]
|
240
|
+
end
|
241
|
+
|
242
|
+
result = schema.build! do
|
243
|
+
typeless_value Struct.new(:name).new(name: 'Kristoph')
|
244
|
+
symbol_value "something"
|
245
|
+
boolean_value true
|
246
|
+
end
|
247
|
+
|
248
|
+
puts result[:typeless_value].name # => "Kristoph"
|
249
|
+
puts result[:symbol_value] # => :something
|
250
|
+
puts result[:boolean_value] # => true
|
251
|
+
```
|
252
|
+
|
253
|
+
- defining
|
254
|
+
- `typeless_value` defines a value that has no type and will accept an assignment of any type
|
255
|
+
- `symbol_value` defines a value that accepts symbols or types that can be coerced into
|
256
|
+
symbols, such as strings (see **Type Coercion**)
|
257
|
+
- `boolean_value` defines a value that can be either `true` or `false`
|
258
|
+
|
259
|
+
## Options
|
260
|
+
|
261
|
+
Both *values* and *objects* can be customized through *options*. The options for both values and
|
262
|
+
objects include `default`, `required`, `as` and `array`. In addition values support the `in`
|
263
|
+
criteria option while objects support the `arguments` option.
|
264
|
+
|
265
|
+
### :default Option
|
266
|
+
|
267
|
+
The `:default` option allows you to specify a default value that will be used if no value is
|
268
|
+
provided during build.
|
269
|
+
|
270
|
+
#### example:
|
271
|
+
|
272
|
+
```ruby
|
273
|
+
schema = DynamicSchema.define do
|
274
|
+
api_version String, default: 'v1'
|
275
|
+
timeout Integer, default: 30
|
276
|
+
end
|
277
|
+
|
278
|
+
result = schema.build!
|
279
|
+
puts result[:api_version] # => "v1"
|
280
|
+
puts result[:timeout] # => 30
|
281
|
+
```
|
282
|
+
|
283
|
+
### :required Option
|
284
|
+
|
285
|
+
The `:required` option ensures that a value must be provided when building the schema. If a
|
286
|
+
required value is missing when using `build!`, `validate`, or `validate!`,
|
287
|
+
a `DynamicSchema::RequiredOptionError` will be raised.
|
288
|
+
|
289
|
+
#### example:
|
290
|
+
|
291
|
+
```ruby
|
292
|
+
schema = DynamicSchema.define do
|
293
|
+
api_key String, required: true
|
294
|
+
timeout Integer, default: 30
|
295
|
+
end
|
296
|
+
|
297
|
+
# This will raise DynamicSchema::RequiredOptionError
|
298
|
+
result = schema.build!
|
299
|
+
|
300
|
+
# This is valid
|
301
|
+
result = schema.build! do
|
302
|
+
api_key 'my-secret-key'
|
303
|
+
end
|
304
|
+
```
|
305
|
+
|
306
|
+
### :array Option
|
307
|
+
|
308
|
+
The `:array` option wraps the value or object in an array in the resulting Hash, even if only
|
309
|
+
one value is provided. This is particularly useful when dealing with APIs that expect array
|
310
|
+
inputs.
|
311
|
+
|
312
|
+
#### example:
|
313
|
+
|
314
|
+
```ruby
|
315
|
+
schema = DynamicSchema.define do
|
316
|
+
tags String, array: true
|
317
|
+
message array: true do
|
318
|
+
text String
|
319
|
+
type String, default: 'plain'
|
320
|
+
end
|
321
|
+
end
|
322
|
+
|
323
|
+
result = schema.build! do
|
324
|
+
tags 'important'
|
325
|
+
message do
|
326
|
+
text 'Hello world'
|
327
|
+
end
|
328
|
+
end
|
329
|
+
|
330
|
+
puts result[:tags] # => ["important"]
|
331
|
+
puts result[:message] # => [{ text: "Hello world", type: "plain" }]
|
332
|
+
```
|
333
|
+
|
334
|
+
### :as Option
|
335
|
+
|
336
|
+
The `:as` option allows you to use a different name in the DSL than what appears in the final
|
337
|
+
Hash. This is particularly useful when interfacing with APIs that have specific key
|
338
|
+
requirements.
|
339
|
+
|
340
|
+
#### example:
|
341
|
+
|
342
|
+
```ruby
|
343
|
+
schema = DynamicSchema.define do
|
344
|
+
content_type String, as: "Content-Type", default: "application/json"
|
345
|
+
api_key String, as: "Authorization"
|
346
|
+
end
|
347
|
+
|
348
|
+
result = schema.build! do
|
349
|
+
api_key 'Bearer abc123'
|
350
|
+
end
|
351
|
+
|
352
|
+
puts result["Content-Type"] # => "application/json"
|
353
|
+
puts result["Authorization"] # => "Bearer abc123"
|
354
|
+
```
|
355
|
+
|
356
|
+
### :in Option
|
357
|
+
|
358
|
+
The `:in` option provides validation for values, ensuring they fall within a specified Range or
|
359
|
+
are included in an Array of allowed values. This option is only available for values.
|
360
|
+
|
361
|
+
#### example:
|
362
|
+
|
363
|
+
```ruby
|
364
|
+
schema = DynamicSchema.define do
|
365
|
+
temperature Float, in: 0..1
|
366
|
+
status String, in: ['pending', 'processing', 'completed']
|
367
|
+
end
|
368
|
+
|
369
|
+
# Valid
|
370
|
+
result = schema.build! do
|
371
|
+
temperature 0.7
|
372
|
+
status 'pending'
|
373
|
+
end
|
374
|
+
|
375
|
+
# Will raise validation error - temperature out of range
|
376
|
+
result = schema.build! do
|
377
|
+
temperature 1.5
|
378
|
+
status 'pending'
|
379
|
+
end
|
380
|
+
|
381
|
+
# Will raise validation error - invalid status
|
382
|
+
result = schema.build! do
|
383
|
+
temperature 0.7
|
384
|
+
status 'invalid'
|
385
|
+
end
|
386
|
+
```
|
387
|
+
|
388
|
+
### :arguments Option
|
389
|
+
|
390
|
+
The `:arguments` option allows objects to accept arguments when building. Any arguments provided
|
391
|
+
must appear when the object is built ( and so are implicitly 'required' ).
|
392
|
+
|
393
|
+
If the an argument is provided, the same argument appears in the attributes hash, or in the object
|
394
|
+
block, the assignemnt in the block will take priority, followed by the attributes assigned and
|
395
|
+
finally the argument.
|
396
|
+
|
397
|
+
#### example:
|
398
|
+
|
399
|
+
```ruby
|
400
|
+
schema = DynamicSchema.define do
|
401
|
+
message arguments: [ :role ], as: :messages, array: true do
|
402
|
+
role Symbol, required: true, in: [ :system, :user, :assistant ]
|
403
|
+
content String
|
404
|
+
end
|
405
|
+
end
|
406
|
+
|
407
|
+
result = schema.build! do
|
408
|
+
message :system do
|
409
|
+
content "You are a helpful assistant."
|
410
|
+
end
|
411
|
+
message :user do
|
412
|
+
content "Hello!"
|
413
|
+
end
|
414
|
+
end
|
415
|
+
```
|
416
|
+
|
417
|
+
## Validation
|
418
|
+
|
419
|
+
DynamicSchema provides three different methods for validating Hash structures against your
|
420
|
+
defined schema: `validate!`, `validate`, and `valid?`.
|
421
|
+
|
422
|
+
These methods allow you to verify that your data conforms to your schema's requirements,
|
423
|
+
including type constraints, required fields, and value ranges.
|
424
|
+
|
425
|
+
### Validation Rules
|
426
|
+
|
427
|
+
When validating, DynamicSchema checks:
|
428
|
+
|
429
|
+
1. **Required Fields**:
|
430
|
+
Any value or object marked as `required: true` are present.
|
431
|
+
2. **Type Constraints**:
|
432
|
+
Any values match their specified types or can be coerced to the specified type.
|
433
|
+
3. **Value Ranges**:
|
434
|
+
Any values fall within their specified `:in` constraints.
|
435
|
+
4. **Objects**:
|
436
|
+
Any objects are recursively validates.
|
437
|
+
5. **Arrays**:
|
438
|
+
Any validation rules are applied to each element when `array: true`
|
439
|
+
|
440
|
+
### validate!
|
441
|
+
|
442
|
+
The `validate!` method performs strict validation and raises an exception when it encounters
|
443
|
+
the first validation error.
|
444
|
+
|
445
|
+
#### example:
|
446
|
+
|
447
|
+
```ruby
|
448
|
+
schema = DynamicSchema.define do
|
449
|
+
api_key String, required: true
|
450
|
+
temperature Float, in: 0..1
|
451
|
+
end
|
452
|
+
|
453
|
+
# this will raise DynamicSchema::RequiredOptionError
|
454
|
+
schema.validate!( { temperature: 0.5 } )
|
455
|
+
|
456
|
+
# this will raise DynamicSchema::IncompatibleTypeError
|
457
|
+
schema.validate!( {
|
458
|
+
api_key: ["not-a-string"],
|
459
|
+
temperature: 0.5
|
460
|
+
} )
|
461
|
+
|
462
|
+
# this will raise DynamicSchema::InOptionError
|
463
|
+
schema.validate!( {
|
464
|
+
api_key: "abc123",
|
465
|
+
temperature: 1.5
|
466
|
+
} )
|
467
|
+
|
468
|
+
# this is valid and will not raise any errors
|
469
|
+
schema.validate!( {
|
470
|
+
api_key: 123,
|
471
|
+
temperature: 0.5
|
472
|
+
} )
|
473
|
+
```
|
474
|
+
|
475
|
+
### validate
|
476
|
+
|
477
|
+
The `validate` method performs validation but instead of raising exceptions, it collects and
|
478
|
+
returns an array of all validation errors encountered.
|
479
|
+
|
480
|
+
#### example:
|
481
|
+
|
482
|
+
```ruby
|
483
|
+
schema = DynamicSchema.define do
|
484
|
+
api_key String, required: true
|
485
|
+
model String, in: ['gpt-3.5-turbo', 'gpt-4']
|
486
|
+
temperature Float, in: 0..1
|
487
|
+
end
|
488
|
+
|
489
|
+
errors = schema.validate({
|
490
|
+
model: 'invalid-model',
|
491
|
+
temperature: 1.5,
|
492
|
+
api_key: ["invalid-type"] # Array cannot be coerced to String
|
493
|
+
})
|
494
|
+
|
495
|
+
# errors will contain:
|
496
|
+
# - IncompatibleTypeError for api_key being an Array
|
497
|
+
# - InOptionError for invalid model
|
498
|
+
# - InOptionError for temperature out of range
|
499
|
+
```
|
500
|
+
|
501
|
+
### valid?
|
502
|
+
|
503
|
+
The `valid?` method provides a simple boolean check of whether a Hash conforms to the schema.
|
504
|
+
|
505
|
+
#### example:
|
506
|
+
|
507
|
+
```ruby
|
508
|
+
schema = DynamicSchema.define do
|
509
|
+
name String, required: true
|
510
|
+
age Integer, in: 0..120
|
511
|
+
id String # Will accept both strings and numbers due to coercion
|
512
|
+
end
|
513
|
+
|
514
|
+
# Returns false
|
515
|
+
schema.valid?({
|
516
|
+
name: ["Not a string"], # Array cannot be coerced to String
|
517
|
+
age: 150 # Outside allowed range
|
518
|
+
})
|
519
|
+
|
520
|
+
# Returns true
|
521
|
+
schema.valid?({
|
522
|
+
name: "John",
|
523
|
+
age: 30,
|
524
|
+
id: 12345 # Numeric value can be coerced to String
|
525
|
+
})
|
526
|
+
```
|
527
|
+
|
528
|
+
### Error Types
|
529
|
+
|
530
|
+
DynamicSchema provides specific error types for different validation failures:
|
531
|
+
|
532
|
+
- `DynamicSchema::RequiredOptionError`: Raised when a required field is missing
|
533
|
+
- `DynamicSchema::IncompatibleTypeError`: Raised when a value's type doesn't match the schema
|
534
|
+
and cannot be coerced
|
535
|
+
- `DynamicSchema::InOptionError`: Raised when a value falls outside its specified range/set
|
536
|
+
- `ArgumentError`: Raised when the provided values structure isn't a Hash
|
537
|
+
|
538
|
+
Each error includes helpful context about the validation failure, including the path to the failing field and the specific constraint that wasn't met.
|
539
|
+
|
540
|
+
---
|
541
|
+
|
542
|
+
## Contributing
|
543
|
+
|
544
|
+
Bug reports and pull requests are welcome on GitHub at [https://github.com/EndlessInternational/adaptive-schema](https://github.com/EndlessInternational/dynamic-schema).
|
545
|
+
|
546
|
+
## License
|
547
|
+
|
548
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
@@ -0,0 +1,39 @@
|
|
1
|
+
Gem::Specification.new do | spec |
|
2
|
+
|
3
|
+
spec.name = 'dynamicschema'
|
4
|
+
spec.version = '1.0.0.beta01'
|
5
|
+
spec.authors = [ 'Kristoph Cichocki-Romanov' ]
|
6
|
+
spec.email = [ 'rubygems.org@kristoph.net' ]
|
7
|
+
|
8
|
+
spec.summary = <<~TEXT.gsub( /(?<!\n)\n(?!\n)/, ' ').strip
|
9
|
+
DynamicSchema is a lightweight and simple yet powerful gem that enables flexible semantic
|
10
|
+
schema definitions for constructing and validating complex configurations and other similar
|
11
|
+
payloads.
|
12
|
+
TEXT
|
13
|
+
spec.description = <<~TEXT.gsub( /(?<!\n)\n(?!\n)/, ' ').strip
|
14
|
+
The DynamicSchema gem provides a elegant and expressive way to define a domain-specific
|
15
|
+
language (DSL) schemas, making it effortless to build and validate complex Ruby hashes.
|
16
|
+
|
17
|
+
This is particularly useful when dealing with intricate configurations or
|
18
|
+
interfacing with external APIs, where data structures need to adhere to specific formats
|
19
|
+
and validations. By allowing default values, type constraints, nested schemas, and
|
20
|
+
transformations, DynamicSchema ensures that your data structures are both robust and
|
21
|
+
flexible.
|
22
|
+
TEXT
|
23
|
+
|
24
|
+
spec.license = 'MIT'
|
25
|
+
spec.homepage = 'https://github.com/EndlessInternational/dynamic_schema'
|
26
|
+
spec.metadata = {
|
27
|
+
'source_code_uri' => 'https://github.com/EndlessInternational/dynamic_schema',
|
28
|
+
'bug_tracker_uri' => 'https://github.com/EndlessInternational/dynamic_schema/issues',
|
29
|
+
# 'documentation_uri' => 'https://github.com/EndlessInternational/dynamic_schema'
|
30
|
+
}
|
31
|
+
|
32
|
+
spec.required_ruby_version = '>= 3.0'
|
33
|
+
spec.files = Dir[ "lib/**/*.rb", "LICENSE", "README.md", "dynamicschema.gemspec" ]
|
34
|
+
spec.require_paths = [ "lib" ]
|
35
|
+
|
36
|
+
spec.add_development_dependency 'rspec', '~> 3.13'
|
37
|
+
spec.add_development_dependency 'debug', '~> 1.9'
|
38
|
+
|
39
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
require_relative 'builder_methods/conversion'
|
2
|
+
require_relative 'builder_methods/validation'
|
3
|
+
require_relative 'resolver'
|
4
|
+
require_relative 'receiver'
|
5
|
+
|
6
|
+
module DynamicSchema
|
7
|
+
class Builder
|
8
|
+
|
9
|
+
include BuilderMethods::Validation
|
10
|
+
include BuilderMethods::Conversion
|
11
|
+
|
12
|
+
def initialize( schema = nil )
|
13
|
+
self.schema = schema
|
14
|
+
super()
|
15
|
+
end
|
16
|
+
|
17
|
+
def define( &block )
|
18
|
+
self.schema = Resolver.new( self.schema ).resolve( &block ).schema
|
19
|
+
self
|
20
|
+
end
|
21
|
+
|
22
|
+
def build( values = nil, &block )
|
23
|
+
receiver = Receiver.new( values, schema: self.schema, converters: self.converters )
|
24
|
+
receiver.instance_eval( &block ) if block
|
25
|
+
receiver.to_h
|
26
|
+
end
|
27
|
+
|
28
|
+
def build!( values = nil, &block )
|
29
|
+
result = self.build( values, &block )
|
30
|
+
validate!( result )
|
31
|
+
result
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
attr_accessor :schema
|
36
|
+
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# types must be included to support coersion
|
2
|
+
require 'time'
|
3
|
+
require 'date'
|
4
|
+
require 'uri'
|
5
|
+
|
6
|
+
module DynamicSchema
|
7
|
+
module BuilderMethods
|
8
|
+
module Conversion
|
9
|
+
|
10
|
+
DEFAULT_CONVERTERS = {
|
11
|
+
|
12
|
+
Array => ->( v ) { Array( v ) },
|
13
|
+
Date => ->( v ) { v.respond_to?( :to_date ) ? v.to_date : Date.parse( v.to_s ) },
|
14
|
+
Time => ->( v ) { v.respond_to?( :to_time ) ? v.to_time : Time.parse( v.to_s ) },
|
15
|
+
URI => ->( v ) { URI.parse( v.to_s ) },
|
16
|
+
String => ->( v ) { String( v ) },
|
17
|
+
Symbol => ->( v ) { v.respond_to?( :to_sym ) ? v.to_sym : nil },
|
18
|
+
Rational => ->( v ) { Rational( v ) },
|
19
|
+
Float => ->( v ) { Float( v ) },
|
20
|
+
Integer => ->( v ) { Integer( v ) },
|
21
|
+
TrueClass => ->( v ) {
|
22
|
+
case v
|
23
|
+
when Numeric
|
24
|
+
v.nonzero? ? true : nil
|
25
|
+
else
|
26
|
+
v.to_s.match( /\A\s*(true|yes)\s*\z/i ) ? true : nil
|
27
|
+
end
|
28
|
+
},
|
29
|
+
FalseClass => ->( v ) {
|
30
|
+
case v
|
31
|
+
when Numeric
|
32
|
+
v.zero? ? false : nil
|
33
|
+
else
|
34
|
+
v.to_s.match( /\A\s*(false|no)\s*\z/i ) ? false : nil
|
35
|
+
end
|
36
|
+
}
|
37
|
+
|
38
|
+
}
|
39
|
+
|
40
|
+
def initialize
|
41
|
+
self.converters = DEFAULT_CONVERTERS.dup
|
42
|
+
end
|
43
|
+
|
44
|
+
def convertor( klass, &block )
|
45
|
+
self.converters[ klass ] = block
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
attr_accessor :converters
|
51
|
+
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,109 @@
|
|
1
|
+
module DynamicSchema
|
2
|
+
module BuilderMethods
|
3
|
+
module Validation
|
4
|
+
|
5
|
+
def validate!( values, schema: self.schema )
|
6
|
+
traverse_and_validate_values( values, schema: schema ) { | error |
|
7
|
+
raise error
|
8
|
+
}
|
9
|
+
end
|
10
|
+
|
11
|
+
def validate( values, schema: self.schema )
|
12
|
+
errors = []
|
13
|
+
traverse_and_validate_values( values, schema: schema ) { | error |
|
14
|
+
errors << error
|
15
|
+
}
|
16
|
+
errors
|
17
|
+
end
|
18
|
+
|
19
|
+
def valid?( values, schema: self.schema )
|
20
|
+
traverse_and_validate_values( values, schema: schema ) {
|
21
|
+
return false
|
22
|
+
}
|
23
|
+
return true
|
24
|
+
end
|
25
|
+
|
26
|
+
protected
|
27
|
+
|
28
|
+
def value_matches_types?( value, types )
|
29
|
+
type_match = false
|
30
|
+
Array( types ).each do | type |
|
31
|
+
type_match = value.is_a?( type )
|
32
|
+
break if type_match
|
33
|
+
end
|
34
|
+
type_match
|
35
|
+
end
|
36
|
+
|
37
|
+
def traverse_and_validate_values( values, schema:, path: nil, options: nil, &block )
|
38
|
+
path.chomp( '/' ) if path
|
39
|
+
unless values.is_a?( Hash )
|
40
|
+
raise ArgumentError, "The values must always be a Hash."
|
41
|
+
end
|
42
|
+
|
43
|
+
schema.each do | key, criteria |
|
44
|
+
|
45
|
+
name = criteria[ :as ] || key
|
46
|
+
value = values[ name ]
|
47
|
+
|
48
|
+
if criteria[ :required ] &&
|
49
|
+
( !value || ( value.respond_to?( :empty ) && value.empty? ) )
|
50
|
+
block.call( RequiredOptionError.new( path: path, key: key ) )
|
51
|
+
elsif criteria[ :in ]
|
52
|
+
Array( value ).each do | v |
|
53
|
+
unless criteria[ :in ].include?( v ) || v.nil?
|
54
|
+
block.call(
|
55
|
+
InOptionError.new( path: path, key: key, option: criteria[ :in ], value: v )
|
56
|
+
)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
elsif !criteria[ :default_assigned ] && !value.nil?
|
60
|
+
unless criteria[ :array ]
|
61
|
+
if criteria[ :type ] == Object
|
62
|
+
traverse_and_validate_values(
|
63
|
+
values[ name ],
|
64
|
+
schema: criteria[ :schema ] ||= criteria[ :resolver ].schema,
|
65
|
+
path: "#{ ( path || '' ) + ( path ? '/' : '' ) + key.to_s }",
|
66
|
+
&block
|
67
|
+
)
|
68
|
+
else
|
69
|
+
if criteria[ :type ] && value && !criteria[ :default_assigned ]
|
70
|
+
unless value_matches_types?( value, criteria[ :type ] )
|
71
|
+
block.call( IncompatibleTypeError.new(
|
72
|
+
path: path, key: key, type: criteria[ :type ], value: value
|
73
|
+
) )
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
else
|
78
|
+
if criteria[ :type ] == Object
|
79
|
+
groups = Array( value )
|
80
|
+
groups.each do | group |
|
81
|
+
traverse_and_validate_values(
|
82
|
+
group,
|
83
|
+
schema: criteria[ :schema ] ||= criteria[ :resolver ].schema,
|
84
|
+
path: "#{ ( path || '' ) + ( path ? '/' : '' ) + key.to_s }",
|
85
|
+
&block
|
86
|
+
)
|
87
|
+
end
|
88
|
+
else
|
89
|
+
if criteria[ :type ] && !criteria[ :default_assigned ]
|
90
|
+
value_array = Array( value )
|
91
|
+
value_array.each do | v |
|
92
|
+
unless value_matches_types?( v, criteria[ :type ] )
|
93
|
+
block.call( IncompatibleTypeError.new(
|
94
|
+
path: path, key: key, type: criteria[ :type ], value: v
|
95
|
+
) )
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
end
|
104
|
+
nil
|
105
|
+
end
|
106
|
+
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
require_relative 'builder'
|
2
|
+
|
3
|
+
module DynamicSchema
|
4
|
+
module Definition
|
5
|
+
|
6
|
+
def schema( schema = nil, &block )
|
7
|
+
return @_schema_builder if ( schema.nil? || schema.empty? ) && !block
|
8
|
+
@_schema_builder = DynamicSchema::Builder.new( schema ).define( &block )
|
9
|
+
end
|
10
|
+
|
11
|
+
def build_with_schema( attributes = nil, &block )
|
12
|
+
raise RuntimeError, "The schema has not been defined." if @_schema_builder.nil?
|
13
|
+
@_schema_builder.build( attributes, &block )
|
14
|
+
end
|
15
|
+
|
16
|
+
def build_with_schema!( attributes = nil, &block )
|
17
|
+
raise RuntimeError, "The schema has not been defined." if @_schema_builder.nil?
|
18
|
+
@_schema_builder.build!( attributes, &block )
|
19
|
+
end
|
20
|
+
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
@@ -0,0 +1,50 @@
|
|
1
|
+
module DynamicSchema
|
2
|
+
|
3
|
+
class Error < StandardError; end
|
4
|
+
|
5
|
+
class IncompatibleTypeError < Error
|
6
|
+
|
7
|
+
attr_reader :keypath
|
8
|
+
attr_reader :key
|
9
|
+
attr_reader :type
|
10
|
+
|
11
|
+
def initialize( path: nil, key:, type:, value: )
|
12
|
+
path = path ? path.to_s.chomp( '/' ) : nil
|
13
|
+
@key = key
|
14
|
+
@keypath = path ? ( path + '/' + @key.to_s ) : @key.to_s
|
15
|
+
@type = type
|
16
|
+
type_text = @type.respond_to?( :join ) ? type.join( ', ' ) : type
|
17
|
+
super( "The attribute '#{@keypath}' expects '#{type_text}' but incompatible '#{value.class.name}' was given." )
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
21
|
+
|
22
|
+
class RequiredOptionError < Error
|
23
|
+
|
24
|
+
attr_reader :keypath
|
25
|
+
attr_reader :key
|
26
|
+
|
27
|
+
def initialize( path: nil, key: )
|
28
|
+
path = path ? path.chomp( '/' ) : nil
|
29
|
+
@key = key
|
30
|
+
@keypath = path ? ( path + '/' + @key.to_s ) : key.to_s
|
31
|
+
super( "The attribute '#{@keypath}' is required but no value was given." )
|
32
|
+
end
|
33
|
+
|
34
|
+
end
|
35
|
+
|
36
|
+
class InOptionError < Error
|
37
|
+
|
38
|
+
attr_reader :keypath
|
39
|
+
attr_reader :key
|
40
|
+
|
41
|
+
def initialize( path: nil, key:, option:, value: )
|
42
|
+
path = path ? path.chomp( '/' ) : nil
|
43
|
+
@key = key
|
44
|
+
@keypath = path ? ( path + '/' + @key.to_s ) : key.to_s
|
45
|
+
super( "The attribute '#{@keypath}' must be in '#{option.to_s}' but '#{value.to_s}' was given." )
|
46
|
+
end
|
47
|
+
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
@@ -0,0 +1,222 @@
|
|
1
|
+
module DynamicSchema
|
2
|
+
class Receiver < BasicObject
|
3
|
+
|
4
|
+
if defined?( ::PP )
|
5
|
+
include ::PP::ObjectMixin
|
6
|
+
def pretty_print( pp )
|
7
|
+
pp.pp( { values: @values, schema: @schema } )
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
def initialize( values = nil, schema:, converters: )
|
12
|
+
raise ArgumentError, 'The Receiver values must be a nil or a Hash.'\
|
13
|
+
unless values.nil? || ( values.respond_to?( :[] ) && values.respond_to?( :key? ) )
|
14
|
+
|
15
|
+
@values = {}
|
16
|
+
@schema = schema
|
17
|
+
@defaults_assigned = {}
|
18
|
+
|
19
|
+
@converters = converters&.dup
|
20
|
+
|
21
|
+
@schema.each do | key, criteria |
|
22
|
+
name = criteria[ :as ] || key
|
23
|
+
if criteria.key?( :default )
|
24
|
+
self.__send__( key, criteria[ :default ] )
|
25
|
+
@defaults_assigned[ key ] = true
|
26
|
+
end
|
27
|
+
self.__send__( key, values[ key ] ) if values && values.key?( key )
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
31
|
+
|
32
|
+
def evaluate( &block )
|
33
|
+
self.instance_eval( &block )
|
34
|
+
self
|
35
|
+
end
|
36
|
+
|
37
|
+
def nil?
|
38
|
+
false
|
39
|
+
end
|
40
|
+
|
41
|
+
def empty?
|
42
|
+
@values.empty?
|
43
|
+
end
|
44
|
+
|
45
|
+
def to_h
|
46
|
+
recursive_to_h = ->( object ) do
|
47
|
+
case object
|
48
|
+
when ::NilClass
|
49
|
+
nil
|
50
|
+
when ::DynamicSchema::Receiver
|
51
|
+
recursive_to_h.call( object.to_h )
|
52
|
+
when ::Hash
|
53
|
+
object.transform_values { | value | recursive_to_h.call( value ) }
|
54
|
+
when ::Array
|
55
|
+
object.map { | element | recursive_to_h.call( element ) }
|
56
|
+
else
|
57
|
+
object.respond_to?( :to_h ) ? object.to_h : object
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
recursive_to_h.call( @values )
|
62
|
+
end
|
63
|
+
|
64
|
+
def to_s
|
65
|
+
inspect
|
66
|
+
end
|
67
|
+
|
68
|
+
def inspect
|
69
|
+
{ values: @values, schema: @schema }.inspect
|
70
|
+
end
|
71
|
+
|
72
|
+
def class
|
73
|
+
::DynamicSchema::Receiver
|
74
|
+
end
|
75
|
+
|
76
|
+
def is_a?( klass )
|
77
|
+
klass == ::DynamicSchema::Receiver || klass == ::BasicObject
|
78
|
+
end
|
79
|
+
|
80
|
+
alias :kind_of? :is_a?
|
81
|
+
|
82
|
+
def method_missing( method, *args, &block )
|
83
|
+
if @schema.key?( method )
|
84
|
+
criteria = @schema[ method ]
|
85
|
+
name = criteria[ :as ] || method
|
86
|
+
value = @values[ name ]
|
87
|
+
|
88
|
+
unless criteria[ :array ]
|
89
|
+
if criteria[ :type ] == ::Object
|
90
|
+
value = __object( method, args, value: value, criteria: criteria, &block )
|
91
|
+
else
|
92
|
+
value = __value( method, args, value: value, criteria: criteria )
|
93
|
+
end
|
94
|
+
else
|
95
|
+
value = @defaults_assigned[ method ] ? ::Array.new : value || ::Array.new
|
96
|
+
if criteria[ :type ] == ::Object
|
97
|
+
value = __object_array( method, args, value: value, criteria: criteria, &block )
|
98
|
+
else
|
99
|
+
value = __values_array( method, args, value: value, criteria: criteria )
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
@defaults_assigned[ method ] = false
|
104
|
+
@values[ name ] = value
|
105
|
+
else
|
106
|
+
super
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def respond_to?( method, include_private = false )
|
111
|
+
@schema.key?( method ) || self.class.instance_methods.include?( method )
|
112
|
+
end
|
113
|
+
|
114
|
+
def respond_to_missing?( method, include_private = false )
|
115
|
+
@schema.key?( method ) || self.class.instance_methods.include?( method )
|
116
|
+
end
|
117
|
+
|
118
|
+
protected
|
119
|
+
|
120
|
+
def __process_arguments( name, arguments, required_arguments: )
|
121
|
+
count = arguments.count
|
122
|
+
required_arguments = [ required_arguments ].flatten if required_arguments
|
123
|
+
required_count = required_arguments&.length || 0
|
124
|
+
::Kernel.raise ::ArgumentError,
|
125
|
+
"The attribute '#{name}' requires #{required_count} arguments " \
|
126
|
+
"(#{required_arguments.join(', ')}) but #{count} was given." \
|
127
|
+
if count < required_count
|
128
|
+
::Kernel.raise ::ArgumentError,
|
129
|
+
"The attribute '#{name}' should have at most #{required_count + 1} arguments but " \
|
130
|
+
"#{count} was given." \
|
131
|
+
if count > required_count + 1
|
132
|
+
result = {}
|
133
|
+
required_arguments&.each_with_index do | name, index |
|
134
|
+
result[ name.to_sym ] = arguments[ index ]
|
135
|
+
end
|
136
|
+
|
137
|
+
last = arguments.last
|
138
|
+
case last
|
139
|
+
when ::Hash
|
140
|
+
result.merge( last )
|
141
|
+
when ::Array
|
142
|
+
last.map { | v | result.merge( v ) }
|
143
|
+
else
|
144
|
+
result
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
def __coerce_value( types, value )
|
149
|
+
return value unless types && !value.nil?
|
150
|
+
|
151
|
+
types = ::Kernel.method( :Array ).call( types )
|
152
|
+
result = nil
|
153
|
+
|
154
|
+
if value.respond_to?( :is_a? )
|
155
|
+
types.each do | type |
|
156
|
+
result = value.is_a?( type ) ? value : nil
|
157
|
+
break unless result.nil?
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
if result.nil?
|
162
|
+
types.each do | type |
|
163
|
+
result = @converters[ type ].call( value ) rescue nil
|
164
|
+
break unless result.nil?
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
result
|
169
|
+
end
|
170
|
+
|
171
|
+
def __value( method, arguments, value:, criteria: )
|
172
|
+
value = arguments.first
|
173
|
+
new_value = criteria[ :type ] ? __coerce_value( criteria[ :type ], value ) : value
|
174
|
+
new_value.nil? ? value : new_value
|
175
|
+
end
|
176
|
+
|
177
|
+
def __values_array( method, arguments, value:, criteria: )
|
178
|
+
values = [ arguments.first ].flatten
|
179
|
+
if type = criteria[ :type ]
|
180
|
+
values = values.map do | v |
|
181
|
+
new_value = __coerce_value( type, v )
|
182
|
+
new_value.nil? ? v : new_value
|
183
|
+
end
|
184
|
+
end
|
185
|
+
value.concat( values )
|
186
|
+
end
|
187
|
+
|
188
|
+
def __object( method, arguments, value:, criteria:, &block )
|
189
|
+
attributes = __process_arguments(
|
190
|
+
method, arguments,
|
191
|
+
required_arguments: criteria[ :arguments ]
|
192
|
+
)
|
193
|
+
if value.nil? || attributes&.any?
|
194
|
+
value =
|
195
|
+
Receiver.new(
|
196
|
+
attributes,
|
197
|
+
converters: @converters,
|
198
|
+
schema: criteria[ :schema ] ||= criteria[ :resolver ].schema
|
199
|
+
)
|
200
|
+
end
|
201
|
+
value.instance_eval( &block ) if block
|
202
|
+
value
|
203
|
+
end
|
204
|
+
|
205
|
+
def __object_array( method, arguments, value:, criteria:, &block )
|
206
|
+
attributes = __process_arguments(
|
207
|
+
method, arguments,
|
208
|
+
required_arguments: criteria[ :arguments ]
|
209
|
+
)
|
210
|
+
value.concat( [ attributes ].flatten.map { | a |
|
211
|
+
receiver = Receiver.new(
|
212
|
+
a,
|
213
|
+
converters: @converters,
|
214
|
+
schema: criteria[ :schema ] ||= criteria[ :resolver ].schema
|
215
|
+
)
|
216
|
+
receiver.instance_eval( &block ) if block
|
217
|
+
receiver
|
218
|
+
} )
|
219
|
+
end
|
220
|
+
|
221
|
+
end
|
222
|
+
end
|
@@ -0,0 +1,126 @@
|
|
1
|
+
require_relative 'receiver'
|
2
|
+
|
3
|
+
module DynamicSchema
|
4
|
+
class Resolver < BasicObject
|
5
|
+
|
6
|
+
def initialize( schema = nil, resolved_blocks: nil )
|
7
|
+
@schema = schema || {}
|
8
|
+
|
9
|
+
@block = nil
|
10
|
+
@resolved = false
|
11
|
+
@resolved_blocks = resolved_blocks || []
|
12
|
+
end
|
13
|
+
|
14
|
+
def resolve( &block )
|
15
|
+
@block = block
|
16
|
+
@resolved = false
|
17
|
+
unless @resolved_blocks.include?( @block )
|
18
|
+
@resolved_blocks << @block
|
19
|
+
self.instance_eval( &@block )
|
20
|
+
@resolved = true
|
21
|
+
end
|
22
|
+
self
|
23
|
+
end
|
24
|
+
|
25
|
+
def schema
|
26
|
+
if !@resolved && @block
|
27
|
+
@resolved_blocks << @block unless @resolved_blocks.include?( @block )
|
28
|
+
self.instance_eval( &@block )
|
29
|
+
@resolved = true
|
30
|
+
end
|
31
|
+
@schema
|
32
|
+
end
|
33
|
+
|
34
|
+
def method_missing( method, *args, &block )
|
35
|
+
first = args.first
|
36
|
+
options = nil
|
37
|
+
if args.empty?
|
38
|
+
options = {}
|
39
|
+
# when called with just options: name as: :streams
|
40
|
+
elsif first.is_a?( ::Hash )
|
41
|
+
options = first
|
42
|
+
# when called with just type: name String
|
43
|
+
# name [ TrueClass, FalseClass ]
|
44
|
+
elsif args.length == 1 &&
|
45
|
+
( first.is_a?( ::Class ) || first.is_a?( ::Module ) || first.is_a?( ::Array ) )
|
46
|
+
options = { type: first }
|
47
|
+
# when called with just type and options: name String, default: 'the default'
|
48
|
+
elsif args.length == 2 &&
|
49
|
+
( first.is_a?( ::Class ) || first.is_a?( ::Module ) || first.is_a?( ::Array ) ) &&
|
50
|
+
args[ 1 ].is_a?( ::Hash )
|
51
|
+
options = args[ 1 ]
|
52
|
+
options[ :type ] = args[ 0 ]
|
53
|
+
else
|
54
|
+
::Kernel.raise \
|
55
|
+
::ArgumentError,
|
56
|
+
"A schema definition may only include the type (Class or Module) followed by options (Hash). "
|
57
|
+
end
|
58
|
+
|
59
|
+
type = options[ :type ]
|
60
|
+
if type == ::Object || type.nil? && block
|
61
|
+
_append_object( method, options, &block )
|
62
|
+
else
|
63
|
+
_append_value( method, options )
|
64
|
+
end
|
65
|
+
|
66
|
+
end
|
67
|
+
|
68
|
+
def to_s
|
69
|
+
inspect
|
70
|
+
end
|
71
|
+
|
72
|
+
def inspect
|
73
|
+
{ schema: @schema }.inspect
|
74
|
+
end
|
75
|
+
|
76
|
+
def class
|
77
|
+
::DynamicSchema::Schema::Resolver
|
78
|
+
end
|
79
|
+
|
80
|
+
def is_a?( klass )
|
81
|
+
klass == ::DynamicSchema::Resolver || klass == ::BasicObject
|
82
|
+
end
|
83
|
+
|
84
|
+
alias :kind_of? :is_a?
|
85
|
+
|
86
|
+
if defined?( ::PP )
|
87
|
+
include ::PP::ObjectMixin
|
88
|
+
def pretty_print( pp )
|
89
|
+
pp.pp( { schema: @schema } )
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
private
|
94
|
+
|
95
|
+
def _append_value( name, options )
|
96
|
+
::Kernel.raise ::NameError, "The name '#{name}' is reserved and cannot be used for parameters." \
|
97
|
+
if ::DynamicSchema::Receiver.instance_methods.include?( name )
|
98
|
+
|
99
|
+
_validate_in!( name, options[ :type ], options[ :in ] ) if options[ :in ]
|
100
|
+
|
101
|
+
@schema[ name ] = options
|
102
|
+
self
|
103
|
+
end
|
104
|
+
|
105
|
+
def _append_object( name, options = {}, &block )
|
106
|
+
::Kernel.raise ::NameError, "The name '#{name}' is reserved and cannot be used for parameters." \
|
107
|
+
if ::DynamicSchema::Receiver.instance_methods.include?( name )
|
108
|
+
|
109
|
+
@schema[ name ] = options.merge( {
|
110
|
+
type: ::Object,
|
111
|
+
resolver: Resolver.new( resolved_blocks: @resolved_blocks ).resolve( &block )
|
112
|
+
} )
|
113
|
+
self
|
114
|
+
end
|
115
|
+
|
116
|
+
def _validate_in!( name, type, in_option )
|
117
|
+
::Kernel.raise ::TypeError,
|
118
|
+
"The parameter '#{name}' includes the :in option but it does not respond to 'include?'." \
|
119
|
+
unless in_option.respond_to?( :include? )
|
120
|
+
end
|
121
|
+
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
|
126
|
+
|
metadata
ADDED
@@ -0,0 +1,92 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: dynamicschema
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0.beta01
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Kristoph Cichocki-Romanov
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2024-10-25 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rspec
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '3.13'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '3.13'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: debug
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.9'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.9'
|
41
|
+
description: "The DynamicSchema gem provides a elegant and expressive way to define
|
42
|
+
a domain-specific language (DSL) schemas, making it effortless to build and validate
|
43
|
+
complex Ruby hashes. \n\nThis is particularly useful when dealing with intricate
|
44
|
+
configurations or interfacing with external APIs, where data structures need to
|
45
|
+
adhere to specific formats and validations. By allowing default values, type constraints,
|
46
|
+
nested schemas, and transformations, DynamicSchema ensures that your data structures
|
47
|
+
are both robust and flexible."
|
48
|
+
email:
|
49
|
+
- rubygems.org@kristoph.net
|
50
|
+
executables: []
|
51
|
+
extensions: []
|
52
|
+
extra_rdoc_files: []
|
53
|
+
files:
|
54
|
+
- LICENSE
|
55
|
+
- README.md
|
56
|
+
- dynamicschema.gemspec
|
57
|
+
- lib/dynamic_schema.rb
|
58
|
+
- lib/dynamic_schema/builder.rb
|
59
|
+
- lib/dynamic_schema/builder_methods/conversion.rb
|
60
|
+
- lib/dynamic_schema/builder_methods/validation.rb
|
61
|
+
- lib/dynamic_schema/definition.rb
|
62
|
+
- lib/dynamic_schema/errors.rb
|
63
|
+
- lib/dynamic_schema/receiver.rb
|
64
|
+
- lib/dynamic_schema/resolver.rb
|
65
|
+
homepage: https://github.com/EndlessInternational/dynamic_schema
|
66
|
+
licenses:
|
67
|
+
- MIT
|
68
|
+
metadata:
|
69
|
+
source_code_uri: https://github.com/EndlessInternational/dynamic_schema
|
70
|
+
bug_tracker_uri: https://github.com/EndlessInternational/dynamic_schema/issues
|
71
|
+
post_install_message:
|
72
|
+
rdoc_options: []
|
73
|
+
require_paths:
|
74
|
+
- lib
|
75
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
76
|
+
requirements:
|
77
|
+
- - ">="
|
78
|
+
- !ruby/object:Gem::Version
|
79
|
+
version: '3.0'
|
80
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
81
|
+
requirements:
|
82
|
+
- - ">="
|
83
|
+
- !ruby/object:Gem::Version
|
84
|
+
version: '0'
|
85
|
+
requirements: []
|
86
|
+
rubygems_version: 3.5.19
|
87
|
+
signing_key:
|
88
|
+
specification_version: 4
|
89
|
+
summary: DynamicSchema is a lightweight and simple yet powerful gem that enables flexible
|
90
|
+
semantic schema definitions for constructing and validating complex configurations
|
91
|
+
and other similar payloads.
|
92
|
+
test_files: []
|