sbmt-pact 0.12.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.
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