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 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: []