verse-schema 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop-https---relaxed-ruby-style-rubocop-yml +153 -0
- data/.rubocop.yml +32 -0
- data/Gemfile +23 -0
- data/Gemfile.lock +87 -0
- data/README.md +1591 -0
- data/Rakefile +15 -0
- data/benchmarks/troubleshoot.rb +73 -0
- data/lib/tasks/readme.rake +94 -0
- data/lib/tasks/readme_doc_extractor.rb +158 -0
- data/lib/verse/schema/base.rb +80 -0
- data/lib/verse/schema/coalescer/register.rb +132 -0
- data/lib/verse/schema/coalescer.rb +81 -0
- data/lib/verse/schema/collection.rb +175 -0
- data/lib/verse/schema/dictionary.rb +160 -0
- data/lib/verse/schema/error_builder.rb +43 -0
- data/lib/verse/schema/field/ext.rb +24 -0
- data/lib/verse/schema/field.rb +394 -0
- data/lib/verse/schema/invalid_schema_error.rb +18 -0
- data/lib/verse/schema/optionable.rb +47 -0
- data/lib/verse/schema/post_processor.rb +56 -0
- data/lib/verse/schema/result.rb +30 -0
- data/lib/verse/schema/scalar.rb +154 -0
- data/lib/verse/schema/selector.rb +172 -0
- data/lib/verse/schema/struct.rb +315 -0
- data/lib/verse/schema/version.rb +7 -0
- data/lib/verse/schema.rb +75 -0
- data/sig/verse/schema.rbs +6 -0
- data/templates/README.md.erb +73 -0
- metadata +83 -0
@@ -0,0 +1,172 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "./base"
|
4
|
+
|
5
|
+
module Verse
|
6
|
+
module Schema
|
7
|
+
class Selector < Base
|
8
|
+
attr_accessor :values
|
9
|
+
|
10
|
+
# Initialize a new selector schema.
|
11
|
+
# Selector schema will select a subset of the input based on the provided values.
|
12
|
+
#
|
13
|
+
# @param values [Hash<Symbol, Class|Array<Class>>] Selected values of the selector schema.
|
14
|
+
# @param post_processors [PostProcessor] The post processors to apply.
|
15
|
+
#
|
16
|
+
# @return [Selector] The new dictionary.
|
17
|
+
def initialize(values:, post_processors: nil)
|
18
|
+
super(post_processors:)
|
19
|
+
|
20
|
+
@values = values.transform_values{ |v| v.is_a?(Array) ? v : [v] }
|
21
|
+
end
|
22
|
+
|
23
|
+
def validate(input, error_builder: nil, locals: {})
|
24
|
+
locals = locals.dup # Ensure they are not modified
|
25
|
+
|
26
|
+
error_builder = \
|
27
|
+
case error_builder
|
28
|
+
when String
|
29
|
+
ErrorBuilder.new(error_builder)
|
30
|
+
when ErrorBuilder
|
31
|
+
error_builder
|
32
|
+
else
|
33
|
+
ErrorBuilder.new
|
34
|
+
end
|
35
|
+
|
36
|
+
locals[:__path__] ||= []
|
37
|
+
|
38
|
+
validate_selector(input, error_builder, locals)
|
39
|
+
end
|
40
|
+
|
41
|
+
def dup
|
42
|
+
Selector.new(
|
43
|
+
values: @values.dup,
|
44
|
+
post_processors: @post_processors&.dup
|
45
|
+
)
|
46
|
+
end
|
47
|
+
|
48
|
+
def inherit?(parent_schema)
|
49
|
+
# Check if parent_schema is a Base and if all parent fields are present in this schema
|
50
|
+
return false unless parent_schema.is_a?(Selector)
|
51
|
+
|
52
|
+
@values.all? do |key, value|
|
53
|
+
# Check if the key exists in the parent schema and if the value is a subclass of the parent value
|
54
|
+
parent_value = parent_schema.values[key]
|
55
|
+
next false unless parent_value
|
56
|
+
|
57
|
+
(parent_value & value).size == parent_schema.size
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def <=(other)
|
62
|
+
other == self || inherit?(other)
|
63
|
+
end
|
64
|
+
|
65
|
+
def <(other)
|
66
|
+
other != self && inherit?(other)
|
67
|
+
end
|
68
|
+
|
69
|
+
# rubocop:disable Style/InverseMethods
|
70
|
+
def >(other)
|
71
|
+
!self.<=(other)
|
72
|
+
end
|
73
|
+
# rubocop:enable Style/InverseMethods
|
74
|
+
|
75
|
+
# Aggregation of two schemas.
|
76
|
+
def +(other)
|
77
|
+
raise ArgumentError, "aggregate must be a selector" unless other.is_a?(Selector)
|
78
|
+
|
79
|
+
new_classes = @values.merge(other.values) do |_key, old_value, new_value|
|
80
|
+
# Merge the arrays of classes
|
81
|
+
(old_value + new_value).uniq
|
82
|
+
end
|
83
|
+
|
84
|
+
new_post_processors = @post_processors&.dup
|
85
|
+
|
86
|
+
if other.post_processors
|
87
|
+
if new_post_processors
|
88
|
+
new_post_processors.attach(other.post_processors)
|
89
|
+
else
|
90
|
+
new_post_processors = other.post_processors.dup
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
Selector.new(
|
95
|
+
values: new_classes,
|
96
|
+
post_processors: new_post_processors
|
97
|
+
)
|
98
|
+
end
|
99
|
+
|
100
|
+
def dataclass_schema
|
101
|
+
return @dataclass_schema if @dataclass_schema
|
102
|
+
|
103
|
+
@dataclass_schema = dup
|
104
|
+
|
105
|
+
@dataclass_schema.values = @dataclass_schema.values.transform_values do |value|
|
106
|
+
if value.is_a?(Array)
|
107
|
+
value.map do |v|
|
108
|
+
if v.is_a?(Base)
|
109
|
+
v.dataclass_schema
|
110
|
+
else
|
111
|
+
v
|
112
|
+
end
|
113
|
+
end
|
114
|
+
elsif value.is_a?(Base)
|
115
|
+
value.dataclass_schema
|
116
|
+
else
|
117
|
+
value
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
@dataclass_schema
|
122
|
+
end
|
123
|
+
|
124
|
+
protected
|
125
|
+
|
126
|
+
def validate_selector(input, error_builder, locals)
|
127
|
+
output = {}
|
128
|
+
|
129
|
+
selector = locals.fetch(:selector) do
|
130
|
+
error_builder.add(
|
131
|
+
nil, "selector not provided for this schema", **locals
|
132
|
+
)
|
133
|
+
|
134
|
+
return Result.new(nil, error_builder.errors)
|
135
|
+
end
|
136
|
+
|
137
|
+
fetched_values = @values.fetch(selector) do
|
138
|
+
@values.fetch(:__else__) do
|
139
|
+
error_builder.add(
|
140
|
+
nil,
|
141
|
+
"selector `#{selector}` is not valid for this schema",
|
142
|
+
**locals
|
143
|
+
)
|
144
|
+
|
145
|
+
return Result.new(output, error_builder.errors)
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
coalesced_value = nil
|
150
|
+
|
151
|
+
begin
|
152
|
+
coalesced_value =
|
153
|
+
Coalescer.transform(
|
154
|
+
input,
|
155
|
+
fetched_values,
|
156
|
+
nil,
|
157
|
+
locals:
|
158
|
+
)
|
159
|
+
|
160
|
+
if coalesced_value.is_a?(Result)
|
161
|
+
error_builder.combine(nil, coalesced_value.errors)
|
162
|
+
coalesced_value = coalesced_value.value
|
163
|
+
end
|
164
|
+
rescue Coalescer::Error => e
|
165
|
+
error_builder.add(nil, e.message, **locals)
|
166
|
+
end
|
167
|
+
|
168
|
+
Result.new(coalesced_value, error_builder.errors)
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
@@ -0,0 +1,315 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "./field"
|
4
|
+
require_relative "./result"
|
5
|
+
require_relative "./error_builder"
|
6
|
+
require_relative "./post_processor"
|
7
|
+
require_relative "./invalid_schema_error"
|
8
|
+
|
9
|
+
module Verse
|
10
|
+
module Schema
|
11
|
+
class Struct < Base
|
12
|
+
attr_accessor :fields
|
13
|
+
|
14
|
+
# Initialize a new schema.
|
15
|
+
#
|
16
|
+
# @param fields [Array<Field>] The fields of the schema.
|
17
|
+
# @param post_processors [PostProcessor] The post processors to apply.
|
18
|
+
# @param extra_fields [Boolean] Whether to allow extra fields.
|
19
|
+
# @param block [Proc] The block to evaluate (DSL).
|
20
|
+
#
|
21
|
+
# @return [Struct] The new schema.
|
22
|
+
def initialize(
|
23
|
+
fields: [],
|
24
|
+
post_processors: nil,
|
25
|
+
extra_fields: false,
|
26
|
+
|
27
|
+
&block
|
28
|
+
)
|
29
|
+
super(post_processors:)
|
30
|
+
@fields = fields
|
31
|
+
@extra_fields = extra_fields
|
32
|
+
|
33
|
+
instance_eval(&block) if block_given?
|
34
|
+
end
|
35
|
+
|
36
|
+
# delegated method useful to write clean DSL
|
37
|
+
def define(from = nil, &block)
|
38
|
+
Verse::Schema.define(from, &block)
|
39
|
+
end
|
40
|
+
|
41
|
+
def field(field_name, type = Object, **opts, &block)
|
42
|
+
@cache_field_name = nil
|
43
|
+
|
44
|
+
if opts[:over] && @fields.none?{ |f| f.name == opts[:over] }
|
45
|
+
# Ensure the `over` field exists and is
|
46
|
+
# already defined.
|
47
|
+
# There is some dependencies in validation,
|
48
|
+
# and I think that's the best trade-off to
|
49
|
+
# raise error early during schema definition.
|
50
|
+
raise ArgumentError, "over field #{opts[:over]} must be defined before #{field_name}"
|
51
|
+
end
|
52
|
+
|
53
|
+
field = Field.new(field_name, type, opts, &block)
|
54
|
+
@fields << field
|
55
|
+
field
|
56
|
+
end
|
57
|
+
|
58
|
+
def field?(field_name, type = Object, **opts, &block)
|
59
|
+
field(field_name, type, **opts, &block).optional
|
60
|
+
end
|
61
|
+
|
62
|
+
# rubocop:disable Style/OptionalBooleanParameter
|
63
|
+
def extra_fields(value = true)
|
64
|
+
@extra_fields = !!value
|
65
|
+
end
|
66
|
+
# rubocop:enable Style/OptionalBooleanParameter
|
67
|
+
|
68
|
+
def extra_fields? = @extra_fields
|
69
|
+
|
70
|
+
def valid?(input) = validate(input).success?
|
71
|
+
|
72
|
+
def validate(input, error_builder: nil, locals: {})
|
73
|
+
error_builder = \
|
74
|
+
case error_builder
|
75
|
+
when String
|
76
|
+
ErrorBuilder.new(error_builder)
|
77
|
+
when ErrorBuilder
|
78
|
+
error_builder
|
79
|
+
else
|
80
|
+
ErrorBuilder.new
|
81
|
+
end
|
82
|
+
|
83
|
+
unless input.is_a?(Hash)
|
84
|
+
error_builder.add(nil, "must be a hash")
|
85
|
+
return Result.new({}, error_builder.errors)
|
86
|
+
end
|
87
|
+
|
88
|
+
locals = locals.dup # Ensure they are not modified
|
89
|
+
|
90
|
+
validate_hash(input, error_builder, locals)
|
91
|
+
end
|
92
|
+
|
93
|
+
def dup
|
94
|
+
Struct.new(
|
95
|
+
fields: @fields.map(&:dup),
|
96
|
+
extra_fields: @extra_fields,
|
97
|
+
post_processors: @post_processors&.dup
|
98
|
+
)
|
99
|
+
end
|
100
|
+
|
101
|
+
def inherit?(parent_schema)
|
102
|
+
# Check if parent_schema is a Struct and if all parent fields are present in this schema
|
103
|
+
parent_schema.is_a?(Struct) &&
|
104
|
+
parent_schema.fields.all? { |parent_field|
|
105
|
+
child_field = @fields.find { |f2| f2.name == parent_field.name }
|
106
|
+
child_field&.inherit?(parent_field)
|
107
|
+
}
|
108
|
+
end
|
109
|
+
|
110
|
+
def <=(other)
|
111
|
+
other == self || inherit?(other)
|
112
|
+
end
|
113
|
+
|
114
|
+
def <(other)
|
115
|
+
other != self && inherit?(other)
|
116
|
+
end
|
117
|
+
|
118
|
+
# rubocop:disable Style/InverseMethods
|
119
|
+
def >(other)
|
120
|
+
!self.<=(other)
|
121
|
+
end
|
122
|
+
# rubocop:enable Style/InverseMethods
|
123
|
+
|
124
|
+
# Aggregation of two schemas.
|
125
|
+
def +(other)
|
126
|
+
raise ArgumentError, "aggregate must be a schema" unless other.is_a?(Struct)
|
127
|
+
|
128
|
+
new_schema = dup
|
129
|
+
|
130
|
+
other.fields.each do |f|
|
131
|
+
field_index = new_schema.fields.find_index{ |f2| f2.name == f.name }
|
132
|
+
|
133
|
+
if field_index
|
134
|
+
field = new_schema.fields[field_index]
|
135
|
+
|
136
|
+
field_type = \
|
137
|
+
if field.type == f.type
|
138
|
+
field.type
|
139
|
+
else
|
140
|
+
[field.type, f.type].flatten.uniq
|
141
|
+
end
|
142
|
+
|
143
|
+
if f.post_processors
|
144
|
+
if field.post_processors
|
145
|
+
field.post_processors.attach(f.post_processors)
|
146
|
+
else
|
147
|
+
field.post_processors = f.post_processors
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
new_schema.fields[field_index] = Field.new(
|
152
|
+
field.name,
|
153
|
+
field_type,
|
154
|
+
field.opts.merge(f.opts),
|
155
|
+
post_processors: field.post_processors
|
156
|
+
)
|
157
|
+
else
|
158
|
+
new_schema.fields << f.dup
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
new_schema
|
163
|
+
end
|
164
|
+
|
165
|
+
def dataclass_schema
|
166
|
+
return @dataclass_schema if @dataclass_schema
|
167
|
+
|
168
|
+
@dataclass_schema = dup
|
169
|
+
|
170
|
+
@dataclass_schema.fields = @dataclass_schema.fields.map do |field|
|
171
|
+
type = field.type
|
172
|
+
|
173
|
+
if type.is_a?(Array)
|
174
|
+
Field.new(
|
175
|
+
field.name,
|
176
|
+
type.map do |t|
|
177
|
+
next t unless t.is_a?(Base)
|
178
|
+
|
179
|
+
t.dataclass_schema
|
180
|
+
end,
|
181
|
+
field.opts.dup,
|
182
|
+
post_processors: field.post_processors&.dup
|
183
|
+
)
|
184
|
+
elsif type.is_a?(Base)
|
185
|
+
Field.new(
|
186
|
+
field.name,
|
187
|
+
type.dataclass_schema,
|
188
|
+
field.opts.dup,
|
189
|
+
post_processors: field.post_processors&.dup
|
190
|
+
)
|
191
|
+
else
|
192
|
+
field.dup
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
this = self
|
197
|
+
fields_map = fields.map(&:name)
|
198
|
+
|
199
|
+
@dataclass_schema.transform do |value|
|
200
|
+
next value unless value.is_a?(Hash)
|
201
|
+
|
202
|
+
if this.extra_fields?
|
203
|
+
standard_fields = value.slice(*fields_map)
|
204
|
+
extra_fields = value.except(*fields_map)
|
205
|
+
|
206
|
+
this.dataclass.from_raw(**standard_fields, extra_fields:)
|
207
|
+
else
|
208
|
+
this.dataclass.from_raw(**value)
|
209
|
+
end
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
# Create a value object class from the schema.
|
214
|
+
def dataclass(&block)
|
215
|
+
return @dataclass if @dataclass
|
216
|
+
|
217
|
+
fields = @fields.map(&:name)
|
218
|
+
|
219
|
+
dataclass_schema = self.dataclass_schema
|
220
|
+
|
221
|
+
fields << :extra_fields if extra_fields?
|
222
|
+
|
223
|
+
# Special case for empty schema (yeah, I know, it happens in my production code...)
|
224
|
+
if fields.empty?
|
225
|
+
@dataclass = Class.new do
|
226
|
+
def self.from_raw(*)=new
|
227
|
+
def self.schema = dataclass_schema
|
228
|
+
|
229
|
+
class_eval(&block) if block
|
230
|
+
end
|
231
|
+
else
|
232
|
+
@dataclass = ::Struct.new(*fields, keyword_init: true) do
|
233
|
+
# Redefine new method
|
234
|
+
define_singleton_method(:from_raw, &method(:new))
|
235
|
+
|
236
|
+
define_singleton_method(:new) do |*args, **kwargs|
|
237
|
+
# Use the schema to generate the hash for our record
|
238
|
+
if args.size > 1
|
239
|
+
raise ArgumentError, "You cannot pass more than one argument"
|
240
|
+
end
|
241
|
+
|
242
|
+
if args.size == 1
|
243
|
+
if kwargs.any?
|
244
|
+
raise ArgumentError, "You cannot pass both a hash and keyword arguments"
|
245
|
+
end
|
246
|
+
|
247
|
+
kwargs = args.first
|
248
|
+
end
|
249
|
+
|
250
|
+
dataclass_schema.new(kwargs)
|
251
|
+
end
|
252
|
+
|
253
|
+
define_singleton_method(:schema){ dataclass_schema }
|
254
|
+
|
255
|
+
class_eval(&block) if block
|
256
|
+
end
|
257
|
+
end
|
258
|
+
end
|
259
|
+
|
260
|
+
def inspect
|
261
|
+
fields_string = @fields.map do |field|
|
262
|
+
if field.type.is_a?(Array)
|
263
|
+
type_str = field.type.map(&:inspect).join("|")
|
264
|
+
else
|
265
|
+
type_str = field.type.inspect
|
266
|
+
end
|
267
|
+
|
268
|
+
optional_marker = field.optional? ? "?" : ""
|
269
|
+
"#{field.name}#{optional_marker}: #{type_str}"
|
270
|
+
end.join(", ")
|
271
|
+
|
272
|
+
extra = @extra_fields ? ", ..." : ""
|
273
|
+
|
274
|
+
"#<struct{#{fields_string}#{extra}} 0x#{object_id.to_s(16)}>"
|
275
|
+
end
|
276
|
+
|
277
|
+
protected
|
278
|
+
|
279
|
+
def validate_hash(input, error_builder, locals)
|
280
|
+
locals[:__path__] ||= []
|
281
|
+
|
282
|
+
input = input.transform_keys(&:to_sym)
|
283
|
+
output = @extra_fields ? input : {}
|
284
|
+
|
285
|
+
@cache_field_name ||= @fields.map(&:key)
|
286
|
+
|
287
|
+
@fields.each do |field|
|
288
|
+
key_sym = field.key
|
289
|
+
|
290
|
+
exists = true
|
291
|
+
value = input.fetch(key_sym) { exists = false }
|
292
|
+
|
293
|
+
if (over = field.opts[:over])
|
294
|
+
locals[:selector] = output[over]
|
295
|
+
end
|
296
|
+
|
297
|
+
if exists
|
298
|
+
field.apply(value, output, error_builder, locals)
|
299
|
+
elsif field.default?
|
300
|
+
field.apply(field.default, output, error_builder, locals)
|
301
|
+
elsif field.required?
|
302
|
+
error_builder.add(field.key, "is required")
|
303
|
+
end
|
304
|
+
end
|
305
|
+
|
306
|
+
if @post_processors && error_builder.errors.empty?
|
307
|
+
output = @post_processors.call(output, nil, error_builder, **locals)
|
308
|
+
end
|
309
|
+
|
310
|
+
Result.new(output, error_builder.errors)
|
311
|
+
end
|
312
|
+
|
313
|
+
end
|
314
|
+
end
|
315
|
+
end
|
data/lib/verse/schema.rb
ADDED
@@ -0,0 +1,75 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "schema/version"
|
4
|
+
|
5
|
+
module Verse
|
6
|
+
module Schema
|
7
|
+
module_function
|
8
|
+
|
9
|
+
require_relative "schema/base"
|
10
|
+
require_relative "schema/coalescer"
|
11
|
+
require_relative "schema/post_processor"
|
12
|
+
|
13
|
+
def define(from = nil, &block)
|
14
|
+
if from
|
15
|
+
schema = from.dup
|
16
|
+
schema.instance_eval(&block) if block_given?
|
17
|
+
schema
|
18
|
+
else
|
19
|
+
Struct.new(&block)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
# Define the schema as an array of values
|
24
|
+
def array(*values, &block)
|
25
|
+
if block_given?
|
26
|
+
raise ArgumentError, "array of value cannot be used with a block" unless values.empty?
|
27
|
+
|
28
|
+
Collection.new(values: [define(&block)])
|
29
|
+
else
|
30
|
+
raise ArgumentError, "block or type is required" if values.empty?
|
31
|
+
|
32
|
+
Collection.new(values:)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def dictionary(*values, &block)
|
37
|
+
if block_given?
|
38
|
+
raise ArgumentError, "array of value cannot be used with a block" unless values.empty?
|
39
|
+
|
40
|
+
Dictionary.new(values: [define(&block)])
|
41
|
+
else
|
42
|
+
raise ArgumentError, "block or type is required" if values.empty?
|
43
|
+
|
44
|
+
Dictionary.new(values:)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def scalar(*values) = Scalar.new(values:)
|
49
|
+
def selector(**values) = Selector.new(values:)
|
50
|
+
|
51
|
+
def empty
|
52
|
+
@empty ||= begin
|
53
|
+
empty_schema = Verse::Schema.define
|
54
|
+
empty_schema.dataclass # Generate the dataclass
|
55
|
+
empty_schema.freeze # Freeze to avoid modification
|
56
|
+
empty_schema
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def rule(message, &block)
|
61
|
+
PostProcessor.new do |value, error|
|
62
|
+
case block.arity
|
63
|
+
when 1, -1, -2
|
64
|
+
error.add(opts[:key], message) unless block.call(value)
|
65
|
+
when 2
|
66
|
+
error.add(opts[:key], message) unless block.call(value, error)
|
67
|
+
else
|
68
|
+
raise ArgumentError, "invalid block arity"
|
69
|
+
end
|
70
|
+
|
71
|
+
value
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
# Verse::Schema
|
2
|
+
|
3
|
+
## Summary
|
4
|
+
|
5
|
+
Verse::Schema is a Ruby gem that provides a DSL for data validation and coercion.
|
6
|
+
|
7
|
+
It is designed to be used in a context where you need to validate and coerce data coming from external sources (e.g. HTTP requests, database, etc...).
|
8
|
+
|
9
|
+
Verse was initially using [dry-validation](https://dry-rb.org/gems/dry-validation/) for this purpose, but we found it too complex to use and to extend. Autodocumentation was almost impossible, and the different concepts (Schema, Params, Contract...) was not really clear in our opinion.
|
10
|
+
|
11
|
+
## Installation
|
12
|
+
|
13
|
+
Add this line to your application's Gemfile:
|
14
|
+
|
15
|
+
```ruby
|
16
|
+
gem 'verse-schema'
|
17
|
+
```
|
18
|
+
|
19
|
+
## Concept
|
20
|
+
|
21
|
+
Verse::Schema provides a flexible and opinionated way to define data structures, validate input, and coerce values. The core philosophy revolves around clear, explicit definitions and predictable transformations.
|
22
|
+
|
23
|
+
**Key Principles:**
|
24
|
+
|
25
|
+
* **Validation and Coercion:** The primary goal is to ensure incoming data conforms to a defined structure and type, automatically coercing values where possible (e.g., string "123" to integer 123).
|
26
|
+
* **Explicit Definitions:** Schemas are defined using a clear DSL, making the expected data structure easy to understand.
|
27
|
+
* **Symbolized Keys:** By design, all hash keys within validated data are converted to symbols for consistency.
|
28
|
+
* **Coalescing:** The library attempts to intelligently convert input values to the target type defined in the schema. This simplifies handling data from various sources (like JSON strings, form parameters, etc.).
|
29
|
+
* **Extensibility:** While opinionated, the library allows for custom rules, post-processing transformations, and schema inheritance.
|
30
|
+
|
31
|
+
**Schema Types (Wrappers):**
|
32
|
+
|
33
|
+
Verse::Schema offers several base schema types to handle different data structures:
|
34
|
+
|
35
|
+
* **`Verse::Schema::Struct`:** The most common type, used for defining hash-like structures with fixed keys and specific types for each value. This is the default when using `Verse::Schema.define { ... }`. It validates the presence, type, and rules for each defined field. It can optionally allow extra fields not explicitly defined.
|
36
|
+
* **`Verse::Schema::Collection`:** Used for defining arrays where each element must conform to a specific type or schema. Created using `Verse::Schema.array(TypeOrSchema)` or `field(:name, Array, of: TypeOrSchema)`.
|
37
|
+
* **`Verse::Schema::Dictionary`:** Defines hash-like structures where keys are symbols and values must conform to a specific type or schema. Useful for key-value stores or maps. Created using `Verse::Schema.dictionary(TypeOrSchema)` or `field(:name, Hash, of: TypeOrSchema)`.
|
38
|
+
* **`Verse::Schema::Scalar`:** Represents a single value that can be one of several specified scalar types (e.g., String, Integer, Boolean). Created using `Verse::Schema.scalar(Type1, Type2, ...)`.
|
39
|
+
* **`Verse::Schema::Selector`:** A powerful type that allows choosing which schema or type to apply based on the value of another field (the "selector" field) or a provided `selector` local variable. This enables handling polymorphic data structures. Created using `Verse::Schema.selector(key1: TypeOrSchema1, key2: TypeOrSchema2, ...)` or `field(:name, { key1: TypeOrSchema1, ... }, over: :selector_field_name)`.
|
40
|
+
|
41
|
+
These building blocks can be nested and combined to define complex data validation and coercion rules.
|
42
|
+
|
43
|
+
|
44
|
+
## Usage
|
45
|
+
|
46
|
+
These examples are extracted directly from the gem's specs, ensuring they are accurate and up-to-date. You can run each example directly in IRB.
|
47
|
+
|
48
|
+
### Table of Contents
|
49
|
+
|
50
|
+
<% chapters.each do |chapter_name, sections| %>
|
51
|
+
- [<%= chapter_name %>](#<%= chapter_name.downcase.gsub(/[^a-z0-9\s-]/, '').gsub(/\s+/, '-') %>)
|
52
|
+
<% sections.each do |section_name, _| %>
|
53
|
+
- [<%= section_name %>](#<%= section_name.downcase.gsub(/[^a-z0-9\s-]/, '').gsub(/\s+/, '-') %>)
|
54
|
+
<% end %>
|
55
|
+
<% end %>
|
56
|
+
|
57
|
+
<% chapters.each do |chapter_name, sections| %>
|
58
|
+
## <%= chapter_name %>
|
59
|
+
|
60
|
+
<% sections.each do |section_name, section_examples| %>
|
61
|
+
### <%= section_name %>
|
62
|
+
|
63
|
+
<% section_examples.each do |example| %>
|
64
|
+
```ruby
|
65
|
+
<%= example %>
|
66
|
+
```
|
67
|
+
<% end %>
|
68
|
+
<% end %>
|
69
|
+
<% end %>
|
70
|
+
|
71
|
+
## Contributing
|
72
|
+
|
73
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/verse-rb/verse-schema.
|