rschema 2.4.0 → 3.0.1.pre1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 215d5577f60ce5957a0f8b6057a82b34bcebe79d
4
- data.tar.gz: a9de23baf8044b7762a8a9611929d67603409eb8
3
+ metadata.gz: 4bf71f16e83556e8bd0ac267c50c3e0bb2b02210
4
+ data.tar.gz: 44e531a24e345526b22def8a8df44c72f111b636
5
5
  SHA512:
6
- metadata.gz: 825c7dfc491f5c9c7eee23e5e437a10086bdc8ddf197ac32b103217d6019c00dca8da565b6e9cde61c7cb50bf23566d154be35354cfa55a6ad03a749e17e2155
7
- data.tar.gz: 90c662e2f3078521e795e772deff2e779d7dd8f1c52e86e038475b1d9357b8716f7ee4c31fb0b974679a7656f65377cd0b40b5f621ce9623eac3a7be6d808519
6
+ metadata.gz: 414b8192259952378b14ce845e6c333306d18cbee48dc12592d91d3dd7c89fcf6481af96e65d1bcb26d5c07982ea5d1393011b8aaa589f4d76a68bfc1ea444d6
7
+ data.tar.gz: 6b3f20c7f6d1d89110c4245788a4a42d1556edfaaee3497dfa0c2aee1eb86fe1a96f0b35ddeea628ae9294391f9e2082cd1ffe410b628e470eb3bae9382447f9
data/README.md CHANGED
@@ -1,186 +1,290 @@
1
1
  [![Build Status](https://travis-ci.org/tomdalling/rschema.svg?branch=master)](https://travis-ci.org/tomdalling/rschema)
2
2
  [![Test Coverage](https://codeclimate.com/github/tomdalling/rschema/badges/coverage.svg)](https://codeclimate.com/github/tomdalling/rschema/coverage)
3
3
 
4
- # RSchema
4
+ RSchema
5
+ =======
5
6
 
6
7
  Schema-based validation and coercion for Ruby data structures. Heavily inspired
7
- by (read: stolen from) [Prismatic/schema][] for Clojure.
8
+ by [Prismatic/schema][].
8
9
 
9
10
  Meet RSchema
10
11
  ------------
11
12
 
12
- A "schema" is a data structure that describes the _shape_ of data.
13
- Schemas are generally just plain old hashes, arrays, and classes.
13
+ RSchema provides a way to describe, validate, and coerce the "shape" of data.
14
+
15
+ First you create a schema:
14
16
 
15
17
  ```ruby
16
- post_schema = {
17
- title: String,
18
- tags: Array[Symbol],
19
- body: String
20
- }
18
+ blog_post_schema = RSchema.define_hash {{
19
+ title: _String,
20
+ tags: Array(_Symbol),
21
+ body: _String,
22
+ }}
21
23
  ```
22
24
 
23
- Schemas can be used to validate data. That is, they can check whether
24
- data is in the correct shape:
25
+ Then you can use the schema to validate data:
25
26
 
26
27
  ```ruby
27
- RSchema.validate(post_schema, {
28
- title: "You won't beleive how this developer foo'd her bar",
29
- tags: [:foos, :bars, :unbeleivable],
28
+ input = {
29
+ title: "One Weird Trick Developers Don't Want You To Know!",
30
+ tags: [:trick, :developers, :unbeleivable],
30
31
  body: '<p>blah blah</p>'
31
- }) #=> true
32
+ }
33
+ blog_post_schema.call(input).valid? #=> true
32
34
  ```
33
35
 
34
- What is a schema?
36
+ What Is A Schema?
35
37
  -----------------
36
38
 
37
- Schemas are Ruby data structures. The simplest type of schema is just a class:
39
+ Schemas are objects that _describe and validate a values_.
40
+
41
+ The simplest schemas are `Type` schemas, which just validate the type of a value.
38
42
 
39
43
  ```ruby
40
- schema = Integer
41
- RSchema.validate(schema, 5) #=> true
42
- RSchema.validate(schema, 'hello') #=> false
44
+ schema = RSchema.define { _Integer }
45
+ schema.class #=> RSchema::Schemas::Type
46
+
47
+ schema.call(1234).valid? #=> true
48
+ schema.call('hi').valid? #=> false
43
49
  ```
44
50
 
45
51
  Then there are composite schemas, which are schemas composed of subschemas.
52
+
46
53
  Arrays are composite schemas:
47
54
 
48
55
  ```ruby
49
- schema = Array[Integer]
50
- RSchema.validate(schema, [10, 11, 12]) #=> true
51
- RSchema.validate(schema, [10, 11, '12']) #=> false
56
+ schema = RSchema.define { Array(_Integer) }
57
+ schema.call([10, 11, 12]).valid? #=> true
58
+ schema.call([10, 11, :hi]).valid? #=> false
52
59
  ```
53
60
 
54
61
  And so are hashes:
55
62
 
56
63
  ```ruby
57
- schema = { fname: String, age: Integer }
58
- RSchema.validate(schema, { fname: 'Jane', age: 27 }) #=> true
59
- RSchema.validate(schema, { fname: 'Johnny' }) #=> false
64
+ schema = RSchema.define do
65
+ Hash(fname: _String, age: _Integer)
66
+ end
67
+
68
+ schema.call({ fname: 'Jane', age: 27 }).valid? #=> true
69
+ schema.call({ fname: 'Johnny' }).valid? #=> false
60
70
  ```
61
71
 
62
- While schemas are just plain old Ruby data structures, RSchema also provides
63
- an extensible DSL for constructing more complicated schemas:
72
+ Schema objects are composable they are designed to be combined.
73
+ This allows schemas to describe complex, nested data structures.
64
74
 
65
75
  ```ruby
66
- schema = RSchema.schema {{
76
+ schema = RSchema.define_hash {{
67
77
  fname: predicate { |n| n.is_a?(String) && n.size > 0 },
68
- favourite_foods: set_of(Symbol),
69
- children_by_age: hash_of(Integer => String)
78
+ favourite_foods: Set(_Symbol),
79
+ children_by_age: VariableHash(_Integer => _String)
70
80
  }}
71
81
 
72
- RSchema.validate(schema, {
82
+ input = {
73
83
  fname: 'Johnny',
74
84
  favourite_foods: Set.new([:bacon, :cheese, :onion]),
75
85
  children_by_age: {
76
86
  7 => 'Jenny',
77
- 5 => 'Simon'
78
- }
79
- }) #=> true
87
+ 5 => 'Simon',
88
+ },
89
+ }
90
+
91
+ schema.call(input).valid? #=> true
80
92
  ```
81
93
 
94
+ RSchema provides many different kinds of schema classes for common tasks, but
95
+ you can also write custom schema classes if you need to.
96
+
97
+
98
+ The DSL
99
+ -------
100
+
101
+ Schemas are usually created and composed via a DSL using `RSchema.define`.
102
+ They can be created manually, although this is often too verbose.
103
+
104
+ For example, the following two schemas are identical. `schema1` is created via the
105
+ DSL, and `schema2` is created manually.
106
+
107
+ ```ruby
108
+ schema1 = RSchema.define { Array(_Symbol) }
109
+
110
+ schema2 = RSchema::Schemas::VariableArray.new(
111
+ RSchema::Schemas::Type.new(Symbol)
112
+ )
113
+ ```
114
+
115
+ You will probably never need to create schemas manually unless you are doing
116
+ something advanced, like writing your own DSL.
117
+
118
+ The DSL is designed to be extensible. You can add your own methods to the
119
+ default DSL, or create a separate, custom DSL to suite your needs.
120
+
121
+
82
122
  When Validation Fails
83
123
  ---------------------
84
124
 
85
- Using `RSchema.validate`, it is often difficult to tell exactly which values
86
- are failing validation.
125
+ When data fails validation, it is often important to know exactly _which
126
+ values_ were invalid, and _why_. RSchema provides details about every
127
+ failure within a result object.
87
128
 
88
129
  ```ruby
89
- schema = RSchema.schema do
90
- Array[{
91
- name: String,
92
- hair: enum([:red, :brown, :blonde, :black])
93
- }]
130
+ schema = RSchema.define do
131
+ Array(
132
+ Hash(
133
+ name: _String,
134
+ hair: enum([:red, :brown, :blonde, :black])
135
+ )
136
+ )
94
137
  end
95
138
 
96
- value = [
139
+ input = [
97
140
  { name: 'Dane', hair: :black },
98
141
  { name: 'Tom', hair: :brown },
99
142
  { name: 'Effie', hair: :blond },
100
143
  { name: 'Chris', hair: :red },
101
144
  ]
102
145
 
103
- RSchema.validate(schema, value) #=> false
146
+ result = schema.call(input)
147
+
148
+ result.class #=> RSchema::Result
149
+ result.valid? #=> false
150
+ result.error #=> { 2 => { :hair => #<RSchema::Error> } }
151
+ result.error[2][:hair].to_s
152
+ #=> "Error RSchema::Schemas::Enum/not_a_member for value: :blond"
104
153
  ```
105
154
 
106
- To see exactly where validation fails, we can look at an
107
- `RSchema::ErrorDetails` object.
155
+ The error above says that the value `:blond`, which exists at location
156
+ `input[2][:hair]`, is not a valid enum member. Looking back at the schema, we
157
+ see that there is a typo, and it should be `:blonde` instead of `:blond`.
108
158
 
109
- The `validate!` method throws an exception when validation fails, and the
110
- exception contains the `RSchema::ErrorDetails` object.
159
+ Error objects contain a lot of information, which can be used to generate
160
+ error messages for developers or users.
111
161
 
112
162
  ```ruby
113
- RSchema.validate!(schema, value) # throws exception:
114
- #=> RSchema::ValidationError: The value at [2, :hair] is not a valid enum member: :blond
163
+ error = result.error[2][:hair]
164
+ error.class #=> RSchema::Error
165
+
166
+ error.value #=> :blond
167
+ error.symbolic_name #=> :not_a_member
168
+ error.schema #=> #<RSchema::Schemas::Enum>
169
+ error.to_s #=> "Error RSchema::Schemas::Enum/not_a_member for value: :blond"
170
+ error.to_s(:detailed) #=>
171
+ # Error: not_a_member
172
+ # Schema: RSchema::Schemas::Enum
173
+ # Value: :blond
174
+ # Vars: nil
115
175
  ```
116
176
 
117
- The error above says that the value `:blond`, which exists at location
118
- `value[2][:hair]`, is not a valid enum member. Looking back at the schema, we
119
- see that there is a typo, and it should be `:blonde` instead of `:blond`.
177
+ Type Schemas
178
+ ------------
120
179
 
121
- To get an `RSchema::ErrorDetails` object _without_ using exceptions, we can use
122
- the `RSchema.validation_error` method.
180
+ The most basic kind of schema is a `Type` schema.
181
+ Type schemas validate the class of a value using `is_a?`.
123
182
 
124
183
  ```ruby
125
- error_details = RSchema.validation_error(schema, value)
184
+ schema = RSchema.define { type(String) }
185
+ schema.call('hi').valid? #=> true
186
+ schema.call(1234).valid? #=> false
187
+ ```
188
+
189
+ Type schemas are so common that the RSchema DSL provides a shorthand way to
190
+ create them, using an underscore prefix:
126
191
 
127
- error_details.failing_value #=> :blond
128
- error_details.reason #=> "is not a valid enum member"
129
- error_details.key_path #=> [2, :hair]
130
- error_details.to_s #=> "The value at [2, :hair] is not a valid enum member: :blond"
192
+ ```ruby
193
+ schema1 = RSchema.define { _Integer }
194
+ # is exactly the same as
195
+ schema2 = RSchema.define { type(Integer) }
131
196
  ```
132
197
 
133
- Array Schemas
134
- -------------
198
+ Because type schemas use `is_a?`, they handle subclasses, and can also be used
199
+ to check for `include`d modules like `Enumerable`:
135
200
 
136
- There are two types of array schemas. When the array schema has a single
137
- element, it is a variable-length array schema:
201
+ ```ruby
202
+ schema = RSchema.define { _Enumerable }
203
+ schema.call([1, 2, 3]).valid? #=> true
204
+ schema.call({ a: 1, b: 2 }).valid? #=> true
205
+ ```
206
+
207
+ Variable-length Array Schemas
208
+ -----------------------------
209
+
210
+ There are two types of array schemas.
211
+ The first type are `VariableLengthArray` schemas, where every element in the
212
+ array conforms to a single subschema:
138
213
 
139
214
  ```ruby
140
- schema = Array[Symbol]
141
- RSchema.validate(schema, [:a, :b, :c]) #=> true
142
- RSchema.validate(schema, [:a]) #=> true
143
- RSchema.validate(schema, []) #=> true
215
+ schema = RSchema.define { Array(_Symbol) }
216
+ schema.class #=> RSchema::Schemas::VariableLengthArray
217
+
218
+ schema.call([:a, :b, :c]).valid? #=> true
219
+ schema.call([:a]).valid? #=> true
220
+ schema.call([]).valid? #=> true
144
221
  ```
145
222
 
146
- Otherwise, it is a fixed-length array schema
223
+ Fixed-length Array Schemas
224
+ --------------------------
225
+
226
+ There are also `FixedLengthArray` schemas, where the array must have a specific
227
+ length, and each element of the array has a separate subschema:
147
228
 
148
229
  ```ruby
149
- schema = Array[Integer, String]
150
- RSchema.validate(schema, [10, 'hello']) #=> true
151
- RSchema.validate(schema, [10, 'hello', 'world']) #=> false
152
- RSchema.validate(schema, [10]) #=> false
230
+ schema = RSchema.define{ Array(_Integer, _String) }
231
+ schema.class #=> RSchema::Schemas::FixedLengthArray
232
+
233
+ schema.call([10, 'hello']).valid? #=> true
234
+ schema.call([22, 'world']).valid? #=> true
235
+ schema.call(['heyoo', 33]).valid? #=> false
153
236
  ```
154
237
 
155
- Hash Schemas
156
- ------------
238
+ Fixed Hash Schemas
239
+ ------------------
240
+
241
+ There are also two kinds of hash schemas.
242
+
243
+ `FixedHash` schemas describes hashes where they keys are known constants:
244
+
245
+ ```ruby
246
+ schema = RSchema.define do
247
+ Hash(name: _String, age: _Integer)
248
+ end
249
+
250
+ schema.call({ name: 'George', age: 2 }).valid? #=> true
251
+ ```
157
252
 
158
- Hash schemas map constant keys to subschema values:
253
+ Elements can be optional:
159
254
 
160
255
  ```ruby
161
- schema = { fname: String }
162
- RSchema.validate(schema, { fname: 'William' }) #=> true
256
+ schema = RSchema.define do
257
+ Hash(
258
+ name: _String,
259
+ optional(:age) => _Integer,
260
+ )
261
+ end
262
+
263
+ schema.call({ name: 'Lucy', age: 21 }).valid? #=> true
264
+ schema.call({ name: 'Tom' }).valid? #=> true
163
265
  ```
164
266
 
165
- Keys can be optional:
267
+ `FixedHash` schemas are common, so the `RSchema.define_hash` method exists
268
+ to make their creation more convenient:
166
269
 
167
270
  ```ruby
168
- schema = RSchema.schema {{
169
- :fname => String,
170
- optional(:age) => Integer
271
+ schema = RSchema.define_hash {{
272
+ name: _String,
273
+ optional(:age) => _Integer,
171
274
  }}
172
- RSchema.validate(schema, { fname: 'Lucy', age: 21 }) #=> true
173
- RSchema.validate(schema, { fname: 'Tom' }) #=> true
174
275
  ```
175
276
 
176
- There is also another type of hash schema that represents hashes with variable
177
- keys:
277
+ Variable Hash Schemas
278
+ ---------------------
279
+
280
+ `VariableHash` schemas are for hashes where the keys are _not_ known constants.
281
+ They contain one subschema for keys, and another subschema for values.
178
282
 
179
283
  ```ruby
180
- schema = RSchema.schema { hash_of(String => Integer) }
181
- RSchema.validate(schema, { 'hello' => 1, 'world' => 2 }) #=> true
182
- RSchema.validate(schema, { 'hello' => 1 }) #=> true
183
- RSchema.validate(schema, {}) #=> true
284
+ schema = RSchema.define { VariableHash(_Symbol => _Integer) }
285
+ schema.call({}).valid? #=> true
286
+ schema.call({ a: 1 }).valid? #=> true
287
+ schema.call({ a: 1, b: 2 }).valid? #=> true
184
288
  ```
185
289
 
186
290
  Other Schema Types
@@ -189,155 +293,262 @@ Other Schema Types
189
293
  RSchema provides a few other schema types through its DSL:
190
294
 
191
295
  ```ruby
192
- # boolean
193
- boolean_schema = RSchema.schema{ boolean }
194
- RSchema.validate(boolean_schema, false) #=> true
195
- RSchema.validate(boolean_schema, nil) #=> false
196
-
197
- # any
198
- any_schema = RSchema.schema{ any }
199
- RSchema.validate(any_schema, "Hi") #=> true
200
- RSchema.validate(any_schema, true) #=> true
201
- RSchema.validate(any_schema, false) #=> true
202
- RSchema.validate(any_schema, nil) #=> true
203
-
204
- # either
205
- either_schema = RSchema.schema{ either(String, Integer, Float) }
206
- RSchema.validate(either_schema, 'hi') #=> true
207
- RSchema.validate(either_schema, 5555) #=> true
208
- RSchema.validate(either_schema, 77.1) #=> true
209
- RSchema.validate(either_schema, nil) #=> false
210
-
211
- # maybe
212
- maybe_schema = RSchema.schema{ maybe(Integer) }
213
- RSchema.validate(maybe_schema, 5) #=> true
214
- RSchema.validate(maybe_schema, nil) #=> true
215
-
216
- # enum
217
- enum_schema = RSchema.schema{ enum([:a, :b, :c]) }
218
- RSchema.validate(enum_schema, :a) #=> true
219
- RSchema.validate(enum_schema, :z) #=> false
220
-
221
- # predicate
222
- predicate_schema = RSchema.schema do
223
- predicate('even') { |x| x.even? }
296
+ # boolean (only true or false)
297
+ boolean_schema = RSchema.define { Boolean() }
298
+ boolean_schema.call(true).valid? #=> true
299
+ boolean_schema.call(false).valid? #=> true
300
+ boolean_schema.call(nil).valid? #=> false
301
+
302
+ # anything (literally any value)
303
+ anything_schema = RSchema.define { anything }
304
+ anything_schema.call('Hi').valid? #=> true
305
+ anything_schema.call(true).valid? #=> true
306
+ anything_schema.call(1234).valid? #=> true
307
+ anything_schema.call(nil).valid? #=> true
308
+
309
+ # either (sum types)
310
+ either_schema = RSchema.define { either(_String, _Integer, _Float) }
311
+ either_schema.call('hi').valid? #=> true
312
+ either_schema.call(5555).valid? #=> true
313
+ either_schema.call(77.1).valid? #=> true
314
+
315
+ # maybe (allows nil)
316
+ maybe_schema = RSchema.define { maybe(_Integer) }
317
+ maybe_schema.call(5).valid? #=> true
318
+ maybe_schema.call(nil).valid? #=> true
319
+
320
+ # enum (a set of valid values)
321
+ enum_schema = RSchema.define { enum([:a, :b, :c]) }
322
+ enum_schema.call(:a).valid? #=> true
323
+ enum_schema.call(:z).valid? #=> false
324
+
325
+ # predicate (block returns true for valid values)
326
+ predicate_schema = RSchema.define do
327
+ predicate { |x| x.even? }
224
328
  end
225
- RSchema.validate(predicate_schema, 4) #=> true
226
- RSchema.validate(predicate_schema, 5) #=> false
329
+ predicate_schema.call(4).valid? #=> true
330
+ predicate_schema.call(5).valid? #=> false
331
+
332
+ # pipeline (apply multiple schemas to a single value, in order)
333
+ pipeline_schema = RSchema.define do
334
+ pipeline(
335
+ either(_Integer, _Float),
336
+ predicate { |x| x.positive? },
337
+ )
338
+ end
339
+ pipeline_schema.call(123).valid? #=> true
340
+ pipeline_schema.call(5.1).valid? #=> true
341
+ pipeline_schema.call(-24).valid? #=> false
227
342
  ```
228
343
 
344
+
229
345
  Coercion
230
346
  --------
231
347
 
232
- RSchema is capable of coercing invalid values into valid ones, in some
233
- situations. Here are some examples:
348
+ Coercers convert invalid data into valid data where possible, according to a
349
+ schema.
350
+
351
+ Take HTTP params as an example. Web forms often contain database IDs, which
352
+ are integers, but are submitted as strings by the browser. Param hash keys
353
+ are often expected to be `Symbol`s, but are also strings. The `HTTPCoercer`
354
+ can automatically convert these strings into the appropriate type, based on a
355
+ schema.
234
356
 
235
357
  ```ruby
236
- RSchema.coerce!(Symbol, "hello") #=> :hello
237
- RSchema.coerce!(String, :hello) #=> "hello"
238
- RSchema.coerce!(Integer, "5") #=> 5
239
- RSchema.coerce!(Integer, "cat") # !!! raises RSchema::ValidationError !!!
240
- RSchema.coerce!(Set, [1, 2, 3]) #=> <Set: {1, 2, 3}>
241
-
242
- schema = RSchema.schema {{
243
- fname: String,
244
- favourite_foods: set_of(Symbol)
358
+ # Input keys and values are all strings.
359
+ input_params = {
360
+ 'whatever_id' => '5',
361
+ 'amount' => '123.45',
362
+ }
363
+
364
+ # The schema expects symbol keys, an integer value, and a float value.
365
+ param_schema = RSchema.define_hash {{
366
+ whatever_id: _Integer,
367
+ amount: _Float,
245
368
  }}
246
369
 
247
- value = {
248
- fname: 'Peggy',
249
- favourite_foods: ['berries', 'cake']
250
- }
370
+ # The schema is wrapped in a HTTPCoercer.
371
+ coercer = RSchema::HTTPCoercer.wrap(param_schema)
372
+
373
+ # Use the coercer like a normal schema object.
374
+ result = coercer.call(input_params)
251
375
 
252
- RSchema.coerce!(schema, value)
253
- #=> { fname: "Peggy", favourite_foods: <Set: #{:berries, :cake}> }
376
+ # The result object contains the coerced value
377
+ result.valid? #=> true
378
+ result.value #=> { :whatever_id => 5, :amount => 123.45 }
254
379
  ```
255
380
 
256
- Extending the DSL
381
+ TODO: explain how to create custom coercers
382
+
383
+ Extending The DSL
257
384
  -----------------
258
385
 
259
- You can create new, custom DSLs that extend the default DSL like so:
386
+ To add methods to the default DSL, first create a module:
260
387
 
261
388
  ```ruby
262
- module MyCustomDSL
263
- extend RSchema::DSL::Base
264
- def self.positive_and_even(type)
265
- predicate { |x| x > 0 && x.even? }
389
+ module MyCustomMethods
390
+ def palendrome
391
+ pipeline(
392
+ _String,
393
+ predicate { |s| s == s.reverse },
394
+ )
266
395
  end
267
396
  end
268
397
  ```
269
398
 
270
- Pass the custom DSL to `RSchema.schema` to use it:
399
+ Then include your module into `RSchema::DefaultDSL`:
271
400
 
272
401
  ```ruby
273
- schema = RSchema.schema(MyCustomDSL) { positive_and_even }
274
- RSchema.validate(schema, 6) #=> true
275
- RSchema.validate(schema, -6) #=> false
402
+ RSchema::DefaultDSL.include(MyCustomMethods)
276
403
  ```
277
404
 
405
+ And your methods will be available via `RSchema.define`:
406
+
407
+ ```ruby
408
+ schema = RSchema.define { palendrome }
409
+
410
+ schema.call('racecar').valid? #=> true
411
+ schema.call('ferrari').valid? #=> false
412
+ ```
413
+
414
+ This is the preferred way for other gems to extend RSchema with new kinds
415
+ of schema classes.
416
+
417
+
418
+ Creating Your Own DSL
419
+ ---------------------
420
+
421
+ The default DSL is designed to be extended (i.e. modified) by external gems/code.
422
+ If you want a DSL that isn't affected by external factors, you can create one
423
+ yourself.
424
+
425
+ Create a new class, and include `RSchema::DSL` to get all the standard DSL
426
+ methods that come built-in to RSchema. You can define your own custom methods
427
+ on this class.
428
+
429
+ ```ruby
430
+ class MyCustomDSL
431
+ include RSchema::DSL
432
+
433
+ def palendrome
434
+ pipeline(
435
+ _String,
436
+ predicate { |s| s == s.reverse },
437
+ )
438
+ end
439
+ end
440
+ ```
441
+
442
+ Then simply use `instance_eval` to make use of your custom DSL.
443
+
444
+ ```ruby
445
+ schema = MyCustomDSL.new.instance_eval { palendrome }
446
+ schema.call('racecar').valid? #=> true
447
+ ```
448
+
449
+ See the implementation of `RSchema.define` for reference.
450
+
451
+
278
452
  Custom Schema Types
279
453
  -------------------
280
454
 
281
- Any Ruby object can be a schema, as long as it implements the `schema_walk`
282
- method. Here is a schema called `Coordinate`, which is an x/y pair of `Float`s
283
- in an array:
455
+ Schemas are objects that conform to a certain interface (i.e. a duck type).
456
+ To create your own schema types, you just need to implement this interface.
457
+
458
+ Below is a custom schema for pairs – arrays with two elements of the same type.
459
+ This is already possible using existing schemas (e.g. `Array(_String, _String)`),
460
+ and is only shown here for the purpose of demonstration.
284
461
 
285
462
  ```ruby
286
- # make the schema type class
287
- class CoordinateSchema
288
- def schema_walk(value, mapper)
289
- # validate `value`
290
- return RSchema::ErrorDetails.new(value, 'is not an Array') unless value.is_a?(Array)
291
- return RSchema::ErrorDetails.new(value, 'does not have two elements') unless value.size == 2
292
-
293
- # walk the subschemas/subvalues
294
- x, x_error = RSchema.walk(Float, value[0], mapper)
295
- y, y_error = RSchema.walk(Float, value[1], mapper)
296
-
297
- # look for subschema errors, and propagate them if found
298
- return x_error.extend_key_path(:x) if x_error
299
- return y_error.extend_key_path(:y) if y_error
300
-
301
- # return the valid value
302
- [x, y]
463
+ class PairSchema
464
+ def initialize(subschema)
465
+ @subschema = subschema
466
+ end
467
+
468
+ def call(pair, options=RSchema::Options.default)
469
+ return not_an_array_failure(pair) unless pair.is_a?(Array)
470
+ return not_a_pair_failure(pair) unless pair.size == 2
471
+
472
+ subresults = pair.map { |x| @subschema.call(x, options) }
473
+
474
+ if subresults.all?(&:valid?)
475
+ RSchema::Result.success(subresults.map(&:value).to_a)
476
+ else
477
+ RSchema::Result.failure(subschema_error(subresults))
478
+ end
303
479
  end
480
+
481
+ def with_wrapped_subschemas(wrapper)
482
+ PairSchema.new(wrapper.wrap(@subschema))
483
+ end
484
+
485
+ private
486
+
487
+ def not_an_array_failure(pair)
488
+ RSchema::Result.failure(
489
+ RSchema::Error.new(
490
+ symbolic_name: :not_an_array,
491
+ schema: self,
492
+ value: pair,
493
+ )
494
+ )
495
+ end
496
+
497
+ def not_a_pair_failure(pair)
498
+ RSchema::Result.failure(
499
+ RSchema::Error.new(
500
+ symbolic_name: :not_a_pair,
501
+ schema: self,
502
+ value: pair,
503
+ vars: {
504
+ expected_size: 2,
505
+ actual_size: pair.size,
506
+ }
507
+ )
508
+ )
509
+ end
510
+
511
+ def subschema_error(subresults)
512
+ subresults
513
+ .each_with_index
514
+ .select { |(result, idx)| result.invalid? }
515
+ .map(&:reverse)
516
+ .to_h
517
+ end
304
518
  end
519
+ ```
520
+
521
+ TODO: need to explain how to implement `#call` and `#with_wrapped_subschemas`
522
+
523
+ Add your new schema class to the default DSL:
305
524
 
306
- # add some DSL
307
- module RSchema::DSL
308
- def self.coordinate
309
- CoordinateSchema.new
525
+ ```ruby
526
+ module PairSchemaDSL
527
+ def pair(subschema)
528
+ PairSchema.new(subschema)
310
529
  end
311
530
  end
312
531
 
313
- # use the custom schema type (coercion works too)
314
- schema = RSchema.schema { coordinate }
315
- RSchema.validate(schema, [1.0, 2.0]) #=> true
316
- RSchema.validate(schema, [1, 2]) #=> false
317
- RSchema.coerce!(schema, ["1", "2"]) #=> [1.0, 2.0]
532
+ RSchema::DefaultDSL.include(PairSchemaDSL)
318
533
  ```
319
534
 
320
- The `schema_walk` method receives two arguments:
321
-
322
- - `value`: the value that is being validated against this schema
323
- - `mapper`: not usually used by the schema, but must be passed to
324
- `RSchema.walk`.
535
+ Then your schema is accessible from `RSchema.define`:
325
536
 
326
- The `schema_walk` method has three responsibilities:
537
+ ```ruby
538
+ gps_coordinate_schema = RSchema.define { pair(_Float) }
539
+ gps_coordinate_schema.call([1.2, 3.4]).valid? #=> true
540
+ ```
327
541
 
328
- 1. It must validate the given value. If the value is invalid, the method must
329
- return an `RSchema::ErrorDetails` object. If the value is valid, it must
330
- return the valid value after walking all subvalues.
542
+ Coercion should work, as long as `#with_wrapped_subschemas` was implemented
543
+ correctly.
331
544
 
332
- 2. For composite schemas, it must walk subvalues by calling `RSchema.walk`.
333
- The example above walks two subvalues (`value[0]` and `value[1]`) with the
334
- `Float` schema.
545
+ ```ruby
546
+ coercer = RSchema::HTTPCoercer.wrap(gps_coordinate_schema)
547
+ result = coercer.call(['1', '2'])
548
+ result.valid? #=> true
549
+ result.value #=> [1.0, 2.0]
550
+ ```
335
551
 
336
- 3. It must propagate any `RSchema::ErrorDetails` objects returned from walking
337
- the subvalues. Walking subvalues with `RSchema.walk` may return an error,
338
- in which case the `rschema_walk` method must also return an error. Use the
339
- method `RSchema::ErrorDetails#extend_key_path` in this situation, to
340
- include additional information in the error before returning it.
341
552
 
342
553
  [Prismatic/schema]: https://github.com/Prismatic/schema
343
554