parametric 0.0.1 → 0.2.10

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -1,193 +1,990 @@
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"]}
44
+ ```
45
+
46
+ If options are defined, it validates that value is in options
47
+
48
+ ```ruby
49
+ form = schema.resolve({title: "A new blog post", status: "foobar"})
50
+ form.errors # => {"$.status" => ["expected one of draft, published but got foobar"]}
36
51
  ```
37
52
 
53
+ ## Nested schemas
54
+
55
+ A schema can have nested schemas, for example for defining complex forms.
56
+
38
57
  ```ruby
39
- order_search = OrderParams.new(status: 'checkout,closed')
40
- order_search.params[:status] #=> ['checkout', 'closed']
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
41
66
  ```
42
67
 
43
- ### Search object pattern
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
+ ```
44
81
 
45
- A class that declares allowed params and defaults, and builds a query.
82
+ Validation errors use [JSON path](http://goessner.net/articles/JsonPath/) expressions to describe errors in nested structures
46
83
 
47
84
  ```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'
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:
55
99
 
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])
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
63
114
  ```
64
115
 
65
- ### :match
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.
66
118
 
67
- Pass a regular expression to match parameter value. Non-matching values will be ignored or use default value, if available.
119
+ To preserve the original block's context, declare two arguments in your block, the defining schema `sc` and options has.
68
120
 
69
121
  ```ruby
70
- class OrdersSearch
71
- include Parametric::Params
72
- param :email, 'Valid email address', match: /\w+@\w+\.\w+/
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)
73
128
  end
74
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.
75
204
 
76
- ### :options array
205
+ ### :present
77
206
 
78
- Declare allowed values in an array. Values not in the options will be ignored or use default value.
207
+ Check that the key exists and the value is not blank.
79
208
 
80
209
  ```ruby
81
- class OrdersSearch
82
- include Parametric::Params
83
- param :sort, 'Sort', options: ['updated_on-desc', 'updated_on-asc'], default: 'updated_on-desc'
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"]
276
+ ```
277
+
278
+ ## Custom policies
279
+
280
+ You can also register your own custom policy objects. A policy must implement the following methods:
281
+
282
+ ```ruby
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
84
309
  end
85
310
  ```
86
311
 
87
- ### :multiple values
312
+ You can register your policy with:
88
313
 
89
- `:multiple` values are separated on "," and treated as arrays.
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.
325
+
326
+ ```ruby
327
+ field(:title).required.policy(:my_policy)
328
+ ```
329
+
330
+ Note that you can also register instances.
90
331
 
91
332
  ```ruby
92
- class OrdersSearch
93
- include Parametric::Params
94
- param :status, 'Order status', multiple: true
333
+ Parametric.policy :my_policy, MyPolicy.new
334
+ ```
335
+
336
+ For example, a policy that can be configured on a field-by-field basis:
337
+
338
+ ```ruby
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
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"}
385
+ ```
386
+
387
+ ## Custom policies, short version
388
+
389
+ For simple policies that don't need all policy methods, you can:
390
+
391
+ ```ruby
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
434
+
435
+ The `#merge` method will merge field definitions in two schemas and produce a new schema instance.
108
436
 
109
- search = OrdersSearch.new(status: 'closed,shipped,abandoned')
110
- search.params[:status] # => ['closed', 'shipped']
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)
461
+ end
462
+
463
+ optional_name_schema = Parametric::Schema.new do
464
+ field(:name)
119
465
  end
120
466
 
121
- search = OrdersSearch.new
122
- search.params[:status] # => ['closed']
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)
470
+ ```
471
+
472
+ ## #meta
473
+
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).
476
+
477
+ ```ruby
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
123
487
  ```
124
488
 
125
- ## `available_params`
489
+ ## #structure
490
+
491
+ A `Schema` instance has a `#structure` method that allows instrospecting schema meta data.
126
492
 
127
- `#available_params` returns the subset of keys that were populated (including defaults). Useful to build query strings.
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.
128
502
 
129
503
  ```ruby
130
- order_search = OrdersSearch.new(page: 2, foo: 'bar')
131
- order_search.available_params # => {page: 2, per_page: 50}
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"
132
508
  ```
133
509
 
134
- ## `schema`
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
520
+
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
+ ```
135
533
 
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).
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
554
+
555
+ You can use schemas and fields on their own, or include the `DSL` module in your own classes to define form objects.
556
+
557
+ ```ruby
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.
151
641
 
152
- The alternative `Parametric::Hash` module makes your objects quack like a hash, instead of exposing the `#params` object directly.
642
+ For example, fields that are _required_ when creating a record might be optional when updating the same record (ie. _PATCH_ operations in APIs).
153
643
 
154
644
  ```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
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
179
677
  end
180
678
  ```
181
679
 
182
- I use this along with [Oat](https://github.com/ismasan/oat) in API projects:
680
+ The child class can use `ignore(*fields)` to ignore fields defined in the parent.
183
681
 
184
682
  ```ruby
185
- def index
186
- search = OrdersSearch.new(params)
187
- render json: OrdersSerializer.new(search)
683
+ class UpdateUserForm < CreateUserForm
684
+ schema.ignore(:uuid, :status) do
685
+ # optionally add new fields here
686
+ end
188
687
  end
189
688
  ```
190
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
705
+ end
706
+ ```
707
+
708
+ The `:noop` policy does nothing. The sub-class can pass its own _default_policy_.
709
+
710
+ ```ruby
711
+ class UpdateUserForm < CreateUserForm
712
+ # this will only run validations keys existing in the input
713
+ schema(default_policy: :declared)
714
+ end
715
+ ```
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
+ ## Structs
853
+
854
+ Structs turn schema definitions into objects graphs with attribute readers.
855
+
856
+ Add optional `Parametrict::Struct` module to define struct-like objects with schema definitions.
857
+
858
+ ```ruby
859
+ require 'parametric/struct'
860
+
861
+ class User
862
+ include Parametric::Struct
863
+
864
+ schema do
865
+ field(:name).type(:string).present
866
+ field(:friends).type(:array).schema do
867
+ field(:name).type(:string).present
868
+ field(:age).type(:integer)
869
+ end
870
+ end
871
+ end
872
+ ```
873
+
874
+ `User` objects can be instantiated with hash data, which will be coerced and validated as per the schema definition.
875
+
876
+ ```ruby
877
+ user = User.new(
878
+ name: 'Joe',
879
+ friends: [
880
+ {name: 'Jane', age: 40},
881
+ {name: 'John', age: 30},
882
+ ]
883
+ )
884
+
885
+ # properties
886
+ user.name # => 'Joe'
887
+ user.friends.first.name # => 'Jane'
888
+ user.friends.last.age # => 30
889
+ ```
890
+
891
+ ### Errors
892
+
893
+ Both the top-level and nested instances contain error information:
894
+
895
+ ```ruby
896
+ user = User.new(
897
+ name: '', # invalid
898
+ friends: [
899
+ # friend name also invalid
900
+ {name: '', age: 40},
901
+ ]
902
+ )
903
+
904
+ user.valid? # false
905
+ user.errors['$.name'] # => "is required and must be present"
906
+ user.errors['$.friends[0].name'] # => "is required and must be present"
907
+
908
+ # also access error in nested instances directly
909
+ user.friends.first.valid? # false
910
+ user.friends.first.errors['$.name'] # "is required and must be valid"
911
+ ```
912
+
913
+ ### .new!(hash)
914
+
915
+ Instantiating structs with `.new!(hash)` will raise a `Parametric::InvalidStructError` exception if the data is validations fail. It will return the struct instance otherwise.
916
+
917
+ `Parametric::InvalidStructError` includes an `#errors` property to inspect the errors raised.
918
+
919
+ ```ruby
920
+ begin
921
+ user = User.new!(name: '')
922
+ rescue Parametric::InvalidStructError => e
923
+ e.errors['$.name'] # "is required and must be present"
924
+ end
925
+ ```
926
+
927
+ ### Nested structs
928
+
929
+ You can also pass separate struct classes in a nested schema definition.
930
+
931
+ ```ruby
932
+ class Friend
933
+ include Parametric::Struct
934
+
935
+ schema do
936
+ field(:name).type(:string).present
937
+ field(:age).type(:integer)
938
+ end
939
+ end
940
+
941
+ class User
942
+ include Parametric::Struct
943
+
944
+ schema do
945
+ field(:name).type(:string).present
946
+ # here we use the Friend class
947
+ field(:friends).type(:array).schema Friend
948
+ end
949
+ end
950
+ ```
951
+
952
+ ### Inheritance
953
+
954
+ Struct subclasses can add to inherited schemas, or override fields defined in the parent.
955
+
956
+ ```ruby
957
+ class AdminUser < User
958
+ # inherits User schema, and can add stuff to its own schema
959
+ schema do
960
+ field(:permissions).type(:array)
961
+ end
962
+ end
963
+ ```
964
+
965
+ ### #to_h
966
+
967
+ `Struct#to_h` returns the ouput hash, with values coerced and any defaults populated.
968
+
969
+ ```ruby
970
+ class User
971
+ include Parametrict::Struct
972
+ schema do
973
+ field(:name).type(:string)
974
+ field(:age).type(:integer).default(30)
975
+ end
976
+ end
977
+
978
+ user = User.new(name: "Joe")
979
+ user.to_h # {name: "Joe", age: 30}
980
+ ```
981
+
982
+ ### Struct equality
983
+
984
+ `Parametric::Struct` implements `#==()` to compare two structs Hash representation (same as `struct1.to_h.eql?(struct2.to_h)`.
985
+
986
+ Users can override `#==()` in their own classes to do whatever they need.
987
+
191
988
  ## Installation
192
989
 
193
990
  Add this line to your application's Gemfile:
@@ -204,7 +1001,7 @@ Or install it yourself as:
204
1001
 
205
1002
  ## Contributing
206
1003
 
207
- 1. Fork it ( http://github.com/<my-github-username>/parametric/fork )
1004
+ 1. Fork it ( http://github.com/ismasan/parametric/fork )
208
1005
  2. Create your feature branch (`git checkout -b my-new-feature`)
209
1006
  3. Commit your changes (`git commit -am 'Add some feature'`)
210
1007
  4. Push to the branch (`git push origin my-new-feature`)