parametric 0.0.5 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -2,302 +2,735 @@
2
2
  [![Build Status](https://travis-ci.org/ismasan/parametric.png)](https://travis-ci.org/ismasan/parametric)
3
3
  [![Gem Version](https://badge.fury.io/rb/parametric.png)](http://badge.fury.io/rb/parametric)
4
4
 
5
- DSL for declaring allowed parameters with options, regexp pattern and default values.
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
- ## Usage
9
+ ## Schema
10
10
 
11
- Declare your parameters
11
+ Define a schema
12
12
 
13
13
  ```ruby
14
- class OrdersSearch
15
- include Parametric::Params
16
- param :q, 'Full text search query'
17
- param :page, 'Page number', default: 1
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
- order_search = OrdersSearch.new(page: 2, q: 'foobar')
27
- order_search.params[:page] # => 2
28
- order_search.params[:per_page] # => 30
29
- order_search.params[:q] # => 'foobar'
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
- order_search = OrdersSearch.new(page: 2, foo: 'bar')
37
- 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"}
38
36
  ```
39
37
 
38
+ Validations are run and errors returned
39
+
40
+
40
41
  ```ruby
41
- order_search = OrderParams.new(status: 'checkout,closed')
42
- order_search.params[:status] #=> ['checkout', 'closed']
42
+ form = schema.resolve({})
43
+ form.errors # => {"$.title" => ["is required"]}
43
44
  ```
44
45
 
45
- ### Search object pattern
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
- class OrdersSearch
51
- include Parametric::Params
52
- param :q, 'Full text search query'
53
- param :page, 'Page number', default: 1
54
- param :per_page, 'Items per page', default: 30
55
- param :status, 'Order status', options: ['checkout', 'pending', 'closed', 'shipped'], multiple: true
56
- param :sort, 'Sort', options: ['updated_on-desc', 'updated_on-asc'], default: 'updated_on-desc'
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
- def results
59
- query = Order.sort(params[:sort])
60
- query = query.where(["code LIKE ? OR user_name LIKE ?", params[:q]]) if params[:q]
61
- query = query.where(status: params[:status]) if params[:status].any?
62
- query = query.paginate(page: params[:page], per_page: params[:per_page])
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
- ### :match
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
- Pass a regular expression to match parameter value. Non-matching values will be ignored or use default value, if available.
98
+ You can optionally use an existing schema instance as a nested schema:
70
99
 
71
100
  ```ruby
72
- class OrdersSearch
73
- include Parametric::Params
74
- param :email, 'Valid email address', match: /\w+@\w+\.\w+/
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
- ### :options array
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
- Declare allowed values in an array. Values not in the options will be ignored or use default value.
124
+ Calls `:to_s` on the value
81
125
 
82
126
  ```ruby
83
- class OrdersSearch
84
- include Parametric::Params
85
- param :sort, 'Sort', options: ['updated_on-desc', 'updated_on-asc'], default: 'updated_on-desc'
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
- ### :multiple values
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
- `:multiple` values are separated on "," and treated as arrays.
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
- class OrdersSearch
95
- include Parametric::Params
96
- param :status, 'Order status', multiple: true
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
- search = OrdersSearch.new(status: 'closed,shipped,abandoned')
100
- search.params[:status] # => ['closed', 'shipped', 'abandoned']
332
+ # Register it
333
+ Parametric.policy :job_title, AddJobTitle
101
334
  ```
102
335
 
103
- If `:options` array is declared, values outside of the options will be filtered out.
336
+ Now you can reuse the same policy with different configuration
104
337
 
105
338
  ```ruby
106
- class OrdersSearch
107
- include Parametric::Params
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
- search = OrdersSearch.new(status: 'closed,shipped,abandoned')
112
- search.params[:status] # => ['closed', 'shipped']
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
- When using `:multiple`, results and defaults are always returned as an array, for consistency.
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
- class OrdersSearch
119
- include Parametric::Params
120
- param :status, 'Order status', multiple: true, default: 'closed'
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
- search = OrdersSearch.new
124
- search.params[:status] # => ['closed']
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
- ### :nullable fields
397
+ ## Merging schemas
128
398
 
129
- In same cases you won't want Parametric to provide nil or empty keys for attributes missing from the input. For example when missing keys has special meaning in your application.
399
+ The `#merge` method will merge field definitions in two schemas and produce a new schema instance.
130
400
 
131
- In those cases you can add the `:nullable` option to said param definitions:
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
- class OrdersSearch
135
- include Parametric::Params
136
- param :query, 'Search query. optional', nullable: true
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
- search = OrdersSearch.new({})
141
- search.params # {tags: []}
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
- ## `available_params`
436
+ ## #meta
145
437
 
146
- `#available_params` returns the subset of keys that were populated (including defaults). Useful for building query strings.
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
- order_search = OrdersSearch.new(page: 2, foo: 'bar')
150
- order_search.available_params # => {page: 2, per_page: 50}
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
- ## `schema`
453
+ ## #structure
154
454
 
155
- `#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).
455
+ A `Schema` instance has a `#structure` method that allows instrospecting schema meta data.
156
456
 
157
457
  ```ruby
158
- order_search.schema[:q].label # => 'Full text search query'
159
- order_search.schema[:q].value # => ''
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
- order_search.schema[:page].label # => 'Page number'
162
- order_search.schema[:page].value # => 1
465
+ Note that many field policies add field meta data.
163
466
 
164
- order_search.schema[:status].label # => 'Order status'
165
- order_search.schema[:status].value # => ['pending']
166
- order_search.schema[:status].options # => ['checkout', 'pending', 'closed', 'shipped']
167
- order_search.schema[:status].multiple # => true
168
- order_search.schema[:status].default # => 'closed'
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
- ## Coercing values
474
+ ## #walk
172
475
 
173
- Param definitions take an optional `:coerce` option with a symbol or proc to coerce resulting values.
476
+ The `#walk` method can recursively walk a schema definition and extract meta data or field attributes.
174
477
 
175
478
  ```ruby
176
- class UsersSearch
177
- include Parametric::Params
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
- search = UsersSearch.new(age: '36', name: 'Ismael')
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
- search.available_params[:age] # => 36
185
- search.available_params[:name] # => 'Mr. Ismael'
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
- ### Parametric::TypedParams
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
- The `Parametric::TypedParams` module includes extra DSL methods to coerce values to standard Ruby types.
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
- class UsersSearch
194
- include Parametric::TypedParams
195
- integer :age, 'User age'
196
- array :accounts
197
- string :country_code
198
- # you can still use :coerce
199
- param :name, 'User name', coerce: lambda{|name| "Mr. #{name}"}
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
- ## Parametric::Hash
561
+ ### Form object inheritance
204
562
 
205
- The alternative `Parametric::Hash` class makes your objects quack like a hash, instead of exposing the `#params` object directly.
563
+ Sub classes of classes using the DSL will inherit schemas defined on the parent class.
206
564
 
207
565
  ```ruby
208
- class OrdersParams < Parametric::Hash
209
- param :q, 'Full text search query'
210
- integer :page, 'Page number', default: 1
211
- integer :per_page, 'Items per page', default: 30
212
- array :status, 'Order status', options: ['checkout', 'pending', 'closed', 'shipped']
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
- order_params = OrdersParams.new(page: 2, q: 'foobar')
218
- order_params[:page] # => 2
219
- order_params[:per_page] # => 30
220
- order_params.each{|key, value| ... }
594
+ class UpdateUserForm < CreateUserForm
595
+ schema.policy(:declared)
596
+ end
221
597
  ```
222
598
 
223
- ## Nested structures
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
- You can also nest parameter definitions. This is useful if you need to model POST payloads, for example.
602
+ Schemas with default policies can still define or re-define fields.
226
603
 
227
604
  ```ruby
228
- class AccountPayload
229
- include Parametric::Params
230
- param :status, 'Account status', default: 'pending', options: ['pending', 'active', 'cancelled']
231
- param :users, 'Users in this account', multiple: true do
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
- The example above expects a data structure like the following:
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
- status: 'active',
248
- users: [
249
- {name: 'Joe Bloggs', email: 'joe@bloggs.com'},
250
- {name: 'jane Bloggs', email: 'jane@bloggs.com', title: 'CEO'}
251
- ],
252
- owner: {
253
- name: 'Olivia Owner',
254
- email: 'olivia@owner.com'
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
- ## Use cases
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
- ### In Rails
639
+ ## Schema options
262
640
 
263
- You can use one-level param definitions in GET actions
641
+ Another way of modifying inherited schemas is by passing options.
264
642
 
265
643
  ```ruby
266
- def index
267
- @search = OrdersSearch.new(params)
268
- @results = @search.results
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
- I use this along with [Oat](https://github.com/ismasan/oat) in API projects:
657
+ The `:noop` policy does nothing. The sub-class can pass its own _default_policy_.
273
658
 
274
659
  ```ruby
275
- def index
276
- search = OrdersSearch.new(params)
277
- render json: OrdersSerializer.new(search)
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
- You can use nested definitions on POST/PUT actions, for example as part of your own strategy objects.
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
- def create
285
- @payload = AccountPayload.new(params)
286
- if @payload.save
287
- render json: AccountSerializer.new(@payload.order)
288
- else
289
- render json: ErrorSerializer.new(@payload.errors), status: 422
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
- You can also use the `#schema` metadata to build Hypermedia "actions" or forms.
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
- # /accounts/new.json
298
- def new
299
- @payload = AccountPayload.new
300
- render json: JsonSchemaSerializer.new(@payload.schema)
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