parametric 0.0.5 → 0.1.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 +4 -4
- data/.gitignore +1 -0
- data/.travis.yml +1 -2
- data/README.md +596 -163
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/lib/parametric/block_validator.rb +64 -0
- data/lib/parametric/context.rb +44 -0
- data/lib/parametric/default_types.rb +95 -0
- data/lib/parametric/dsl.rb +47 -0
- data/lib/parametric/field.rb +111 -0
- data/lib/parametric/field_dsl.rb +20 -0
- data/lib/parametric/policies.rb +94 -55
- data/lib/parametric/registry.rb +21 -0
- data/lib/parametric/results.rb +13 -0
- data/lib/parametric/schema.rb +151 -0
- data/lib/parametric/version.rb +1 -1
- data/lib/parametric.rb +16 -6
- data/parametric.gemspec +2 -1
- data/spec/dsl_spec.rb +135 -0
- data/spec/field_spec.rb +404 -0
- data/spec/policies_spec.rb +72 -0
- data/spec/schema_spec.rb +253 -0
- data/spec/schema_walk_spec.rb +42 -0
- data/spec/spec_helper.rb +1 -0
- data/spec/validators_spec.rb +97 -0
- metadata +54 -24
- data/lib/parametric/hash.rb +0 -38
- data/lib/parametric/params.rb +0 -86
- data/lib/parametric/typed_params.rb +0 -23
- data/lib/parametric/utils.rb +0 -24
- data/lib/support/class_attribute.rb +0 -68
- data/spec/nested_params_spec.rb +0 -90
- data/spec/parametric_spec.rb +0 -261
data/README.md
CHANGED
@@ -2,302 +2,735 @@
|
|
2
2
|
[](https://travis-ci.org/ismasan/parametric)
|
3
3
|
[](http://badge.fury.io/rb/parametric)
|
4
4
|
|
5
|
-
|
5
|
+
Declaratively define data schemas in your Ruby objects, and use them to whitelist, validate or transform inputs to your programs.
|
6
6
|
|
7
|
-
Useful for building self-documeting APIs, search or form objects.
|
7
|
+
Useful for building self-documeting APIs, search or form objects. Or possibly as an alternative to Rails' _strong parameters_ (it has no dependencies on Rails and can be used stand-alone).
|
8
8
|
|
9
|
-
##
|
9
|
+
## Schema
|
10
10
|
|
11
|
-
|
11
|
+
Define a schema
|
12
12
|
|
13
13
|
```ruby
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
param :per_page, 'Items per page', default: 30
|
19
|
-
param :status, 'Order status', options: ['checkout', 'pending', 'closed', 'shipped'], multiple: true
|
14
|
+
schema = Parametric::Schema.new do
|
15
|
+
field(:title).type(:string).present
|
16
|
+
field(:status).options(["draft", "published"]).default("draft")
|
17
|
+
field(:tags).type(:array)
|
20
18
|
end
|
21
19
|
```
|
22
20
|
|
23
21
|
Populate and use. Missing keys return defaults, if provided.
|
24
22
|
|
25
23
|
```ruby
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
order_search.params[:status] # => nil
|
24
|
+
form = schema.resolve(title: "A new blog post", tags: ["tech"])
|
25
|
+
|
26
|
+
form.output # => {title: "A new blog post", tags: ["tech"], status: "draft"}
|
27
|
+
form.errors # => {}
|
31
28
|
```
|
32
29
|
|
33
30
|
Undeclared keys are ignored.
|
34
31
|
|
35
32
|
```ruby
|
36
|
-
|
37
|
-
|
33
|
+
form = schema.resolve(foobar: "BARFOO", title: "A new blog post", tags: ["tech"])
|
34
|
+
|
35
|
+
form.output # => {title: "A new blog post", tags: ["tech"], status: "draft"}
|
38
36
|
```
|
39
37
|
|
38
|
+
Validations are run and errors returned
|
39
|
+
|
40
|
+
|
40
41
|
```ruby
|
41
|
-
|
42
|
-
|
42
|
+
form = schema.resolve({})
|
43
|
+
form.errors # => {"$.title" => ["is required"]}
|
43
44
|
```
|
44
45
|
|
45
|
-
|
46
|
-
|
47
|
-
A class that declares allowed params and defaults, and builds a query.
|
46
|
+
If options are defined, it validates that value is in options
|
48
47
|
|
49
48
|
```ruby
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
49
|
+
form = schema.resolve({title: "A new blog post", status: "foobar"})
|
50
|
+
form.errors # => {"$.status" => ["expected one of draft, published but got foobar"]}
|
51
|
+
```
|
52
|
+
|
53
|
+
## Nested schemas
|
54
|
+
|
55
|
+
A schema can have nested schemas, for example for defining complex forms.
|
57
56
|
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
57
|
+
```ruby
|
58
|
+
person_schema = Parametric::Schema.new do
|
59
|
+
field(:name).type(:string).required
|
60
|
+
field(:age).type(:integer)
|
61
|
+
field(:friends).type(:array).schema do
|
62
|
+
field(:name).type(:string).required
|
63
|
+
field(:email).policy(:email)
|
63
64
|
end
|
64
65
|
end
|
65
66
|
```
|
66
67
|
|
67
|
-
|
68
|
+
It works as expected
|
69
|
+
|
70
|
+
```ruby
|
71
|
+
results = person_schema.resolve(
|
72
|
+
name: "Joe",
|
73
|
+
age: "38",
|
74
|
+
friends: [
|
75
|
+
{name: "Jane", email: "jane@email.com"}
|
76
|
+
]
|
77
|
+
)
|
78
|
+
|
79
|
+
results.output # => {name: "Joe", age: 38, friends: [{name: "Jane", email: "jane@email.com"}]}
|
80
|
+
```
|
81
|
+
|
82
|
+
Validation errors use [JSON path](http://goessner.net/articles/JsonPath/) expressions to describe errors in nested structures
|
83
|
+
|
84
|
+
```ruby
|
85
|
+
results = person_schema.resolve(
|
86
|
+
name: "Joe",
|
87
|
+
age: "38",
|
88
|
+
friends: [
|
89
|
+
{email: "jane@email.com"}
|
90
|
+
]
|
91
|
+
)
|
92
|
+
|
93
|
+
results.errors # => {"$.friends[0].name" => "is required"}
|
94
|
+
```
|
95
|
+
|
96
|
+
### Reusing nested schemas
|
68
97
|
|
69
|
-
|
98
|
+
You can optionally use an existing schema instance as a nested schema:
|
70
99
|
|
71
100
|
```ruby
|
72
|
-
|
73
|
-
|
74
|
-
|
101
|
+
friends_schema = Parametric::Schema.new do
|
102
|
+
field(:friends).type(:array).schema do
|
103
|
+
field(:name).type(:string).required
|
104
|
+
field(:email).policy(:email)
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
person_schema = Parametric::Schema.new do
|
109
|
+
field(:name).type(:string).required
|
110
|
+
field(:age).type(:integer)
|
111
|
+
# Nest friends_schema
|
112
|
+
field(:friends).type(:array).schema(friends_schema)
|
75
113
|
end
|
76
114
|
```
|
77
115
|
|
78
|
-
|
116
|
+
## Built-in policies
|
117
|
+
|
118
|
+
Type coercions (the `type` method) and validations (the `validate` method) are all _policies_.
|
119
|
+
|
120
|
+
Parametric ships with a number of built-in policies.
|
121
|
+
|
122
|
+
### :string
|
79
123
|
|
80
|
-
|
124
|
+
Calls `:to_s` on the value
|
81
125
|
|
82
126
|
```ruby
|
83
|
-
|
84
|
-
|
85
|
-
|
127
|
+
field(:title).type(:string)
|
128
|
+
```
|
129
|
+
|
130
|
+
### :integer
|
131
|
+
|
132
|
+
Calls `:to_i` on the value
|
133
|
+
|
134
|
+
```ruby
|
135
|
+
field(:age).type(:integer)
|
136
|
+
```
|
137
|
+
|
138
|
+
### :number
|
139
|
+
|
140
|
+
Calls `:to_f` on the value
|
141
|
+
|
142
|
+
```ruby
|
143
|
+
field(:price).type(:number)
|
144
|
+
```
|
145
|
+
|
146
|
+
### :boolean
|
147
|
+
|
148
|
+
Returns `true` or `false` (`nil` is converted to `false`).
|
149
|
+
|
150
|
+
```ruby
|
151
|
+
field(:published).type(:boolean)
|
152
|
+
```
|
153
|
+
|
154
|
+
### :datetime
|
155
|
+
|
156
|
+
Attempts parsing value with [Datetime.parse](http://ruby-doc.org/stdlib-2.3.1/libdoc/date/rdoc/DateTime.html#method-c-parse). If invalid, the error will be added to the output's `errors` object.
|
157
|
+
|
158
|
+
```ruby
|
159
|
+
field(:expires_on).type(:datetime)
|
160
|
+
```
|
161
|
+
|
162
|
+
### :format
|
163
|
+
|
164
|
+
Check value against custom regexp
|
165
|
+
|
166
|
+
```ruby
|
167
|
+
field(:salutation).policy(:format, /^Mr\/s/)
|
168
|
+
# optional custom error message
|
169
|
+
field(:salutation).policy(:format, /^Mr\/s\./, "must start with Mr/s.")
|
170
|
+
```
|
171
|
+
|
172
|
+
### :email
|
173
|
+
|
174
|
+
```ruby
|
175
|
+
field(:business_email).policy(:email)
|
176
|
+
```
|
177
|
+
|
178
|
+
### :required
|
179
|
+
|
180
|
+
Check that the key exists in the input.
|
181
|
+
|
182
|
+
```ruby
|
183
|
+
field(:name).required
|
184
|
+
|
185
|
+
# same as
|
186
|
+
field(:name).policy(:required)
|
187
|
+
```
|
188
|
+
|
189
|
+
Note that _required_ does not validate that the value is not empty. Use _present_ for that.
|
190
|
+
|
191
|
+
### :present
|
192
|
+
|
193
|
+
Check that the key exists and the value is not blank.
|
194
|
+
|
195
|
+
```ruby
|
196
|
+
field(:name).present
|
197
|
+
|
198
|
+
# same as
|
199
|
+
field(:name).policy(:present)
|
200
|
+
```
|
201
|
+
|
202
|
+
If the value is a `String`, it validates that it's not blank. If an `Array`, it checks that it's not empty. Otherwise it checks that the value is not `nil`.
|
203
|
+
|
204
|
+
### :declared
|
205
|
+
|
206
|
+
Check that a key exists in the input, or stop any further validations otherwise.
|
207
|
+
This is useful when chained to other validations. For example:
|
208
|
+
|
209
|
+
```ruby
|
210
|
+
field(:name).declared.present
|
211
|
+
```
|
212
|
+
|
213
|
+
The example above will check that the value is not empty, but only if the key exists. If the key doesn't exist no validations will run.
|
214
|
+
|
215
|
+
### :gt
|
216
|
+
|
217
|
+
Validate that the value is greater than a number
|
218
|
+
|
219
|
+
```ruby
|
220
|
+
field(:age).policy(:gt, 21)
|
221
|
+
```
|
222
|
+
|
223
|
+
### :lt
|
224
|
+
|
225
|
+
Validate that the value is less than a number
|
226
|
+
|
227
|
+
```ruby
|
228
|
+
field(:age).policy(:lt, 21)
|
229
|
+
```
|
230
|
+
|
231
|
+
### :options
|
232
|
+
|
233
|
+
Pass allowed values for a field
|
234
|
+
|
235
|
+
```ruby
|
236
|
+
field(:status).options(["draft", "published"])
|
237
|
+
|
238
|
+
# Same as
|
239
|
+
field(:status).policy(:options, ["draft", "published"])
|
240
|
+
```
|
241
|
+
|
242
|
+
### :split
|
243
|
+
|
244
|
+
Split comma-separated string values into an array.
|
245
|
+
Useful for parsing comma-separated query-string parameters.
|
246
|
+
|
247
|
+
```ruby
|
248
|
+
field(:status).policy(:split) # turns "pending,confirmed" into ["pending", "confirmed"]
|
249
|
+
```
|
250
|
+
|
251
|
+
## Custom policies
|
252
|
+
|
253
|
+
You can also register your own custom policy objects. A policy must implement the following methods:
|
254
|
+
|
255
|
+
```ruby
|
256
|
+
class MyPolicy
|
257
|
+
# Validation error message, if invalid
|
258
|
+
def message
|
259
|
+
'is invalid'
|
260
|
+
end
|
261
|
+
|
262
|
+
# Whether or not to validate and coerce this value
|
263
|
+
# if false, no other policies will be run on the field
|
264
|
+
def eligible?(value, key, payload)
|
265
|
+
true
|
266
|
+
end
|
267
|
+
|
268
|
+
# Transform the value
|
269
|
+
def coerce(value, key, context)
|
270
|
+
value
|
271
|
+
end
|
272
|
+
|
273
|
+
# Is the value valid?
|
274
|
+
def valid?(value, key, payload)
|
275
|
+
true
|
276
|
+
end
|
86
277
|
end
|
87
278
|
```
|
88
279
|
|
89
|
-
|
280
|
+
You can register your policy with:
|
281
|
+
|
282
|
+
```ruby
|
283
|
+
Parametric.policy :my_policy, MyPolicy
|
284
|
+
```
|
285
|
+
|
286
|
+
And then refer to it by name when declaring your schema fields
|
287
|
+
|
288
|
+
```ruby
|
289
|
+
field(:title).policy(:my_policy)
|
290
|
+
```
|
291
|
+
|
292
|
+
You can chain custom policies with other policies.
|
90
293
|
|
91
|
-
|
294
|
+
```ruby
|
295
|
+
field(:title).required.policy(:my_policy)
|
296
|
+
```
|
297
|
+
|
298
|
+
Note that you can also register instances.
|
92
299
|
|
93
300
|
```ruby
|
94
|
-
|
95
|
-
|
96
|
-
|
301
|
+
Parametric.policy :my_policy, MyPolicy.new
|
302
|
+
```
|
303
|
+
|
304
|
+
For example, a policy that can be configured on a field-by-field basis:
|
305
|
+
|
306
|
+
```ruby
|
307
|
+
class AddJobTitle
|
308
|
+
def initialize(job_title)
|
309
|
+
@job_title = job_title
|
310
|
+
end
|
311
|
+
|
312
|
+
def message
|
313
|
+
'is invalid'
|
314
|
+
end
|
315
|
+
|
316
|
+
# Noop
|
317
|
+
def eligible?(value, key, payload)
|
318
|
+
true
|
319
|
+
end
|
320
|
+
|
321
|
+
# Add job title to value
|
322
|
+
def coerce(value, key, context)
|
323
|
+
"#{value}, #{@job_title}"
|
324
|
+
end
|
325
|
+
|
326
|
+
# Noop
|
327
|
+
def valid?(value, key, payload)
|
328
|
+
true
|
329
|
+
end
|
97
330
|
end
|
98
331
|
|
99
|
-
|
100
|
-
|
332
|
+
# Register it
|
333
|
+
Parametric.policy :job_title, AddJobTitle
|
101
334
|
```
|
102
335
|
|
103
|
-
|
336
|
+
Now you can reuse the same policy with different configuration
|
104
337
|
|
105
338
|
```ruby
|
106
|
-
|
107
|
-
|
108
|
-
param :status, 'Order status', options: ['checkout', 'pending', 'closed', 'shipped'], multiple: true
|
339
|
+
manager_schema = Parametric::Schema.new do
|
340
|
+
field(:name).type(:string).policy(:job_title, "manager")
|
109
341
|
end
|
110
342
|
|
111
|
-
|
112
|
-
|
343
|
+
cto_schema = Parametric::Schema.new do
|
344
|
+
field(:name).type(:string).policy(:job_title, "CTO")
|
345
|
+
end
|
346
|
+
|
347
|
+
manager_schema.resolve(name: "Joe Bloggs").output # => {name: "Joe Bloggs, manager"}
|
348
|
+
cto_schema.resolve(name: "Joe Bloggs").output # => {name: "Joe Bloggs, CTO"}
|
113
349
|
```
|
114
350
|
|
115
|
-
|
351
|
+
## Custom policies, short version
|
352
|
+
|
353
|
+
For simple policies that don't need all policy methods, you can:
|
116
354
|
|
117
355
|
```ruby
|
118
|
-
|
119
|
-
|
120
|
-
|
356
|
+
Parametric.policy :cto_job_title do
|
357
|
+
coerce do |value, key, context|
|
358
|
+
"#{value}, CTO"
|
359
|
+
end
|
360
|
+
end
|
361
|
+
|
362
|
+
# use it
|
363
|
+
cto_schema = Parametric::Schema.new do
|
364
|
+
field(:name).type(:string).policy(:cto_job_title)
|
365
|
+
end
|
366
|
+
```
|
367
|
+
|
368
|
+
```ruby
|
369
|
+
Parametric.policy :over_21_and_under_25 do
|
370
|
+
coerce do |age, key, context|
|
371
|
+
age.to_i
|
372
|
+
end
|
373
|
+
|
374
|
+
validate do |age, key, context|
|
375
|
+
age > 21 && age < 25
|
376
|
+
end
|
121
377
|
end
|
378
|
+
```
|
379
|
+
|
380
|
+
## Cloning schemas
|
381
|
+
|
382
|
+
The `#clone` method returns a new instance of a schema with all field definitions copied over.
|
383
|
+
|
384
|
+
```ruby
|
385
|
+
new_schema = original_schema.clone
|
386
|
+
```
|
122
387
|
|
123
|
-
|
124
|
-
|
388
|
+
New copies can be further manipulated without affecting the original.
|
389
|
+
|
390
|
+
```ruby
|
391
|
+
# See below for #policy and #ignore
|
392
|
+
new_schema = original_schema.clone.policy(:declared).ignore(:id) do |sc|
|
393
|
+
field(:another_field).present
|
394
|
+
end
|
125
395
|
```
|
126
396
|
|
127
|
-
|
397
|
+
## Merging schemas
|
128
398
|
|
129
|
-
|
399
|
+
The `#merge` method will merge field definitions in two schemas and produce a new schema instance.
|
130
400
|
|
131
|
-
|
401
|
+
```ruby
|
402
|
+
basic_user_schema = Parametric::Schema.new do
|
403
|
+
field(:name).type(:string).required
|
404
|
+
field(:age).type(:integer)
|
405
|
+
end
|
406
|
+
|
407
|
+
friends_schema = Parametric::Schema.new do
|
408
|
+
field(:friends).type(:array).schema do
|
409
|
+
field(:name).required
|
410
|
+
field(:email).policy(:email)
|
411
|
+
end
|
412
|
+
end
|
413
|
+
|
414
|
+
user_with_friends_schema = basic_user_schema.merge(friends_schema)
|
415
|
+
|
416
|
+
results = user_with_friends_schema.resolve(input)
|
417
|
+
```
|
418
|
+
|
419
|
+
Fields defined in the merged schema will override fields with the same name in the original schema.
|
132
420
|
|
133
421
|
```ruby
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
param :tags, 'Tags', multiple: true
|
422
|
+
required_name_schema = Parametric::Schema.new do
|
423
|
+
field(:name).required
|
424
|
+
field(:age)
|
138
425
|
end
|
139
426
|
|
140
|
-
|
141
|
-
|
427
|
+
optional_name_schema = Parametric::Schema.new do
|
428
|
+
field(:name)
|
429
|
+
end
|
430
|
+
|
431
|
+
# This schema now has :name and :age fields.
|
432
|
+
# :name has been redefined to not be required.
|
433
|
+
new_schema = required_name_schema.merge(optional_name_schema)
|
142
434
|
```
|
143
435
|
|
144
|
-
##
|
436
|
+
## #meta
|
145
437
|
|
146
|
-
`#
|
438
|
+
The `#meta` field method can be used to add custom meta data to field definitions.
|
439
|
+
These meta data can be used later when instrospecting schemas (ie. to generate documentation or error notices).
|
147
440
|
|
148
441
|
```ruby
|
149
|
-
|
150
|
-
|
442
|
+
create_user_schema = Parametric::Schema.do
|
443
|
+
field(:name).required.type(:string).meta(label: "User's full name")
|
444
|
+
field(:status).options(["published", "unpublished"]).default("published")
|
445
|
+
field(:age).type(:integer).meta(label: "User's age")
|
446
|
+
field(:friends).type(:array).meta(label: "User friends").schema do
|
447
|
+
field(:name).type(:string).present.meta(label: "Friend full name")
|
448
|
+
field(:email).policy(:email).meta(label: "Friend's email")
|
449
|
+
end
|
450
|
+
end
|
151
451
|
```
|
152
452
|
|
153
|
-
##
|
453
|
+
## #structure
|
154
454
|
|
155
|
-
|
455
|
+
A `Schema` instance has a `#structure` method that allows instrospecting schema meta data.
|
156
456
|
|
157
457
|
```ruby
|
158
|
-
|
159
|
-
|
458
|
+
create_user_schema.structure[:name][:label] # => "User's full name"
|
459
|
+
create_user_schema.structure[:age][:label] # => "User's age"
|
460
|
+
create_user_schema.structure[:friends][:label] # => "User friends"
|
461
|
+
# Recursive schema structures
|
462
|
+
create_user_schema.structure[:friends].structure[:name].label # => "Friend full name"
|
463
|
+
```
|
160
464
|
|
161
|
-
|
162
|
-
order_search.schema[:page].value # => 1
|
465
|
+
Note that many field policies add field meta data.
|
163
466
|
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
467
|
+
```ruby
|
468
|
+
create_user_schema.schema[:name][:type] # => :string
|
469
|
+
create_user_schema.schema[:name][:required] # => true
|
470
|
+
create_user_schema.schema[:status][:options] # => ["published", "unpublished"]
|
471
|
+
create_user_schema.schema[:status][:default] # => "published"
|
169
472
|
```
|
170
473
|
|
171
|
-
##
|
474
|
+
## #walk
|
172
475
|
|
173
|
-
|
476
|
+
The `#walk` method can recursively walk a schema definition and extract meta data or field attributes.
|
174
477
|
|
175
478
|
```ruby
|
176
|
-
|
177
|
-
|
178
|
-
param :age, 'User age', coerce: :to_i
|
179
|
-
param :name, 'User name', coerce: lambda{|name| "Mr. #{name}"}
|
479
|
+
schema_documentation = create_user_schema.walk do |field|
|
480
|
+
{type: field.meta_data[:type], label: field.meta_data[:label]}
|
180
481
|
end
|
181
482
|
|
182
|
-
|
483
|
+
# Returns
|
484
|
+
|
485
|
+
{
|
486
|
+
name: {type: :string, label: "User's full name"},
|
487
|
+
age: {type: :integer, label: "User's age"},
|
488
|
+
status: {type: :string, label: nil},
|
489
|
+
friends: [
|
490
|
+
{
|
491
|
+
name: {type: :string, label: "Friend full name"},
|
492
|
+
email: {type: nil, label: "Friend email"}
|
493
|
+
}
|
494
|
+
]
|
495
|
+
}
|
496
|
+
```
|
497
|
+
|
498
|
+
When passed a _symbol_, it will collect that key from field meta data.
|
183
499
|
|
184
|
-
|
185
|
-
|
500
|
+
```ruby
|
501
|
+
schema_labels = create_user_schema.walk(:label)
|
502
|
+
|
503
|
+
# returns
|
504
|
+
|
505
|
+
{
|
506
|
+
name: "User's full name",
|
507
|
+
age: "User's age",
|
508
|
+
status: nil,
|
509
|
+
friends: [
|
510
|
+
{name: "Friend full name", email: "Friend email"}
|
511
|
+
]
|
512
|
+
}
|
186
513
|
```
|
187
514
|
|
188
|
-
|
515
|
+
Potential uses for this are generating documentation (HTML, or [JSON Schema](http://json-schema.org/), [Swagger](http://swagger.io/), or maybe even mock API endpoints with example data.
|
516
|
+
|
517
|
+
## Form objects DSL
|
189
518
|
|
190
|
-
|
519
|
+
You can use schemas and fields on their own, or include the `DSL` module in your own classes to define form objects.
|
191
520
|
|
192
521
|
```ruby
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
522
|
+
require "parametric/dsl"
|
523
|
+
|
524
|
+
class CreateUserForm
|
525
|
+
include Parametric::DSL
|
526
|
+
|
527
|
+
schema do
|
528
|
+
field(:name).type(:string).required
|
529
|
+
field(:email).policy(:email).required
|
530
|
+
field(:age).type(:integer)
|
531
|
+
end
|
532
|
+
|
533
|
+
attr_reader :params, :errors
|
534
|
+
|
535
|
+
def initialize(input_data)
|
536
|
+
results = self.class.schema.resolve(input_data)
|
537
|
+
@params = results.output
|
538
|
+
@errors = results.errors
|
539
|
+
end
|
540
|
+
|
541
|
+
def run!
|
542
|
+
if !valid?
|
543
|
+
raise InvalidFormError.new(errors)
|
544
|
+
end
|
545
|
+
|
546
|
+
run
|
547
|
+
end
|
548
|
+
|
549
|
+
def valid?
|
550
|
+
!errors.any?
|
551
|
+
end
|
552
|
+
|
553
|
+
private
|
554
|
+
|
555
|
+
def run
|
556
|
+
User.create!(params)
|
557
|
+
end
|
200
558
|
end
|
201
559
|
```
|
202
560
|
|
203
|
-
|
561
|
+
### Form object inheritance
|
204
562
|
|
205
|
-
|
563
|
+
Sub classes of classes using the DSL will inherit schemas defined on the parent class.
|
206
564
|
|
207
565
|
```ruby
|
208
|
-
class
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
566
|
+
class UpdateUserForm < CreateUserForm
|
567
|
+
# All field definitions in the parent are conserved.
|
568
|
+
# New fields can be defined
|
569
|
+
# or existing fields overriden
|
570
|
+
schema do
|
571
|
+
# make this field optional
|
572
|
+
field(:name).declared.present
|
573
|
+
end
|
574
|
+
|
575
|
+
def initialize(user, input_data)
|
576
|
+
super input_data
|
577
|
+
@user = user
|
578
|
+
end
|
579
|
+
|
580
|
+
private
|
581
|
+
def run
|
582
|
+
@user.update params
|
583
|
+
end
|
213
584
|
end
|
214
585
|
```
|
215
586
|
|
587
|
+
### Schema-wide policies
|
588
|
+
|
589
|
+
Sometimes it's useful to apply the same policy to all fields in a schema.
|
590
|
+
|
591
|
+
For example, fields that are _required_ when creating a record might be optional when updating the same record (ie. _PATCH_ operations in APIs).
|
592
|
+
|
216
593
|
```ruby
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
order_params.each{|key, value| ... }
|
594
|
+
class UpdateUserForm < CreateUserForm
|
595
|
+
schema.policy(:declared)
|
596
|
+
end
|
221
597
|
```
|
222
598
|
|
223
|
-
|
599
|
+
This will prefix the `:declared` policy to all fields inherited from the parent class.
|
600
|
+
This means that only fields whose keys are present in the input will be validated.
|
224
601
|
|
225
|
-
|
602
|
+
Schemas with default policies can still define or re-define fields.
|
226
603
|
|
227
604
|
```ruby
|
228
|
-
class
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
param :name, 'User name'
|
233
|
-
param :title, 'Job title', default: 'Employee'
|
234
|
-
param :email, 'User email', match: /\w+@\w+\.\w+/
|
235
|
-
end
|
236
|
-
param :owner, 'Owner user' do
|
237
|
-
param :name, 'User name'
|
238
|
-
param :email, 'User email', match: /\w+@\w+\.\w+/
|
605
|
+
class UpdateUserForm < CreateUserForm
|
606
|
+
schema.policy(:declared) do
|
607
|
+
# Validation will only run if key exists
|
608
|
+
field(:age).type(:integer).present
|
239
609
|
end
|
240
610
|
end
|
241
611
|
```
|
242
612
|
|
243
|
-
|
613
|
+
### Ignoring fields defined in the parent class
|
614
|
+
|
615
|
+
Sometimes you'll want a child class to inherit most fields from the parent, but ignoring some.
|
244
616
|
|
245
617
|
```ruby
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
}
|
256
|
-
}
|
618
|
+
class CreateUserForm
|
619
|
+
include Parametric::DSL
|
620
|
+
|
621
|
+
schema do
|
622
|
+
field(:uuid).present
|
623
|
+
field(:status).required.options(["inactive", "active"])
|
624
|
+
field(:name)
|
625
|
+
end
|
626
|
+
end
|
257
627
|
```
|
258
628
|
|
259
|
-
|
629
|
+
The child class can use `ignore(*fields)` to ignore fields defined in the parent.
|
630
|
+
|
631
|
+
```ruby
|
632
|
+
class UpdateUserForm < CreateUserForm
|
633
|
+
schema.ignore(:uuid, :status) do
|
634
|
+
# optionally add new fields here
|
635
|
+
end
|
636
|
+
end
|
637
|
+
```
|
260
638
|
|
261
|
-
|
639
|
+
## Schema options
|
262
640
|
|
263
|
-
|
641
|
+
Another way of modifying inherited schemas is by passing options.
|
264
642
|
|
265
643
|
```ruby
|
266
|
-
|
267
|
-
|
268
|
-
|
644
|
+
class CreateUserForm
|
645
|
+
include Parametric::DSL
|
646
|
+
|
647
|
+
schema(default_policy: :noop) do |opts|
|
648
|
+
field(:name).policy(opts[:default_policy]).type(:string).required
|
649
|
+
field(:email).policy(opts[:default_policy).policy(:email).required
|
650
|
+
field(:age).type(:integer)
|
651
|
+
end
|
652
|
+
|
653
|
+
# etc
|
269
654
|
end
|
270
655
|
```
|
271
656
|
|
272
|
-
|
657
|
+
The `:noop` policy does nothing. The sub-class can pass its own _default_policy_.
|
273
658
|
|
274
659
|
```ruby
|
275
|
-
|
276
|
-
|
277
|
-
|
660
|
+
class UpdateUserForm < CreateUserForm
|
661
|
+
# this will only run validations keys existing in the input
|
662
|
+
schema(default_policy: :declared)
|
278
663
|
end
|
279
664
|
```
|
280
665
|
|
281
|
-
|
666
|
+
## A pattern: changing schema policy on the fly.
|
667
|
+
|
668
|
+
You can use a combination of `#clone` and `#policy` to change schema-wide field policies on the fly.
|
669
|
+
|
670
|
+
For example, you might have a form object that supports creating a new user and defining mandatory fields.
|
282
671
|
|
283
672
|
```ruby
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
673
|
+
class CreateUserForm
|
674
|
+
include Parametric::DSL
|
675
|
+
|
676
|
+
schema do
|
677
|
+
field(:name).present
|
678
|
+
field(:age).present
|
679
|
+
end
|
680
|
+
|
681
|
+
attr_reader :errors, :params
|
682
|
+
|
683
|
+
def initialize(payload: {})
|
684
|
+
@payload = payload
|
685
|
+
results = self.class.schema.resolve(params)
|
686
|
+
@errors = results.errors
|
687
|
+
@params = results.output
|
688
|
+
end
|
689
|
+
|
690
|
+
def run!
|
691
|
+
User.create(params)
|
290
692
|
end
|
291
693
|
end
|
292
694
|
```
|
293
695
|
|
294
|
-
|
696
|
+
Now you might want to use the same form object to _update_ and existing user supporting partial updates.
|
697
|
+
In this case, however, attributes should only be validated if the attributes exist in the payload. We need to apply the `:declared` policy to all schema fields, only if a user exists.
|
698
|
+
|
699
|
+
We can do this by producing a clone of the class-level schema and applying any necessary policies on the fly.
|
295
700
|
|
296
701
|
```ruby
|
297
|
-
|
298
|
-
|
299
|
-
|
300
|
-
|
702
|
+
class CreateUserForm
|
703
|
+
include Parametric::DSL
|
704
|
+
|
705
|
+
schema do
|
706
|
+
field(:name).present
|
707
|
+
field(:age).present
|
708
|
+
end
|
709
|
+
|
710
|
+
attr_reader :errors, :params
|
711
|
+
|
712
|
+
def initialize(payload: {}, user: nil)
|
713
|
+
@payload = payload
|
714
|
+
@user = user
|
715
|
+
|
716
|
+
# pick a policy based on user
|
717
|
+
policy = user ? :declared : :noop
|
718
|
+
# clone original schema and apply policy
|
719
|
+
schema = self.class.schema.clone.policy(policy)
|
720
|
+
|
721
|
+
# resolve params
|
722
|
+
results = schema.resolve(params)
|
723
|
+
@errors = results.errors
|
724
|
+
@params = results.output
|
725
|
+
end
|
726
|
+
|
727
|
+
def run!
|
728
|
+
if @user
|
729
|
+
@user.update_attributes(params)
|
730
|
+
else
|
731
|
+
User.create(params)
|
732
|
+
end
|
733
|
+
end
|
301
734
|
end
|
302
735
|
```
|
303
736
|
|