sbmt-pact 0.12.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (77) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +62 -0
  4. data/Appraisals +23 -0
  5. data/CHANGELOG.md +96 -0
  6. data/Dockerfile +14 -0
  7. data/Gemfile +5 -0
  8. data/LICENSE +21 -0
  9. data/README.md +212 -0
  10. data/Rakefile +12 -0
  11. data/dip.yml +86 -0
  12. data/docker-compose.yml +40 -0
  13. data/docs/sbmt-pact-arch.png +0 -0
  14. data/lefthook-local.dip_example.yml +4 -0
  15. data/lefthook.yml +6 -0
  16. data/lib/sbmt/pact/configuration.rb +23 -0
  17. data/lib/sbmt/pact/consumer/grpc_interaction_builder.rb +193 -0
  18. data/lib/sbmt/pact/consumer/http_interaction_builder.rb +149 -0
  19. data/lib/sbmt/pact/consumer/interaction_contents.rb +47 -0
  20. data/lib/sbmt/pact/consumer/message_interaction_builder.rb +285 -0
  21. data/lib/sbmt/pact/consumer/mock_server.rb +92 -0
  22. data/lib/sbmt/pact/consumer/pact_config/base.rb +24 -0
  23. data/lib/sbmt/pact/consumer/pact_config/grpc.rb +26 -0
  24. data/lib/sbmt/pact/consumer/pact_config/http.rb +26 -0
  25. data/lib/sbmt/pact/consumer/pact_config/message.rb +17 -0
  26. data/lib/sbmt/pact/consumer/pact_config.rb +24 -0
  27. data/lib/sbmt/pact/consumer.rb +8 -0
  28. data/lib/sbmt/pact/matchers/base.rb +67 -0
  29. data/lib/sbmt/pact/matchers/v1/equality.rb +19 -0
  30. data/lib/sbmt/pact/matchers/v2/regex.rb +19 -0
  31. data/lib/sbmt/pact/matchers/v2/type.rb +17 -0
  32. data/lib/sbmt/pact/matchers/v3/boolean.rb +17 -0
  33. data/lib/sbmt/pact/matchers/v3/date.rb +18 -0
  34. data/lib/sbmt/pact/matchers/v3/date_time.rb +18 -0
  35. data/lib/sbmt/pact/matchers/v3/decimal.rb +17 -0
  36. data/lib/sbmt/pact/matchers/v3/each.rb +42 -0
  37. data/lib/sbmt/pact/matchers/v3/include.rb +17 -0
  38. data/lib/sbmt/pact/matchers/v3/integer.rb +17 -0
  39. data/lib/sbmt/pact/matchers/v3/number.rb +17 -0
  40. data/lib/sbmt/pact/matchers/v3/time.rb +18 -0
  41. data/lib/sbmt/pact/matchers/v4/each_key.rb +26 -0
  42. data/lib/sbmt/pact/matchers/v4/each_key_value.rb +32 -0
  43. data/lib/sbmt/pact/matchers/v4/each_value.rb +33 -0
  44. data/lib/sbmt/pact/matchers/v4/not_empty.rb +17 -0
  45. data/lib/sbmt/pact/matchers.rb +94 -0
  46. data/lib/sbmt/pact/native/blocking_verifier.rb +17 -0
  47. data/lib/sbmt/pact/native/logger.rb +25 -0
  48. data/lib/sbmt/pact/provider/async_message_verifier.rb +32 -0
  49. data/lib/sbmt/pact/provider/base_verifier.rb +158 -0
  50. data/lib/sbmt/pact/provider/grpc_verifier.rb +42 -0
  51. data/lib/sbmt/pact/provider/gruf_server.rb +75 -0
  52. data/lib/sbmt/pact/provider/http_server.rb +66 -0
  53. data/lib/sbmt/pact/provider/http_verifier.rb +46 -0
  54. data/lib/sbmt/pact/provider/message_provider_servlet.rb +80 -0
  55. data/lib/sbmt/pact/provider/pact_broker_proxy.rb +85 -0
  56. data/lib/sbmt/pact/provider/pact_broker_proxy_runner.rb +71 -0
  57. data/lib/sbmt/pact/provider/pact_config/async.rb +25 -0
  58. data/lib/sbmt/pact/provider/pact_config/base.rb +92 -0
  59. data/lib/sbmt/pact/provider/pact_config/grpc.rb +30 -0
  60. data/lib/sbmt/pact/provider/pact_config/http.rb +25 -0
  61. data/lib/sbmt/pact/provider/pact_config.rb +24 -0
  62. data/lib/sbmt/pact/provider/provider_server_runner.rb +89 -0
  63. data/lib/sbmt/pact/provider/provider_state_configuration.rb +32 -0
  64. data/lib/sbmt/pact/provider/provider_state_servlet.rb +84 -0
  65. data/lib/sbmt/pact/provider.rb +8 -0
  66. data/lib/sbmt/pact/railtie.rb +13 -0
  67. data/lib/sbmt/pact/rspec/support/pact_consumer_helpers.rb +46 -0
  68. data/lib/sbmt/pact/rspec/support/pact_message_helpers.rb +42 -0
  69. data/lib/sbmt/pact/rspec/support/pact_provider_helpers.rb +87 -0
  70. data/lib/sbmt/pact/rspec/support/waterdrop/pact_waterdrop_client.rb +27 -0
  71. data/lib/sbmt/pact/rspec/support/webmock/webmock_helpers.rb +30 -0
  72. data/lib/sbmt/pact/rspec.rb +17 -0
  73. data/lib/sbmt/pact/tasks/pact.rake +13 -0
  74. data/lib/sbmt/pact/version.rb +7 -0
  75. data/lib/sbmt/pact.rb +48 -0
  76. data/sbmt-pact.gemspec +59 -0
  77. metadata +462 -0
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pact/ffi/sync_message_consumer"
4
+ require "pact/ffi/plugin_consumer"
5
+ require "pact/ffi/logger"
6
+
7
+ module Sbmt
8
+ module Pact
9
+ module Consumer
10
+ class GrpcInteractionBuilder
11
+ DESCRIPTION_PREFIX = "grpc: "
12
+ CONTENT_TYPE = "application/protobuf"
13
+ GRPC_CONTENT_TYPE = "application/grpc"
14
+ PROTOBUF_PLUGIN_NAME = "protobuf"
15
+ PROTOBUF_PLUGIN_VERSION = "0.4.0"
16
+
17
+ class PluginInitError < Sbmt::Pact::FfiError; end
18
+
19
+ # https://docs.rs/pact_ffi/0.4.17/pact_ffi/plugins/fn.pactffi_using_plugin.html
20
+ INIT_PLUGIN_ERRORS = {
21
+ 1 => {reason: :internal_error, status: 1, description: "A general panic was caught"},
22
+ 2 => {reason: :plugin_load_failed, status: 2, description: "Failed to load the plugin"},
23
+ 3 => {reason: :invalid_handle, status: 3, description: "Pact Handle is not valid"}
24
+ }.freeze
25
+
26
+ # https://docs.rs/pact_ffi/0.4.17/pact_ffi/plugins/fn.pactffi_interaction_contents.html
27
+ CREATE_INTERACTION_ERRORS = {
28
+ 1 => {reason: :internal_error, status: 1, description: "A general panic was caught"},
29
+ 2 => {reason: :mock_server_already_running, status: 2, description: "The mock server has already been started"},
30
+ 3 => {reason: :invalid_handle, status: 3, description: "The interaction handle is invalid"},
31
+ 4 => {reason: :invalid_content_type, status: 4, description: "The content type is not valid"},
32
+ 5 => {reason: :invalid_contents, status: 5, description: "The contents JSON is not valid JSON"},
33
+ 6 => {reason: :plugin_error, status: 6, description: "The plugin returned an error"}
34
+ }.freeze
35
+
36
+ class CreateInteractionError < Sbmt::Pact::FfiError; end
37
+
38
+ class InteractionMismatchesError < Sbmt::Pact::Error; end
39
+
40
+ class InteractionBuilderError < Sbmt::Pact::Error; end
41
+
42
+ def initialize(pact_config, description: nil)
43
+ @pact_config = pact_config
44
+ @description = description || ""
45
+
46
+ @proto_path = nil
47
+ @proto_include_dirs = []
48
+ @service_name = nil
49
+ @method_name = nil
50
+ @request = nil
51
+ @response = nil
52
+ @response_meta = nil
53
+ @provider_state_meta = nil
54
+ end
55
+
56
+ def with_service(proto_path, method, include_dirs = [])
57
+ raise InteractionBuilderError.new("invalid grpc method: cannot be blank") if method.blank?
58
+
59
+ service_name, method_name = method.split("/") || []
60
+ raise InteractionBuilderError.new("invalid grpc method: #{method}, should be like service/SomeMethod") if service_name.blank? || method_name.blank?
61
+
62
+ absolute_path = File.expand_path(proto_path)
63
+ raise InteractionBuilderError.new("proto file #{proto_path} does not exist") unless File.exist?(absolute_path)
64
+
65
+ @proto_path = absolute_path
66
+ @service_name = service_name
67
+ @method_name = method_name
68
+ @proto_include_dirs = include_dirs.map { |dir| File.expand_path(dir) }
69
+
70
+ self
71
+ end
72
+
73
+ def given(provider_state, metadata = {})
74
+ @provider_state_meta = {provider_state => metadata}
75
+ self
76
+ end
77
+
78
+ def upon_receiving(description)
79
+ @description = description
80
+ self
81
+ end
82
+
83
+ def with_request(req_hash)
84
+ @request = InteractionContents.plugin(req_hash)
85
+ self
86
+ end
87
+
88
+ def with_response(resp_hash)
89
+ @response = InteractionContents.plugin(resp_hash)
90
+ self
91
+ end
92
+
93
+ def with_response_meta(meta_hash)
94
+ @response_meta = InteractionContents.plugin(meta_hash)
95
+ self
96
+ end
97
+
98
+ def interaction_json
99
+ result = {
100
+ "pact:proto": @proto_path,
101
+ "pact:proto-service": "#{@service_name}/#{@method_name}",
102
+ "pact:content-type": CONTENT_TYPE,
103
+ request: @request
104
+ }
105
+
106
+ result["pact:protobuf-config"] = {additionalIncludes: @proto_include_dirs} if @proto_include_dirs.present?
107
+
108
+ result[:response] = @response if @response.is_a?(Hash)
109
+ result[:responseMetadata] = @response_meta if @response_meta.is_a?(Hash)
110
+
111
+ JSON.dump(result)
112
+ end
113
+
114
+ def validate!
115
+ raise InteractionBuilderError.new("uninitialized service params, use #with_service to configure") if @proto_path.blank? || @service_name.blank? || @method_name.blank?
116
+ raise InteractionBuilderError.new("invalid request format, should be a hash") unless @request.is_a?(Hash)
117
+ raise InteractionBuilderError.new("invalid response format, should be a hash") unless @response.is_a?(Hash) || @response_meta.is_a?(Hash)
118
+ end
119
+
120
+ def execute(&block)
121
+ raise InteractionBuilderError.new("interaction is designed to be used one-time only") if defined?(@used)
122
+
123
+ validate!
124
+
125
+ pact_handle = init_pact
126
+ init_plugin!(pact_handle)
127
+
128
+ message_pact = PactFfi::SyncMessageConsumer.new_interaction(pact_handle, description)
129
+ @provider_state_meta&.each_pair do |provider_state, meta|
130
+ if meta.present?
131
+ meta.each_pair { |k, v| PactFfi.given_with_param(message_pact, provider_state, k.to_s, v.to_s) }
132
+ else
133
+ PactFfi.given(message_pact, provider_state)
134
+ end
135
+ end
136
+
137
+ result = PactFfi::PluginConsumer.interaction_contents(message_pact, 0, GRPC_CONTENT_TYPE, interaction_json)
138
+ if CREATE_INTERACTION_ERRORS[result].present?
139
+ error = CREATE_INTERACTION_ERRORS[result]
140
+ raise CreateInteractionError.new("There was an error while trying to add interaction \"#{description}\"", error[:reason], error[:status])
141
+ end
142
+
143
+ mock_server = MockServer.create_for_grpc!(pact: pact_handle, host: @pact_config.mock_host, port: @pact_config.mock_port)
144
+
145
+ yield(message_pact, mock_server)
146
+
147
+ if mock_server.matched?
148
+ mock_server.write_pacts!(@pact_config.pact_dir)
149
+ else
150
+ msg = mismatches_error_msg(mock_server)
151
+ raise InteractionMismatchesError.new(msg)
152
+ end
153
+ ensure
154
+ @used = true
155
+ mock_server&.cleanup
156
+ PactFfi::PluginConsumer.cleanup_plugins(pact_handle)
157
+ PactFfi.free_pact_handle(pact_handle)
158
+ end
159
+
160
+ private
161
+
162
+ def mismatches_error_msg(mock_server)
163
+ rspec_example_desc = RSpec.current_example&.full_description
164
+ return "interaction for #{@service_name}/#{@method_name} has mismatches: #{mock_server.mismatches}" if rspec_example_desc.blank?
165
+
166
+ "#{rspec_example_desc} has mismatches: #{mock_server.mismatches}"
167
+ end
168
+
169
+ def init_pact
170
+ handle = PactFfi.new_pact(@pact_config.consumer_name, @pact_config.provider_name)
171
+ PactFfi.with_specification(handle, PactFfi::FfiSpecificationVersion["SPECIFICATION_VERSION_V4"])
172
+ PactFfi.with_pact_metadata(handle, "sbmt-pact", "pact-ffi", PactFfi.version)
173
+
174
+ Sbmt::Pact::Native::Logger.log_to_stdout(@pact_config.log_level)
175
+
176
+ handle
177
+ end
178
+
179
+ def init_plugin!(pact_handle)
180
+ result = PactFfi::PluginConsumer.using_plugin(pact_handle, PROTOBUF_PLUGIN_NAME, PROTOBUF_PLUGIN_VERSION)
181
+ return result if INIT_PLUGIN_ERRORS[result].blank?
182
+
183
+ error = INIT_PLUGIN_ERRORS[result]
184
+ raise PluginInitError.new("There was an error while trying to initialize plugin #{PROTOBUF_PLUGIN_NAME}/#{PROTOBUF_PLUGIN_VERSION}", error[:reason], error[:status])
185
+ end
186
+
187
+ def description
188
+ "#{DESCRIPTION_PREFIX}#{@description}"
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pact/ffi/sync_message_consumer"
4
+ require "pact/ffi/plugin_consumer"
5
+ require "pact/ffi/logger"
6
+
7
+ module Sbmt
8
+ module Pact
9
+ module Consumer
10
+ class HttpInteractionBuilder
11
+ DESCRIPTION_PREFIX = "http: "
12
+
13
+ # https://docs.rs/pact_ffi/0.4.17/pact_ffi/plugins/fn.pactffi_interaction_contents.html
14
+ CREATE_INTERACTION_ERRORS = {
15
+ 1 => {reason: :internal_error, status: 1, description: "A general panic was caught"},
16
+ 2 => {reason: :mock_server_already_running, status: 2, description: "The mock server has already been started"},
17
+ 3 => {reason: :invalid_handle, status: 3, description: "The interaction handle is invalid"},
18
+ 4 => {reason: :invalid_content_type, status: 4, description: "The content type is not valid"},
19
+ 5 => {reason: :invalid_contents, status: 5, description: "The contents JSON is not valid JSON"},
20
+ 6 => {reason: :plugin_error, status: 6, description: "The plugin returned an error"}
21
+ }.freeze
22
+
23
+ class CreateInteractionError < Sbmt::Pact::FfiError; end
24
+
25
+ class InteractionMismatchesError < Sbmt::Pact::Error; end
26
+
27
+ class InteractionBuilderError < Sbmt::Pact::Error; end
28
+
29
+ class << self
30
+ def create_finalizer(pact_handle)
31
+ proc { PactFfi.free_pact_handle(pact_handle) }
32
+ end
33
+ end
34
+
35
+ def initialize(pact_config, description: nil)
36
+ @pact_config = pact_config
37
+ @description = description || ""
38
+
39
+ @pact_handle = init_pact
40
+ @pact_interaction = PactFfi.new_interaction(pact_handle, full_description)
41
+
42
+ ObjectSpace.define_finalizer(self, self.class.create_finalizer(pact_interaction))
43
+ end
44
+
45
+ def given(provider_state, metadata = {})
46
+ if metadata.present?
47
+ PactFfi.given_with_params(pact_interaction, provider_state, JSON.dump(metadata))
48
+ else
49
+ PactFfi.given(pact_interaction, provider_state)
50
+ end
51
+
52
+ self
53
+ end
54
+
55
+ def upon_receiving(description)
56
+ @description = description
57
+ PactFfi.upon_receiving(pact_interaction, full_description)
58
+ self
59
+ end
60
+
61
+ def with_request(method, path, query: {}, headers: {}, body: nil)
62
+ interaction_part = PactFfi::FfiInteractionPart["INTERACTION_PART_REQUEST"]
63
+ PactFfi.with_request(pact_interaction, method.to_s, format_value(path))
64
+
65
+ InteractionContents.basic(query).each_pair do |key, value_item|
66
+ PactFfi.with_query_parameter_v2(pact_interaction, key.to_s, 0, format_value(value_item))
67
+ end
68
+
69
+ InteractionContents.basic(headers).each_pair do |key, value_item|
70
+ PactFfi.with_header_v2(pact_interaction, interaction_part, key.to_s, 0, format_value(value_item))
71
+ end
72
+
73
+ if body
74
+ PactFfi.with_body(pact_interaction, interaction_part, "application/json", format_value(InteractionContents.basic(body)))
75
+ end
76
+
77
+ self
78
+ end
79
+
80
+ def with_response(status, headers: {}, body: nil)
81
+ interaction_part = PactFfi::FfiInteractionPart["INTERACTION_PART_RESPONSE"]
82
+ PactFfi.response_status(pact_interaction, status)
83
+
84
+ InteractionContents.basic(headers).each_pair do |key, value_item|
85
+ PactFfi.with_header_v2(pact_interaction, interaction_part, key.to_s, 0, format_value(value_item))
86
+ end
87
+
88
+ if body
89
+ PactFfi.with_body(pact_interaction, interaction_part, "application/json", format_value(InteractionContents.basic(body)))
90
+ end
91
+
92
+ self
93
+ end
94
+
95
+ def execute(&block)
96
+ raise InteractionBuilderError.new("interaction is designed to be used one-time only") if defined?(@used)
97
+
98
+ mock_server = MockServer.create_for_http!(
99
+ pact: pact_handle, host: pact_config.mock_host, port: pact_config.mock_port
100
+ )
101
+
102
+ yield(mock_server)
103
+
104
+ if mock_server.matched?
105
+ mock_server.write_pacts!(pact_config.pact_dir)
106
+ else
107
+ msg = mismatches_error_msg(mock_server)
108
+ raise InteractionMismatchesError.new(msg)
109
+ end
110
+ ensure
111
+ @used = true
112
+ mock_server&.cleanup
113
+ end
114
+
115
+ private
116
+
117
+ attr_reader :pact_handle, :pact_interaction, :pact_config
118
+
119
+ def mismatches_error_msg(mock_server)
120
+ rspec_example_desc = RSpec.current_example&.full_description
121
+
122
+ "#{rspec_example_desc} has mismatches: #{mock_server.mismatches}"
123
+ end
124
+
125
+ def init_pact
126
+ handle = PactFfi.new_pact(pact_config.consumer_name, pact_config.provider_name)
127
+ PactFfi.with_specification(handle, PactFfi::FfiSpecificationVersion["SPECIFICATION_VERSION_V4"])
128
+ PactFfi.with_pact_metadata(handle, "sbmt-pact", "pact-ffi", PactFfi.version)
129
+
130
+ Sbmt::Pact::Native::Logger.log_to_stdout(pact_config.log_level)
131
+
132
+ handle
133
+ end
134
+
135
+ def format_value(obj)
136
+ return obj if obj.is_a?(String)
137
+
138
+ return JSON.dump({value: obj}) if obj.is_a?(Array)
139
+
140
+ JSON.dump(obj)
141
+ end
142
+
143
+ def full_description
144
+ "#{DESCRIPTION_PREFIX}#{@description}"
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sbmt
4
+ module Pact
5
+ module Consumer
6
+ class InteractionContents < Hash
7
+ BASIC_FORMAT = :basic
8
+ PLUGIN_FORMAT = :plugin
9
+
10
+ attr_reader :format
11
+
12
+ def self.basic(contents_hash)
13
+ new(contents_hash, BASIC_FORMAT)
14
+ end
15
+
16
+ def self.plugin(contents_hash)
17
+ new(contents_hash, PLUGIN_FORMAT)
18
+ end
19
+
20
+ def initialize(contents_hash, format)
21
+ init_hash(contents_hash, format).each_pair { |k, v| self[k] = v }
22
+ @format = format
23
+ end
24
+
25
+ private
26
+
27
+ def serialize(hash, format)
28
+ # serialize recursively
29
+ hash.each_pair do |key, value|
30
+ next serialize(value, format) if value.is_a?(Hash)
31
+ next hash[key] = value.map { |v| serialize(v, format) } if value.is_a?(Array)
32
+ if value.is_a?(Sbmt::Pact::Matchers::Base)
33
+ hash[key] = value.as_basic if format == :basic
34
+ hash[key] = value.as_plugin if format == :plugin
35
+ end
36
+ end
37
+
38
+ hash
39
+ end
40
+
41
+ def init_hash(hash, format)
42
+ serialize(hash.deep_dup, format)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pact/ffi/message_consumer"
4
+ require "pact/ffi/plugin_consumer"
5
+ require "pact/ffi/logger"
6
+
7
+ module Sbmt
8
+ module Pact
9
+ module Consumer
10
+ class MessageInteractionBuilder
11
+ DESCRIPTION_PREFIX = "async: "
12
+ META_CONTENT_TYPE_HEADER = "contentType"
13
+
14
+ JSON_CONTENT_TYPE = "application/json"
15
+ PROTO_CONTENT_TYPE = "application/protobuf"
16
+
17
+ PROTOBUF_PLUGIN_NAME = "protobuf"
18
+ PROTOBUF_PLUGIN_VERSION = "0.4.0"
19
+
20
+ # https://docs.rs/pact_ffi/latest/pact_ffi/mock_server/handles/fn.pactffi_write_message_pact_file.html
21
+ WRITE_PACT_FILE_ERRORS = {
22
+ 1 => {reason: :file_not_accessible, status: 1, description: "The pact file was not able to be written"},
23
+ 2 => {reason: :internal_error, status: 2, description: "The message pact for the given handle was not found"}
24
+ }.freeze
25
+
26
+ class PluginInitError < Sbmt::Pact::FfiError; end
27
+
28
+ # https://docs.rs/pact_ffi/0.4.17/pact_ffi/plugins/fn.pactffi_using_plugin.html
29
+ INIT_PLUGIN_ERRORS = {
30
+ 1 => {reason: :internal_error, status: 1, description: "A general panic was caught"},
31
+ 2 => {reason: :plugin_load_failed, status: 2, description: "Failed to load the plugin"},
32
+ 3 => {reason: :invalid_handle, status: 3, description: "Pact Handle is not valid"}
33
+ }.freeze
34
+
35
+ # https://docs.rs/pact_ffi/0.4.17/pact_ffi/plugins/fn.pactffi_interaction_contents.html
36
+ CREATE_INTERACTION_ERRORS = {
37
+ 1 => {reason: :internal_error, status: 1, description: "A general panic was caught"},
38
+ 2 => {reason: :mock_server_already_running, status: 2, description: "The mock server has already been started"},
39
+ 3 => {reason: :invalid_handle, status: 3, description: "The interaction handle is invalid"},
40
+ 4 => {reason: :invalid_content_type, status: 4, description: "The content type is not valid"},
41
+ 5 => {reason: :invalid_contents, status: 5, description: "The contents JSON is not valid JSON"},
42
+ 6 => {reason: :plugin_error, status: 6, description: "The plugin returned an error"}
43
+ }.freeze
44
+
45
+ class CreateInteractionError < Sbmt::Pact::FfiError; end
46
+
47
+ class InteractionMismatchesError < Sbmt::Pact::Error; end
48
+
49
+ class InteractionBuilderError < Sbmt::Pact::Error; end
50
+
51
+ def initialize(pact_config, description: nil)
52
+ @pact_config = pact_config
53
+ @description = description
54
+
55
+ @json_contents = nil
56
+ @proto_contents = nil
57
+ @proto_path = nil
58
+ @proto_message_class = nil
59
+ @proto_include_dirs = []
60
+ @meta = {}
61
+ @headers = {}
62
+ @provider_state_meta = nil
63
+ end
64
+
65
+ def given(provider_state, metadata = {})
66
+ @provider_state_meta = {provider_state => metadata}
67
+ self
68
+ end
69
+
70
+ def upon_receiving(description)
71
+ @description = description
72
+ self
73
+ end
74
+
75
+ def with_json_contents(contents_hash)
76
+ @json_contents = InteractionContents.basic(contents_hash)
77
+ self
78
+ end
79
+
80
+ def with_proto_class(proto_path, message_class_name, include_dirs = [])
81
+ absolute_path = File.expand_path(proto_path)
82
+ raise InteractionBuilderError.new("proto file #{proto_path} does not exist") unless File.exist?(absolute_path)
83
+
84
+ @proto_path = absolute_path
85
+ @proto_message_class = message_class_name
86
+ @proto_include_dirs = include_dirs.map { |dir| File.expand_path(dir) }
87
+ self
88
+ end
89
+
90
+ def with_proto_contents(contents_hash)
91
+ @proto_contents = InteractionContents.plugin(contents_hash)
92
+ self
93
+ end
94
+
95
+ def with_metadata(meta_hash)
96
+ @meta = InteractionContents.basic(meta_hash)
97
+ self
98
+ end
99
+
100
+ def with_headers(headers_hash)
101
+ @headers = InteractionContents.basic(headers_hash)
102
+ self
103
+ end
104
+
105
+ def with_header(key, value)
106
+ @headers[key] = value
107
+ self
108
+ end
109
+
110
+ def validate!
111
+ if proto_interaction?
112
+ raise InteractionBuilderError.new("proto_path / proto_message are not defined, please set ones with #with_proto_message") if @proto_contents.blank? || @proto_message_class.blank?
113
+ raise InteractionBuilderError.new("invalid request format, should be a hash") unless @proto_contents.is_a?(Hash)
114
+ else
115
+ raise InteractionBuilderError.new("invalid request format, should be a hash") unless @json_contents.is_a?(Hash)
116
+ end
117
+ raise InteractionBuilderError.new("description is required for message interactions, please set one with #upon_receiving") if @description.blank?
118
+ end
119
+
120
+ def execute(&block)
121
+ raise InteractionBuilderError.new("interaction is designed to be used one-time only") if defined?(@used)
122
+
123
+ validate!
124
+ pact_handle = init_pact
125
+ init_plugin!(pact_handle) if proto_interaction?
126
+
127
+ message_pact = PactFfi::MessageConsumer.new_message_interaction(pact_handle, description)
128
+
129
+ configure_interaction!(message_pact)
130
+
131
+ # strip out matchers and get raw payload/metadata
132
+ payload, metadata = fetch_reified_message(pact_handle)
133
+ configure_provider_state(message_pact, metadata)
134
+
135
+ yield(payload, metadata)
136
+
137
+ write_pacts!(pact_handle, @pact_config.pact_dir)
138
+ ensure
139
+ @used = true
140
+ PactFfi::MessageConsumer.free_handle(message_pact)
141
+ PactFfi.free_pact_handle(pact_handle)
142
+ end
143
+
144
+ def build_interaction_json
145
+ return JSON.dump(@json_contents) unless proto_interaction?
146
+
147
+ contents = {
148
+ "pact:proto": @proto_path,
149
+ "pact:message-type": @proto_message_class,
150
+ "pact:content-type": PROTO_CONTENT_TYPE
151
+ }.merge(@proto_contents)
152
+
153
+ contents["pact:protobuf-config"] = {additionalIncludes: @proto_include_dirs} if @proto_include_dirs.present?
154
+
155
+ JSON.dump(contents)
156
+ end
157
+
158
+ private
159
+
160
+ def write_pacts!(handle, dir)
161
+ result = PactFfi.write_message_pact_file(handle, @pact_config.pact_dir, false)
162
+ return result if WRITE_PACT_FILE_ERRORS[result].blank?
163
+
164
+ error = WRITE_PACT_FILE_ERRORS[result]
165
+ raise WritePactsError.new("There was an error while trying to write pact file to #{dir}", error[:reason], error[:status])
166
+ end
167
+
168
+ def init_pact
169
+ handle = PactFfi::MessageConsumer.new_message_pact(@pact_config.consumer_name, @pact_config.provider_name)
170
+ PactFfi.with_specification(handle, PactFfi::FfiSpecificationVersion["SPECIFICATION_VERSION_V4"])
171
+ PactFfi.with_pact_metadata(handle, "sbmt-pact", "pact-ffi", PactFfi.version)
172
+
173
+ Sbmt::Pact::Native::Logger.log_to_stdout(@pact_config.log_level)
174
+
175
+ handle
176
+ end
177
+
178
+ def fetch_reified_message(pact_handle)
179
+ iterator = PactFfi::MessageConsumer.pact_handle_get_message_iter(pact_handle)
180
+ raise InteractionBuilderError.new("cannot get message iterator: internal error") if iterator.blank?
181
+
182
+ message_handle = PactFfi.pact_message_iter_next(iterator)
183
+ raise InteractionBuilderError.new("cannot get message from iterator: no messages") if message_handle.blank?
184
+
185
+ contents = fetch_reified_message_body(message_handle)
186
+ meta = fetch_reified_message_headers(message_handle)
187
+
188
+ [contents, meta.compact]
189
+ ensure
190
+ PactFfi.pact_message_iter_delete(iterator) if iterator.present?
191
+ end
192
+
193
+ def fetch_reified_message_headers(message_handle)
194
+ meta = {"headers" => {}}
195
+
196
+ meta[META_CONTENT_TYPE_HEADER] = PactFfi.message_find_metadata(message_handle, META_CONTENT_TYPE_HEADER)
197
+
198
+ @meta.each_key do |key|
199
+ meta[key.to_s] = PactFfi.message_find_metadata(message_handle, key.to_s)
200
+ end
201
+
202
+ @headers.each_key do |key|
203
+ meta["headers"][key.to_s] = PactFfi.message_find_metadata(message_handle, key.to_s)
204
+ end
205
+
206
+ meta
207
+ end
208
+
209
+ def configure_provider_state(message_pact, reified_metadata)
210
+ content_type = reified_metadata[META_CONTENT_TYPE_HEADER]
211
+ @provider_state_meta&.each_pair do |provider_state, meta|
212
+ if meta.present?
213
+ meta.each_pair { |k, v| PactFfi.given_with_param(message_pact, provider_state, k.to_s, v.to_s) }
214
+ PactFfi.given_with_param(message_pact, provider_state, META_CONTENT_TYPE_HEADER, content_type.to_s) if content_type
215
+ elsif content_type.present?
216
+ PactFfi.given_with_param(message_pact, provider_state, META_CONTENT_TYPE_HEADER, content_type.to_s)
217
+ else
218
+ PactFfi.given(message_pact, provider_state)
219
+ end
220
+ end
221
+ end
222
+
223
+ def fetch_reified_message_body(message_handle)
224
+ if proto_interaction?
225
+ len = PactFfi::MessageConsumer.get_contents_length(message_handle)
226
+ ptr = PactFfi::MessageConsumer.get_contents_bin(message_handle)
227
+ return nil if ptr.blank? || len == 0
228
+
229
+ return String.new(ptr.read_string_length(len))
230
+ end
231
+
232
+ contents = PactFfi::MessageConsumer.get_contents(message_handle)
233
+ return nil if contents.blank?
234
+
235
+ JSON.parse(contents)
236
+ end
237
+
238
+ def configure_interaction!(message_pact)
239
+ interaction_json = build_interaction_json
240
+
241
+ if proto_interaction?
242
+ result = PactFfi::PluginConsumer.interaction_contents(message_pact, 0, PROTO_CONTENT_TYPE, interaction_json)
243
+ if CREATE_INTERACTION_ERRORS[result].present?
244
+ error = CREATE_INTERACTION_ERRORS[result]
245
+ raise CreateInteractionError.new("There was an error while trying to add interaction \"#{description}\"", error[:reason], error[:status])
246
+ end
247
+ else
248
+ result = PactFfi.with_body(message_pact, 0, JSON_CONTENT_TYPE, interaction_json)
249
+ unless result
250
+ raise InteractionMismatchesError.new("There was an error while trying to add message interaction contents \"#{description}\"")
251
+ end
252
+ end
253
+
254
+ # meta should be configured last to avoid resetting after body is set
255
+ InteractionContents.basic(@meta.merge(@headers)).each_pair do |key, value|
256
+ PactFfi::MessageConsumer.with_metadata_v2(message_pact, key.to_s, JSON.dump(value))
257
+ end
258
+ end
259
+
260
+ def description
261
+ "#{DESCRIPTION_PREFIX}#{@description}"
262
+ end
263
+
264
+ def init_plugin!(pact_handle)
265
+ result = PactFfi::PluginConsumer.using_plugin(pact_handle, PROTOBUF_PLUGIN_NAME, PROTOBUF_PLUGIN_VERSION)
266
+ return result if INIT_PLUGIN_ERRORS[result].blank?
267
+
268
+ error = INIT_PLUGIN_ERRORS[result]
269
+ raise PluginInitError.new("There was an error while trying to initialize plugin #{PROTOBUF_PLUGIN_NAME}/#{PROTOBUF_PLUGIN_VERSION}", error[:reason], error[:status])
270
+ end
271
+
272
+ def serialize_metadata(metadata_hash)
273
+ metadata = metadata_hash.deep_dup
274
+ serialize_as!(metadata, :basic)
275
+
276
+ metadata
277
+ end
278
+
279
+ def proto_interaction?
280
+ @proto_contents.present?
281
+ end
282
+ end
283
+ end
284
+ end
285
+ end