parametric 0.0.1 → 0.2.12
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/.gitignore +1 -0
- data/.travis.yml +2 -1
- data/Gemfile +4 -0
- data/README.md +1017 -96
- data/bench/struct_bench.rb +53 -0
- data/bin/console +14 -0
- data/lib/parametric/block_validator.rb +66 -0
- data/lib/parametric/context.rb +49 -0
- data/lib/parametric/default_types.rb +97 -0
- data/lib/parametric/dsl.rb +70 -0
- data/lib/parametric/field.rb +113 -0
- data/lib/parametric/field_dsl.rb +26 -0
- data/lib/parametric/policies.rb +111 -38
- data/lib/parametric/registry.rb +23 -0
- data/lib/parametric/results.rb +15 -0
- data/lib/parametric/schema.rb +228 -0
- data/lib/parametric/struct.rb +108 -0
- data/lib/parametric/version.rb +3 -1
- data/lib/parametric.rb +18 -5
- data/parametric.gemspec +2 -3
- data/spec/custom_block_validator_spec.rb +21 -0
- data/spec/dsl_spec.rb +176 -0
- data/spec/expand_spec.rb +29 -0
- data/spec/field_spec.rb +430 -0
- data/spec/policies_spec.rb +72 -0
- data/spec/schema_lifecycle_hooks_spec.rb +133 -0
- data/spec/schema_spec.rb +289 -0
- data/spec/schema_walk_spec.rb +42 -0
- data/spec/spec_helper.rb +1 -0
- data/spec/struct_spec.rb +298 -0
- data/spec/validators_spec.rb +106 -0
- metadata +49 -23
- data/lib/parametric/hash.rb +0 -36
- data/lib/parametric/params.rb +0 -60
- data/lib/parametric/utils.rb +0 -24
- data/spec/parametric_spec.rb +0 -182
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
|
-
|
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
|
-
##
|
9
|
+
## Schema
|
8
10
|
|
9
|
-
|
11
|
+
Define a schema
|
10
12
|
|
11
13
|
```ruby
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
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
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
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
|
-
|
35
|
-
|
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
|
-
|
40
|
-
|
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
|
-
|
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
|
-
|
68
|
+
It works as expected
|
46
69
|
|
47
70
|
```ruby
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
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
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
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
|
-
|
278
|
+
## Custom policies
|
66
279
|
|
67
|
-
|
280
|
+
You can also register your own custom policy objects. A policy must implement the following methods:
|
68
281
|
|
69
282
|
```ruby
|
70
|
-
class
|
71
|
-
|
72
|
-
|
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
|
-
|
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
|
-
|
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
|
82
|
-
|
83
|
-
|
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
|
-
|
387
|
+
## Custom policies, short version
|
88
388
|
|
89
|
-
|
389
|
+
For simple policies that don't need all policy methods, you can:
|
90
390
|
|
91
391
|
```ruby
|
92
|
-
|
93
|
-
|
94
|
-
|
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
|
-
|
98
|
-
|
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
|
-
|
424
|
+
New copies can be further manipulated without affecting the original.
|
102
425
|
|
103
426
|
```ruby
|
104
|
-
|
105
|
-
|
106
|
-
|
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
|
-
|
110
|
-
|
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
|
-
|
455
|
+
Fields defined in the merged schema will override fields with the same name in the original schema.
|
114
456
|
|
115
457
|
```ruby
|
116
|
-
|
117
|
-
|
118
|
-
|
458
|
+
required_name_schema = Parametric::Schema.new do
|
459
|
+
field(:name).required
|
460
|
+
field(:age)
|
119
461
|
end
|
120
462
|
|
121
|
-
|
122
|
-
|
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
|
-
##
|
472
|
+
## #meta
|
126
473
|
|
127
|
-
`#
|
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
|
-
|
131
|
-
|
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
|
-
##
|
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
|
-
|
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
|
-
|
537
|
+
schema_labels = create_user_schema.walk(:label).output
|
538
|
+
|
539
|
+
# returns
|
140
540
|
|
141
541
|
{
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
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
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
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
|
-
|
664
|
+
### Ignoring fields defined in the parent class
|
172
665
|
|
173
|
-
|
666
|
+
Sometimes you'll want a child class to inherit most fields from the parent, but ignoring some.
|
174
667
|
|
175
668
|
```ruby
|
176
|
-
|
177
|
-
|
178
|
-
|
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
|
-
|
708
|
+
The `:noop` policy does nothing. The sub-class can pass its own _default_policy_.
|
183
709
|
|
184
710
|
```ruby
|
185
|
-
|
186
|
-
|
187
|
-
|
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
|
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`)
|