dynamicschema 1.0.0.beta01

Sign up to get free protection for your applications and to get access to all the features.
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
+
@@ -0,0 +1,9 @@
1
+ require_relative 'dynamic_schema/errors'
2
+ require_relative 'dynamic_schema/builder'
3
+ require_relative 'dynamic_schema/definition'
4
+
5
+ module DynamicSchema
6
+ def self.define( schema = {}, &block )
7
+ Builder.new( schema ).define( &block )
8
+ end
9
+ end
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: []