contr 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []