parametric 0.2.11 → 0.2.15
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +124 -0
- data/lib/parametric/context.rb +4 -1
- data/lib/parametric/field.rb +32 -2
- data/lib/parametric/schema.rb +34 -2
- data/lib/parametric/struct.rb +4 -0
- data/lib/parametric/version.rb +1 -1
- data/spec/field_spec.rb +22 -0
- data/spec/schema_lifecycle_hooks_spec.rb +133 -0
- data/spec/struct_spec.rb +4 -0
- metadata +8 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 247b7e008d418f1b7604c894a429fcc241a916d2af2c94c6544719d60ba9e3e5
|
4
|
+
data.tar.gz: 3bd66cba4a18db64adbbfa3d7212987498bb5457dce0941ca9c6855b5381372a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: '00439640c00a0323a7f9d028dfdff7509a0431d5cf96d7091c17f8eae0ef19b7c06799a343b16712f048a8895d6ab0653b0d93f344128c375847de02227099d0'
|
7
|
+
data.tar.gz: 1eb8eddcd01b0f797f39128ef263052229f91b3195578f502c84f97b4ad4855ecb8a048478e8246f3345b67ee8469a027ff0be99b4fa7dd45f063472c573134f
|
data/README.md
CHANGED
@@ -849,6 +849,130 @@ results.errors["$.Weight"] # => ["is required and value must be present"]
|
|
849
849
|
|
850
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
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
|
+
|
852
976
|
## Structs
|
853
977
|
|
854
978
|
Structs turn schema definitions into objects graphs with attribute readers.
|
data/lib/parametric/context.rb
CHANGED
@@ -28,6 +28,10 @@ module Parametric
|
|
28
28
|
top.add_error(string_path, msg)
|
29
29
|
end
|
30
30
|
|
31
|
+
def add_base_error(key, msg)
|
32
|
+
top.add_error(key, msg)
|
33
|
+
end
|
34
|
+
|
31
35
|
def sub(key)
|
32
36
|
self.class.new(path + [key], top)
|
33
37
|
end
|
@@ -42,5 +46,4 @@ module Parametric
|
|
42
46
|
end.join
|
43
47
|
end
|
44
48
|
end
|
45
|
-
|
46
49
|
end
|
data/lib/parametric/field.rb
CHANGED
@@ -45,6 +45,19 @@ module Parametric
|
|
45
45
|
policy sc.schema
|
46
46
|
end
|
47
47
|
|
48
|
+
def from(another_field)
|
49
|
+
meta another_field.meta_data
|
50
|
+
another_field.policies.each do |plc|
|
51
|
+
policies << plc
|
52
|
+
end
|
53
|
+
|
54
|
+
self
|
55
|
+
end
|
56
|
+
|
57
|
+
def has_policy?(key)
|
58
|
+
policies.any? { |pol| pol.key == key }
|
59
|
+
end
|
60
|
+
|
48
61
|
def visit(meta_key = nil, &visitor)
|
49
62
|
if sc = meta_data[:schema]
|
50
63
|
r = sc.visit(meta_key, &visitor)
|
@@ -85,8 +98,13 @@ module Parametric
|
|
85
98
|
Result.new(eligible, value)
|
86
99
|
end
|
87
100
|
|
101
|
+
protected
|
102
|
+
|
103
|
+
attr_reader :policies
|
104
|
+
|
88
105
|
private
|
89
|
-
|
106
|
+
|
107
|
+
attr_reader :registry, :default_block
|
90
108
|
|
91
109
|
def resolve_one(policy, value, context)
|
92
110
|
begin
|
@@ -106,7 +124,19 @@ module Parametric
|
|
106
124
|
|
107
125
|
raise ConfigurationError, "No policies defined for #{key.inspect}" unless obj
|
108
126
|
|
109
|
-
obj
|
127
|
+
obj = obj.new(*args) if obj.respond_to?(:new)
|
128
|
+
obj = PolicyWithKey.new(obj, key)
|
129
|
+
|
130
|
+
obj
|
131
|
+
end
|
132
|
+
|
133
|
+
class PolicyWithKey < SimpleDelegator
|
134
|
+
attr_reader :key
|
135
|
+
|
136
|
+
def initialize(policy, key)
|
137
|
+
super policy
|
138
|
+
@key = key
|
139
|
+
end
|
110
140
|
end
|
111
141
|
end
|
112
142
|
end
|
data/lib/parametric/schema.rb
CHANGED
@@ -14,6 +14,8 @@ module Parametric
|
|
14
14
|
@default_field_policies = []
|
15
15
|
@ignored_field_keys = []
|
16
16
|
@expansions = {}
|
17
|
+
@before_hooks = []
|
18
|
+
@after_hooks = []
|
17
19
|
end
|
18
20
|
|
19
21
|
def schema
|
@@ -92,6 +94,20 @@ module Parametric
|
|
92
94
|
end
|
93
95
|
end
|
94
96
|
|
97
|
+
def before_resolve(klass = nil, &block)
|
98
|
+
raise ArgumentError, '#before_resolve expects a callable object, or a block' if !klass && !block_given?
|
99
|
+
callable = klass || block
|
100
|
+
before_hooks << callable
|
101
|
+
self
|
102
|
+
end
|
103
|
+
|
104
|
+
def after_resolve(klass = nil, &block)
|
105
|
+
raise ArgumentError, '#after_resolve expects a callable object, or a block' if !klass && !block_given?
|
106
|
+
callable = klass || block
|
107
|
+
after_hooks << callable
|
108
|
+
self
|
109
|
+
end
|
110
|
+
|
95
111
|
def expand(exp, &block)
|
96
112
|
expansions[exp] = block
|
97
113
|
self
|
@@ -141,19 +157,35 @@ module Parametric
|
|
141
157
|
|
142
158
|
protected
|
143
159
|
|
144
|
-
attr_reader :definitions, :options
|
160
|
+
attr_reader :definitions, :options, :before_hooks, :after_hooks
|
145
161
|
|
146
162
|
private
|
147
163
|
|
148
164
|
attr_reader :default_field_policies, :ignored_field_keys, :expansions
|
149
165
|
|
150
166
|
def coerce_one(val, context, flds: fields)
|
151
|
-
|
167
|
+
val = run_before_hooks(val, context)
|
168
|
+
|
169
|
+
out = flds.each_with_object({}) do |(_, field), m|
|
152
170
|
r = field.resolve(val, context.sub(field.key))
|
153
171
|
if r.eligible?
|
154
172
|
m[field.key] = r.value
|
155
173
|
end
|
156
174
|
end
|
175
|
+
|
176
|
+
run_after_hooks(out, context)
|
177
|
+
end
|
178
|
+
|
179
|
+
def run_before_hooks(val, context)
|
180
|
+
before_hooks.reduce(val) do |value, callable|
|
181
|
+
callable.call(value, context)
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
def run_after_hooks(val, context)
|
186
|
+
after_hooks.reduce(val) do |value, callable|
|
187
|
+
callable.call(value, context)
|
188
|
+
end
|
157
189
|
end
|
158
190
|
|
159
191
|
class MatchContext
|
data/lib/parametric/struct.rb
CHANGED
data/lib/parametric/version.rb
CHANGED
data/spec/field_spec.rb
CHANGED
@@ -88,6 +88,14 @@ describe Parametric::Field do
|
|
88
88
|
end
|
89
89
|
end
|
90
90
|
|
91
|
+
describe '#has_policy?' do
|
92
|
+
it 'is a boolean' do
|
93
|
+
subject.policy(:integer)
|
94
|
+
expect(subject.has_policy?(:integer)).to be(true)
|
95
|
+
expect(subject.has_policy?(:string)).to be(false)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
91
99
|
describe "#default" do
|
92
100
|
it "is default if missing key" do
|
93
101
|
resolve(subject.default("AA"), foobar: 1).tap do |r|
|
@@ -112,6 +120,20 @@ describe Parametric::Field do
|
|
112
120
|
end
|
113
121
|
end
|
114
122
|
|
123
|
+
describe '#from' do
|
124
|
+
it 'copies policies and metadata from an existing field' do
|
125
|
+
subject.policy(:string).present.options(['a', 'b', 'c'])
|
126
|
+
|
127
|
+
field = described_class.new(:another_key, registry).from(subject)
|
128
|
+
resolve(field, another_key: "abc").tap do |r|
|
129
|
+
has_errors
|
130
|
+
end
|
131
|
+
expect(field.meta_data[:type]).to eq(:string)
|
132
|
+
expect(field.meta_data[:present]).to be(true)
|
133
|
+
expect(field.meta_data[:options]).to eq(['a', 'b', 'c'])
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
115
137
|
describe "#present" do
|
116
138
|
it "is valid if value is present" do
|
117
139
|
resolve(subject.present, a_key: "abc").tap do |r|
|
@@ -0,0 +1,133 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Parametric::Schema do
|
4
|
+
describe '#before_resolve' do
|
5
|
+
it 'passes payload through before_resolve block, if defined' do
|
6
|
+
schema = described_class.new do
|
7
|
+
before_resolve do |payload, _context|
|
8
|
+
payload[:slug] = payload[:name].to_s.downcase.gsub(/\s+/, '-') unless payload[:slug]
|
9
|
+
payload
|
10
|
+
end
|
11
|
+
|
12
|
+
field(:name).policy(:string).present
|
13
|
+
field(:slug).policy(:string).present
|
14
|
+
field(:variants).policy(:array).schema do
|
15
|
+
before_resolve do |payload, _context|
|
16
|
+
payload[:slug] = "v: #{payload[:name].to_s.downcase}"
|
17
|
+
payload
|
18
|
+
end
|
19
|
+
field(:name).policy(:string).present
|
20
|
+
field(:slug).type(:string).present
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
result = schema.resolve({ name: 'A name', variants: [{ name: 'A variant' }] })
|
25
|
+
expect(result.valid?).to be true
|
26
|
+
expect(result.output[:slug]).to eq 'a-name'
|
27
|
+
expect(result.output[:variants].first[:slug]).to eq 'v: a variant'
|
28
|
+
end
|
29
|
+
|
30
|
+
it 'collects errors added in before_resolve blocks' do
|
31
|
+
schema = described_class.new do
|
32
|
+
field(:variants).type(:array).schema do
|
33
|
+
before_resolve do |payload, context|
|
34
|
+
context.add_error 'nope!' if payload[:name] == 'with errors'
|
35
|
+
payload
|
36
|
+
end
|
37
|
+
field(:name).type(:string)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
results = schema.resolve({ variants: [ {name: 'no errors'}, {name: 'with errors'}]})
|
42
|
+
expect(results.valid?).to be false
|
43
|
+
expect(results.errors['$.variants[1]']).to eq ['nope!']
|
44
|
+
end
|
45
|
+
|
46
|
+
it 'copies before_resolve hooks to merged schemas' do
|
47
|
+
schema1 = described_class.new do
|
48
|
+
before_resolve do |payload, _context|
|
49
|
+
payload[:slug] = payload[:name].to_s.downcase.gsub(/\s+/, '-') unless payload[:slug]
|
50
|
+
payload
|
51
|
+
end
|
52
|
+
field(:name).present.type(:string)
|
53
|
+
field(:slug).present.type(:string)
|
54
|
+
end
|
55
|
+
|
56
|
+
schema2 = described_class.new do
|
57
|
+
before_resolve do |payload, _context|
|
58
|
+
payload[:slug] = "slug-#{payload[:slug]}" if payload[:slug]
|
59
|
+
payload
|
60
|
+
end
|
61
|
+
|
62
|
+
field(:age).type(:integer)
|
63
|
+
end
|
64
|
+
|
65
|
+
schema3 = schema1.merge(schema2)
|
66
|
+
|
67
|
+
results = schema3.resolve({ name: 'Ismael Celis', age: 41 })
|
68
|
+
expect(results.output[:slug]).to eq 'slug-ismael-celis'
|
69
|
+
end
|
70
|
+
|
71
|
+
it 'works with any callable' do
|
72
|
+
slug_maker = Class.new do
|
73
|
+
def initialize(slug_field, from:)
|
74
|
+
@slug_field, @from = slug_field, from
|
75
|
+
end
|
76
|
+
|
77
|
+
def call(payload, _context)
|
78
|
+
payload.merge(
|
79
|
+
@slug_field => payload[@from].to_s.downcase.gsub(/\s+/, '-')
|
80
|
+
)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
schema = described_class.new do |sc, _opts|
|
85
|
+
sc.before_resolve slug_maker.new(:slug, from: :name)
|
86
|
+
|
87
|
+
sc.field(:name).type(:string)
|
88
|
+
sc.field(:slug).type(:string)
|
89
|
+
end
|
90
|
+
|
91
|
+
results = schema.resolve(name: 'Ismael Celis')
|
92
|
+
expect(results.output[:slug]).to eq 'ismael-celis'
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
describe '#after_resolve' do
|
97
|
+
let!(:schema) do
|
98
|
+
described_class.new do
|
99
|
+
after_resolve do |payload, ctx|
|
100
|
+
ctx.add_base_error('deposit', 'cannot be greater than house price') if payload[:deposit] > payload[:house_price]
|
101
|
+
payload.merge(desc: 'hello')
|
102
|
+
end
|
103
|
+
|
104
|
+
field(:deposit).policy(:integer).present
|
105
|
+
field(:house_price).policy(:integer).present
|
106
|
+
field(:desc).policy(:string)
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
it 'passes payload through after_resolve block, if defined' do
|
111
|
+
result = schema.resolve({ deposit: 1100, house_price: 1000 })
|
112
|
+
expect(result.valid?).to be false
|
113
|
+
expect(result.output[:deposit]).to eq 1100
|
114
|
+
expect(result.output[:house_price]).to eq 1000
|
115
|
+
expect(result.output[:desc]).to eq 'hello'
|
116
|
+
end
|
117
|
+
|
118
|
+
it 'copies after hooks when merging schemas' do
|
119
|
+
child_schema = described_class.new do
|
120
|
+
field(:name).type(:string)
|
121
|
+
end
|
122
|
+
|
123
|
+
union = schema.merge(child_schema)
|
124
|
+
|
125
|
+
result = union.resolve({ name: 'Joe', deposit: 1100, house_price: 1000 })
|
126
|
+
expect(result.valid?).to be false
|
127
|
+
expect(result.output[:deposit]).to eq 1100
|
128
|
+
expect(result.output[:house_price]).to eq 1000
|
129
|
+
expect(result.output[:desc]).to eq 'hello'
|
130
|
+
expect(result.output[:name]).to eq 'Joe'
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
data/spec/struct_spec.rb
CHANGED
@@ -40,6 +40,10 @@ describe Parametric::Struct do
|
|
40
40
|
expect(instance.friends.first.name).to eq 'Ismael'
|
41
41
|
expect(instance.friends.first).to be_a friend_class
|
42
42
|
|
43
|
+
# Hash access with #[]
|
44
|
+
expect(instance[:title]).to eq instance.title
|
45
|
+
expect(instance[:friends].first[:name]).to eq instance.friends.first.name
|
46
|
+
|
43
47
|
invalid_instance = klass.new({
|
44
48
|
friends: [
|
45
49
|
{name: 'Ismael', age: 40},
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: parametric
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.2.
|
4
|
+
version: 0.2.15
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Ismael Celis
|
8
|
-
autorequire:
|
8
|
+
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2021-07-30 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rake
|
@@ -88,6 +88,7 @@ files:
|
|
88
88
|
- spec/expand_spec.rb
|
89
89
|
- spec/field_spec.rb
|
90
90
|
- spec/policies_spec.rb
|
91
|
+
- spec/schema_lifecycle_hooks_spec.rb
|
91
92
|
- spec/schema_spec.rb
|
92
93
|
- spec/schema_walk_spec.rb
|
93
94
|
- spec/spec_helper.rb
|
@@ -97,7 +98,7 @@ homepage: ''
|
|
97
98
|
licenses:
|
98
99
|
- MIT
|
99
100
|
metadata: {}
|
100
|
-
post_install_message:
|
101
|
+
post_install_message:
|
101
102
|
rdoc_options: []
|
102
103
|
require_paths:
|
103
104
|
- lib
|
@@ -112,8 +113,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
112
113
|
- !ruby/object:Gem::Version
|
113
114
|
version: '0'
|
114
115
|
requirements: []
|
115
|
-
rubygems_version: 3.
|
116
|
-
signing_key:
|
116
|
+
rubygems_version: 3.0.3
|
117
|
+
signing_key:
|
117
118
|
specification_version: 4
|
118
119
|
summary: DSL for declaring allowed parameters with options, regexp patern and default
|
119
120
|
values.
|
@@ -123,6 +124,7 @@ test_files:
|
|
123
124
|
- spec/expand_spec.rb
|
124
125
|
- spec/field_spec.rb
|
125
126
|
- spec/policies_spec.rb
|
127
|
+
- spec/schema_lifecycle_hooks_spec.rb
|
126
128
|
- spec/schema_spec.rb
|
127
129
|
- spec/schema_walk_spec.rb
|
128
130
|
- spec/spec_helper.rb
|