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.
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