blood_contracts 0.1.0 → 0.2.0

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