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 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
@@ -0,0 +1,4 @@
1
+ 0.1.0
2
+ ----------
3
+
4
+ - Initial release!
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
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Contr
4
+ class Logger
5
+ class Base
6
+ def log(state)
7
+ raise NotImplementedError, "logger should implement `#log` method"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -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
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Contr
4
+ class Sampler
5
+ class Base
6
+ def sample!(state)
7
+ raise NotImplementedError, "sampler should implement `#sample!` method"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Contr
4
+ VERSION = "0.1.0"
5
+ 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: []