contr 0.1.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 +7 -0
- data/CHANGELOG.md +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +447 -0
- data/lib/contr/base.rb +128 -0
- data/lib/contr/logger/default.rb +29 -0
- data/lib/contr/logger.rb +11 -0
- data/lib/contr/matcher/sync.rb +53 -0
- data/lib/contr/matcher.rb +78 -0
- data/lib/contr/sampler/default.rb +72 -0
- data/lib/contr/sampler.rb +11 -0
- data/lib/contr/version.rb +5 -0
- data/lib/contr.rb +13 -0
- metadata +59 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 83b6121c17aee582566059f1664b3d72a167d458f56f685e754b2baee68c35ee
|
4
|
+
data.tar.gz: 1daa46552ab5bfd252654e608608169d6cd5bad555c9dc3263377fdeb164c534
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: e8d219a62e8dfc86375875c450294cca263146d4d187bf910824d0b56fa3a15a6208d0719c388dd4f2f5d968f6a310f9f19b0bea24a70af8fb4869316bbe16b1
|
7
|
+
data.tar.gz: 21860bfa5ae3787cfd2ca35b4fe2c26fe5e25ba90e94866c7d56880bd3227cf8d3f7d31335d799fe922918a1f8ea0891669e88bf3b124375b7937e5a32dc1746
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2024 Dmytro Horoshko
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,447 @@
|
|
1
|
+
# Contr
|
2
|
+
|
3
|
+
Minimalistic contracts in plain Ruby.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Install the gem and add to Gemfile:
|
8
|
+
|
9
|
+
```sh
|
10
|
+
bundle add contr
|
11
|
+
```
|
12
|
+
|
13
|
+
Or install it manually:
|
14
|
+
|
15
|
+
```sh
|
16
|
+
gem install contr
|
17
|
+
```
|
18
|
+
|
19
|
+
## Terminology
|
20
|
+
|
21
|
+
Contract consists of rules of 2 types:
|
22
|
+
|
23
|
+
- guarantees - the ones that *__should__* be valid
|
24
|
+
- expectations - the ones that *__could__* be valid
|
25
|
+
|
26
|
+
Contract is called *__matched__* when:
|
27
|
+
- *__all__* guarantees are matched (if present)
|
28
|
+
- *__at least one__* expectation is matched (if present)
|
29
|
+
|
30
|
+
Rule is *__matched__* when it returns *__truthy__* value.\
|
31
|
+
Rule is *__not matched__* when it returns *__falsey__* value (`nil`, `false`) or *__raises an error__*.
|
32
|
+
|
33
|
+
Contract is triggered *__after__* operation under guard is succesfully executed.
|
34
|
+
|
35
|
+
## Usage
|
36
|
+
|
37
|
+
Example of basic contract:
|
38
|
+
|
39
|
+
```ruby
|
40
|
+
class PostRemovalContract < Contr::Act # or Contr::Base if you're a boring person
|
41
|
+
guarantee :verified_via_api do |user_id, post_id, _|
|
42
|
+
!API.post_exists?(user_id, post_id)
|
43
|
+
end
|
44
|
+
|
45
|
+
guarantee :verified_via_web do |*, post_url|
|
46
|
+
!Web.post_exists?(post_url)
|
47
|
+
end
|
48
|
+
|
49
|
+
expect :removed_from_user_feed do |user_id, post_id, _|
|
50
|
+
!Feed::User.post_exists?(user_id, post_id)
|
51
|
+
end
|
52
|
+
|
53
|
+
expect :removed_from_global_feed do |user_id, post_id, _|
|
54
|
+
!Feed::Global.post_exists?(user_id, post_id)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
contract = PostRemovalContract.new
|
59
|
+
```
|
60
|
+
|
61
|
+
Contract check can be run in 2 modes: `sync` and `async`.
|
62
|
+
|
63
|
+
### Sync
|
64
|
+
|
65
|
+
In `sync` mode all rules are executed sequentially in the same thread with the operation.
|
66
|
+
|
67
|
+
If contract is matched - operation result is returned.
|
68
|
+
|
69
|
+
```ruby
|
70
|
+
api_response = contract.check(user_id, post_id, post_url) { API.delete_post(*some_args) }
|
71
|
+
# => {data: {deleted: true}}
|
72
|
+
```
|
73
|
+
|
74
|
+
If contract fails - the contract state is dumped via [Sampler](#Sampler), logged via [Logger](#Logger) and match error is raised:
|
75
|
+
|
76
|
+
```ruby
|
77
|
+
contract.check(*args) { operation }
|
78
|
+
# if one of the guarantees failed
|
79
|
+
# => Contr::Matcher::GuaranteesNotMatched: failed rules: [...], args: [...]
|
80
|
+
|
81
|
+
# if all expectations failed
|
82
|
+
# => Contr::Matcher::ExpectationsNotMatched: failed rules: [...], args: [...]
|
83
|
+
```
|
84
|
+
|
85
|
+
If operation raises an error it will be propagated right away, without triggering the contract itself:
|
86
|
+
|
87
|
+
```ruby
|
88
|
+
contract.check(*args) { raise StandardError, "some error" }
|
89
|
+
# => StandardError: some error
|
90
|
+
```
|
91
|
+
|
92
|
+
### Async
|
93
|
+
|
94
|
+
WIP
|
95
|
+
|
96
|
+
## Sampler
|
97
|
+
|
98
|
+
Default sampler creates marshalized dumps of contract state in specified folder with sampling period frequency:
|
99
|
+
|
100
|
+
```ruby
|
101
|
+
# state structure
|
102
|
+
{
|
103
|
+
ts: "2024-02-26T14:16:28.044Z",
|
104
|
+
contract_name: "PostRemovalContract",
|
105
|
+
failed_rules: [
|
106
|
+
{type: :expectation, name: :removed_from_user_feed, status: :unexpected_error, error: error_instance},
|
107
|
+
{type: :expectation, name: :removed_from_global_feed, status: :failed}
|
108
|
+
],
|
109
|
+
ok_rules: [
|
110
|
+
{type: :guarantee, name: :verified_via_api, status: :ok},
|
111
|
+
{type: :guarantee, name: :verified_via_web, status: :ok}
|
112
|
+
],
|
113
|
+
async: false,
|
114
|
+
args: [1, 2, "url"],
|
115
|
+
result: {data: {deleted: true}}
|
116
|
+
}
|
117
|
+
|
118
|
+
# default sampler can be reconfigured
|
119
|
+
ConfigedSampler = Contr::Sampler::Default.new(
|
120
|
+
folder: "/tmp/contract_dumps", # default: "/tmp/contracts"
|
121
|
+
path_template: "%<contract_name>_%<period_id>i.bin", # default: "%<contract_name>s/%<period_id>i.dump"
|
122
|
+
period: 3600 # default: 600 (= 10 minutes)
|
123
|
+
)
|
124
|
+
|
125
|
+
class SomeContract < Contr::Act
|
126
|
+
sampler ConfigedSampler
|
127
|
+
|
128
|
+
# ...
|
129
|
+
end
|
130
|
+
|
131
|
+
# it will create dumps:
|
132
|
+
# /tmp/contract_dumps/SomeContract_474750.bin
|
133
|
+
# /tmp/contract_dumps/SomeContract_474751.bin
|
134
|
+
# /tmp/contract_dumps/SomeContract_474752.bin
|
135
|
+
# ...
|
136
|
+
|
137
|
+
# NOTE: `period_id` is calculated as <unix_ts> / `period`
|
138
|
+
```
|
139
|
+
|
140
|
+
Sampler is enabled by default:
|
141
|
+
|
142
|
+
```ruby
|
143
|
+
class SomeContract < Contr::Act
|
144
|
+
end
|
145
|
+
|
146
|
+
SomeContract.new.sampler
|
147
|
+
# => #<Contr::Sampler::Default:...>
|
148
|
+
```
|
149
|
+
|
150
|
+
It's possible to define custom sampler and use it instead:
|
151
|
+
|
152
|
+
```ruby
|
153
|
+
class CustomSampler < Contr::Sampler::Base
|
154
|
+
# optional
|
155
|
+
def initialize(*some_args)
|
156
|
+
# ...
|
157
|
+
end
|
158
|
+
|
159
|
+
# required
|
160
|
+
def sample!(state)
|
161
|
+
# ...
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
class SomeContract < Contr::Act
|
166
|
+
sampler CustomSampler.new(*some_args)
|
167
|
+
|
168
|
+
# ...
|
169
|
+
end
|
170
|
+
```
|
171
|
+
|
172
|
+
As well as to disable sampler completely:
|
173
|
+
|
174
|
+
```ruby
|
175
|
+
class SomeContract < Contr::Act
|
176
|
+
sampler nil # or `false`
|
177
|
+
end
|
178
|
+
```
|
179
|
+
|
180
|
+
Default sampler also provides a helper method to read created dumps:
|
181
|
+
|
182
|
+
```ruby
|
183
|
+
contract.sampler
|
184
|
+
# => #<Contr::Sampler::Default:...>
|
185
|
+
|
186
|
+
# using absolute path
|
187
|
+
contract.sampler.read(path: "/tmp/contracts/SomeContract/474750.dump")
|
188
|
+
# => {ts: "2024-02-26T14:16:28.044Z", contract_name: "SomeContract", failed_rules: [...], ...}
|
189
|
+
|
190
|
+
# using `contract_name` + `period_id` args
|
191
|
+
# it uses `folder` and `path_template` from sampler config
|
192
|
+
contract.sampler.read(contract_name: "SomeContract", period_id: "474750")
|
193
|
+
# => {ts: "2024-02-26T14:16:28.044Z", contract_name: "SomeContract", failed_rules: [...], ...}
|
194
|
+
```
|
195
|
+
|
196
|
+
## Logger
|
197
|
+
|
198
|
+
Default logger logs contract state to specified stream in JSON format. State structure is the same as for sampler with a small addition of `tag` field.
|
199
|
+
|
200
|
+
```ruby
|
201
|
+
# state structure
|
202
|
+
{
|
203
|
+
**sampler_state,
|
204
|
+
tag: "contract-failed"
|
205
|
+
}
|
206
|
+
|
207
|
+
# default logger can be reconfigured
|
208
|
+
ConfigedLogger = Contr::Logger::Default.new(
|
209
|
+
stream: $stderr, # default: $stdout
|
210
|
+
log_level: :warn, # default: :debug
|
211
|
+
tag: "shit-happened" # default: "contract-failed"
|
212
|
+
)
|
213
|
+
|
214
|
+
class SomeContract < Contr::Act
|
215
|
+
logger ConfigedLogger
|
216
|
+
|
217
|
+
# ...
|
218
|
+
end
|
219
|
+
|
220
|
+
# it will print:
|
221
|
+
# => W, [2024-02-27T14:36:53.607088 #58112] WARN -- : {"ts":"...","contract_name":"...", ... "tag":"shit-happened"}
|
222
|
+
```
|
223
|
+
|
224
|
+
Logger is enabled by default:
|
225
|
+
|
226
|
+
```ruby
|
227
|
+
class SomeContract < Contr::Act
|
228
|
+
end
|
229
|
+
|
230
|
+
SomeContract.new.logger
|
231
|
+
# => #<Contr::Logger::Default:...>
|
232
|
+
```
|
233
|
+
|
234
|
+
It's possible to define custom logger in the same manner as with sampler:
|
235
|
+
|
236
|
+
```ruby
|
237
|
+
class CustomLogger < Contr::Sampler::Base
|
238
|
+
# optional
|
239
|
+
def initialize(*some_args)
|
240
|
+
# ...
|
241
|
+
end
|
242
|
+
|
243
|
+
# required
|
244
|
+
def log(state)
|
245
|
+
# ...
|
246
|
+
end
|
247
|
+
end
|
248
|
+
|
249
|
+
class SomeContract < Contr::Act
|
250
|
+
logger CustomLogger.new(*some_args)
|
251
|
+
|
252
|
+
# ...
|
253
|
+
end
|
254
|
+
```
|
255
|
+
|
256
|
+
As well as to disable logger completely:
|
257
|
+
|
258
|
+
```ruby
|
259
|
+
class SomeContract < Contr::Act
|
260
|
+
logger nil # or `false`
|
261
|
+
end
|
262
|
+
```
|
263
|
+
|
264
|
+
## Configuration
|
265
|
+
|
266
|
+
Contracts can be deeply inherited:
|
267
|
+
|
268
|
+
```ruby
|
269
|
+
class SomeContract < Contr::Act
|
270
|
+
guarantee :check_1 do
|
271
|
+
# ...
|
272
|
+
end
|
273
|
+
|
274
|
+
expect :check_2 do
|
275
|
+
# ...
|
276
|
+
end
|
277
|
+
end
|
278
|
+
# guarantees: check_1
|
279
|
+
# expecations: check_2
|
280
|
+
# sampler: Contr::Sampler::Default
|
281
|
+
# logger: Contr::Logger:Default
|
282
|
+
|
283
|
+
class OtherContract < SomeContract
|
284
|
+
sampler CustomSampler.new
|
285
|
+
|
286
|
+
guarantee :check_3 do
|
287
|
+
# ...
|
288
|
+
end
|
289
|
+
end
|
290
|
+
# guarantees: check_1, check_3
|
291
|
+
# expecations: check_2
|
292
|
+
# sampler: CustomSampler
|
293
|
+
# logger: Contr::Logger:Default
|
294
|
+
|
295
|
+
class AnotherContract < OtherContract
|
296
|
+
logger nil
|
297
|
+
|
298
|
+
expect :check_4 do
|
299
|
+
# ...
|
300
|
+
end
|
301
|
+
end
|
302
|
+
# guarantees: check_1, check_3
|
303
|
+
# expecations: check_2, check_4
|
304
|
+
# sampler: CustomSampler
|
305
|
+
# logger: nil
|
306
|
+
```
|
307
|
+
|
308
|
+
Contract can be configured using args passed to `.new` method:
|
309
|
+
|
310
|
+
```ruby
|
311
|
+
class SomeContract < Contr::Act
|
312
|
+
end
|
313
|
+
|
314
|
+
contract = SomeContract.new(sampler: CustomSampler.new, logger: CustomLogger.new)
|
315
|
+
|
316
|
+
contract.sampler
|
317
|
+
# => #<CustomSampler:...>
|
318
|
+
|
319
|
+
contract.logger
|
320
|
+
# => #<CustomLogger:...>
|
321
|
+
```
|
322
|
+
|
323
|
+
Rule block arguments are optional:
|
324
|
+
|
325
|
+
```ruby
|
326
|
+
class SomeContract < Contr::Act
|
327
|
+
guarantee :args_used do |arg_1, arg_2|
|
328
|
+
arg_1 # => 1
|
329
|
+
arg_2 # => 2
|
330
|
+
end
|
331
|
+
|
332
|
+
guarantee :args_not_used do
|
333
|
+
# ...
|
334
|
+
end
|
335
|
+
end
|
336
|
+
|
337
|
+
SomeContract.new.check(1, 2) { operation }
|
338
|
+
```
|
339
|
+
|
340
|
+
Each rule has access to contract variables:
|
341
|
+
|
342
|
+
```ruby
|
343
|
+
class SomeContract < Contr::Act
|
344
|
+
guarantee :check_1 do
|
345
|
+
@args # => [1, [2, 3], {key: :value}]
|
346
|
+
@result # => 2
|
347
|
+
@contract # => #<SomeContract:...>
|
348
|
+
@guarantees # => [{type: :guarantee, name: :check_1, block: #<Proc:...>}]
|
349
|
+
@expectations # => []
|
350
|
+
@sampler # => #<Contr::Sampler::Default:...>
|
351
|
+
@logger # => #<Contr::Logger::Default:...>
|
352
|
+
end
|
353
|
+
end
|
354
|
+
|
355
|
+
SomeContract.new.check(1, [2, 3], {key: :value}) { 1 + 1 }
|
356
|
+
```
|
357
|
+
|
358
|
+
Having access to `@result` can be really useful in contracts where operation produces a data that should be used inside the rules:
|
359
|
+
|
360
|
+
```ruby
|
361
|
+
class PostCreationContract < Contr::Act
|
362
|
+
guarantee :verified_via_api do |user_id|
|
363
|
+
post_id = @result["id"]
|
364
|
+
API.post_exists?(user_id, post_id)
|
365
|
+
end
|
366
|
+
|
367
|
+
# ...
|
368
|
+
end
|
369
|
+
|
370
|
+
contract = PostCreationContract.new
|
371
|
+
|
372
|
+
contract.check(user_id) { API.create_post(*some_args) }
|
373
|
+
# => {"id":1050118621198921700, "text":"Post text", ...}
|
374
|
+
```
|
375
|
+
|
376
|
+
Having access to `@guarantees` and `@expectations` makes possible building dynamic contracts (in case you really need it):
|
377
|
+
|
378
|
+
```ruby
|
379
|
+
class SomeContract < Contr::Act
|
380
|
+
guarantee :guarantee_1 do
|
381
|
+
# add new guarantee to the end of guarantees list
|
382
|
+
@guarantees << {
|
383
|
+
type: :guarantee,
|
384
|
+
name: :guarantee_2,
|
385
|
+
block: proc do
|
386
|
+
puts "guarantee 2"
|
387
|
+
true
|
388
|
+
end
|
389
|
+
}
|
390
|
+
|
391
|
+
puts "guarantee 1"
|
392
|
+
true
|
393
|
+
end
|
394
|
+
|
395
|
+
expect :expect_1 do
|
396
|
+
# add new expectation to the end of expectations list
|
397
|
+
@expectations << {
|
398
|
+
type: :expectation,
|
399
|
+
name: :expect_2,
|
400
|
+
block: proc do |*args|
|
401
|
+
puts "expect 2, args: #{args}"
|
402
|
+
true
|
403
|
+
end
|
404
|
+
}
|
405
|
+
|
406
|
+
puts "expect 1"
|
407
|
+
false
|
408
|
+
end
|
409
|
+
end
|
410
|
+
|
411
|
+
SomeContract.new.check(1, 2) { operation }
|
412
|
+
|
413
|
+
# it will print:
|
414
|
+
# => guarantee 1
|
415
|
+
# => guarantee 2
|
416
|
+
# => expect 1
|
417
|
+
# => expect 2, args: [1, 2]
|
418
|
+
```
|
419
|
+
|
420
|
+
Other instance variables (e.g. `@args`, `@logger` etc.) can be modified on the fly too but make sure you really know what you do.
|
421
|
+
|
422
|
+
## TODO
|
423
|
+
|
424
|
+
- [x] Contract definition
|
425
|
+
- [x] Sampler
|
426
|
+
- [x] Logger
|
427
|
+
- [x] Sync matcher
|
428
|
+
- [ ] Async matcher
|
429
|
+
- [ ] Add `before` block for contract definition
|
430
|
+
|
431
|
+
## Development
|
432
|
+
|
433
|
+
```sh
|
434
|
+
bin/setup # install deps
|
435
|
+
bin/console # interactive prompt to play around
|
436
|
+
rake spec # run tests
|
437
|
+
rake rubocop # lint code
|
438
|
+
rake rubocop:md # lint docs
|
439
|
+
```
|
440
|
+
|
441
|
+
## Contributing
|
442
|
+
|
443
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/ocvit/contr.
|
444
|
+
|
445
|
+
## License
|
446
|
+
|
447
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/lib/contr/base.rb
ADDED
@@ -0,0 +1,128 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Contr
|
4
|
+
class Base
|
5
|
+
class << self
|
6
|
+
attr_reader :config, :guarantees, :expectations
|
7
|
+
|
8
|
+
def logger(logger)
|
9
|
+
set_config(:logger, logger)
|
10
|
+
end
|
11
|
+
|
12
|
+
def sampler(sampler)
|
13
|
+
set_config(:sampler, sampler)
|
14
|
+
end
|
15
|
+
|
16
|
+
def guarantee(name, &block)
|
17
|
+
add_guarantee(name, block)
|
18
|
+
end
|
19
|
+
|
20
|
+
def expect(name, &block)
|
21
|
+
add_expectation(name, block)
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def set_config(key, value)
|
27
|
+
@config ||= {}
|
28
|
+
@config[key] = value
|
29
|
+
end
|
30
|
+
|
31
|
+
def add_guarantee(name, block)
|
32
|
+
@guarantees ||= []
|
33
|
+
@guarantees << {type: :guarantee, name: name, block: block}
|
34
|
+
end
|
35
|
+
|
36
|
+
def add_expectation(name, block)
|
37
|
+
@expectations ||= []
|
38
|
+
@expectations << {type: :expectation, name: name, block: block}
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
attr_reader :logger, :sampler
|
43
|
+
|
44
|
+
def initialize(instance_config = {})
|
45
|
+
@logger = choose_logger(instance_config)
|
46
|
+
@sampler = choose_sampler(instance_config)
|
47
|
+
|
48
|
+
validate_logger!
|
49
|
+
validate_sampler!
|
50
|
+
end
|
51
|
+
|
52
|
+
def check(*args)
|
53
|
+
result = yield
|
54
|
+
Matcher::Sync.new(self, args, result).match
|
55
|
+
result
|
56
|
+
end
|
57
|
+
|
58
|
+
def name
|
59
|
+
self.class.name
|
60
|
+
end
|
61
|
+
|
62
|
+
def guarantees
|
63
|
+
@guarantees ||= aggregate_rules(:guarantees)
|
64
|
+
end
|
65
|
+
|
66
|
+
def expectations
|
67
|
+
@expectations ||= aggregate_rules(:expectations)
|
68
|
+
end
|
69
|
+
|
70
|
+
private
|
71
|
+
|
72
|
+
def choose_logger(instance_config)
|
73
|
+
parent_config = find_parent_config(:logger)
|
74
|
+
|
75
|
+
case [instance_config, self.class.config, parent_config]
|
76
|
+
in [{logger: }, *]
|
77
|
+
logger
|
78
|
+
in [_, {logger: }, _]
|
79
|
+
logger
|
80
|
+
in [*, {logger: }]
|
81
|
+
logger
|
82
|
+
else
|
83
|
+
Logger::Default.new
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def choose_sampler(instance_config)
|
88
|
+
parent_config = find_parent_config(:sampler)
|
89
|
+
|
90
|
+
case [instance_config, self.class.config, parent_config]
|
91
|
+
in [{sampler: }, *]
|
92
|
+
sampler
|
93
|
+
in [_, {sampler: }, _]
|
94
|
+
sampler
|
95
|
+
in [*, {sampler: }]
|
96
|
+
sampler
|
97
|
+
else
|
98
|
+
Sampler::Default.new
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def find_parent_config(key)
|
103
|
+
parents = self.class.ancestors.take_while { |klass| klass != Contr::Base }.drop(1)
|
104
|
+
parents.detect { |klass| klass.config&.key?(key) }&.config
|
105
|
+
end
|
106
|
+
|
107
|
+
def aggregate_rules(rule_type)
|
108
|
+
contracts_chain = self.class.ancestors.take_while { |klass| klass != Contr::Base }.reverse
|
109
|
+
contracts_chain.flat_map(&rule_type).compact
|
110
|
+
end
|
111
|
+
|
112
|
+
def validate_logger!
|
113
|
+
return unless logger
|
114
|
+
return if logger.class.ancestors.include?(Logger::Base)
|
115
|
+
|
116
|
+
raise ArgumentError, "logger should be inherited from Contr::Logger::Base or be falsey, received: #{logger.inspect}"
|
117
|
+
end
|
118
|
+
|
119
|
+
def validate_sampler!
|
120
|
+
return unless sampler
|
121
|
+
return if sampler.class.ancestors.include?(Sampler::Base)
|
122
|
+
|
123
|
+
raise ArgumentError, "sampler should be inherited from Contr::Sampler::Base or be falsey, received: #{sampler.inspect}"
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
Act = Base
|
128
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "logger"
|
5
|
+
|
6
|
+
module Contr
|
7
|
+
class Logger
|
8
|
+
class Default < Logger::Base
|
9
|
+
DEFAULT_STREAM = $stdout
|
10
|
+
DEFAULT_LOG_LEVEL = :debug
|
11
|
+
DEFAULT_TAG = "contract-failed"
|
12
|
+
|
13
|
+
attr_reader :stream, :stream_logger, :log_level, :tag
|
14
|
+
|
15
|
+
def initialize(stream: DEFAULT_STREAM, log_level: DEFAULT_LOG_LEVEL, tag: DEFAULT_TAG)
|
16
|
+
@stream = stream
|
17
|
+
@stream_logger = ::Logger.new(stream)
|
18
|
+
@log_level = log_level
|
19
|
+
@tag = tag
|
20
|
+
end
|
21
|
+
|
22
|
+
def log(state)
|
23
|
+
message = state.merge(tag: tag).to_json
|
24
|
+
|
25
|
+
stream_logger.send(log_level, message)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
data/lib/contr/logger.rb
ADDED
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Contr
|
4
|
+
class Matcher
|
5
|
+
class Sync < Matcher::Base
|
6
|
+
def initialize(*)
|
7
|
+
super
|
8
|
+
|
9
|
+
@async = false
|
10
|
+
@ok_rules = []
|
11
|
+
@failed_rules = []
|
12
|
+
end
|
13
|
+
|
14
|
+
def match
|
15
|
+
guarantees_matched? || dump_state_and_raise(GuaranteesNotMatched)
|
16
|
+
expectations_matched? || dump_state_and_raise(ExpectationsNotMatched)
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def guarantees_matched?
|
22
|
+
return true if @guarantees.empty?
|
23
|
+
|
24
|
+
@guarantees.all? { |rule| ok_rule?(rule) }
|
25
|
+
end
|
26
|
+
|
27
|
+
def expectations_matched?
|
28
|
+
return true if @expectations.empty?
|
29
|
+
|
30
|
+
@expectations.any? { |rule| ok_rule?(rule) }
|
31
|
+
end
|
32
|
+
|
33
|
+
def ok_rule?(rule)
|
34
|
+
match_result = match_rule(rule)
|
35
|
+
checked_rule = rule.slice(:type, :name).merge!(match_result)
|
36
|
+
|
37
|
+
if match_result[:status] == :ok
|
38
|
+
@ok_rules << checked_rule
|
39
|
+
true
|
40
|
+
else
|
41
|
+
@failed_rules << checked_rule
|
42
|
+
false
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def dump_state_and_raise(error_class)
|
47
|
+
dump_state!
|
48
|
+
|
49
|
+
raise error_class.new(@failed_rules, @args)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "forwardable"
|
4
|
+
require "time"
|
5
|
+
|
6
|
+
module Contr
|
7
|
+
class Matcher
|
8
|
+
class RulesNotMatched < StandardError
|
9
|
+
def initialize(failed_rules, args)
|
10
|
+
@failed_rules_minimized = failed_rules.map(&:values)
|
11
|
+
@args = args
|
12
|
+
end
|
13
|
+
|
14
|
+
def message
|
15
|
+
"failed rules: #{@failed_rules_minimized.inspect}, args: #{@args.inspect}"
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
class GuaranteesNotMatched < RulesNotMatched
|
20
|
+
end
|
21
|
+
|
22
|
+
class ExpectationsNotMatched < RulesNotMatched
|
23
|
+
end
|
24
|
+
|
25
|
+
class Base
|
26
|
+
def initialize(contract, args, result)
|
27
|
+
@contract = contract
|
28
|
+
@args = args
|
29
|
+
@result = result
|
30
|
+
|
31
|
+
# def_delegators would be slickier but it breaks consistency of
|
32
|
+
# variables names when used within the rules definitions
|
33
|
+
@guarantees = @contract.guarantees
|
34
|
+
@expectations = @contract.expectations
|
35
|
+
@sampler = @contract.sampler
|
36
|
+
@logger = @contract.logger
|
37
|
+
end
|
38
|
+
|
39
|
+
def match
|
40
|
+
raise NotImplementedError, "matcher should implement `#match` method"
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def match_rule(rule)
|
46
|
+
block = rule[:block]
|
47
|
+
|
48
|
+
block_result = instance_exec(*@args, &block)
|
49
|
+
block_result ? {status: :ok} : {status: :failed}
|
50
|
+
rescue => error
|
51
|
+
{status: :unexpected_error, error: error}
|
52
|
+
end
|
53
|
+
|
54
|
+
def dump_state!
|
55
|
+
state = compile_state
|
56
|
+
|
57
|
+
if @sampler
|
58
|
+
dump_info = @sampler.sample!(state)
|
59
|
+
state[:dump_info] = dump_info if dump_info
|
60
|
+
end
|
61
|
+
|
62
|
+
@logger&.log(state)
|
63
|
+
end
|
64
|
+
|
65
|
+
def compile_state
|
66
|
+
{
|
67
|
+
ts: Time.now.utc.iso8601(3),
|
68
|
+
contract_name: @contract.name,
|
69
|
+
failed_rules: @failed_rules,
|
70
|
+
ok_rules: @ok_rules,
|
71
|
+
async: @async,
|
72
|
+
args: @args,
|
73
|
+
result: @result
|
74
|
+
}
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Contr
|
4
|
+
class Sampler
|
5
|
+
class Default < Sampler::Base
|
6
|
+
DEFAULT_FOLDER = "/tmp/contracts"
|
7
|
+
DEFAULT_PATH_TEMPLATE = "%<contract_name>s/%<period_id>i.dump"
|
8
|
+
DEFAULT_PERIOD = 10 * 60 # 10 minutes
|
9
|
+
|
10
|
+
attr_reader :folder, :path_template, :period
|
11
|
+
|
12
|
+
def initialize(folder: DEFAULT_FOLDER, path_template: DEFAULT_PATH_TEMPLATE, period: DEFAULT_PERIOD)
|
13
|
+
@folder = folder
|
14
|
+
@path_template = path_template
|
15
|
+
@period = period
|
16
|
+
end
|
17
|
+
|
18
|
+
def sample!(state)
|
19
|
+
path = dump_path(state[:contract_name])
|
20
|
+
return if dump_present?(path)
|
21
|
+
|
22
|
+
dump = Marshal.dump(state)
|
23
|
+
|
24
|
+
save_dump(dump, path)
|
25
|
+
create_dump_info(path)
|
26
|
+
end
|
27
|
+
|
28
|
+
def read(path: nil, contract_name: nil, period_id: nil)
|
29
|
+
path ||= dump_path(contract_name, period_id)
|
30
|
+
|
31
|
+
dump = File.read(path)
|
32
|
+
Marshal.load(dump)
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def dump_path(contract_name, period_id = current_period_id)
|
38
|
+
raise ArgumentError, "`contract_name` should be defined" unless contract_name
|
39
|
+
raise ArgumentError, "`period_id` should be defined" unless period_id
|
40
|
+
|
41
|
+
file_path = @path_template % {
|
42
|
+
contract_name: contract_name,
|
43
|
+
period_id: period_id
|
44
|
+
}
|
45
|
+
|
46
|
+
File.join(@folder, file_path)
|
47
|
+
end
|
48
|
+
|
49
|
+
def save_dump(dump, path)
|
50
|
+
init_dump_folder!(path)
|
51
|
+
File.write(path, dump)
|
52
|
+
end
|
53
|
+
|
54
|
+
def create_dump_info(path)
|
55
|
+
{path: path}
|
56
|
+
end
|
57
|
+
|
58
|
+
def dump_present?(path)
|
59
|
+
File.exist?(path)
|
60
|
+
end
|
61
|
+
|
62
|
+
def current_period_id
|
63
|
+
Time.now.to_i / @period
|
64
|
+
end
|
65
|
+
|
66
|
+
def init_dump_folder!(path)
|
67
|
+
dump_folder = File.dirname(path)
|
68
|
+
FileUtils.mkdir_p(dump_folder)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
data/lib/contr.rb
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "contr/version"
|
4
|
+
require_relative "contr/logger"
|
5
|
+
require_relative "contr/logger/default"
|
6
|
+
require_relative "contr/sampler"
|
7
|
+
require_relative "contr/sampler/default"
|
8
|
+
require_relative "contr/matcher"
|
9
|
+
require_relative "contr/matcher/sync"
|
10
|
+
require_relative "contr/base"
|
11
|
+
|
12
|
+
module Contr
|
13
|
+
end
|
metadata
ADDED
@@ -0,0 +1,59 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: contr
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Dmytro Horoshko
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2024-02-28 00:00:00.000000000 Z
|
12
|
+
dependencies: []
|
13
|
+
description: Minimalistic contracts in plain Ruby.
|
14
|
+
email:
|
15
|
+
- electric.molfar@gmail.com
|
16
|
+
executables: []
|
17
|
+
extensions: []
|
18
|
+
extra_rdoc_files: []
|
19
|
+
files:
|
20
|
+
- CHANGELOG.md
|
21
|
+
- LICENSE.txt
|
22
|
+
- README.md
|
23
|
+
- lib/contr.rb
|
24
|
+
- lib/contr/base.rb
|
25
|
+
- lib/contr/logger.rb
|
26
|
+
- lib/contr/logger/default.rb
|
27
|
+
- lib/contr/matcher.rb
|
28
|
+
- lib/contr/matcher/sync.rb
|
29
|
+
- lib/contr/sampler.rb
|
30
|
+
- lib/contr/sampler/default.rb
|
31
|
+
- lib/contr/version.rb
|
32
|
+
homepage: https://github.com/ocvit/contr
|
33
|
+
licenses:
|
34
|
+
- MIT
|
35
|
+
metadata:
|
36
|
+
bug_tracker_uri: https://github.com/ocvit/contr/issues
|
37
|
+
changelog_uri: https://github.com/ocvit/contr/blob/main/CHANGELOG.md
|
38
|
+
homepage_uri: https://github.com/ocvit/contr
|
39
|
+
source_code_uri: https://github.com/ocvit/contr
|
40
|
+
post_install_message:
|
41
|
+
rdoc_options: []
|
42
|
+
require_paths:
|
43
|
+
- lib
|
44
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
45
|
+
requirements:
|
46
|
+
- - ">="
|
47
|
+
- !ruby/object:Gem::Version
|
48
|
+
version: '2.7'
|
49
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
50
|
+
requirements:
|
51
|
+
- - ">="
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '0'
|
54
|
+
requirements: []
|
55
|
+
rubygems_version: 3.5.3
|
56
|
+
signing_key:
|
57
|
+
specification_version: 4
|
58
|
+
summary: Minimalistic contracts in plain Ruby
|
59
|
+
test_files: []
|