blood_contracts-core 0.3.5 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +31 -0
- data/.travis.yml +16 -4
- data/CHANGELOG.md +14 -0
- data/README.md +363 -5
- data/Rakefile +1 -1
- data/blood_contracts-core.gemspec +18 -25
- data/examples/json_response.rb +33 -41
- data/examples/tariff_contract.rb +35 -32
- data/examples/tuple.rb +11 -12
- data/lib/blood_contracts/core/anything.rb +23 -0
- data/lib/blood_contracts/core/contract.rb +37 -23
- data/lib/blood_contracts/core/contract_failure.rb +50 -0
- data/lib/blood_contracts/core/pipe.rb +143 -77
- data/lib/blood_contracts/core/refined.rb +148 -125
- data/lib/blood_contracts/core/sum.rb +81 -49
- data/lib/blood_contracts/core/tuple.rb +151 -66
- data/lib/blood_contracts/core/tuple_contract_failure.rb +31 -0
- data/lib/blood_contracts/core.rb +16 -10
- data/spec/blood_contracts/core_spec.rb +314 -0
- data/spec/spec_helper.rb +26 -0
- metadata +36 -10
- data/lib/blood_contracts/core/version.rb +0 -5
@@ -0,0 +1,31 @@
|
|
1
|
+
module BloodContracts::Core
|
2
|
+
# Represents failure in during of Tuple data matching
|
3
|
+
class TupleContractFailure < ContractFailure
|
4
|
+
# Hash of attributes (name & type pairs)
|
5
|
+
#
|
6
|
+
# @return [Hash<String, Refined>]
|
7
|
+
#
|
8
|
+
def attributes
|
9
|
+
@context[:attributes]
|
10
|
+
end
|
11
|
+
|
12
|
+
# Subset of attributes which are invalid
|
13
|
+
#
|
14
|
+
# @return [Hash<String, ContractFailure>]
|
15
|
+
#
|
16
|
+
def attribute_errors
|
17
|
+
attributes.select { |_name, type| type.invalid? }
|
18
|
+
end
|
19
|
+
|
20
|
+
# Unpacked matching errors in form of a hash per attribute
|
21
|
+
#
|
22
|
+
# @return [Hash<String, ContractFailure>]
|
23
|
+
#
|
24
|
+
def unpack_h
|
25
|
+
@unpack_h ||= attribute_errors.transform_values(&:unpack)
|
26
|
+
end
|
27
|
+
alias to_hash unpack_h
|
28
|
+
alias to_h unpack_h
|
29
|
+
alias unpack_attributes unpack_h
|
30
|
+
end
|
31
|
+
end
|
data/lib/blood_contracts/core.rb
CHANGED
@@ -1,14 +1,20 @@
|
|
1
|
-
|
2
|
-
require_relative "./core/pipe.rb"
|
3
|
-
require_relative "./core/contract.rb"
|
4
|
-
require_relative "./core/sum.rb"
|
5
|
-
require_relative "./core/tuple.rb"
|
6
|
-
require_relative "./core/version.rb"
|
7
|
-
|
8
|
-
|
1
|
+
# Top-level scope for BloodContracts collection of gems
|
9
2
|
module BloodContracts
|
10
|
-
|
3
|
+
# Scope for BloodContracts::Core classes
|
4
|
+
module Core
|
5
|
+
require_relative "./core/refined.rb"
|
6
|
+
require_relative "./core/contract_failure.rb"
|
7
|
+
require_relative "./core/anything.rb"
|
8
|
+
require_relative "./core/pipe.rb"
|
9
|
+
require_relative "./core/contract.rb"
|
10
|
+
require_relative "./core/sum.rb"
|
11
|
+
require_relative "./core/tuple.rb"
|
12
|
+
require_relative "./core/tuple_contract_failure.rb"
|
13
|
+
|
14
|
+
# constant aliases
|
15
|
+
Or = Sum
|
16
|
+
AndThen = Pipe
|
17
|
+
end
|
11
18
|
end
|
12
19
|
|
13
20
|
BC = BloodContracts::Core
|
14
|
-
|
@@ -0,0 +1,314 @@
|
|
1
|
+
|
2
|
+
require "spec_helper"
|
3
|
+
|
4
|
+
RSpec.describe BloodContracts::Core do
|
5
|
+
before do
|
6
|
+
module Test
|
7
|
+
class Json < ::BC::Refined
|
8
|
+
require "json"
|
9
|
+
|
10
|
+
def match
|
11
|
+
context[:raw_value] = unpack_refined(value).to_s
|
12
|
+
context[:parsed] =
|
13
|
+
JSON.parse(context[:raw_value], symbolize_names: true)
|
14
|
+
nil
|
15
|
+
rescue JSON::ParserError => exception
|
16
|
+
failure(exception)
|
17
|
+
end
|
18
|
+
|
19
|
+
def mapped
|
20
|
+
context[:parsed]
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
class Phone < ::BC::Refined
|
25
|
+
REGEX = /\A(\+7|8)(9|8)\d{9}\z/i
|
26
|
+
|
27
|
+
def match
|
28
|
+
context[:phone] = unpack_refined(value).to_s
|
29
|
+
context[:clean_phone] = context[:phone].gsub(/[\s\(\)-]/, "")
|
30
|
+
return if context[:clean_phone] =~ REGEX
|
31
|
+
|
32
|
+
failure("Not a phone")
|
33
|
+
end
|
34
|
+
|
35
|
+
def mapped
|
36
|
+
context[:clean_phone]
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
class Email < ::BC::Refined
|
41
|
+
REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
|
42
|
+
|
43
|
+
def match
|
44
|
+
context[:email] = unpack_refined(value).to_s
|
45
|
+
return if context[:email] =~ REGEX
|
46
|
+
|
47
|
+
failure("Not an email")
|
48
|
+
end
|
49
|
+
|
50
|
+
def mapped
|
51
|
+
context[:email]
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
class Ascii < ::BC::Refined
|
56
|
+
REGEX = /^[[:ascii:]]+$/i
|
57
|
+
|
58
|
+
def match
|
59
|
+
context[:ascii_string] = value.to_s
|
60
|
+
return if context[:ascii_string] =~ REGEX
|
61
|
+
|
62
|
+
failure("Not ASCII")
|
63
|
+
end
|
64
|
+
|
65
|
+
def mapped
|
66
|
+
context[:ascii_string]
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
context "standalone validation" do
|
73
|
+
subject { Test::Ascii.match(value) }
|
74
|
+
|
75
|
+
context "when value is valid" do
|
76
|
+
let(:value) { "i'm written in pure ASCII" }
|
77
|
+
|
78
|
+
it do
|
79
|
+
is_expected.to be_valid
|
80
|
+
expect { subject.unpack }.not_to raise_error
|
81
|
+
expect(subject.unpack).to eq(value)
|
82
|
+
expect(subject.context).to match({ ascii_string: value })
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
context "when value is invalid" do
|
87
|
+
let(:value) { "I'm ∑ritten nøt in åßçii" }
|
88
|
+
let(:error) { { Test::Ascii => ["Not ASCII"] } }
|
89
|
+
let(:validation_context) { { ascii_string: value, errors: [error] } }
|
90
|
+
|
91
|
+
it do
|
92
|
+
is_expected.to be_invalid
|
93
|
+
expect(subject.errors).to match([error])
|
94
|
+
expect(subject.messages).to match(["Not ASCII"])
|
95
|
+
expect(subject.unpack).to match(error)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
context "Sum composition" do
|
101
|
+
before do
|
102
|
+
module Test
|
103
|
+
Login = Email.or_a(Phone)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
subject { Test::Login.match(login) }
|
108
|
+
|
109
|
+
context "when login is valid" do
|
110
|
+
context "when login is email" do
|
111
|
+
let(:login) { "admin@example.com" }
|
112
|
+
let(:errors) { [{ Test::Phone => ["Not a phone"] }] }
|
113
|
+
let(:validation_context) do
|
114
|
+
hash_including(
|
115
|
+
phone: login,
|
116
|
+
clean_phone: login,
|
117
|
+
errors: errors,
|
118
|
+
email: login
|
119
|
+
)
|
120
|
+
end
|
121
|
+
|
122
|
+
it do
|
123
|
+
is_expected.to be_valid
|
124
|
+
expect { subject.unpack }.not_to raise_error
|
125
|
+
expect(subject.unpack).to eq(login)
|
126
|
+
expect(subject.context).to match(validation_context)
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
context "when login is phone" do
|
131
|
+
let(:login) { "8(800) 200 - 11 - 00" }
|
132
|
+
let(:cleaned_phone) { "88002001100" }
|
133
|
+
let(:errors) { [{ Test::Email => ["Not an email"] }] }
|
134
|
+
let(:validation_context) do
|
135
|
+
hash_including(
|
136
|
+
phone: login,
|
137
|
+
clean_phone: cleaned_phone,
|
138
|
+
errors: errors,
|
139
|
+
email: login
|
140
|
+
)
|
141
|
+
end
|
142
|
+
|
143
|
+
it do
|
144
|
+
is_expected.to be_valid
|
145
|
+
expect { subject.unpack }.not_to raise_error
|
146
|
+
expect(subject.unpack).to eq(cleaned_phone)
|
147
|
+
expect(subject.context).to match(validation_context)
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
context "when login is invalid" do
|
153
|
+
let(:login) { "I'm something else" }
|
154
|
+
let(:errors) do
|
155
|
+
[
|
156
|
+
{ Test::Email => ["Not an email"] },
|
157
|
+
{ Test::Phone => ["Not a phone"] },
|
158
|
+
{ Test::Login => [:no_matches] }
|
159
|
+
]
|
160
|
+
end
|
161
|
+
|
162
|
+
it do
|
163
|
+
is_expected.to be_invalid
|
164
|
+
expect(subject.errors).to match_array(errors)
|
165
|
+
expect(subject.unpack).to match({ Test::Login => [:no_matches] })
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
context "Tuple composition" do
|
171
|
+
before do
|
172
|
+
module Test
|
173
|
+
class RegistrationInput < ::BC::Tuple
|
174
|
+
attribute :email, Email
|
175
|
+
attribute :password, Ascii
|
176
|
+
end
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
subject { Test::RegistrationInput.match(email, password) }
|
181
|
+
|
182
|
+
context "when valid input" do
|
183
|
+
let(:email) { "admin@mail.com" }
|
184
|
+
let(:password) { "newP@ssw0rd" }
|
185
|
+
let(:attributes) do
|
186
|
+
{ email: kind_of(Test::Email), password: kind_of(Test::Ascii) }
|
187
|
+
end
|
188
|
+
|
189
|
+
it do
|
190
|
+
expect(subject).to be_valid
|
191
|
+
expect(subject.attributes).to match(attributes)
|
192
|
+
expect(subject.to_h).to match(email: email, password: password)
|
193
|
+
expect(subject.errors).to be_empty
|
194
|
+
expect(subject.attribute_errors).to be_empty
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
context "when input is invalid" do
|
199
|
+
let(:email) { "admin" }
|
200
|
+
let(:email_error) { { Test::Email => ["Not an email"] } }
|
201
|
+
let(:password) { "newP@ssw0rd" }
|
202
|
+
let(:attributes) do
|
203
|
+
attribute_errors.merge(password: kind_of(Test::Ascii))
|
204
|
+
end
|
205
|
+
let(:attribute_errors) { { email: kind_of(BC::ContractFailure) } }
|
206
|
+
let(:tuple_invalid) { { Test::RegistrationInput => [:invalid_tuple] } }
|
207
|
+
|
208
|
+
it do
|
209
|
+
expect(subject).to be_invalid
|
210
|
+
expect(subject.attributes).to match(attributes)
|
211
|
+
expect(subject.to_h).to match(email: email_error)
|
212
|
+
expect(subject.errors).to match_array([email_error, tuple_invalid])
|
213
|
+
expect(subject.attribute_errors).to match(attribute_errors)
|
214
|
+
end
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
context "Pipe composition" do
|
219
|
+
before do
|
220
|
+
module Test
|
221
|
+
class RegistrationInput < ::BC::Tuple
|
222
|
+
attribute :login, Email.or_a(Phone)
|
223
|
+
attribute :password, Ascii
|
224
|
+
end
|
225
|
+
|
226
|
+
ResponseParser = BC::Pipe.new do
|
227
|
+
step :parse, Json
|
228
|
+
step :validate, RegistrationInput
|
229
|
+
end
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
subject { Test::ResponseParser.match(response) }
|
234
|
+
|
235
|
+
context "when value is invalid JSON" do
|
236
|
+
let(:response) { "<xml>" }
|
237
|
+
let(:error) { { Test::Json => [kind_of(JSON::ParserError)] } }
|
238
|
+
let(:validation_context) do
|
239
|
+
{
|
240
|
+
raw_value: response,
|
241
|
+
errors: [error],
|
242
|
+
steps: ["BloodContracts::Core::ContractFailure"],
|
243
|
+
steps_values: { parse: response }
|
244
|
+
}
|
245
|
+
end
|
246
|
+
|
247
|
+
it do
|
248
|
+
is_expected.to be_invalid
|
249
|
+
is_expected.to be_kind_of(BC::ContractFailure)
|
250
|
+
expect(subject.unpack).to match(error)
|
251
|
+
expect(subject.context).to match(validation_context)
|
252
|
+
end
|
253
|
+
end
|
254
|
+
|
255
|
+
context "when value is valid JSON" do
|
256
|
+
context "when value is invalid registration data" do
|
257
|
+
let(:response) { '{"phone":"+78889992211"}' }
|
258
|
+
let(:error) { { Test::RegistrationInput => [:invalid_tuple] } }
|
259
|
+
let(:errors) do
|
260
|
+
[
|
261
|
+
{ Test::Email => ["Not an email"] },
|
262
|
+
{ Test::Phone => ["Not a phone"] },
|
263
|
+
{ Test::Ascii => ["Not ASCII"] },
|
264
|
+
{ Test::RegistrationInput => [:invalid_tuple] }
|
265
|
+
]
|
266
|
+
end
|
267
|
+
let(:validation_context) do
|
268
|
+
hash_including(
|
269
|
+
raw_value: response,
|
270
|
+
errors: array_including(errors),
|
271
|
+
steps: ["Test::Json", "BloodContracts::Core::TupleContractFailure"],
|
272
|
+
steps_values: {
|
273
|
+
parse: response,
|
274
|
+
validate: { phone: "+78889992211" }
|
275
|
+
}
|
276
|
+
)
|
277
|
+
end
|
278
|
+
|
279
|
+
it do
|
280
|
+
is_expected.to be_invalid
|
281
|
+
is_expected.to be_kind_of(BC::TupleContractFailure)
|
282
|
+
expect(subject.unpack).to match(error)
|
283
|
+
expect(subject.context).to match(validation_context)
|
284
|
+
end
|
285
|
+
end
|
286
|
+
|
287
|
+
context "when value is valid registration data" do
|
288
|
+
context "when login is an email" do
|
289
|
+
let(:response) { '{"login":"admin@example.com", "password":"111"}' }
|
290
|
+
let(:payload) { { login: "admin@example.com", password: "111" } }
|
291
|
+
|
292
|
+
it do
|
293
|
+
is_expected.to be_valid
|
294
|
+
is_expected.to be_kind_of(Test::RegistrationInput)
|
295
|
+
expect(subject.unpack).to match(["admin@example.com", "111"])
|
296
|
+
expect(subject.to_h).to match(payload)
|
297
|
+
end
|
298
|
+
end
|
299
|
+
|
300
|
+
context "when login is a phone" do
|
301
|
+
let(:response) { '{"login":"8 (999) 123-33-12", "password":"111"}' }
|
302
|
+
let(:payload) { { login: "89991233312", password: "111" } }
|
303
|
+
|
304
|
+
it do
|
305
|
+
is_expected.to be_valid
|
306
|
+
is_expected.to be_kind_of(Test::RegistrationInput)
|
307
|
+
expect(subject.unpack).to match(%w[89991233312 111])
|
308
|
+
expect(subject.to_h).to match(payload)
|
309
|
+
end
|
310
|
+
end
|
311
|
+
end
|
312
|
+
end
|
313
|
+
end
|
314
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
begin
|
2
|
+
require "pry"
|
3
|
+
rescue LoadError
|
4
|
+
nil
|
5
|
+
end
|
6
|
+
|
7
|
+
require "bundler/setup"
|
8
|
+
require "blood_contracts/core"
|
9
|
+
|
10
|
+
RSpec.configure do |config|
|
11
|
+
# Enable flags like --only-failures and --next-failure
|
12
|
+
config.example_status_persistence_file_path = ".rspec_status"
|
13
|
+
|
14
|
+
# Disable RSpec exposing methods globally on `Module` and `main`
|
15
|
+
config.disable_monkey_patching!
|
16
|
+
|
17
|
+
config.expect_with :rspec do |c|
|
18
|
+
c.syntax = :expect
|
19
|
+
end
|
20
|
+
|
21
|
+
config.around do |example|
|
22
|
+
module Test; end
|
23
|
+
example.run
|
24
|
+
Object.send(:remove_const, :Test)
|
25
|
+
end
|
26
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: blood_contracts-core
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.4.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
|
-
- Sergey Dolganov
|
7
|
+
- Sergey Dolganov (sclinede)
|
8
8
|
autorequire:
|
9
|
-
bindir: bin
|
9
|
+
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2019-
|
11
|
+
date: 2019-07-02 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|
@@ -66,16 +66,36 @@ dependencies:
|
|
66
66
|
- - "~>"
|
67
67
|
- !ruby/object:Gem::Version
|
68
68
|
version: '3.0'
|
69
|
-
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rubocop
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0.49'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0.49'
|
83
|
+
description: Core classes for data validation with contracts approach (using Either
|
84
|
+
+ Writer monad combination & ADT for composition)
|
70
85
|
email:
|
71
86
|
- sclinede@evilmartians.com
|
72
87
|
executables: []
|
73
88
|
extensions: []
|
74
|
-
extra_rdoc_files:
|
89
|
+
extra_rdoc_files:
|
90
|
+
- CODE_OF_CONDUCT.md
|
91
|
+
- README.md
|
92
|
+
- CHANGELOG.md
|
75
93
|
files:
|
76
94
|
- ".gitignore"
|
77
95
|
- ".rspec"
|
96
|
+
- ".rubocop.yml"
|
78
97
|
- ".travis.yml"
|
98
|
+
- CHANGELOG.md
|
79
99
|
- CODE_OF_CONDUCT.md
|
80
100
|
- Gemfile
|
81
101
|
- LICENSE.txt
|
@@ -89,12 +109,16 @@ files:
|
|
89
109
|
- examples/tuple.rb
|
90
110
|
- lib/blood_contracts-core.rb
|
91
111
|
- lib/blood_contracts/core.rb
|
112
|
+
- lib/blood_contracts/core/anything.rb
|
92
113
|
- lib/blood_contracts/core/contract.rb
|
114
|
+
- lib/blood_contracts/core/contract_failure.rb
|
93
115
|
- lib/blood_contracts/core/pipe.rb
|
94
116
|
- lib/blood_contracts/core/refined.rb
|
95
117
|
- lib/blood_contracts/core/sum.rb
|
96
118
|
- lib/blood_contracts/core/tuple.rb
|
97
|
-
- lib/blood_contracts/core/
|
119
|
+
- lib/blood_contracts/core/tuple_contract_failure.rb
|
120
|
+
- spec/blood_contracts/core_spec.rb
|
121
|
+
- spec/spec_helper.rb
|
98
122
|
homepage: https://github.com/sclinede/blood_contracts-core
|
99
123
|
licenses:
|
100
124
|
- MIT
|
@@ -107,7 +131,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
107
131
|
requirements:
|
108
132
|
- - ">="
|
109
133
|
- !ruby/object:Gem::Version
|
110
|
-
version: '
|
134
|
+
version: '2.4'
|
111
135
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
112
136
|
requirements:
|
113
137
|
- - ">="
|
@@ -117,5 +141,7 @@ requirements: []
|
|
117
141
|
rubygems_version: 3.0.3
|
118
142
|
signing_key:
|
119
143
|
specification_version: 4
|
120
|
-
summary: Core classes for
|
121
|
-
test_files:
|
144
|
+
summary: Core classes for data validation with contracts approach
|
145
|
+
test_files:
|
146
|
+
- spec/blood_contracts/core_spec.rb
|
147
|
+
- spec/spec_helper.rb
|