sbmt-pact 0.12.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +62 -0
- data/Appraisals +23 -0
- data/CHANGELOG.md +96 -0
- data/Dockerfile +14 -0
- data/Gemfile +5 -0
- data/LICENSE +21 -0
- data/README.md +212 -0
- data/Rakefile +12 -0
- data/dip.yml +86 -0
- data/docker-compose.yml +40 -0
- data/docs/sbmt-pact-arch.png +0 -0
- data/lefthook-local.dip_example.yml +4 -0
- data/lefthook.yml +6 -0
- data/lib/sbmt/pact/configuration.rb +23 -0
- data/lib/sbmt/pact/consumer/grpc_interaction_builder.rb +193 -0
- data/lib/sbmt/pact/consumer/http_interaction_builder.rb +149 -0
- data/lib/sbmt/pact/consumer/interaction_contents.rb +47 -0
- data/lib/sbmt/pact/consumer/message_interaction_builder.rb +285 -0
- data/lib/sbmt/pact/consumer/mock_server.rb +92 -0
- data/lib/sbmt/pact/consumer/pact_config/base.rb +24 -0
- data/lib/sbmt/pact/consumer/pact_config/grpc.rb +26 -0
- data/lib/sbmt/pact/consumer/pact_config/http.rb +26 -0
- data/lib/sbmt/pact/consumer/pact_config/message.rb +17 -0
- data/lib/sbmt/pact/consumer/pact_config.rb +24 -0
- data/lib/sbmt/pact/consumer.rb +8 -0
- data/lib/sbmt/pact/matchers/base.rb +67 -0
- data/lib/sbmt/pact/matchers/v1/equality.rb +19 -0
- data/lib/sbmt/pact/matchers/v2/regex.rb +19 -0
- data/lib/sbmt/pact/matchers/v2/type.rb +17 -0
- data/lib/sbmt/pact/matchers/v3/boolean.rb +17 -0
- data/lib/sbmt/pact/matchers/v3/date.rb +18 -0
- data/lib/sbmt/pact/matchers/v3/date_time.rb +18 -0
- data/lib/sbmt/pact/matchers/v3/decimal.rb +17 -0
- data/lib/sbmt/pact/matchers/v3/each.rb +42 -0
- data/lib/sbmt/pact/matchers/v3/include.rb +17 -0
- data/lib/sbmt/pact/matchers/v3/integer.rb +17 -0
- data/lib/sbmt/pact/matchers/v3/number.rb +17 -0
- data/lib/sbmt/pact/matchers/v3/time.rb +18 -0
- data/lib/sbmt/pact/matchers/v4/each_key.rb +26 -0
- data/lib/sbmt/pact/matchers/v4/each_key_value.rb +32 -0
- data/lib/sbmt/pact/matchers/v4/each_value.rb +33 -0
- data/lib/sbmt/pact/matchers/v4/not_empty.rb +17 -0
- data/lib/sbmt/pact/matchers.rb +94 -0
- data/lib/sbmt/pact/native/blocking_verifier.rb +17 -0
- data/lib/sbmt/pact/native/logger.rb +25 -0
- data/lib/sbmt/pact/provider/async_message_verifier.rb +32 -0
- data/lib/sbmt/pact/provider/base_verifier.rb +158 -0
- data/lib/sbmt/pact/provider/grpc_verifier.rb +42 -0
- data/lib/sbmt/pact/provider/gruf_server.rb +75 -0
- data/lib/sbmt/pact/provider/http_server.rb +66 -0
- data/lib/sbmt/pact/provider/http_verifier.rb +46 -0
- data/lib/sbmt/pact/provider/message_provider_servlet.rb +80 -0
- data/lib/sbmt/pact/provider/pact_broker_proxy.rb +85 -0
- data/lib/sbmt/pact/provider/pact_broker_proxy_runner.rb +71 -0
- data/lib/sbmt/pact/provider/pact_config/async.rb +25 -0
- data/lib/sbmt/pact/provider/pact_config/base.rb +92 -0
- data/lib/sbmt/pact/provider/pact_config/grpc.rb +30 -0
- data/lib/sbmt/pact/provider/pact_config/http.rb +25 -0
- data/lib/sbmt/pact/provider/pact_config.rb +24 -0
- data/lib/sbmt/pact/provider/provider_server_runner.rb +89 -0
- data/lib/sbmt/pact/provider/provider_state_configuration.rb +32 -0
- data/lib/sbmt/pact/provider/provider_state_servlet.rb +84 -0
- data/lib/sbmt/pact/provider.rb +8 -0
- data/lib/sbmt/pact/railtie.rb +13 -0
- data/lib/sbmt/pact/rspec/support/pact_consumer_helpers.rb +46 -0
- data/lib/sbmt/pact/rspec/support/pact_message_helpers.rb +42 -0
- data/lib/sbmt/pact/rspec/support/pact_provider_helpers.rb +87 -0
- data/lib/sbmt/pact/rspec/support/waterdrop/pact_waterdrop_client.rb +27 -0
- data/lib/sbmt/pact/rspec/support/webmock/webmock_helpers.rb +30 -0
- data/lib/sbmt/pact/rspec.rb +17 -0
- data/lib/sbmt/pact/tasks/pact.rake +13 -0
- data/lib/sbmt/pact/version.rb +7 -0
- data/lib/sbmt/pact.rb +48 -0
- data/sbmt-pact.gemspec +59 -0
- 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
|