parametric 0.0.1 → 0.2.12

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -1,193 +1,1114 @@
1
1
  # Parametric
2
+ [![Build Status](https://travis-ci.org/ismasan/parametric.png)](https://travis-ci.org/ismasan/parametric)
3
+ [![Gem Version](https://badge.fury.io/rb/parametric.png)](http://badge.fury.io/rb/parametric)
2
4
 
3
- DSL for declaring allowed parameters with options, regexp patern and default values.
5
+ Declaratively define data schemas in your Ruby objects, and use them to whitelist, validate or transform inputs to your programs.
4
6
 
5
- 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).
6
8
 
7
- ## Usage
9
+ ## Schema
8
10
 
9
- Declare your parameters
11
+ Define a schema
10
12
 
11
13
  ```ruby
12
- class OrdersSearch
13
- include Parametric::Params
14
- param :q, 'Full text search query'
15
- param :page, 'Page number', default: 1
16
- param :per_page, 'Items per page', default: 30
17
- 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)
18
18
  end
19
19
  ```
20
20
 
21
21
  Populate and use. Missing keys return defaults, if provided.
22
22
 
23
23
  ```ruby
24
- order_search = OrdersSearch.new(page: 2, q: 'foobar')
25
- order_search.params[:page] # => 2
26
- order_search.params[:per_page] # => 30
27
- order_search.params[:q] # => 'foobar'
28
- 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 # => {}
29
28
  ```
30
29
 
31
30
  Undeclared keys are ignored.
32
31
 
33
32
  ```ruby
34
- order_search = OrdersSearch.new(page: 2, foo: 'bar')
35
- order_params.params.has_key?(:foo) # => false
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"}
36
+ ```
37
+
38
+ Validations are run and errors returned
39
+
40
+
41
+ ```ruby
42
+ form = schema.resolve({})
43
+ form.errors # => {"$.title" => ["is required"]}
36
44
  ```
37
45
 
46
+ If options are defined, it validates that value is in options
47
+
38
48
  ```ruby
39
- order_search = OrderParams.new(status: 'checkout,closed')
40
- order_search.params[:status] #=> ['checkout', 'closed']
49
+ form = schema.resolve({title: "A new blog post", status: "foobar"})
50
+ form.errors # => {"$.status" => ["expected one of draft, published but got foobar"]}
41
51
  ```
42
52
 
43
- ### Search object pattern
53
+ ## Nested schemas
54
+
55
+ A schema can have nested schemas, for example for defining complex forms.
56
+
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)
64
+ end
65
+ end
66
+ ```
44
67
 
45
- A class that declares allowed params and defaults, and builds a query.
68
+ It works as expected
46
69
 
47
70
  ```ruby
48
- class OrdersSearch
49
- include Parametric::Params
50
- param :q, 'Full text search query'
51
- param :page, 'Page number', default: 1
52
- param :per_page, 'Items per page', default: 30
53
- param :status, 'Order status', options: ['checkout', 'pending', 'closed', 'shipped'], multiple: true
54
- param :sort, 'Sort', options: ['updated_on-desc', 'updated_on-asc'], default: 'updated_on-desc'
71
+ results = person_schema.resolve(
72
+ name: "Joe",
73
+ age: "38",
74
+ friends: [
75
+ {name: "Jane", email: "jane@email.com"}
76
+ ]
77
+ )
55
78
 
56
- def results
57
- query = Order.sort(params[:sort])
58
- query = query.where(["code LIKE ? OR user_name ?", params[:q]]) if params[:q]
59
- query = query.where(status: params[:status]) if params[:status].any?
60
- query = query.paginate(page: params[:page], per_page: params[:per_page])
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
97
+
98
+ You can optionally use an existing schema instance as a nested schema:
99
+
100
+ ```ruby
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)
61
105
  end
62
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)
113
+ end
114
+ ```
115
+
116
+ Note that _person_schema_'s definition has access to `FRIENDS_SCHEMA` because it's a constant.
117
+ Definition blocks are run in the context of the defining schema instance by default.
118
+
119
+ To preserve the original block's context, declare two arguments in your block, the defining schema `sc` and options has.
120
+
121
+ ```ruby
122
+ person_schema = Parametric::Schema.new do |sc, options|
123
+ # this block now preserves its context. Call `sc.field` to add fields to the current schema.
124
+ sc.field(:name).type(:string).required
125
+ sc.field(:age).type(:integer)
126
+ # We now have access to local variables
127
+ sc.field(:friends).type(:array).schema(friends_schema)
128
+ end
129
+ ```
130
+ ## Built-in policies
131
+
132
+ Type coercions (the `type` method) and validations (the `validate` method) are all _policies_.
133
+
134
+ Parametric ships with a number of built-in policies.
135
+
136
+ ### :string
137
+
138
+ Calls `:to_s` on the value
139
+
140
+ ```ruby
141
+ field(:title).type(:string)
142
+ ```
143
+
144
+ ### :integer
145
+
146
+ Calls `:to_i` on the value
147
+
148
+ ```ruby
149
+ field(:age).type(:integer)
150
+ ```
151
+
152
+ ### :number
153
+
154
+ Calls `:to_f` on the value
155
+
156
+ ```ruby
157
+ field(:price).type(:number)
158
+ ```
159
+
160
+ ### :boolean
161
+
162
+ Returns `true` or `false` (`nil` is converted to `false`).
163
+
164
+ ```ruby
165
+ field(:published).type(:boolean)
166
+ ```
167
+
168
+ ### :datetime
169
+
170
+ 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.
171
+
172
+ ```ruby
173
+ field(:expires_on).type(:datetime)
174
+ ```
175
+
176
+ ### :format
177
+
178
+ Check value against custom regexp
179
+
180
+ ```ruby
181
+ field(:salutation).policy(:format, /^Mr\/s/)
182
+ # optional custom error message
183
+ field(:salutation).policy(:format, /^Mr\/s\./, "must start with Mr/s.")
184
+ ```
185
+
186
+ ### :email
187
+
188
+ ```ruby
189
+ field(:business_email).policy(:email)
190
+ ```
191
+
192
+ ### :required
193
+
194
+ Check that the key exists in the input.
195
+
196
+ ```ruby
197
+ field(:name).required
198
+
199
+ # same as
200
+ field(:name).policy(:required)
201
+ ```
202
+
203
+ Note that _required_ does not validate that the value is not empty. Use _present_ for that.
204
+
205
+ ### :present
206
+
207
+ Check that the key exists and the value is not blank.
208
+
209
+ ```ruby
210
+ field(:name).present
211
+
212
+ # same as
213
+ field(:name).policy(:present)
214
+ ```
215
+
216
+ 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`.
217
+
218
+ ### :declared
219
+
220
+ Check that a key exists in the input, or stop any further validations otherwise.
221
+ This is useful when chained to other validations. For example:
222
+
223
+ ```ruby
224
+ field(:name).declared.present
225
+ ```
226
+
227
+ 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.
228
+ Note that any defaults will still be returned.
229
+
230
+ ```ruby
231
+ field(:name).declared.present.default('return this')
232
+ ```
233
+
234
+ ### :declared_no_default
235
+
236
+ Like `:declared`, it stops the policy chain if a key is not in input, but it also skips any default value.
237
+
238
+ ```ruby
239
+ field(:name).policy(:declared_no_default).present
240
+ ```
241
+
242
+ ### :gt
243
+
244
+ Validate that the value is greater than a number
245
+
246
+ ```ruby
247
+ field(:age).policy(:gt, 21)
248
+ ```
249
+
250
+ ### :lt
251
+
252
+ Validate that the value is less than a number
253
+
254
+ ```ruby
255
+ field(:age).policy(:lt, 21)
256
+ ```
257
+
258
+ ### :options
259
+
260
+ Pass allowed values for a field
261
+
262
+ ```ruby
263
+ field(:status).options(["draft", "published"])
264
+
265
+ # Same as
266
+ field(:status).policy(:options, ["draft", "published"])
267
+ ```
268
+
269
+ ### :split
270
+
271
+ Split comma-separated string values into an array.
272
+ Useful for parsing comma-separated query-string parameters.
273
+
274
+ ```ruby
275
+ field(:status).policy(:split) # turns "pending,confirmed" into ["pending", "confirmed"]
63
276
  ```
64
277
 
65
- ### :match
278
+ ## Custom policies
66
279
 
67
- Pass a regular expression to match parameter value. Non-matching values will be ignored or use default value, if available.
280
+ You can also register your own custom policy objects. A policy must implement the following methods:
68
281
 
69
282
  ```ruby
70
- class OrdersSearch
71
- include Parametric::Params
72
- param :email, 'Valid email address', match: /\w+@\w+\.\w+/
283
+ class MyPolicy
284
+ # Validation error message, if invalid
285
+ def message
286
+ 'is invalid'
287
+ end
288
+
289
+ # Whether or not to validate and coerce this value
290
+ # if false, no other policies will be run on the field
291
+ def eligible?(value, key, payload)
292
+ true
293
+ end
294
+
295
+ # Transform the value
296
+ def coerce(value, key, context)
297
+ value
298
+ end
299
+
300
+ # Is the value valid?
301
+ def valid?(value, key, payload)
302
+ true
303
+ end
304
+
305
+ # merge this object into the field's meta data
306
+ def meta_data
307
+ {type: :string}
308
+ end
73
309
  end
74
310
  ```
75
311
 
76
- ### :options array
312
+ You can register your policy with:
313
+
314
+ ```ruby
315
+ Parametric.policy :my_policy, MyPolicy
316
+ ```
317
+
318
+ And then refer to it by name when declaring your schema fields
319
+
320
+ ```ruby
321
+ field(:title).policy(:my_policy)
322
+ ```
323
+
324
+ You can chain custom policies with other policies.
77
325
 
78
- Declare allowed values in an array. Values not in the options will be ignored or use default value.
326
+ ```ruby
327
+ field(:title).required.policy(:my_policy)
328
+ ```
329
+
330
+ Note that you can also register instances.
331
+
332
+ ```ruby
333
+ Parametric.policy :my_policy, MyPolicy.new
334
+ ```
335
+
336
+ For example, a policy that can be configured on a field-by-field basis:
79
337
 
80
338
  ```ruby
81
- class OrdersSearch
82
- include Parametric::Params
83
- param :sort, 'Sort', options: ['updated_on-desc', 'updated_on-asc'], default: 'updated_on-desc'
339
+ class AddJobTitle
340
+ def initialize(job_title)
341
+ @job_title = job_title
342
+ end
343
+
344
+ def message
345
+ 'is invalid'
346
+ end
347
+
348
+ # Noop
349
+ def eligible?(value, key, payload)
350
+ true
351
+ end
352
+
353
+ # Add job title to value
354
+ def coerce(value, key, context)
355
+ "#{value}, #{@job_title}"
356
+ end
357
+
358
+ # Noop
359
+ def valid?(value, key, payload)
360
+ true
361
+ end
362
+
363
+ def meta_data
364
+ {}
365
+ end
84
366
  end
367
+
368
+ # Register it
369
+ Parametric.policy :job_title, AddJobTitle
370
+ ```
371
+
372
+ Now you can reuse the same policy with different configuration
373
+
374
+ ```ruby
375
+ manager_schema = Parametric::Schema.new do
376
+ field(:name).type(:string).policy(:job_title, "manager")
377
+ end
378
+
379
+ cto_schema = Parametric::Schema.new do
380
+ field(:name).type(:string).policy(:job_title, "CTO")
381
+ end
382
+
383
+ manager_schema.resolve(name: "Joe Bloggs").output # => {name: "Joe Bloggs, manager"}
384
+ cto_schema.resolve(name: "Joe Bloggs").output # => {name: "Joe Bloggs, CTO"}
85
385
  ```
86
386
 
87
- ### :multiple values
387
+ ## Custom policies, short version
88
388
 
89
- `:multiple` values are separated on "," and treated as arrays.
389
+ For simple policies that don't need all policy methods, you can:
90
390
 
91
391
  ```ruby
92
- class OrdersSearch
93
- include Parametric::Params
94
- param :status, 'Order status', multiple: true
392
+ Parametric.policy :cto_job_title do
393
+ coerce do |value, key, context|
394
+ "#{value}, CTO"
395
+ end
396
+ end
397
+
398
+ # use it
399
+ cto_schema = Parametric::Schema.new do
400
+ field(:name).type(:string).policy(:cto_job_title)
401
+ end
402
+ ```
403
+
404
+ ```ruby
405
+ Parametric.policy :over_21_and_under_25 do
406
+ coerce do |age, key, context|
407
+ age.to_i
408
+ end
409
+
410
+ validate do |age, key, context|
411
+ age > 21 && age < 25
412
+ end
95
413
  end
414
+ ```
96
415
 
97
- search = OrdersSearch.new(status: 'closed,shipped,abandoned')
98
- search.params[:status] # => ['closed', 'shipped', 'abandoned']
416
+ ## Cloning schemas
417
+
418
+ The `#clone` method returns a new instance of a schema with all field definitions copied over.
419
+
420
+ ```ruby
421
+ new_schema = original_schema.clone
99
422
  ```
100
423
 
101
- If `:options` array is declared, values outside of the options will be filtered out.
424
+ New copies can be further manipulated without affecting the original.
102
425
 
103
426
  ```ruby
104
- class OrdersSearch
105
- include Parametric::Params
106
- param :status, 'Order status', options: ['checkout', 'pending', 'closed', 'shipped'], multiple: true
427
+ # See below for #policy and #ignore
428
+ new_schema = original_schema.clone.policy(:declared).ignore(:id) do |sc|
429
+ field(:another_field).present
107
430
  end
431
+ ```
432
+
433
+ ## Merging schemas
108
434
 
109
- search = OrdersSearch.new(status: 'closed,shipped,abandoned')
110
- search.params[:status] # => ['closed', 'shipped']
435
+ The `#merge` method will merge field definitions in two schemas and produce a new schema instance.
436
+
437
+ ```ruby
438
+ basic_user_schema = Parametric::Schema.new do
439
+ field(:name).type(:string).required
440
+ field(:age).type(:integer)
441
+ end
442
+
443
+ friends_schema = Parametric::Schema.new do
444
+ field(:friends).type(:array).schema do
445
+ field(:name).required
446
+ field(:email).policy(:email)
447
+ end
448
+ end
449
+
450
+ user_with_friends_schema = basic_user_schema.merge(friends_schema)
451
+
452
+ results = user_with_friends_schema.resolve(input)
111
453
  ```
112
454
 
113
- When using `:multiple`, results and defaults are always returned as an array, for consistency.
455
+ Fields defined in the merged schema will override fields with the same name in the original schema.
114
456
 
115
457
  ```ruby
116
- class OrdersSearch
117
- include Parametric::Params
118
- param :status, 'Order status', multiple: true, default: 'closed'
458
+ required_name_schema = Parametric::Schema.new do
459
+ field(:name).required
460
+ field(:age)
119
461
  end
120
462
 
121
- search = OrdersSearch.new
122
- search.params[:status] # => ['closed']
463
+ optional_name_schema = Parametric::Schema.new do
464
+ field(:name)
465
+ end
466
+
467
+ # This schema now has :name and :age fields.
468
+ # :name has been redefined to not be required.
469
+ new_schema = required_name_schema.merge(optional_name_schema)
123
470
  ```
124
471
 
125
- ## `available_params`
472
+ ## #meta
126
473
 
127
- `#available_params` returns the subset of keys that were populated (including defaults). Useful to build query strings.
474
+ The `#meta` field method can be used to add custom meta data to field definitions.
475
+ These meta data can be used later when instrospecting schemas (ie. to generate documentation or error notices).
128
476
 
129
477
  ```ruby
130
- order_search = OrdersSearch.new(page: 2, foo: 'bar')
131
- order_search.available_params # => {page: 2, per_page: 50}
478
+ create_user_schema = Parametric::Schema.do
479
+ field(:name).required.type(:string).meta(label: "User's full name")
480
+ field(:status).options(["published", "unpublished"]).default("published")
481
+ field(:age).type(:integer).meta(label: "User's age")
482
+ field(:friends).type(:array).meta(label: "User friends").schema do
483
+ field(:name).type(:string).present.meta(label: "Friend full name")
484
+ field(:email).policy(:email).meta(label: "Friend's email")
485
+ end
486
+ end
132
487
  ```
133
488
 
134
- ## `schema`
489
+ ## #structure
490
+
491
+ A `Schema` instance has a `#structure` method that allows instrospecting schema meta data.
492
+
493
+ ```ruby
494
+ create_user_schema.structure[:name][:label] # => "User's full name"
495
+ create_user_schema.structure[:age][:label] # => "User's age"
496
+ create_user_schema.structure[:friends][:label] # => "User friends"
497
+ # Recursive schema structures
498
+ create_user_schema.structure[:friends].structure[:name].label # => "Friend full name"
499
+ ```
500
+
501
+ Note that many field policies add field meta data.
502
+
503
+ ```ruby
504
+ create_user_schema.structure[:name][:type] # => :string
505
+ create_user_schema.structure[:name][:required] # => true
506
+ create_user_schema.structure[:status][:options] # => ["published", "unpublished"]
507
+ create_user_schema.structure[:status][:default] # => "published"
508
+ ```
509
+
510
+ ## #walk
511
+
512
+ The `#walk` method can recursively walk a schema definition and extract meta data or field attributes.
513
+
514
+ ```ruby
515
+ schema_documentation = create_user_schema.walk do |field|
516
+ {type: field.meta_data[:type], label: field.meta_data[:label]}
517
+ end.output
518
+
519
+ # Returns
135
520
 
136
- `#schema` returns a data structure including meta-data on each parameter, such as "label" and "options". Useful for building forms or self-documented Hypermedia APIs (or maybe [json-schema](http://json-schema.org/example2.html) endpoints).
521
+ {
522
+ name: {type: :string, label: "User's full name"},
523
+ age: {type: :integer, label: "User's age"},
524
+ status: {type: :string, label: nil},
525
+ friends: [
526
+ {
527
+ name: {type: :string, label: "Friend full name"},
528
+ email: {type: nil, label: "Friend email"}
529
+ }
530
+ ]
531
+ }
532
+ ```
533
+
534
+ When passed a _symbol_, it will collect that key from field meta data.
137
535
 
138
536
  ```ruby
139
- order_search.schema # =>
537
+ schema_labels = create_user_schema.walk(:label).output
538
+
539
+ # returns
140
540
 
141
541
  {
142
- q: {label: 'Full text search query', value: ''},
143
- page: {label: 'Page number', value: 1},
144
- per_page: {label: 'Items per page', value: 30},
145
- status: {label: 'Order status', value: '', options: ['checkout', 'pending', 'closed', 'shipped'], multiple: true},
146
- sort: {label: 'Sort', value: 'updated_on-desc', options: ['updated_on-desc', 'updated_on-asc']}
542
+ name: "User's full name",
543
+ age: "User's age",
544
+ status: nil,
545
+ friends: [
546
+ {name: "Friend full name", email: "Friend email"}
547
+ ]
147
548
  }
148
549
  ```
149
550
 
150
- ## Parametric::Hash
551
+ 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.
552
+
553
+ ## Form objects DSL
151
554
 
152
- The alternative `Parametric::Hash` module makes your objects quack like a hash, instead of exposing the `#params` object directly.
555
+ You can use schemas and fields on their own, or include the `DSL` module in your own classes to define form objects.
153
556
 
154
557
  ```ruby
155
- class OrdersParams
156
- include Parametric::Hash
157
- param :q, 'Full text search query'
158
- param :page, 'Page number', default: 1
159
- param :per_page, 'Items per page', default: 30
160
- param :status, 'Order status', options: ['checkout', 'pending', 'closed', 'shipped'], multiple: true
558
+ require "parametric/dsl"
559
+
560
+ class CreateUserForm
561
+ include Parametric::DSL
562
+
563
+ schema do
564
+ field(:name).type(:string).required
565
+ field(:email).policy(:email).required
566
+ field(:age).type(:integer)
567
+ end
568
+
569
+ attr_reader :params, :errors
570
+
571
+ def initialize(input_data)
572
+ results = self.class.schema.resolve(input_data)
573
+ @params = results.output
574
+ @errors = results.errors
575
+ end
576
+
577
+ def run!
578
+ if !valid?
579
+ raise InvalidFormError.new(errors)
580
+ end
581
+
582
+ run
583
+ end
584
+
585
+ def valid?
586
+ !errors.any?
587
+ end
588
+
589
+ private
590
+
591
+ def run
592
+ User.create!(params)
593
+ end
594
+ end
595
+ ```
596
+
597
+ Form schemas can also be defined by passing another form or schema instance. This can be useful when building form classes in runtime.
598
+
599
+ ```ruby
600
+ UserSchema = Parametric::Schema.new do
601
+ field(:name).type(:string).present
602
+ field(:age).type(:integer)
603
+ end
604
+
605
+ class CreateUserForm
606
+ include Parametric::DSL
607
+ # copy from UserSchema
608
+ schema UserSchema
609
+ end
610
+ ```
611
+
612
+ ### Form object inheritance
613
+
614
+ Sub classes of classes using the DSL will inherit schemas defined on the parent class.
615
+
616
+ ```ruby
617
+ class UpdateUserForm < CreateUserForm
618
+ # All field definitions in the parent are conserved.
619
+ # New fields can be defined
620
+ # or existing fields overriden
621
+ schema do
622
+ # make this field optional
623
+ field(:name).declared.present
624
+ end
625
+
626
+ def initialize(user, input_data)
627
+ super input_data
628
+ @user = user
629
+ end
630
+
631
+ private
632
+ def run
633
+ @user.update params
634
+ end
635
+ end
636
+ ```
637
+
638
+ ### Schema-wide policies
639
+
640
+ Sometimes it's useful to apply the same policy to all fields in a schema.
641
+
642
+ For example, fields that are _required_ when creating a record might be optional when updating the same record (ie. _PATCH_ operations in APIs).
643
+
644
+ ```ruby
645
+ class UpdateUserForm < CreateUserForm
646
+ schema.policy(:declared)
161
647
  end
162
648
  ```
163
649
 
650
+ This will prefix the `:declared` policy to all fields inherited from the parent class.
651
+ This means that only fields whose keys are present in the input will be validated.
652
+
653
+ Schemas with default policies can still define or re-define fields.
654
+
164
655
  ```ruby
165
- order_params = OrdersParams.new(page: 2, q: 'foobar')
166
- order_params[:page] # => 2
167
- order_params[:per_page] # => 30
168
- order_params.each{|key, value| ... }
656
+ class UpdateUserForm < CreateUserForm
657
+ schema.policy(:declared) do
658
+ # Validation will only run if key exists
659
+ field(:age).type(:integer).present
660
+ end
661
+ end
169
662
  ```
170
663
 
171
- ## Use cases
664
+ ### Ignoring fields defined in the parent class
172
665
 
173
- ### In Rails
666
+ Sometimes you'll want a child class to inherit most fields from the parent, but ignoring some.
174
667
 
175
668
  ```ruby
176
- def index
177
- @search = OrdersSearch.new(params)
178
- @results = @search.results
669
+ class CreateUserForm
670
+ include Parametric::DSL
671
+
672
+ schema do
673
+ field(:uuid).present
674
+ field(:status).required.options(["inactive", "active"])
675
+ field(:name)
676
+ end
677
+ end
678
+ ```
679
+
680
+ The child class can use `ignore(*fields)` to ignore fields defined in the parent.
681
+
682
+ ```ruby
683
+ class UpdateUserForm < CreateUserForm
684
+ schema.ignore(:uuid, :status) do
685
+ # optionally add new fields here
686
+ end
687
+ end
688
+ ```
689
+
690
+ ## Schema options
691
+
692
+ Another way of modifying inherited schemas is by passing options.
693
+
694
+ ```ruby
695
+ class CreateUserForm
696
+ include Parametric::DSL
697
+
698
+ schema(default_policy: :noop) do |opts|
699
+ field(:name).policy(opts[:default_policy]).type(:string).required
700
+ field(:email).policy(opts[:default_policy).policy(:email).required
701
+ field(:age).type(:integer)
702
+ end
703
+
704
+ # etc
179
705
  end
180
706
  ```
181
707
 
182
- I use this along with [Oat](https://github.com/ismasan/oat) in API projects:
708
+ The `:noop` policy does nothing. The sub-class can pass its own _default_policy_.
183
709
 
184
710
  ```ruby
185
- def index
186
- search = OrdersSearch.new(params)
187
- render json: OrdersSerializer.new(search)
711
+ class UpdateUserForm < CreateUserForm
712
+ # this will only run validations keys existing in the input
713
+ schema(default_policy: :declared)
188
714
  end
189
715
  ```
190
716
 
717
+ ## A pattern: changing schema policy on the fly.
718
+
719
+ You can use a combination of `#clone` and `#policy` to change schema-wide field policies on the fly.
720
+
721
+ For example, you might have a form object that supports creating a new user and defining mandatory fields.
722
+
723
+ ```ruby
724
+ class CreateUserForm
725
+ include Parametric::DSL
726
+
727
+ schema do
728
+ field(:name).present
729
+ field(:age).present
730
+ end
731
+
732
+ attr_reader :errors, :params
733
+
734
+ def initialize(payload: {})
735
+ results = self.class.schema.resolve(payload)
736
+ @errors = results.errors
737
+ @params = results.output
738
+ end
739
+
740
+ def run!
741
+ User.create(params)
742
+ end
743
+ end
744
+ ```
745
+
746
+ Now you might want to use the same form object to _update_ and existing user supporting partial updates.
747
+ 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.
748
+
749
+ We can do this by producing a clone of the class-level schema and applying any necessary policies on the fly.
750
+
751
+ ```ruby
752
+ class CreateUserForm
753
+ include Parametric::DSL
754
+
755
+ schema do
756
+ field(:name).present
757
+ field(:age).present
758
+ end
759
+
760
+ attr_reader :errors, :params
761
+
762
+ def initialize(payload: {}, user: nil)
763
+ @payload = payload
764
+ @user = user
765
+
766
+ # pick a policy based on user
767
+ policy = user ? :declared : :noop
768
+ # clone original schema and apply policy
769
+ schema = self.class.schema.clone.policy(policy)
770
+
771
+ # resolve params
772
+ results = schema.resolve(params)
773
+ @errors = results.errors
774
+ @params = results.output
775
+ end
776
+
777
+ def run!
778
+ if @user
779
+ @user.update_attributes(params)
780
+ else
781
+ User.create(params)
782
+ end
783
+ end
784
+ end
785
+ ```
786
+
787
+ ## Multiple schema definitions
788
+
789
+ Form objects can optionally define more than one schema by giving them names:
790
+
791
+ ```ruby
792
+ class UpdateUserForm
793
+ include Parametric::DSL
794
+
795
+ # a schema named :query
796
+ # for example for query parameters
797
+ schema(:query) do
798
+ field(:user_id).type(:integer).present
799
+ end
800
+
801
+ # a schema for PUT body parameters
802
+ schema(:payload) do
803
+ field(:name).present
804
+ field(:age).present
805
+ end
806
+ end
807
+ ```
808
+
809
+ Named schemas are inherited and can be extended and given options in the same way as the nameless version.
810
+
811
+ Named schemas can be retrieved by name, ie. `UpdateUserForm.schema(:query)`.
812
+
813
+ If no name given, `.schema` uses `:schema` as default schema name.
814
+
815
+ ## Expanding fields dynamically
816
+
817
+ Sometimes you don't know the exact field names but you want to allow arbitrary fields depending on a given pattern.
818
+
819
+ ```ruby
820
+ # with this payload:
821
+ # {
822
+ # title: "A title",
823
+ # :"custom_attr_Color" => "red",
824
+ # :"custom_attr_Material" => "leather"
825
+ # }
826
+
827
+ schema = Parametric::Schema.new do
828
+ field(:title).type(:string).present
829
+ # here we allow any field starting with /^custom_attr/
830
+ # this yields a MatchData object to the block
831
+ # where you can define a Field and validations on the fly
832
+ # https://ruby-doc.org/core-2.2.0/MatchData.html
833
+ expand(/^custom_attr_(.+)/) do |match|
834
+ field(match[1]).type(:string).present
835
+ end
836
+ end
837
+
838
+ results = schema.resolve({
839
+ title: "A title",
840
+ :"custom_attr_Color" => "red",
841
+ :"custom_attr_Material" => "leather",
842
+ :"custom_attr_Weight" => "",
843
+ })
844
+
845
+ results.ouput[:Color] # => "red"
846
+ results.ouput[:Material] # => "leather"
847
+ results.errors["$.Weight"] # => ["is required and value must be present"]
848
+ ```
849
+
850
+ NOTES: dynamically expanded field names are not included in `Schema#structure` metadata, and they are only processes if fields with the given expressions are present in the payload. This means that validations applied to those fields only run if keys are present in the first place.
851
+
852
+ ## Before and after resolve hooks
853
+
854
+ `Schema#before_resolve` can be used to register blocks to modify the entire input payload _before_ individual fields are validated and coerced.
855
+ This can be useful when you need to pre-populate fields relative to other fields' values, or fetch extra data from other sources.
856
+
857
+ ```ruby
858
+ # This example computes the value of the :slug field based on :name
859
+ schema = Parametric::Schema.new do
860
+ # Note1: These blocks run before field validations, so :name might be blank or invalid at this point.
861
+ # Note2: Before hooks _must_ return a payload hash.
862
+ before_resolve do |payload, context|
863
+ payload.merge(
864
+ slug: payload[:name].to_s.downcase.gsub(/\s+/, '-')
865
+ )
866
+ end
867
+
868
+ # You still need to define the fields you want
869
+ field(:name).type(:string).present
870
+ field(:slug).type(:string).present
871
+ end
872
+
873
+ result = schema.resolve( name: 'Joe Bloggs' )
874
+ result.output # => { name: 'Joe Bloggs', slug: 'joe-bloggs' }
875
+ ```
876
+
877
+ Before hooks can be added to nested schemas, too:
878
+
879
+ ```ruby
880
+ schema = Parametric::Schema.new do
881
+ field(:friends).type(:array).schema do
882
+ before_resolve do |friend_payload, context|
883
+ friend_payload.merge(title: "Mr/Ms #{friend_payload[:name]}")
884
+ end
885
+
886
+ field(:name).type(:string)
887
+ field(:title).type(:string)
888
+ end
889
+ end
890
+ ```
891
+
892
+ You can use inline blocks, but anything that responds to `#call(payload, context)` will work, too:
893
+
894
+ ```ruby
895
+ class SlugMaker
896
+ def initialize(slug_field, from:)
897
+ @slug_field, @from = slug_field, from
898
+ end
899
+
900
+ def call(payload, context)
901
+ payload.merge(
902
+ @slug_field => payload[@from].to_s.downcase.gsub(/\s+/, '-')
903
+ )
904
+ end
905
+ end
906
+
907
+ schema = Parametric::Schema.new do
908
+ before_resolve SlugMaker.new(:slug, from: :name)
909
+
910
+ field(:name).type(:string)
911
+ field(:slug).type(:slug)
912
+ end
913
+ ```
914
+
915
+ The `context` argument can be used to add custom validation errors in a before hook block.
916
+
917
+ ```ruby
918
+ schema = Parametric::Schema.new do
919
+ before_resolve do |payload, context|
920
+ # validate that there's no duplicate friend names
921
+ friends = payload[:friends] || []
922
+ if friends.any? && friends.map{ |fr| fr[:name] }.uniq.size < friends.size
923
+ context.add_error 'friend names must be unique'
924
+ end
925
+
926
+ # don't forget to return the payload
927
+ payload
928
+ end
929
+
930
+ field(:friends).type(:array).schema do
931
+ field(:name).type(:string)
932
+ end
933
+ end
934
+
935
+ result = schema.resolve(
936
+ friends: [
937
+ {name: 'Joe Bloggs'},
938
+ {name: 'Joan Bloggs'},
939
+ {name: 'Joe Bloggs'}
940
+ ]
941
+ )
942
+
943
+ result.valid? # => false
944
+ result.errors # => {'$' => ['friend names must be unique']}
945
+ ```
946
+
947
+ In most cases you should be validating individual fields using field policies. Only validate in before hooks in cases you have dependencies between fields.
948
+
949
+ `Schema#after_resolve` takes the sanitized input hash, and can be used to further validate fields that depend on eachother.
950
+
951
+ ```ruby
952
+ schema = Parametric::Schema.new do
953
+ after_resolve do |payload, ctx|
954
+ # Add a top level error using an arbitrary key name
955
+ ctx.add_base_error('deposit', 'cannot be greater than house price') if payload[:deposit] > payload[:house_price]
956
+ # Or add an error keyed after the current position in the schema
957
+ # ctx.add_error('some error') if some_condition
958
+ # after_resolve hooks must also return the payload, or a modified copy of it
959
+ # note that any changes added here won't be validated.
960
+ payload.merge(desc: 'hello')
961
+ end
962
+
963
+ field(:deposit).policy(:integer).present
964
+ field(:house_price).policy(:integer).present
965
+ field(:desc).policy(:string)
966
+ end
967
+
968
+ result = schema.resolve({ deposit: 1100, house_price: 1000 })
969
+ result.valid? # false
970
+ result.errors[:deposit] # ['cannot be greater than house price']
971
+ result.output[:deposit] # 1100
972
+ result.output[:house_price] # 1000
973
+ result.output[:desc] # 'hello'
974
+ ```
975
+
976
+ ## Structs
977
+
978
+ Structs turn schema definitions into objects graphs with attribute readers.
979
+
980
+ Add optional `Parametrict::Struct` module to define struct-like objects with schema definitions.
981
+
982
+ ```ruby
983
+ require 'parametric/struct'
984
+
985
+ class User
986
+ include Parametric::Struct
987
+
988
+ schema do
989
+ field(:name).type(:string).present
990
+ field(:friends).type(:array).schema do
991
+ field(:name).type(:string).present
992
+ field(:age).type(:integer)
993
+ end
994
+ end
995
+ end
996
+ ```
997
+
998
+ `User` objects can be instantiated with hash data, which will be coerced and validated as per the schema definition.
999
+
1000
+ ```ruby
1001
+ user = User.new(
1002
+ name: 'Joe',
1003
+ friends: [
1004
+ {name: 'Jane', age: 40},
1005
+ {name: 'John', age: 30},
1006
+ ]
1007
+ )
1008
+
1009
+ # properties
1010
+ user.name # => 'Joe'
1011
+ user.friends.first.name # => 'Jane'
1012
+ user.friends.last.age # => 30
1013
+ ```
1014
+
1015
+ ### Errors
1016
+
1017
+ Both the top-level and nested instances contain error information:
1018
+
1019
+ ```ruby
1020
+ user = User.new(
1021
+ name: '', # invalid
1022
+ friends: [
1023
+ # friend name also invalid
1024
+ {name: '', age: 40},
1025
+ ]
1026
+ )
1027
+
1028
+ user.valid? # false
1029
+ user.errors['$.name'] # => "is required and must be present"
1030
+ user.errors['$.friends[0].name'] # => "is required and must be present"
1031
+
1032
+ # also access error in nested instances directly
1033
+ user.friends.first.valid? # false
1034
+ user.friends.first.errors['$.name'] # "is required and must be valid"
1035
+ ```
1036
+
1037
+ ### .new!(hash)
1038
+
1039
+ Instantiating structs with `.new!(hash)` will raise a `Parametric::InvalidStructError` exception if the data is validations fail. It will return the struct instance otherwise.
1040
+
1041
+ `Parametric::InvalidStructError` includes an `#errors` property to inspect the errors raised.
1042
+
1043
+ ```ruby
1044
+ begin
1045
+ user = User.new!(name: '')
1046
+ rescue Parametric::InvalidStructError => e
1047
+ e.errors['$.name'] # "is required and must be present"
1048
+ end
1049
+ ```
1050
+
1051
+ ### Nested structs
1052
+
1053
+ You can also pass separate struct classes in a nested schema definition.
1054
+
1055
+ ```ruby
1056
+ class Friend
1057
+ include Parametric::Struct
1058
+
1059
+ schema do
1060
+ field(:name).type(:string).present
1061
+ field(:age).type(:integer)
1062
+ end
1063
+ end
1064
+
1065
+ class User
1066
+ include Parametric::Struct
1067
+
1068
+ schema do
1069
+ field(:name).type(:string).present
1070
+ # here we use the Friend class
1071
+ field(:friends).type(:array).schema Friend
1072
+ end
1073
+ end
1074
+ ```
1075
+
1076
+ ### Inheritance
1077
+
1078
+ Struct subclasses can add to inherited schemas, or override fields defined in the parent.
1079
+
1080
+ ```ruby
1081
+ class AdminUser < User
1082
+ # inherits User schema, and can add stuff to its own schema
1083
+ schema do
1084
+ field(:permissions).type(:array)
1085
+ end
1086
+ end
1087
+ ```
1088
+
1089
+ ### #to_h
1090
+
1091
+ `Struct#to_h` returns the ouput hash, with values coerced and any defaults populated.
1092
+
1093
+ ```ruby
1094
+ class User
1095
+ include Parametrict::Struct
1096
+ schema do
1097
+ field(:name).type(:string)
1098
+ field(:age).type(:integer).default(30)
1099
+ end
1100
+ end
1101
+
1102
+ user = User.new(name: "Joe")
1103
+ user.to_h # {name: "Joe", age: 30}
1104
+ ```
1105
+
1106
+ ### Struct equality
1107
+
1108
+ `Parametric::Struct` implements `#==()` to compare two structs Hash representation (same as `struct1.to_h.eql?(struct2.to_h)`.
1109
+
1110
+ Users can override `#==()` in their own classes to do whatever they need.
1111
+
191
1112
  ## Installation
192
1113
 
193
1114
  Add this line to your application's Gemfile:
@@ -204,7 +1125,7 @@ Or install it yourself as:
204
1125
 
205
1126
  ## Contributing
206
1127
 
207
- 1. Fork it ( http://github.com/<my-github-username>/parametric/fork )
1128
+ 1. Fork it ( http://github.com/ismasan/parametric/fork )
208
1129
  2. Create your feature branch (`git checkout -b my-new-feature`)
209
1130
  3. Commit your changes (`git commit -am 'Add some feature'`)
210
1131
  4. Push to the branch (`git push origin my-new-feature`)