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.
- 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
|