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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 234b795bf582f8978bd4ba789c388cbad2539e20
4
- data.tar.gz: 46f86eb3b20f692d9fa948fb58e275a94d921012
2
+ SHA256:
3
+ metadata.gz: 6a3374d5e7af34959e56cb4f31d22cd807fe84f3ee1f74e9446646b7d4b49f27
4
+ data.tar.gz: 48907e94554abde9079f25e8a657fc24dbda9fac02468f239c547031eff0df0e
5
5
  SHA512:
6
- metadata.gz: a7bd966f422b0020d06d981680811c1466859214dbcb90ab346516b403e4216f4cb3355773f697482b75f2b8818c4f419571715d5ee0604aaf975978d31033f4
7
- data.tar.gz: a400a28a9d84ab53321a1b13c134b1c0bfcfa40c09bde47b1666bac429461869fabb702ec72bf6222b9634d65398fd66f97c8bb750b51a65e4d7340630c1a380
6
+ metadata.gz: 9fc96fb5b769b22fb747573dd4ae5bf8da1cf2a40e429ddde58f82d78263aba3e9957d31183c0c5b0af3206abe0172e511d10e312d9624c0533cef030c502d84
7
+ data.tar.gz: c22e684f33f0d4f4ab37a5e08174c4dcefae73afa09079250eb1b158c2e03d572be92a40a202a2724f8bf0026e91fd8b89308cb1843de2f8f6ceff0d9fa4ff92
data/.gitignore CHANGED
@@ -9,3 +9,4 @@
9
9
 
10
10
  # rspec failure tracking
11
11
  .rspec_status
12
+ Gemfile.lock
data/Gemfile CHANGED
@@ -1,6 +1,6 @@
1
1
  source "https://rubygems.org"
2
2
 
3
- git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
3
+ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
4
4
 
5
5
  # Specify your gem's dependencies in blood_contracts.gemspec
6
6
  gemspec
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
@@ -3,4 +3,4 @@ require "rspec/core/rake_task"
3
3
 
4
4
  RSpec::Core::RakeTask.new(:spec)
5
5
 
6
- task :default => :spec
6
+ task default: :spec
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
- # require "pry"
11
- # Pry.start
12
-
13
- require "irb"
14
- IRB.start(__FILE__)
10
+ require "pry"
11
+ Pry.start
@@ -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 = %q{Ruby gem to define and validate behavior of API using contracts.}
13
- spec.description = %q{Ruby gem to define and validate behavior of API using contracts.}
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.add_runtime_dependency "dry-initializer", "~> 2.0"
27
+ spec.required_ruby_version = '>= 2.2.0'
27
28
 
28
- # Will be removed soon
29
- spec.add_runtime_dependency "activesupport", ">= 3.1"
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
- unexpected = match_rules(*run) do |input, output, rules|
26
- suite.storage.save_run(
27
- input: input, output: output, rules: rules, context: context,
28
- )
29
- end.empty?
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
- last_run_stats = run_stats
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 found_unexpected_behavior?
50
+ if validator.expected_behavior?
54
51
  "#{intro}\n#{contract_description}\n"\
55
- " during #{iterations} run(s) but got unexpected behavior.\n\n"\
56
- "For further investigations open: #{unexpected_behavior_report_path}/"
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:\n#{stats_description}\n\n"\
60
- "For further investigations open: #{suite.storage.path}"
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
- def unexpected_behavior_report_path
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#{stats_description}\n\n"\
71
- "For further investigations open: #{suite.storage.path}\n"
64
+ " during #{iterations} run(s). Stats:\n#{statistics}\n\n"\
65
+ "For further investigations open: #{storage.suggestion}\n"
72
66
  end
73
67
 
74
- private
68
+ protected
75
69
 
76
- def run
77
- input = suite.data_generator.call
78
- [input, checking_proc.call(input)]
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 = 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 match_rules(input, output)
108
- matched_rules = suite.contract.select do |_name, rule|
109
- rule.check.call(input, output)
110
- end.keys
111
- matched_rules = [Storage::UNDEFINED_RULE] if matched_rules.empty?
112
- yield(input, output, matched_rules)
113
-
114
- matched_rules
115
- # FIXME: Possible recursion?
116
- # Write test about error in the yield (e.g. writing error)
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
- # Split date and time, for more comfortable Dirs navigation
6
- option :start_time, default: -> { Time.current.to_s(:number) }
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
- UNDEFINED_RULE = :__no_tag_match__
13
+ option :contract_name
14
14
 
15
- def default_path
16
- "./tmp/contract_tests/"
15
+ DEFAULT_WRITER = -> (options) do
16
+ "INPUT:\n#{options.input}\n\n#{'=' * 90}\n\nOUTPUT:\n#{options.output}"
17
17
  end
18
18
 
19
- def path
20
- @path ||= File.join(default_path, custom_path.to_s, start_time)
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 input_writer=(writer)
24
- fail ArgumentError unless writer.respond_to?(:call) ||
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 output_writer=(writer)
30
- fail ArgumentError unless writer.respond_to?(:call) ||
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 run_name(tag)
36
- run_name = File.join(root, "#{tag}/#{Time.current.to_s(:number)}")
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 write(writer, context, input, output)
42
- return default_write_pattern(input, output) unless writer
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 default_write_pattern(input, output)
48
- [
49
- "INPUT:",
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 save_run(input:, output:, rules:, context:)
71
+ def store(options:, rules:, context:)
72
+ options = Hashie::Mash.new(options)
73
+
60
74
  Array(rules).each do |rule_name|
61
- stats[rule_name] += 1
62
- run_name = run_name(rule_name)
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, default: -> { Hashie::Mash.new }
7
- option :input_writer, optional: true
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
- fail ArgumentError unless generator.respond_to?(:call)
18
+ raise ArgumentError unless generator.respond_to?(:call)
14
19
  @data_generator = generator
15
20
  end
16
21
 
17
22
  def contract=(contract)
18
- fail ArgumentError unless contract.respond_to?(:to_h)
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(input_writer: input_writer, output_writer: output_writer)
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
@@ -1,3 +1,3 @@
1
1
  module BloodContracts
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0".freeze
3
3
  end
@@ -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
- # Use https://github.com/razum2um/lurker/blob/master/lib/lurker/spec_helper/rspec.rb
9
- if defined?(RSpec) && RSpec.respond_to?(:configure)
10
- module MeetContractMatcher
11
- extend RSpec::Matchers::DSL
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
- chain :halt_on_unexpected do
83
- @_halt_on_unexpected = true
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.1.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-02-22 00:00:00.000000000 Z
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: activesupport
28
+ name: nanoid
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
- - - ">="
31
+ - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: '3.1'
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: '3.1'
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: '0'
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.14
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.