blood_contracts 0.1.0 → 0.2.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 +5 -5
- data/.gitignore +1 -0
- data/Gemfile +1 -1
- data/README.md +2 -0
- data/Rakefile +1 -1
- data/bin/console +2 -5
- data/blood_contracts.gemspec +8 -5
- data/lib/blood_contracts/base_contract.rb +42 -0
- data/lib/blood_contracts/contracts/description.rb +25 -0
- data/lib/blood_contracts/contracts/matcher.rb +38 -0
- data/lib/blood_contracts/contracts/validator.rb +47 -0
- data/lib/blood_contracts/debugger.rb +39 -0
- data/lib/blood_contracts/runner.rb +49 -117
- data/lib/blood_contracts/statistics.rb +32 -0
- data/lib/blood_contracts/storage.rb +53 -53
- data/lib/blood_contracts/storages/base_backend.rb +59 -0
- data/lib/blood_contracts/storages/file_backend.rb +132 -0
- data/lib/blood_contracts/storages/serializer.rb +38 -0
- data/lib/blood_contracts/suite.rb +15 -5
- data/lib/blood_contracts/version.rb +1 -1
- data/lib/blood_contracts.rb +26 -76
- data/lib/extensions/string.rb +31 -0
- data/lib/rspec/meet_contract_matcher.rb +104 -0
- metadata +48 -9
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 6a3374d5e7af34959e56cb4f31d22cd807fe84f3ee1f74e9446646b7d4b49f27
|
4
|
+
data.tar.gz: 48907e94554abde9079f25e8a657fc24dbda9fac02468f239c547031eff0df0e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 9fc96fb5b769b22fb747573dd4ae5bf8da1cf2a40e429ddde58f82d78263aba3e9957d31183c0c5b0af3206abe0172e511d10e312d9624c0533cef030c502d84
|
7
|
+
data.tar.gz: c22e684f33f0d4f4ab37a5e08174c4dcefae73afa09079250eb1b158c2e03d572be92a40a202a2724f8bf0026e91fd8b89308cb1843de2f8f6ceff0d9fa4ff92
|
data/.gitignore
CHANGED
data/Gemfile
CHANGED
data/README.md
CHANGED
@@ -7,6 +7,8 @@ Possible use-cases:
|
|
7
7
|
- Automated detection of unexpected external API behavior (Rack::request/response pairs that don't match contract);
|
8
8
|
- Contract definition assistance tool (generate real-a-like requests and iterate through oddities of your system behavior)
|
9
9
|
|
10
|
+
<a href="https://evilmartians.com/">
|
11
|
+
<img src="https://evilmartians.com/badges/sponsored-by-evil-martians.svg" alt="Sponsored by Evil Martians" width="236" height="54"></a>
|
10
12
|
|
11
13
|
## Installation
|
12
14
|
|
data/Rakefile
CHANGED
data/bin/console
CHANGED
@@ -7,8 +7,5 @@ require "blood_contracts"
|
|
7
7
|
# with your gem easier. You can also use a different console, if you like.
|
8
8
|
|
9
9
|
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
require "irb"
|
14
|
-
IRB.start(__FILE__)
|
10
|
+
require "pry"
|
11
|
+
Pry.start
|
data/blood_contracts.gemspec
CHANGED
@@ -5,12 +5,13 @@ require "blood_contracts/version"
|
|
5
5
|
|
6
6
|
Gem::Specification.new do |spec|
|
7
7
|
spec.name = "blood_contracts"
|
8
|
+
|
8
9
|
spec.version = BloodContracts::VERSION
|
9
10
|
spec.authors = ["Sergey Dolganov"]
|
10
11
|
spec.email = ["dolganov@evl.ms"]
|
11
12
|
|
12
|
-
spec.summary =
|
13
|
-
spec.description =
|
13
|
+
spec.summary = "Ruby gem to define and validate behavior of API using contracts."
|
14
|
+
spec.description = "Ruby gem to define and validate behavior of API using contracts."
|
14
15
|
spec.homepage = "https://github.com/sclinede/blood_contracts"
|
15
16
|
spec.license = "MIT"
|
16
17
|
|
@@ -23,12 +24,14 @@ Gem::Specification.new do |spec|
|
|
23
24
|
# spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
24
25
|
spec.require_paths = ["lib"]
|
25
26
|
|
26
|
-
spec.
|
27
|
+
spec.required_ruby_version = '>= 2.2.0'
|
27
28
|
|
28
|
-
|
29
|
-
spec.add_runtime_dependency "
|
29
|
+
spec.add_runtime_dependency "dry-initializer", "~> 2.0"
|
30
|
+
spec.add_runtime_dependency "nanoid", "~> 0.2"
|
31
|
+
spec.add_runtime_dependency "hashie", "~> 3.0"
|
30
32
|
|
31
33
|
spec.add_development_dependency "bundler", "~> 1.16"
|
32
34
|
spec.add_development_dependency "rake", "~> 10.0"
|
33
35
|
spec.add_development_dependency "rspec", "~> 3.0"
|
36
|
+
spec.add_development_dependency "pry", "~> 0.9"
|
34
37
|
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module BloodContracts
|
2
|
+
class BaseContract
|
3
|
+
extend Dry::Initializer
|
4
|
+
option :action
|
5
|
+
|
6
|
+
class << self
|
7
|
+
def rules
|
8
|
+
@rules ||= Set.new
|
9
|
+
end
|
10
|
+
|
11
|
+
def contract_rule(name, &block)
|
12
|
+
define_method("_#{name}", block)
|
13
|
+
rules << name
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def call(data)
|
18
|
+
return yield(data) if block_given?
|
19
|
+
action.call(data)
|
20
|
+
end
|
21
|
+
|
22
|
+
def contract
|
23
|
+
@contract ||= Hash[
|
24
|
+
self.class.rules.map { |name| [name, {check: method("_#{name}")}] }
|
25
|
+
]
|
26
|
+
end
|
27
|
+
|
28
|
+
def build_storage(name)
|
29
|
+
s = Storage.new(contract_name: name)
|
30
|
+
s.input_writer = method(:input_writer) if defined? input_writer
|
31
|
+
s.output_writer = method(:output_writer) if defined? output_writer
|
32
|
+
s.input_serializer = input_serializer if defined? input_serializer
|
33
|
+
s.output_serializer = output_serializer if defined? output_serializer
|
34
|
+
s.meta_serializer = meta_serializer if defined? meta_serializer
|
35
|
+
s
|
36
|
+
end
|
37
|
+
|
38
|
+
def to_contract_suite(name: self.class.to_s.pathize)
|
39
|
+
Suite.new(storage: build_storage(name), contract: contract)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module BloodContracts
|
2
|
+
module Contracts
|
3
|
+
class Description
|
4
|
+
def self.call(contract_hash)
|
5
|
+
Hashie::Mash.new(contract_hash).map do |name, rule|
|
6
|
+
rule_description = " - '#{name}' "
|
7
|
+
if rule.threshold
|
8
|
+
rule_description << <<~TEXT
|
9
|
+
in more then #{(rule.threshold * 100).round(2)}% of cases;
|
10
|
+
TEXT
|
11
|
+
elsif rule.limit
|
12
|
+
rule_description << <<~TEXT
|
13
|
+
in less then #{(rule.limit * 100).round(2)}% of cases;
|
14
|
+
TEXT
|
15
|
+
else
|
16
|
+
rule_description << <<~TEXT
|
17
|
+
in any number of cases;
|
18
|
+
TEXT
|
19
|
+
end
|
20
|
+
rule_description
|
21
|
+
end.compact.join
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module BloodContracts
|
2
|
+
module Contracts
|
3
|
+
class Matcher
|
4
|
+
extend Dry::Initializer
|
5
|
+
|
6
|
+
param :contract_hash, ->(v) { Hashie::Mash.new(v) }
|
7
|
+
|
8
|
+
def call(input, output, meta = Hash.new, storage:)
|
9
|
+
options = Hashie::Mash.new(input: input, output: output, meta: meta)
|
10
|
+
|
11
|
+
rule_names = select_matched_rules!(options).keys
|
12
|
+
rule_names = [Storage::UNDEFINED_RULE] if rule_names.empty?
|
13
|
+
Array(rule_names).each(&storage.method(:store))
|
14
|
+
|
15
|
+
yield rule_names, options if block_given?
|
16
|
+
|
17
|
+
!storage.found_unexpected_behavior?
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def with_rule_options(rule_name, options)
|
23
|
+
rule_options = options.shallow_merge(meta: {})
|
24
|
+
result = yield(rule_options)
|
25
|
+
options.meta.merge!(rule_name.to_sym => rule_options.meta)
|
26
|
+
result
|
27
|
+
end
|
28
|
+
|
29
|
+
def select_matched_rules!(options)
|
30
|
+
contract_hash.select do |name, rule|
|
31
|
+
with_rule_options(name, options) do |rule_options|
|
32
|
+
rule.check.call(rule_options)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module BloodContracts
|
2
|
+
module Contracts
|
3
|
+
class Validator
|
4
|
+
extend Dry::Initializer
|
5
|
+
|
6
|
+
param :contract_hash, ->(v) { Hashie::Mash.new(v) }
|
7
|
+
|
8
|
+
def valid?(statistics)
|
9
|
+
return if statistics.found_unexpected_behavior?
|
10
|
+
|
11
|
+
last_run_stats = statistics.to_h
|
12
|
+
expectations.all? do |rule, check|
|
13
|
+
percent = last_run_stats[rule.name]&.percent || 0.0
|
14
|
+
check.call(percent, rule)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def expectations
|
21
|
+
Hash[
|
22
|
+
contract_hash.map do |name, rule|
|
23
|
+
if rule.threshold
|
24
|
+
[rule.merge(name: name), method(:threshold_check)]
|
25
|
+
elsif rule.limit
|
26
|
+
[rule.merge(name: name), method(:limit_check)]
|
27
|
+
else
|
28
|
+
[rule.merge(name: name), method(:anyway)]
|
29
|
+
end
|
30
|
+
end.compact
|
31
|
+
]
|
32
|
+
end
|
33
|
+
|
34
|
+
def threshold_check(value, rule)
|
35
|
+
value > rule.threshold
|
36
|
+
end
|
37
|
+
|
38
|
+
def limit_check(value, rule)
|
39
|
+
value <= rule.limit
|
40
|
+
end
|
41
|
+
|
42
|
+
def anyway(_value, _rule)
|
43
|
+
true
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module BloodContracts
|
2
|
+
class Debugger < Runner
|
3
|
+
def runs
|
4
|
+
@runs ||= storage.find_all_samples(ENV["debug"]).each
|
5
|
+
end
|
6
|
+
|
7
|
+
def iterations
|
8
|
+
runs.size
|
9
|
+
end
|
10
|
+
|
11
|
+
def call
|
12
|
+
return super if debugging_samples?
|
13
|
+
true
|
14
|
+
end
|
15
|
+
|
16
|
+
def description
|
17
|
+
return super if debugging_samples?
|
18
|
+
"be skipped in current debugging session"
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def match_rules?(matches_storage:)
|
24
|
+
matcher.call(*storage.load_sample(runs.next), storage: matches_storage)
|
25
|
+
end
|
26
|
+
|
27
|
+
def unexpected_further_investigation
|
28
|
+
ENV["debug"]
|
29
|
+
end
|
30
|
+
|
31
|
+
def further_investigation
|
32
|
+
ENV["debug"]
|
33
|
+
end
|
34
|
+
|
35
|
+
def debugging_samples?
|
36
|
+
runs.size.positive?
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -1,93 +1,89 @@
|
|
1
|
+
require_relative "contracts/validator"
|
2
|
+
require_relative "contracts/matcher"
|
3
|
+
require_relative "contracts/description"
|
4
|
+
|
1
5
|
module BloodContracts
|
2
6
|
class Runner
|
3
7
|
extend Dry::Initializer
|
4
8
|
|
5
9
|
param :checking_proc
|
6
|
-
option :context, optional: true
|
7
10
|
|
8
11
|
option :suite
|
12
|
+
option :storage, default: -> { suite.storage }
|
9
13
|
|
10
14
|
option :iterations, ->(v) do
|
11
15
|
v = ENV["iterations"] if ENV["iterations"]
|
12
16
|
v.to_i.positive? ? v.to_i : 1
|
13
17
|
end, default: -> { 1 }
|
14
|
-
|
15
|
-
|
16
18
|
option :time_to_run, ->(v) do
|
17
19
|
v = ENV["duration"] if ENV["duration"]
|
18
20
|
v.to_f if v.to_f.positive?
|
19
21
|
end, optional: true
|
20
22
|
|
23
|
+
option :context, optional: true
|
21
24
|
option :stop_on_unexpected, default: -> { false }
|
22
25
|
|
26
|
+
option :statistics, default: -> { Statistics.new(iterations) }
|
27
|
+
option :matcher, default: -> { Contracts::Matcher.new(suite.contract) }
|
28
|
+
option :validator, default: -> { Contracts::Validator.new(suite.contract) }
|
29
|
+
option :contract_description, default: -> do
|
30
|
+
Contracts::Description.call(suite.contract)
|
31
|
+
end
|
32
|
+
|
23
33
|
def call
|
24
34
|
iterate do
|
25
|
-
|
26
|
-
suite.
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
throw :unexpected_behavior, :stop if stop_on_unexpected && unexpected
|
35
|
+
next if match_rules?(matches_storage: statistics) do
|
36
|
+
input = suite.data_generator.call
|
37
|
+
[input, checking_proc.call(input)]
|
38
|
+
end
|
39
|
+
throw :unexpected_behavior, :stop if stop_on_unexpected
|
31
40
|
end
|
32
|
-
valid?
|
33
|
-
end
|
34
|
-
|
35
|
-
def valid?
|
36
41
|
return if stopped_by_unexpected_behavior?
|
37
|
-
return if found_unexpected_behavior?
|
38
42
|
|
39
|
-
|
40
|
-
expectations_checks.all? do |rule, check|
|
41
|
-
percent = last_run_stats[rule.name]&.percent || 0.0
|
42
|
-
check.call(percent, rule)
|
43
|
-
end
|
44
|
-
end
|
45
|
-
|
46
|
-
def found_unexpected_behavior?
|
47
|
-
run_stats.key?(Storage::UNDEFINED_RULE)
|
43
|
+
validator.valid?(statistics)
|
48
44
|
end
|
49
45
|
|
46
|
+
# FIXME: Move to locales
|
50
47
|
def failure_message
|
51
48
|
intro = "expected that given Proc would meet the contract:"
|
52
49
|
|
53
|
-
if
|
50
|
+
if validator.expected_behavior?
|
54
51
|
"#{intro}\n#{contract_description}\n"\
|
55
|
-
|
56
|
-
|
52
|
+
" during #{iterations} run(s) but got:\n#{statistics}\n\n"\
|
53
|
+
"For further investigations open: #{storage.suggestion}"
|
57
54
|
else
|
58
55
|
"#{intro}\n#{contract_description}\n"\
|
59
|
-
" during #{iterations} run(s) but got
|
60
|
-
"For further investigations open: #{
|
56
|
+
" during #{iterations} run(s) but got unexpected behavior.\n\n"\
|
57
|
+
"For further investigations open: #{storage.unexpected_suggestion}"
|
61
58
|
end
|
62
59
|
end
|
63
60
|
|
64
|
-
|
65
|
-
File.join(suite.storage.path, Storage::UNDEFINED_RULE.to_s)
|
66
|
-
end
|
67
|
-
|
61
|
+
# FIXME: Move to locales
|
68
62
|
def description
|
69
63
|
"meet the contract:\n#{contract_description} \n"\
|
70
|
-
" during #{iterations} run(s). Stats:\n#{
|
71
|
-
"For further investigations open: #{
|
64
|
+
" during #{iterations} run(s). Stats:\n#{statistics}\n\n"\
|
65
|
+
"For further investigations open: #{storage.suggestion}\n"
|
72
66
|
end
|
73
67
|
|
74
|
-
|
68
|
+
protected
|
75
69
|
|
76
|
-
def
|
77
|
-
|
78
|
-
|
70
|
+
def match_rules?(matches_storage:)
|
71
|
+
matcher.call(*yield, storage: matches_storage) do |rules, options|
|
72
|
+
storage.store(options: options, rules: rules, context: context)
|
73
|
+
end
|
74
|
+
rescue StandardError => error
|
75
|
+
# FIXME: Possible recursion?
|
76
|
+
# Write test about error in the storage#store (e.g. writing error)
|
77
|
+
store_exception(error, input, output, context)
|
78
|
+
raise
|
79
79
|
end
|
80
80
|
|
81
81
|
def stopped_by_unexpected_behavior?
|
82
82
|
@_stopped_by_unexpected_behavior == :stop
|
83
83
|
end
|
84
84
|
|
85
|
-
def stats
|
86
|
-
suite.storage.stats
|
87
|
-
end
|
88
|
-
|
89
85
|
def iterate
|
90
|
-
run_iterations
|
86
|
+
run_iterations ||= iterations
|
91
87
|
|
92
88
|
if time_to_run
|
93
89
|
run_iterations = iterations_count_from_time_to_run { yield }
|
@@ -104,80 +100,16 @@ module BloodContracts
|
|
104
100
|
(time_to_run / time_per_action.real).ceil
|
105
101
|
end
|
106
102
|
|
107
|
-
def
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
rescue => e
|
118
|
-
yield [Storage::UNDEFINED_RULE]
|
119
|
-
raise e
|
120
|
-
end
|
121
|
-
|
122
|
-
def threshold_check(value, rule)
|
123
|
-
value > rule.threshold
|
124
|
-
end
|
125
|
-
|
126
|
-
def limit_check(value, rule)
|
127
|
-
value <= rule.limit
|
128
|
-
end
|
129
|
-
|
130
|
-
def expectations_checks
|
131
|
-
Hash[
|
132
|
-
suite.contract.map do |name, rule|
|
133
|
-
if rule.threshold
|
134
|
-
[rule.merge(name: name), method(:threshold_check)]
|
135
|
-
elsif rule.limit
|
136
|
-
[rule.merge(name: name), method(:limit_check)]
|
137
|
-
else
|
138
|
-
nil
|
139
|
-
end
|
140
|
-
end.compact
|
141
|
-
]
|
142
|
-
end
|
143
|
-
|
144
|
-
def contract_description
|
145
|
-
suite.contract.map do |name, rule|
|
146
|
-
rule_description = " - '#{name}' "
|
147
|
-
if rule.threshold
|
148
|
-
rule_description << <<~TEXT
|
149
|
-
in more then #{(rule.threshold * 100).round(2)}% of cases;
|
150
|
-
TEXT
|
151
|
-
elsif rule.limit
|
152
|
-
rule_description << <<~TEXT
|
153
|
-
in less then #{(rule.limit * 100).round(2)}% of cases;
|
154
|
-
TEXT
|
155
|
-
else
|
156
|
-
next
|
157
|
-
end
|
158
|
-
rule_description
|
159
|
-
end.compact.join
|
160
|
-
end
|
161
|
-
|
162
|
-
def stats_description
|
163
|
-
run_stats.map do |name, occasions|
|
164
|
-
" - '#{name}' happened #{occasions.times} time(s) "\
|
165
|
-
"(#{(occasions.percent * 100).round(2)}% of the time)"
|
166
|
-
end.join("; \n")
|
167
|
-
end
|
168
|
-
|
169
|
-
def run_stats
|
170
|
-
Hash[
|
171
|
-
stats.map do |rule_name, times|
|
172
|
-
[
|
173
|
-
rule_name,
|
174
|
-
Hashie::Mash.new(
|
175
|
-
times: times,
|
176
|
-
percent: (times.to_f / iterations),
|
177
|
-
),
|
178
|
-
]
|
179
|
-
end
|
180
|
-
]
|
103
|
+
def store_exception(error, input, output, context)
|
104
|
+
storage.store(
|
105
|
+
options: {
|
106
|
+
input: input,
|
107
|
+
output: output || {},
|
108
|
+
meta: {exception: error},
|
109
|
+
},
|
110
|
+
rules: [Storage::EXCEPTION_CAUGHT],
|
111
|
+
context: context,
|
112
|
+
)
|
181
113
|
end
|
182
114
|
end
|
183
115
|
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module BloodContracts
|
2
|
+
class Statistics
|
3
|
+
extend Dry::Initializer
|
4
|
+
param :iterations
|
5
|
+
option :storage, default: -> { Hash.new(0) }
|
6
|
+
|
7
|
+
def store(rule)
|
8
|
+
storage[rule] += 1
|
9
|
+
end
|
10
|
+
|
11
|
+
def to_h
|
12
|
+
Hash[storage.map { |rule_name, times| [rule_name, rule_stats(times)] }]
|
13
|
+
end
|
14
|
+
|
15
|
+
def to_s
|
16
|
+
to_h.map do |name, occasions|
|
17
|
+
" - '#{name}' happened #{occasions.times} time(s) "\
|
18
|
+
"(#{(occasions.percent * 100).round(2)}% of the time)"
|
19
|
+
end.join("; \n")
|
20
|
+
end
|
21
|
+
|
22
|
+
def found_unexpected_behavior?
|
23
|
+
storage.key?(Storage::UNDEFINED_RULE)
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def rule_stats(times)
|
29
|
+
Hashie::Mash.new(times: times, percent: (times.to_f / iterations))
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -1,79 +1,79 @@
|
|
1
|
+
require_relative "./storages/base_backend.rb"
|
2
|
+
require_relative "./storages/file_backend.rb"
|
3
|
+
require_relative "./storages/serializer.rb"
|
4
|
+
|
1
5
|
module BloodContracts
|
2
6
|
class Storage
|
3
7
|
extend Dry::Initializer
|
8
|
+
extend Forwardable
|
4
9
|
|
5
|
-
|
6
|
-
|
7
|
-
option :custom_path, optional: true
|
8
|
-
option :root, default: -> { Rails.root.join(path) }
|
9
|
-
option :stats, default: -> { Hash.new(0) }
|
10
|
-
option :input_writer, optional: true
|
11
|
-
option :output_writer, optional: true
|
10
|
+
Serializer = BloodContracts::Storages::Serializer
|
11
|
+
FileBackend = BloodContracts::Storages::FileBackend
|
12
12
|
|
13
|
-
|
13
|
+
option :contract_name
|
14
14
|
|
15
|
-
|
16
|
-
"
|
15
|
+
DEFAULT_WRITER = -> (options) do
|
16
|
+
"INPUT:\n#{options.input}\n\n#{'=' * 90}\n\nOUTPUT:\n#{options.output}"
|
17
17
|
end
|
18
18
|
|
19
|
-
|
20
|
-
|
19
|
+
option :input_writer,
|
20
|
+
->(v) { valid_writer(v) }, default: -> { DEFAULT_WRITER }
|
21
|
+
option :output_writer,
|
22
|
+
->(v) { valid_writer(v) }, default: -> { DEFAULT_WRITER }
|
23
|
+
|
24
|
+
option :input_serializer,
|
25
|
+
->(v) { Serializer.call(v) }, default: -> { default_serializer }
|
26
|
+
option :output_serializer,
|
27
|
+
->(v) { Serializer.call(v) }, default: -> { default_serializer }
|
28
|
+
option :meta_serializer,
|
29
|
+
->(v) { Serializer.call(v) }, default: -> { default_serializer }
|
30
|
+
|
31
|
+
option :backend, default: -> { FileBackend.new(self, contract_name) }
|
32
|
+
|
33
|
+
def_delegators :@backend, :sample_exists?,
|
34
|
+
:load_sample, :find_all_samples,
|
35
|
+
:serialize_sample, :describe_sample,
|
36
|
+
:suggestion, :unexpected_suggestion
|
37
|
+
|
38
|
+
def self.valid_writer(writer)
|
39
|
+
return writer if writer.respond_to?(:call) || writer.respond_to?(:to_sym)
|
40
|
+
raise ArgumentError
|
21
41
|
end
|
22
42
|
|
23
|
-
def
|
24
|
-
|
25
|
-
writer.respond_to?(:to_sym)
|
26
|
-
@input_writer = writer
|
43
|
+
def input_serializer=(serializer)
|
44
|
+
@input_serializer = Serializer.call(serializer)
|
27
45
|
end
|
28
46
|
|
29
|
-
def
|
30
|
-
|
31
|
-
writer.respond_to?(:to_sym)
|
32
|
-
@output_writer = writer
|
47
|
+
def output_serializer=(serializer)
|
48
|
+
@output_serializer = Serializer.call(serializer)
|
33
49
|
end
|
34
50
|
|
35
|
-
def
|
36
|
-
|
37
|
-
FileUtils.mkdir_p File.join(root, "#{tag}")
|
38
|
-
run_name
|
51
|
+
def meta_serializer=(serializer)
|
52
|
+
@meta_serializer = Serializer.call(serializer)
|
39
53
|
end
|
40
54
|
|
41
|
-
def
|
42
|
-
|
43
|
-
writer = context.method(writer) if context && writer.respond_to?(:to_sym)
|
44
|
-
writer.call(input, output)
|
55
|
+
def input_writer=(writer)
|
56
|
+
@input_writer = self.class.valid_writer(writer)
|
45
57
|
end
|
46
58
|
|
47
|
-
def
|
48
|
-
|
49
|
-
|
50
|
-
input,
|
51
|
-
"\n#{'=' * 90}\n",
|
52
|
-
"OUTPUT:",
|
53
|
-
output
|
54
|
-
].map(&:to_s).join("\n")
|
59
|
+
def output_writer=(writer)
|
60
|
+
@output_writer = self.class.valid_writer(writer)
|
61
|
+
end
|
55
62
|
|
63
|
+
UNDEFINED_RULE = :__no_tag_match__
|
64
|
+
EXCEPTION_CAUGHT = :__exception_raised__
|
65
|
+
|
66
|
+
def default_serializer
|
67
|
+
{ load: Oj.method(:load), dump: Oj.method(:dump) }
|
56
68
|
end
|
57
69
|
|
58
70
|
# Quick open: `vim -O tmp/contract_tests/<tstamp>/<tag>/<tstamp>.*`
|
59
|
-
def
|
71
|
+
def store(options:, rules:, context:)
|
72
|
+
options = Hashie::Mash.new(options)
|
73
|
+
|
60
74
|
Array(rules).each do |rule_name|
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
# TODO: Write to HTML
|
65
|
-
input_fname = "#{run_name}.input"
|
66
|
-
output_fname = "#{run_name}.output"
|
67
|
-
File.open(input_fname, "w+") do |f|
|
68
|
-
f << write(input_writer, context, input, output).encode(
|
69
|
-
'UTF-8', invalid: :replace, undef: :replace, replace: '?'
|
70
|
-
)
|
71
|
-
end
|
72
|
-
File.open(output_fname, "w+") do |f|
|
73
|
-
f << write(output_writer, context, input, output).encode(
|
74
|
-
'UTF-8', invalid: :replace, undef: :replace, replace: '?'
|
75
|
-
)
|
76
|
-
end
|
75
|
+
describe_sample(rule_name, options, context)
|
76
|
+
serialize_sample(rule_name, options, context)
|
77
77
|
end
|
78
78
|
end
|
79
79
|
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
require 'nanoid'
|
2
|
+
|
3
|
+
module BloodContracts
|
4
|
+
module Storages
|
5
|
+
class BaseBackend
|
6
|
+
extend Dry::Initializer
|
7
|
+
extend Forwardable
|
8
|
+
|
9
|
+
param :storage
|
10
|
+
param :example_name
|
11
|
+
option :name, default: -> do
|
12
|
+
BloodContracts.run_name || ::Nanoid.generate(size: 10)
|
13
|
+
end
|
14
|
+
def_delegators :@storage, :input_writer, :output_writer,
|
15
|
+
:input_serializer, :output_serializer, :meta_serializer
|
16
|
+
|
17
|
+
|
18
|
+
def sample_exists?(sample_name)
|
19
|
+
raise NotImplementedError
|
20
|
+
end
|
21
|
+
|
22
|
+
def find_all_samples(run, tag, sample)
|
23
|
+
raise NotImplementedError
|
24
|
+
end
|
25
|
+
|
26
|
+
def load_sample(_sample_name)
|
27
|
+
%i(input output meta).map do |type|
|
28
|
+
load_sample_chunk(type, _sample_name)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def load_sample_chunk(_dump_type, _sample_name)
|
33
|
+
raise NotImplementedError
|
34
|
+
end
|
35
|
+
|
36
|
+
def describe_sample(_tag, _options, _context)
|
37
|
+
raise NotImplementedError
|
38
|
+
end
|
39
|
+
|
40
|
+
def serialize_sample(tag, options, context)
|
41
|
+
%i(input output meta).each do |type|
|
42
|
+
serialize_sample_chunk(type, tag, options, context)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def serialize_sample_chunk(_type, _tag, _option, _context)
|
47
|
+
raise NotImplementedError
|
48
|
+
end
|
49
|
+
|
50
|
+
def suggestion
|
51
|
+
raise NotImplementedError
|
52
|
+
end
|
53
|
+
|
54
|
+
def unexpected_suggestion
|
55
|
+
raise NotImplementedError
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,132 @@
|
|
1
|
+
module BloodContracts
|
2
|
+
module Storages
|
3
|
+
class FileBackend < BaseBackend
|
4
|
+
option :root, default: -> { Rails.root.join(path) }
|
5
|
+
|
6
|
+
def suggestion
|
7
|
+
"#{path}/*/*"
|
8
|
+
end
|
9
|
+
|
10
|
+
def unexpected_suggestion
|
11
|
+
"#{path}/#{Storage::UNDEFINED_RULE}/*"
|
12
|
+
end
|
13
|
+
|
14
|
+
def default_path
|
15
|
+
"./tmp/contract_tests/"
|
16
|
+
end
|
17
|
+
|
18
|
+
def timestamp
|
19
|
+
@timestamp ||= Time.current.to_s(:usec)[8..-3]
|
20
|
+
end
|
21
|
+
|
22
|
+
def reset_timestamp!
|
23
|
+
@timestamp = nil
|
24
|
+
end
|
25
|
+
|
26
|
+
def parse_sample_name(sample_name)
|
27
|
+
path_items = sample_name.split("/")
|
28
|
+
sample = path_items.pop
|
29
|
+
tag = path_items.pop
|
30
|
+
path_str = path_items.join("/")
|
31
|
+
run_n_example_str = path_str.sub(default_path, '')
|
32
|
+
if run_n_example_str.end_with?('*')
|
33
|
+
[
|
34
|
+
run_n_example_str.chomp("*"),
|
35
|
+
tag,
|
36
|
+
sample
|
37
|
+
]
|
38
|
+
elsif run_n_example_str.end_with?(example_name)
|
39
|
+
[
|
40
|
+
run_n_example_str.chomp(example_name),
|
41
|
+
tag,
|
42
|
+
sample
|
43
|
+
]
|
44
|
+
else
|
45
|
+
%w(__no_match__ __no_match__ __no_match__)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def find_all_samples(sample_name)
|
50
|
+
run, tag, sample = parse_sample_name(sample_name)
|
51
|
+
run_path = path(run_name: run)
|
52
|
+
files = Dir.glob("#{run_path}/#{tag.to_s}/#{sample}*")
|
53
|
+
files.select { |f| f.end_with?(".output") }
|
54
|
+
.map { |f| f.chomp(".output") }
|
55
|
+
end
|
56
|
+
|
57
|
+
def path(run_name: name)
|
58
|
+
File.join(default_path, run_name, example_name.to_s)
|
59
|
+
end
|
60
|
+
|
61
|
+
def sample_name(tag, run_path: root, sample: timestamp)
|
62
|
+
File.join(run_path, tag.to_s, sample)
|
63
|
+
end
|
64
|
+
|
65
|
+
def sample_exists?(sample_name)
|
66
|
+
run, tag, sample = parse_sample_name(sample_name)
|
67
|
+
name = sample_name(tag, run_path: path(run_name: run), sample: sample)
|
68
|
+
File.exist?("#{name}.input")
|
69
|
+
end
|
70
|
+
|
71
|
+
def load_sample_chunk(dump_type, sample_name)
|
72
|
+
run, tag, sample = parse_sample_name(sample_name)
|
73
|
+
name = sample_name(tag, run_path: path(run_name: run), sample: sample)
|
74
|
+
send("#{dump_type}_serializer")[:load].call(
|
75
|
+
File.read("#{name}.#{dump_type}.dump")
|
76
|
+
)
|
77
|
+
end
|
78
|
+
|
79
|
+
def write(writer, cntxt, options)
|
80
|
+
writer = cntxt.method(writer) if cntxt && writer.respond_to?(:to_sym)
|
81
|
+
writer.call(options).encode(
|
82
|
+
"UTF-8", invalid: :replace, undef: :replace, replace: "?",
|
83
|
+
)
|
84
|
+
end
|
85
|
+
|
86
|
+
def describe_sample(tag, options, context)
|
87
|
+
FileUtils.mkdir_p File.join(root, tag.to_s)
|
88
|
+
|
89
|
+
reset_timestamp!
|
90
|
+
name = sample_name(tag)
|
91
|
+
File.open("#{name}.input", "w+") do |f|
|
92
|
+
f << write(input_writer, context, options)
|
93
|
+
end
|
94
|
+
File.open("#{name}.output", "w+") do |f|
|
95
|
+
f << write(output_writer, context, options)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def serialize_sample_chunk(type, tag, options, context)
|
100
|
+
return unless (dump_proc = send("#{type}_serializer")[:dump])
|
101
|
+
name, data = sample_name(tag), options.send(type)
|
102
|
+
File.open("#{name}.#{type}.dump", "w+") do |f|
|
103
|
+
f << write(dump_proc, context, data)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
#
|
107
|
+
# def serialize_input(tag, options, context)
|
108
|
+
# return unless (dump_proc = input_serializer[:dump])
|
109
|
+
# name = sample_name(tag)
|
110
|
+
# File.open("#{name}.input.dump", "w+") do |f|
|
111
|
+
# f << write(dump_proc, context, options.input)
|
112
|
+
# end
|
113
|
+
# end
|
114
|
+
#
|
115
|
+
# def serialize_output(tag, options, context)
|
116
|
+
# return unless (dump_proc = output_serializer[:dump])
|
117
|
+
# name = sample_name(tag)
|
118
|
+
# File.open("#{name}.output.dump", "w+") do |f|
|
119
|
+
# f << write(dump_proc, context, options.output)
|
120
|
+
# end
|
121
|
+
# end
|
122
|
+
#
|
123
|
+
# def serialize_meta(tag, options, context)
|
124
|
+
# return unless (dump_proc = meta_serializer[:dump])
|
125
|
+
# name = sample_name(tag)
|
126
|
+
# File.open("#{name}.meta.dump", "w+") do |f|
|
127
|
+
# f << write(dump_proc, context, options.meta)
|
128
|
+
# end
|
129
|
+
# end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module BloodContracts
|
2
|
+
module Storages
|
3
|
+
class Serializer
|
4
|
+
extend Dry::Initializer
|
5
|
+
|
6
|
+
param :serializer
|
7
|
+
|
8
|
+
def self.call(*args)
|
9
|
+
new(*args).call
|
10
|
+
end
|
11
|
+
|
12
|
+
def call
|
13
|
+
return object_serializer_to_hash if object_serializer?
|
14
|
+
return serializer.to_hash if hash_serializer?
|
15
|
+
|
16
|
+
raise "Both #dump and #load methods should be defined for serialization"
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def object_serializer?
|
22
|
+
serializer.respond_to?(:dump) && serializer.respond_to?(:load)
|
23
|
+
end
|
24
|
+
|
25
|
+
def object_serializer_to_hash
|
26
|
+
{
|
27
|
+
load: serializer.method(:load),
|
28
|
+
dump: serializer.method(:dump),
|
29
|
+
}
|
30
|
+
end
|
31
|
+
|
32
|
+
def hash_serializer?
|
33
|
+
return unless serializer.respond_to?(:to_hash)
|
34
|
+
(%i[dump load] - serializer.to_hash.keys).empty?
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -3,19 +3,24 @@ module BloodContracts
|
|
3
3
|
extend Dry::Initializer
|
4
4
|
|
5
5
|
option :data_generator, optional: true
|
6
|
-
option :contract,
|
7
|
-
|
6
|
+
option :contract, ->(v) { Hashie::Mash.new(v) }, default: -> { Hash.new }
|
7
|
+
|
8
|
+
option :input_writer, optional: true
|
8
9
|
option :output_writer, optional: true
|
10
|
+
|
11
|
+
option :input_serializer, ->(v) { parse_serializer(v) }, optional: true
|
12
|
+
option :output_serializer, ->(v) { parse_serializer(v) }, optional: true
|
13
|
+
|
9
14
|
option :storage_backend, optional: true
|
10
15
|
option :storage, default: -> { default_storage }
|
11
16
|
|
12
17
|
def data_generator=(generator)
|
13
|
-
|
18
|
+
raise ArgumentError unless generator.respond_to?(:call)
|
14
19
|
@data_generator = generator
|
15
20
|
end
|
16
21
|
|
17
22
|
def contract=(contract)
|
18
|
-
|
23
|
+
raise ArgumentError unless contract.respond_to?(:to_h)
|
19
24
|
@contract = Hashie::Mash.new(contract.to_h)
|
20
25
|
end
|
21
26
|
|
@@ -28,7 +33,12 @@ module BloodContracts
|
|
28
33
|
end
|
29
34
|
|
30
35
|
def default_storage
|
31
|
-
Storage.new(
|
36
|
+
Storage.new(
|
37
|
+
input_writer: input_writer,
|
38
|
+
output_writer: output_writer,
|
39
|
+
input_serializer: input_serializer,
|
40
|
+
output_serializer: output_serializer,
|
41
|
+
)
|
32
42
|
end
|
33
43
|
end
|
34
44
|
end
|
data/lib/blood_contracts.rb
CHANGED
@@ -1,88 +1,38 @@
|
|
1
1
|
require "blood_contracts/version"
|
2
|
+
|
3
|
+
require_relative "extensions/string.rb"
|
4
|
+
require "dry-initializer"
|
5
|
+
require "hashie/mash"
|
6
|
+
|
2
7
|
require_relative "blood_contracts/suite"
|
3
8
|
require_relative "blood_contracts/storage"
|
9
|
+
require_relative "blood_contracts/statistics"
|
4
10
|
require_relative "blood_contracts/runner"
|
11
|
+
require_relative "blood_contracts/debugger"
|
12
|
+
require_relative "blood_contracts/base_contract"
|
5
13
|
|
6
14
|
module BloodContracts
|
15
|
+
def run_name
|
16
|
+
@__contracts_run_name
|
17
|
+
end
|
18
|
+
module_function :run_name
|
7
19
|
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
matcher :meet_contract_rules do |options|
|
14
|
-
match do |actual|
|
15
|
-
raise ArgumentError unless actual.respond_to?(:call)
|
16
|
-
|
17
|
-
@_contract_runner = Runner.new(
|
18
|
-
actual,
|
19
|
-
context: self,
|
20
|
-
suite: build_suite(options),
|
21
|
-
iterations: @_iterations,
|
22
|
-
time_to_run: @_time_to_run,
|
23
|
-
stop_on_unexpected: @_halt_on_unexpected,
|
24
|
-
)
|
25
|
-
@_contract_runner.call
|
26
|
-
end
|
27
|
-
|
28
|
-
def build_suite(options)
|
29
|
-
storage = Storage.new(custom_path: _example_name_to_path)
|
30
|
-
storage.input_writer = _input_writer if _input_writer
|
31
|
-
storage.output_writer = _output_writer if _output_writer
|
32
|
-
|
33
|
-
suite = options[:contract_suite] || Suite.new(storage: storage)
|
34
|
-
|
35
|
-
suite.data_generator = @_generator if @_generator
|
36
|
-
suite.contract = options[:contract] if options[:contract]
|
37
|
-
suite
|
38
|
-
end
|
39
|
-
|
40
|
-
def _example_name_to_path
|
41
|
-
method_missing(:class).
|
42
|
-
name.
|
43
|
-
sub("RSpec::ExampleGroups::", "").
|
44
|
-
snakecase
|
45
|
-
end
|
46
|
-
|
47
|
-
def _input_writer
|
48
|
-
input_writer = @_writers.to_h[:input]
|
49
|
-
input_writer ||= :input_writer if defined? self.input_writer
|
50
|
-
input_writer
|
51
|
-
end
|
52
|
-
|
53
|
-
def _output_writer
|
54
|
-
output_writer = @_writers.to_h[:output]
|
55
|
-
output_writer ||= :output_writer if defined? self.output_writer
|
56
|
-
output_writer
|
57
|
-
end
|
58
|
-
|
59
|
-
supports_block_expectations
|
60
|
-
|
61
|
-
failure_message { @_contract_runner.failure_message }
|
62
|
-
|
63
|
-
description { @_contract_runner.description }
|
64
|
-
|
65
|
-
chain :using_generator do |generator|
|
66
|
-
if generator.respond_to?(:to_sym)
|
67
|
-
@_generator = method(generator.to_sym)
|
68
|
-
else
|
69
|
-
fail ArgumentError unless generator.respond_to?(:call)
|
70
|
-
@_generator = generator
|
71
|
-
end
|
72
|
-
end
|
73
|
-
|
74
|
-
chain :during_n_iterations_run do |iterations|
|
75
|
-
@_iterations = Integer(iterations)
|
76
|
-
end
|
77
|
-
|
78
|
-
chain :during_n_seconds_run do |time_to_run|
|
79
|
-
@_time_to_run = Float(time_to_run)
|
80
|
-
end
|
20
|
+
def run_name=(run_name)
|
21
|
+
@__contracts_run_name = run_name
|
22
|
+
end
|
23
|
+
module_function :run_name=
|
81
24
|
|
82
|
-
|
83
|
-
|
84
|
-
end
|
25
|
+
if defined?(RSpec) && RSpec.respond_to?(:configure)
|
26
|
+
require_relative "rspec/meet_contract_matcher"
|
85
27
|
|
28
|
+
RSpec.configure do |config|
|
29
|
+
config.include ::RSpec::MeetContractMatcher
|
30
|
+
config.filter_run_excluding contract: true
|
31
|
+
config.before(:suite) do
|
32
|
+
BloodContracts.run_name = ::Nanoid.generate(size: 10)
|
33
|
+
end
|
34
|
+
config.define_derived_metadata(file_path: %r{/spec/contracts/}) do |meta|
|
35
|
+
meta[:contract] = true
|
86
36
|
end
|
87
37
|
end
|
88
38
|
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
class String
|
2
|
+
# See gem Facets String#pathize
|
3
|
+
|
4
|
+
# Transforms a namespace, i.e. a class or module name, into a viable
|
5
|
+
# file path.
|
6
|
+
#
|
7
|
+
# "ExamplePathize".pathize #=> "example_pathize"
|
8
|
+
# "ExamplePathize::Example".pathize #=> "example_pathize/example"
|
9
|
+
#
|
10
|
+
# Compare this method to {String#modulize) and {String#methodize).
|
11
|
+
#
|
12
|
+
def pathize
|
13
|
+
gsub(/([A-Z]+)([A-Z])/,'\1_\2').
|
14
|
+
gsub(/([a-z])([A-Z])/,'\1_\2').
|
15
|
+
gsub('__','/').
|
16
|
+
gsub('::','/').
|
17
|
+
gsub(/\s+/, ''). # spaces are bad form
|
18
|
+
gsub(/[?%*:|"<>.]+/, ''). # reserved characters
|
19
|
+
downcase
|
20
|
+
end
|
21
|
+
|
22
|
+
# Compare to Rails definition:
|
23
|
+
#
|
24
|
+
# gsub(/__/, '/').
|
25
|
+
# gsub(/::/, '/').
|
26
|
+
# gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2').
|
27
|
+
# gsub(/([a-z\d])([A-Z])/,'\1_\2').
|
28
|
+
# tr("-", "_").
|
29
|
+
# downcase
|
30
|
+
#
|
31
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
module RSpec
|
2
|
+
module MeetContractMatcher
|
3
|
+
extend RSpec::Matchers::DSL
|
4
|
+
Runner = ::BloodContracts::Runner
|
5
|
+
Debugger = ::BloodContracts::Debugger
|
6
|
+
|
7
|
+
matcher :meet_contract_rules do |args|
|
8
|
+
match do |subject|
|
9
|
+
runner = ENV["debug"] ? Debugger : Runner
|
10
|
+
|
11
|
+
@_contract_runner = runner.new(
|
12
|
+
subject,
|
13
|
+
context: self,
|
14
|
+
suite: build_suite(args || subject),
|
15
|
+
iterations: @_iterations,
|
16
|
+
time_to_run: @_time_to_run,
|
17
|
+
stop_on_unexpected: @_halt_on_unexpected,
|
18
|
+
)
|
19
|
+
@_contract_runner.call
|
20
|
+
end
|
21
|
+
|
22
|
+
def build_suite(args)
|
23
|
+
suite = nil
|
24
|
+
if args.respond_to?(:to_contract_suite)
|
25
|
+
suite = args.to_contract_suite(name: _example_name_to_path)
|
26
|
+
elsif args.respond_to?(:to_hash) && args.fetch(:contract) { false }
|
27
|
+
suite = ::BloodContracts::Suite.new(storage: new_storage)
|
28
|
+
suite.contract = args[:contract]
|
29
|
+
else
|
30
|
+
raise "Matcher arguments is not a Blood Contract"
|
31
|
+
end
|
32
|
+
suite.data_generator = @_generator if @_generator
|
33
|
+
suite
|
34
|
+
end
|
35
|
+
|
36
|
+
def new_storage
|
37
|
+
storage = Storage.new(contract_name: _example_name_to_path)
|
38
|
+
storage.input_writer = _input_writer if _input_writer
|
39
|
+
storage.output_writer = _output_writer if _output_writer
|
40
|
+
if @_input_serializer
|
41
|
+
storage.input_serializer = @_input_serializer
|
42
|
+
end
|
43
|
+
if @_output_serializer
|
44
|
+
storage.output_serializer = @_output_serializer
|
45
|
+
end
|
46
|
+
storage
|
47
|
+
end
|
48
|
+
|
49
|
+
def _example_name_to_path
|
50
|
+
method_missing(:class)
|
51
|
+
.name
|
52
|
+
.sub("RSpec::ExampleGroups::", "")
|
53
|
+
.pathize
|
54
|
+
end
|
55
|
+
|
56
|
+
def _input_writer
|
57
|
+
input_writer = @_writers.to_h[:input]
|
58
|
+
input_writer ||= :input_writer if defined? self.input_writer
|
59
|
+
input_writer
|
60
|
+
end
|
61
|
+
|
62
|
+
def _output_writer
|
63
|
+
output_writer = @_writers.to_h[:output]
|
64
|
+
output_writer ||= :output_writer if defined? self.output_writer
|
65
|
+
output_writer
|
66
|
+
end
|
67
|
+
|
68
|
+
supports_block_expectations
|
69
|
+
|
70
|
+
failure_message { @_contract_runner.failure_message }
|
71
|
+
|
72
|
+
description { @_contract_runner.description }
|
73
|
+
|
74
|
+
chain :using_generator do |generator|
|
75
|
+
if generator.respond_to?(:to_sym)
|
76
|
+
@_generator = method(generator.to_sym)
|
77
|
+
else
|
78
|
+
raise ArgumentError unless generator.respond_to?(:call)
|
79
|
+
@_generator = generator
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
chain :during_n_iterations_run do |iterations|
|
84
|
+
@_iterations = Integer(iterations)
|
85
|
+
end
|
86
|
+
|
87
|
+
chain :during_n_seconds_run do |time_to_run|
|
88
|
+
@_time_to_run = Float(time_to_run)
|
89
|
+
end
|
90
|
+
|
91
|
+
chain :halt_on_unexpected do
|
92
|
+
@_halt_on_unexpected = true
|
93
|
+
end
|
94
|
+
|
95
|
+
chain :serialize_input do |serializer|
|
96
|
+
@_input_serializer = serializer
|
97
|
+
end
|
98
|
+
|
99
|
+
chain :serialize_output do |serializer|
|
100
|
+
@_output_serializer = serializer
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: blood_contracts
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Sergey Dolganov
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2018-
|
11
|
+
date: 2018-03-07 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: dry-initializer
|
@@ -25,19 +25,33 @@ dependencies:
|
|
25
25
|
- !ruby/object:Gem::Version
|
26
26
|
version: '2.0'
|
27
27
|
- !ruby/object:Gem::Dependency
|
28
|
-
name:
|
28
|
+
name: nanoid
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
30
30
|
requirements:
|
31
|
-
- - "
|
31
|
+
- - "~>"
|
32
32
|
- !ruby/object:Gem::Version
|
33
|
-
version: '
|
33
|
+
version: '0.2'
|
34
34
|
type: :runtime
|
35
35
|
prerelease: false
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
37
37
|
requirements:
|
38
|
-
- - "
|
38
|
+
- - "~>"
|
39
39
|
- !ruby/object:Gem::Version
|
40
|
-
version: '
|
40
|
+
version: '0.2'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: hashie
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '3.0'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '3.0'
|
41
55
|
- !ruby/object:Gem::Dependency
|
42
56
|
name: bundler
|
43
57
|
requirement: !ruby/object:Gem::Requirement
|
@@ -80,6 +94,20 @@ dependencies:
|
|
80
94
|
- - "~>"
|
81
95
|
- !ruby/object:Gem::Version
|
82
96
|
version: '3.0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: pry
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - "~>"
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '0.9'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - "~>"
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0.9'
|
83
111
|
description: Ruby gem to define and validate behavior of API using contracts.
|
84
112
|
email:
|
85
113
|
- dolganov@evl.ms
|
@@ -98,10 +126,21 @@ files:
|
|
98
126
|
- bin/setup
|
99
127
|
- blood_contracts.gemspec
|
100
128
|
- lib/blood_contracts.rb
|
129
|
+
- lib/blood_contracts/base_contract.rb
|
130
|
+
- lib/blood_contracts/contracts/description.rb
|
131
|
+
- lib/blood_contracts/contracts/matcher.rb
|
132
|
+
- lib/blood_contracts/contracts/validator.rb
|
133
|
+
- lib/blood_contracts/debugger.rb
|
101
134
|
- lib/blood_contracts/runner.rb
|
135
|
+
- lib/blood_contracts/statistics.rb
|
102
136
|
- lib/blood_contracts/storage.rb
|
137
|
+
- lib/blood_contracts/storages/base_backend.rb
|
138
|
+
- lib/blood_contracts/storages/file_backend.rb
|
139
|
+
- lib/blood_contracts/storages/serializer.rb
|
103
140
|
- lib/blood_contracts/suite.rb
|
104
141
|
- lib/blood_contracts/version.rb
|
142
|
+
- lib/extensions/string.rb
|
143
|
+
- lib/rspec/meet_contract_matcher.rb
|
105
144
|
homepage: https://github.com/sclinede/blood_contracts
|
106
145
|
licenses:
|
107
146
|
- MIT
|
@@ -114,7 +153,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
114
153
|
requirements:
|
115
154
|
- - ">="
|
116
155
|
- !ruby/object:Gem::Version
|
117
|
-
version:
|
156
|
+
version: 2.2.0
|
118
157
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
119
158
|
requirements:
|
120
159
|
- - ">="
|
@@ -122,7 +161,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
122
161
|
version: '0'
|
123
162
|
requirements: []
|
124
163
|
rubyforge_project:
|
125
|
-
rubygems_version: 2.6
|
164
|
+
rubygems_version: 2.7.6
|
126
165
|
signing_key:
|
127
166
|
specification_version: 4
|
128
167
|
summary: Ruby gem to define and validate behavior of API using contracts.
|