dynamicschema 1.0.0.beta01
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 +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: []
|