blood_contracts-core 0.3.5 → 0.4.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
[![Build Status](https://travis-ci.org/sclinede/blood_contracts-core.svg?branch=master)][travis]
|
2
|
+
[![Code Climate](https://codeclimate.com/github/sclinede/blood_contracts-core/badges/gpa.svg)][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}"
|