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
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7c89ea50c7af537eb81bfd8f949165ffaffde2bdeb9aec92668e601acc90802c
|
4
|
+
data.tar.gz: 6c85bee20832ba39fc933987823b1dcbc7583ab0f991599837a2f0625d74d826
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: fe7e788aac1df68e58bd5e28f2dfcf875f5b47deca6b0012cd309b81749f147b712290608d513bf97421f5064813e3a44a14888d63c774ac8f01cfa0027c27c0
|
7
|
+
data.tar.gz: 0a349cdc0f9f668fe7cda58a3531e322f6b76ee8116640ce4aad9e25d7ae58680f8a3c00cadc80c202b525974d6a66637b0c35ec5e7947a93d9e70bb375af061
|
data/.rubocop.yml
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
---
|
2
|
+
|
3
|
+
AllCops:
|
4
|
+
DisplayCopNames: true
|
5
|
+
DisplayStyleGuide: true
|
6
|
+
StyleGuideCopsOnly: true
|
7
|
+
TargetRubyVersion: 2.4
|
8
|
+
Exclude:
|
9
|
+
- examples/*
|
10
|
+
- blood_contracts-core.gemspec
|
11
|
+
- vendor/bundle/**/*
|
12
|
+
|
13
|
+
Metrics/LineLength:
|
14
|
+
AllowHeredoc: true
|
15
|
+
AllowURI: true
|
16
|
+
URISchemes:
|
17
|
+
- http
|
18
|
+
- https
|
19
|
+
|
20
|
+
Style/ClassAndModuleChildren:
|
21
|
+
Enabled: false
|
22
|
+
|
23
|
+
Style/Documentation:
|
24
|
+
Enabled: false
|
25
|
+
|
26
|
+
Style/StringLiterals:
|
27
|
+
EnforcedStyle: double_quotes
|
28
|
+
|
29
|
+
Naming/FileName:
|
30
|
+
Exclude:
|
31
|
+
- lib/blood_contracts-core.rb
|
data/.travis.yml
CHANGED
@@ -1,7 +1,19 @@
|
|
1
1
|
---
|
2
|
-
sudo:
|
2
|
+
sudo: false
|
3
3
|
language: ruby
|
4
|
-
cache:
|
4
|
+
cache: bundler
|
5
|
+
before_install:
|
6
|
+
- gem install bundler --no-document
|
7
|
+
- gem update --system
|
8
|
+
script:
|
9
|
+
- bundle exec rspec
|
10
|
+
- bundle exec rubocop
|
5
11
|
rvm:
|
6
|
-
- 2.
|
7
|
-
|
12
|
+
- 2.4.0
|
13
|
+
- 2.6.0
|
14
|
+
- ruby-head
|
15
|
+
- jruby-head
|
16
|
+
matrix:
|
17
|
+
allow_failures:
|
18
|
+
- rvm: ruby-head
|
19
|
+
- rvm: jruby-head
|
data/CHANGELOG.md
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
# Change Log
|
2
|
+
|
3
|
+
All notable changes to this project will be documented in this file.
|
4
|
+
|
5
|
+
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
6
|
+
and this project adheres to [Semantic Versioning](http://semver.org/).
|
7
|
+
|
8
|
+
## [0.4.0] - [2019-06-25]
|
9
|
+
|
10
|
+
This is a first public release marked in change log with features extracted from production app.
|
11
|
+
Includes:
|
12
|
+
- Base class BloodContracs::Core::Refined to write your own validations
|
13
|
+
- Meta classes BloodContracs::Core::Pipe, BloodContracs::Core::Sum and BloodContracs::Core::Tuple for validations composition in ADT style
|
14
|
+
- BloodContracs::Core::Contract meta class as a syntactic sugar to define your own contracts with Refined validations under the hood
|
data/README.md
CHANGED
@@ -1,7 +1,56 @@
|
|
1
|
+
[][travis]
|
2
|
+
[][codeclimate]
|
3
|
+
|
4
|
+
[gem]: https://rubygems.org/gems/blood_contracts-core
|
5
|
+
[travis]: https://travis-ci.org/sclinede/blood_contracts-core
|
6
|
+
[codeclimate]: https://codeclimate.com/github/sclinede/blood_contracts-core
|
7
|
+
[adt_wiki]: https://en.wikipedia.org/wiki/Algebraic_data_type
|
8
|
+
[functional_programming_wiki]: https://en.wikipedia.org/wiki/Functional_programming
|
9
|
+
[refinement_types_wiki]: https://en.wikipedia.org/wiki/Refinement_type
|
10
|
+
[ebaymag]: https://ebaymag.com/
|
11
|
+
|
1
12
|
# BloodContracts::Core
|
2
13
|
|
3
|
-
Simple
|
4
|
-
|
14
|
+
Simple and agile Ruby data validation tool inspired by refinement types and functional approach
|
15
|
+
|
16
|
+
* **Powerful**. [Algebraic Data Type][adt_wiki] guarantees that gem is enough to implement any kind of complex data validation, while [Functional Approach][functional_programming_wiki] gives you full control over validation outcomes
|
17
|
+
* **Simple**. You could write your first [Refinment Type][refinement_types_wiki] as simple as single Ruby method in single class
|
18
|
+
* **Independent**. It have no dependencies and you need nothing more to write your complex validations
|
19
|
+
* **Rubyish**. DSL is inspired by Ruby Struct. If you love Ruby way you'd like the BloodContracts types
|
20
|
+
* **Born in production**. Created on basis of [eBaymag][ebaymag] project, used as a tool to control and monitor data inside API communication
|
21
|
+
|
22
|
+
```ruby
|
23
|
+
# Write your "types" as simple as...
|
24
|
+
class Email < ::BC::Refined
|
25
|
+
REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
|
26
|
+
|
27
|
+
def match
|
28
|
+
return if (context[:email] = value.to_s) =~ REGEX
|
29
|
+
failure(:invalid_email)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
class Phone < ::BC::Refined
|
34
|
+
REGEX = /\A(\+7|8)(9|8)\d{9}\z/i
|
35
|
+
|
36
|
+
def match
|
37
|
+
return if (context[:phone] = value.to_s) =~ REGEX
|
38
|
+
failure(:invalid_phone)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# ... compose them...
|
43
|
+
Login = Email.or_a(Phone)
|
44
|
+
|
45
|
+
# ... and match!
|
46
|
+
case match = Login.match("not-a-login")
|
47
|
+
when Phone, Email
|
48
|
+
match # use as you wish, you exactly know what kind of login you received
|
49
|
+
when BC::ContractFailure # translate error message
|
50
|
+
match.messages # => [:no_matches, :invalid_phone, :invalid_email]
|
51
|
+
else raise # to make sure you covered all scenarios (Functional Way)
|
52
|
+
end
|
53
|
+
```
|
5
54
|
|
6
55
|
## Installation
|
7
56
|
|
@@ -19,15 +68,324 @@ Or install it yourself as:
|
|
19
68
|
|
20
69
|
$ gem install blood_contracts-core
|
21
70
|
|
22
|
-
##
|
71
|
+
## Refinment Data Type (BC::Refined class)
|
72
|
+
|
73
|
+
Refinement type is an Algebraic Data Type (read, you could compose it with other types) with some predicate to check against the data.
|
74
|
+
In Ruby we've implemented it as a class with method `.match` which accepts single argument - _value_ which could be any kind of object.
|
75
|
+
This method ALWAYS returns ancestor of BC::Refined. So the most common usage would be:
|
76
|
+
|
77
|
+
```ruby
|
78
|
+
case match = RegistrationFormType.match(params)
|
79
|
+
when RegistrationFormType
|
80
|
+
match.to_h # converts your data to valid Ruby hash
|
81
|
+
when BC::ContractFailure
|
82
|
+
match.messages # deal with error messages
|
83
|
+
else raise # remember the matching should be exhaustive (simplifies debugging, I promise 🙏)
|
84
|
+
end
|
85
|
+
```
|
86
|
+
|
87
|
+
To create your first type just inherit class from BC::Refined and implement method `#match`.
|
88
|
+
|
89
|
+
The method should:
|
90
|
+
- return self or nil on successful validation
|
91
|
+
- return BC::ContractFailure instance by calling method `#failure` and provide error text/symbol
|
92
|
+
|
93
|
+
```ruby
|
94
|
+
require 'countries' # gem with data about countries
|
95
|
+
class Country < BC::Refined
|
96
|
+
def match
|
97
|
+
return if ISO3166::Country.find_country_by_alpha2(context[:country_name] = value.to_s)
|
98
|
+
failure(:unknown_country)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
```
|
102
|
+
|
103
|
+
Also, you could improve the successful outcome by mapping VALID data to something more appropriate, for example you could normalize data. For that you need only implement `#mapped`
|
104
|
+
|
105
|
+
```ruby
|
106
|
+
require 'countries' # gem with data about countries
|
107
|
+
class Country < BC::Refined
|
108
|
+
def match
|
109
|
+
context[:country_string] = value.to_s
|
110
|
+
context[:found_country] = ISO3166::Country.find_country_by_alpha2(context[:country_string])
|
111
|
+
return if context[:found_country]
|
112
|
+
failure(:unknown_country)
|
113
|
+
end
|
114
|
+
|
115
|
+
def mapped
|
116
|
+
context[:found_country].name
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
case match = Country.call("CI")
|
121
|
+
when Country
|
122
|
+
match.unpack # => "Côte d'Ivoire"
|
123
|
+
when BC::ContractFailure
|
124
|
+
match.messages # => [:unknown_country]
|
125
|
+
else raise # ... you know why
|
126
|
+
end
|
127
|
+
```
|
128
|
+
|
129
|
+
Okay, we passed through single value validation. How about complex cases?
|
130
|
+
|
131
|
+
Imagine you want to validate response from some JSON API, let's write your first API client with refinement types together.
|
132
|
+
|
133
|
+
For this example we'll create RubygemsAPI client:
|
134
|
+
|
135
|
+
```ruby
|
136
|
+
require 'net/http'
|
137
|
+
|
138
|
+
module RubygemsAPI
|
139
|
+
class Client
|
140
|
+
ROOT = "https://rubygems.org/api/v1/gems/".freeze
|
141
|
+
|
142
|
+
def self.get(path)
|
143
|
+
uri = URI.parse(File.join(ROOT, path))
|
144
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
145
|
+
http.use_ssl = true
|
146
|
+
http.get(uri.request_uri).body
|
147
|
+
end
|
148
|
+
|
149
|
+
def self.gem(name)
|
150
|
+
Validation.call get("#{name}.json")
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
```
|
155
|
+
|
156
|
+
But what is the RubygemsAPI::Validation class?
|
157
|
+
|
158
|
+
## "And Then" Composition (BC::Pipe class)
|
159
|
+
|
160
|
+
Our API client just reads a document from the Internet, which is why first we need to parse it as JSON and then extract something useful.
|
161
|
+
This is where `#and_then` method quite useful. It runs validation over first BC::Refined and only if the first validation was successful calls the other one.
|
162
|
+
Otherwise we'll just receive `BC::ContractFailure`, you know.
|
163
|
+
|
164
|
+
Our first challenge is to read Ruby gem info from the API, so we need two types: Json (for parsing) and Gem (for gem info)
|
165
|
+
|
166
|
+
```ruby
|
167
|
+
module RubygemsAPI
|
168
|
+
require 'json'
|
169
|
+
|
170
|
+
class Json < BC::Refined
|
171
|
+
def match
|
172
|
+
# now it's easy to understand why we caught JSON::ParserError
|
173
|
+
context[:response] = value.to_s
|
174
|
+
context[:parsed] = ::JSON.parse(context[:response])
|
175
|
+
self
|
176
|
+
rescue JSON::ParserError => ex
|
177
|
+
context[:exception] = ex # now we could easily playaround with exception and reraise it
|
178
|
+
failure(:invalid_json)
|
179
|
+
end
|
180
|
+
|
181
|
+
# so the next validation in the pipe will receive parsed response, not unparsed string
|
182
|
+
def mapped
|
183
|
+
context[:parsed]
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
class GemInfo < BC::Refined
|
188
|
+
# I chose some data that is interesing for me
|
189
|
+
INFO_KEYS = %w(name downloads info authors version homepage_uri source_code_uri)
|
190
|
+
|
191
|
+
def match
|
192
|
+
# We have to make sure that result is a hash with appropriate keys
|
193
|
+
is_a_project = value.is_a?(Hash) && (INFO_KEYS - value.keys).empty?
|
194
|
+
return failure(:reponse_is_not_gem_info) unless is_a_project
|
195
|
+
|
196
|
+
context[:gem_info] = value.slice(*INFO_KEYS)
|
197
|
+
self
|
198
|
+
end
|
199
|
+
|
200
|
+
def mapped
|
201
|
+
context[:gem_info]
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
# Simple "and_then" composition will look like that:
|
206
|
+
Validation = Json.and_then(GemInfo)
|
207
|
+
end
|
208
|
+
```
|
209
|
+
|
210
|
+
Let's test our API client!
|
211
|
+
|
212
|
+
```ruby
|
213
|
+
gem = RubygemsAPI::Client.gem("rack") # => #<RubygemAPI::GemInfo ...>
|
214
|
+
gem.unpack # => {"name" => ..., "authors" => ...}
|
215
|
+
```
|
216
|
+
|
217
|
+
Nice!
|
218
|
+
But wait, what if we misspelled gem name?
|
219
|
+
|
220
|
+
```ruby
|
221
|
+
gem = RubygemsAPI::Client.gem("big-bada-bum") # => #<BC::ContractFailure ...>
|
222
|
+
gem.messages # => [:invalid_json]
|
223
|
+
# hmmm, wait... what?
|
224
|
+
gem.context[:response] # => "This rubygem could not be found."
|
225
|
+
# it is plain text. yes. :(
|
226
|
+
```
|
227
|
+
|
228
|
+
It would be great to show that original message to our user, but how?
|
229
|
+
|
230
|
+
|
231
|
+
## "Or" Composition (BC::Sum class)
|
232
|
+
|
233
|
+
Actually, we could add another type in our validation using "Or" composition. Use it by calling `#or_a` / `#or_an` method on your BC::Refined class.
|
234
|
+
Let's try:
|
235
|
+
|
236
|
+
```ruby
|
237
|
+
module RubygemsAPI
|
238
|
+
# ...
|
239
|
+
|
240
|
+
class PlainTextError < BC::Refined
|
241
|
+
def match
|
242
|
+
context[:response] = value.to_s
|
243
|
+
# to avoid multiple parsing of response, we'll try to save it
|
244
|
+
context[:parsed] = JSON.parse(context[:response])
|
245
|
+
failure(:non_plain_text_response)
|
246
|
+
rescue JSON::ParserError
|
247
|
+
self
|
248
|
+
end
|
249
|
+
|
250
|
+
def mapped
|
251
|
+
context[:response]
|
252
|
+
end
|
253
|
+
end
|
254
|
+
|
255
|
+
Validation = PlainTextError.or_a(Json.and_then(GemInfo))
|
256
|
+
end
|
257
|
+
```
|
258
|
+
|
259
|
+
Let's test our API client, again!
|
260
|
+
|
261
|
+
```ruby
|
262
|
+
gem = RubygemsAPI::Client.gem("rack") # => #<RubygemAPI::GemInfo ...>
|
263
|
+
gem.unpack # => {"name" => ..., "authors" => ...}
|
264
|
+
|
265
|
+
# good, but how about not found case?
|
266
|
+
gem = RubygemsAPI::Client.gem("big-bada-bum") # => #<RubygemAPI::PlainTextError ...>
|
267
|
+
gem.unpack # => "This rubygem could not be found."
|
268
|
+
```
|
269
|
+
|
270
|
+
And of course we could use it in a case statement:
|
271
|
+
```ruby
|
272
|
+
case gem = RubygemsAPI::Client.gem("rack")
|
273
|
+
when GemInfo
|
274
|
+
gem.unpack # show data to user
|
275
|
+
when PlaintTextError
|
276
|
+
{message: gem.unpack, status: 400} # wrap it into json response
|
277
|
+
when BC::ContractFailure
|
278
|
+
match.messages
|
279
|
+
else raise # ... you know why
|
280
|
+
end
|
281
|
+
```
|
282
|
+
|
283
|
+
It was a nice run!
|
284
|
+
|
285
|
+
So actually only one other scenario left to show.
|
286
|
+
|
287
|
+
Do you remember the Login type from the beginning? Let's try to implement simple registration form validation.
|
288
|
+
|
289
|
+
## "And" Composition (BC::Tuple class)
|
290
|
+
|
291
|
+
If you'll try to represent complex data with refinement types the best tool is "and" composition or "product" of types. Sounds wierd?
|
292
|
+
|
293
|
+
But you actually work with that concept all the time. It's just a record or struct.
|
294
|
+
|
295
|
+
Let's write our registration form validation with a Struct:
|
296
|
+
|
297
|
+
```ruby
|
298
|
+
RegistrationForm = Struct.new(:login, :password) do
|
299
|
+
def self.call(login, password)
|
300
|
+
# validation logic
|
301
|
+
end
|
302
|
+
end
|
303
|
+
```
|
304
|
+
|
305
|
+
So, the BloodContracts version will look the same, except you only need to implement types for login and password:
|
306
|
+
|
307
|
+
```ruby
|
308
|
+
module Registration
|
309
|
+
class Email < ::BC::Refined
|
310
|
+
REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
|
311
|
+
|
312
|
+
def match
|
313
|
+
context[:email_input] = value.to_s
|
314
|
+
return failure(:invalid_email) unless context[:email_input] =~ REGEX
|
315
|
+
context[:email] = context[:email_input]
|
316
|
+
self
|
317
|
+
end
|
318
|
+
end
|
319
|
+
|
320
|
+
class Phone < ::BC::Refined
|
321
|
+
REGEX = /\A(\+7|8)(9|8)\d{9}\z/i
|
322
|
+
|
323
|
+
def match
|
324
|
+
context[:phone_input] = value.to_s
|
325
|
+
return failure(:invalid_phone) unless context[:phone_input] =~ REGEX
|
326
|
+
context[:phone] = context[:phone_input]
|
327
|
+
self
|
328
|
+
end
|
329
|
+
end
|
330
|
+
|
331
|
+
class Ascii < ::BC::Refined
|
332
|
+
REGEX = /^[[:ascii:]]+$/i
|
333
|
+
|
334
|
+
def match
|
335
|
+
context[:ascii_input] = value.to_s
|
336
|
+
return failure(:not_ascii) unless context[:ascii_input] =~ REGEX
|
337
|
+
context[:ascii_string] = context[:ascii_input]
|
338
|
+
self
|
339
|
+
end
|
340
|
+
end
|
341
|
+
|
342
|
+
# Create meta class as the Struct.new
|
343
|
+
Form = BC::Tuple.new do
|
344
|
+
# defines a reader and applies validation on `.match` call
|
345
|
+
attribute :login, Email.or_a(Phone)
|
346
|
+
attribute :password, Ascii
|
347
|
+
end
|
348
|
+
end
|
349
|
+
```
|
350
|
+
|
351
|
+
And the code that you'll put in your controller is something like that:
|
352
|
+
|
353
|
+
```ruby
|
354
|
+
class RegistrationController < ActionController::Base
|
355
|
+
def create
|
356
|
+
case match = Registration::Form.match(params)
|
357
|
+
when Registration::Form
|
358
|
+
# login here is either Phone or Email
|
359
|
+
# password here is always ASCII only string
|
360
|
+
user = User.find_or_create!(login: match.login) do |user|
|
361
|
+
user.password = match.password
|
362
|
+
user.email = match.context[:email]
|
363
|
+
user.phone = match.context[:phone]
|
364
|
+
end
|
365
|
+
render json: {code: 200, user_id: user.id, message: "User was successfully created!"}
|
366
|
+
when BC::ContractFailure
|
367
|
+
message = match.messages.map(&I18n.method(:t)).join("\n")
|
368
|
+
render json: {code: 400, message: message}
|
369
|
+
else
|
370
|
+
Honeybadger.notify("Invalid BloodContracts usage", context: match.inspect)
|
371
|
+
render json: {code: 500, message: "Unexpected contract behavior. Fix me ASAP"}
|
372
|
+
end
|
373
|
+
end
|
374
|
+
end
|
375
|
+
```
|
376
|
+
|
377
|
+
Now, you're ready to write any kind of complex data validation with BloodContracts
|
378
|
+
|
379
|
+
What are the next steps?
|
23
380
|
|
24
|
-
|
381
|
+
Soon we'll announce `blood_contracts-extended` and `blood_contracts-monitoring`, which will help you monitor the data (what types and how often matches in your system) and
|
382
|
+
even collect for you unique samples of the communication (up to the types that matched).
|
25
383
|
|
26
384
|
## Development
|
27
385
|
|
28
386
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
29
387
|
|
30
|
-
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `
|
388
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `gemspec`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
31
389
|
|
32
390
|
## Contributing
|
33
391
|
|
data/Rakefile
CHANGED
@@ -1,30 +1,23 @@
|
|
1
|
+
Gem::Specification.new do |gem|
|
2
|
+
gem.name = "blood_contracts-core"
|
3
|
+
gem.version = "0.4.0"
|
4
|
+
gem.authors = ["Sergey Dolganov (sclinede)"]
|
5
|
+
gem.email = ["sclinede@evilmartians.com"]
|
1
6
|
|
2
|
-
|
3
|
-
|
4
|
-
|
7
|
+
gem.summary = "Core classes for data validation with contracts approach"
|
8
|
+
gem.description = "Core classes for data validation with contracts approach (using Either + Writer monad combination & ADT for composition)"
|
9
|
+
gem.homepage = "https://github.com/sclinede/blood_contracts-core"
|
10
|
+
gem.license = "MIT"
|
5
11
|
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
spec.authors = ["Sergey Dolganov"]
|
10
|
-
spec.email = ["sclinede@evilmartians.com"]
|
12
|
+
gem.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR)
|
13
|
+
gem.test_files = gem.files.grep(/^spec/)
|
14
|
+
gem.extra_rdoc_files = Dir["CODE_OF_CONDUCT.md", "README.md", "LICENSE", "CHANGELOG.md"]
|
11
15
|
|
12
|
-
|
13
|
-
spec.description = %q{Core classes for Contracts API validation}
|
14
|
-
spec.homepage = "https://github.com/sclinede/blood_contracts-core"
|
15
|
-
spec.license = "MIT"
|
16
|
+
gem.required_ruby_version = ">= 2.4"
|
16
17
|
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
spec.bindir = "bin/"
|
23
|
-
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
24
|
-
spec.require_paths = ["lib"]
|
25
|
-
|
26
|
-
spec.add_development_dependency "bundler", "~> 2.0"
|
27
|
-
spec.add_development_dependency "pry"
|
28
|
-
spec.add_development_dependency "rake", "~> 10.0"
|
29
|
-
spec.add_development_dependency "rspec", "~> 3.0"
|
18
|
+
gem.add_development_dependency "bundler", "~> 2.0"
|
19
|
+
gem.add_development_dependency "pry"
|
20
|
+
gem.add_development_dependency "rake", "~> 10.0"
|
21
|
+
gem.add_development_dependency "rspec", "~> 3.0"
|
22
|
+
gem.add_development_dependency "rubocop", "~> 0.49"
|
30
23
|
end
|
data/examples/json_response.rb
CHANGED
@@ -1,57 +1,51 @@
|
|
1
|
-
require
|
2
|
-
require
|
1
|
+
require "json"
|
2
|
+
require "blood_contracts/core"
|
3
3
|
|
4
4
|
module Types
|
5
5
|
class JSON < BC::Refined
|
6
6
|
def match
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
rescue StandardError => error
|
12
|
-
failure(error)
|
13
|
-
end
|
14
|
-
end
|
7
|
+
context[:parsed] = ::JSON.parse(value)
|
8
|
+
self
|
9
|
+
rescue StandardError => error
|
10
|
+
failure(error)
|
15
11
|
end
|
16
12
|
|
17
|
-
def
|
18
|
-
|
13
|
+
def mapped
|
14
|
+
context[:parsed]
|
19
15
|
end
|
20
16
|
end
|
21
17
|
|
22
18
|
class Tariff < BC::Refined
|
23
19
|
def match
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
end
|
20
|
+
context[:data] = value.slice("cost", "cur").compact
|
21
|
+
context[:tariff_context] = 1
|
22
|
+
return if context[:data].size == 2
|
23
|
+
|
24
|
+
failure(:not_a_tariff)
|
30
25
|
end
|
31
26
|
|
32
|
-
def
|
33
|
-
|
27
|
+
def mapped
|
28
|
+
context[:data]
|
34
29
|
end
|
35
30
|
end
|
36
31
|
|
37
32
|
class Error < BC::Refined
|
38
33
|
def match
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
end
|
34
|
+
context[:data] = unpack_refined(value).slice("code", "message").compact
|
35
|
+
context[:known_error_context] = 1
|
36
|
+
return if context[:data].size == 2
|
37
|
+
|
38
|
+
failure(:not_a_known_error)
|
45
39
|
end
|
46
40
|
|
47
|
-
def
|
48
|
-
|
41
|
+
def mapped
|
42
|
+
context[:data]
|
49
43
|
end
|
50
44
|
end
|
51
45
|
|
52
46
|
Response = BC::Pipe.new(
|
53
47
|
BC::Anything, JSON, (Tariff | Error | Tariff),
|
54
|
-
names: [
|
48
|
+
names: %i[raw parsed mapped]
|
55
49
|
)
|
56
50
|
|
57
51
|
# The same is
|
@@ -70,14 +64,6 @@ end
|
|
70
64
|
def match_response(response)
|
71
65
|
match = Types::Response.match(response)
|
72
66
|
case match
|
73
|
-
when BC::ContractFailure
|
74
|
-
puts "Honeybadger.notify 'Unexpected behavior in Russian Post', context: #{match.context}"
|
75
|
-
puts "render json: { errors: 'Ooops! Not working, we've been notified. Please, try again later' }"
|
76
|
-
|
77
|
-
return unless ENV["RAISE"]
|
78
|
-
match.errors.values.flatten.find do |v|
|
79
|
-
raise v if StandardError === v
|
80
|
-
end
|
81
67
|
when Types::Tariff
|
82
68
|
# работаем с тарифом
|
83
69
|
puts "match.context # => #{match.context} \n\n"
|
@@ -86,8 +72,16 @@ def match_response(response)
|
|
86
72
|
# работаем с ошибкой, e.g. адрес слишком длинный
|
87
73
|
puts "match.context # => #{match.context} \n\n"
|
88
74
|
puts "render json: { errors: [#{match.unpack['message']}] } }"
|
75
|
+
when BC::ContractFailure
|
76
|
+
puts "Honeybadger.notify 'Unexpected behavior in Russian Post', context: #{match.context}"
|
77
|
+
puts "render json: { errors: 'Ooops! Not working, we've been notified. Please, try again later' }"
|
78
|
+
|
79
|
+
return unless ENV["RAISE"]
|
80
|
+
match.errors.values.flatten.find do |v|
|
81
|
+
raise v if StandardError === v
|
82
|
+
end
|
89
83
|
else
|
90
|
-
|
84
|
+
raise
|
91
85
|
end
|
92
86
|
end
|
93
87
|
|
@@ -98,16 +92,14 @@ valid_response = '{"cost": 1000, "cur": "RUB"}'
|
|
98
92
|
match_response(valid_response)
|
99
93
|
puts "#{'=' * 20}================================#{'=' * 20}"
|
100
94
|
|
101
|
-
|
102
95
|
puts "\n\n\n"
|
103
96
|
puts "#{'=' * 20} WHEN KNOWN API ERROR RESPONSE: #{'=' * 20}"
|
104
97
|
error_response = '{"code": 123, "message": "Too Long Address"}'
|
105
98
|
match_response(error_response)
|
106
99
|
puts "#{'=' * 20}================================#{'=' * 20}"
|
107
100
|
|
108
|
-
|
109
101
|
puts "ss => errors }\n\n\n"
|
110
102
|
puts "#{'=' * 20} WHEN UNEXPECTED RESPONSE: #{'=' * 20}"
|
111
|
-
invalid_response =
|
103
|
+
invalid_response = "<xml>"
|
112
104
|
match_response(invalid_response)
|
113
105
|
puts "#{'=' * 20}================================#{'=' * 20}"
|