Skip to content

Commit ca19f86

Browse files
committed
feat: add pact plugin consumer interfaces for http/async/sync messages
1 parent f92e503 commit ca19f86

22 files changed

+859
-38
lines changed

.github/workflows/test.yml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ jobs:
2323
with:
2424
ruby-version: ${{ matrix.ruby_version }}
2525
bundler-cache: true
26+
- uses: you54f/pact-cli@main
27+
if: matrix.os != 'windows-latest'
28+
- run: pact-cli plugin install --yes https://github.com/mefellows/pact-matt-plugin/releases/tag/v0.1.1
29+
if: matrix.os != 'windows-latest'
2630
- name: Test Pact-Ruby Specs
2731
run: "bundle exec rake"
2832
- name: Test Pact-Ruby Zoo App Specs
@@ -59,12 +63,15 @@ jobs:
5963
with:
6064
ruby-version: ${{ matrix.ruby_version }}
6165
bundler-cache: true
66+
- uses: you54f/pact-cli@main
67+
if: matrix.os != 'windows-latest'
68+
- run: pact-cli plugin install --yes https://github.com/mefellows/pact-matt-plugin/releases/tag/v0.1.1
69+
if: matrix.os != 'windows-latest'
6270
- run: "bundle exec appraisal install"
6371
- run: "bundle exec appraisal rack-2 rake"
6472
- run: "bundle exec appraisal rack-2 rake spec:v2"
6573
- name: Test Mixed Pacts (Http/Kafaka/Grpc) - Pact-Ruby v2
6674
run: "bundle exec appraisal rack-2 rake pact:v2:spec"
67-
if: matrix.os != 'windows-latest' && matrix.ruby_version > '3.0'
6875
- name: Verify Mixed Pacts (Http/Kafaka/Grpc) - Pact-Ruby v2
6976
run: "bundle exec appraisal rack-2 rake pact:v2:verify"
7077
if: matrix.os != 'windows-latest' && matrix.ruby_version > '3.0'
@@ -85,6 +92,7 @@ jobs:
8592
with:
8693
ruby-version: ${{ matrix.ruby_version }}
8794
bundler-cache: true
95+
- run: bundle install
8896
- run: "bundle exec appraisal install"
8997
name: "install active support - pact-ruby"
9098
- run: "bundle exec appraisal activesupport rake spec_with_active_support"

lib/pact/v2/consumer/grpc_interaction_builder.rb

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ def with_service(proto_path, method, include_dirs = [])
6868
self
6969
end
7070

71+
def with_pact_protobuf_plugin_version(version)
72+
raise InteractionBuilderError.new("version is required") if version.blank?
73+
74+
@proto_plugin_version = version
75+
self
76+
end
77+
7178
def given(provider_state, metadata = {})
7279
@provider_state_meta = {provider_state => metadata}
7380
self
@@ -175,11 +182,11 @@ def init_pact
175182
end
176183

177184
def init_plugin!(pact_handle)
178-
result = PactFfi::PluginConsumer.using_plugin(pact_handle, PROTOBUF_PLUGIN_NAME, PROTOBUF_PLUGIN_VERSION)
185+
result = PactFfi::PluginConsumer.using_plugin(pact_handle, PROTOBUF_PLUGIN_NAME, @proto_plugin_version || PROTOBUF_PLUGIN_VERSION)
179186
return result if INIT_PLUGIN_ERRORS[result].blank?
180187

181188
error = INIT_PLUGIN_ERRORS[result]
182-
raise PluginInitError.new("There was an error while trying to initialize plugin #{PROTOBUF_PLUGIN_NAME}/#{PROTOBUF_PLUGIN_VERSION}", error[:reason], error[:status])
189+
raise PluginInitError.new("There was an error while trying to initialize plugin #{PROTOBUF_PLUGIN_NAME}/#{@proto_plugin_version || PROTOBUF_PLUGIN_VERSION}", error[:reason], error[:status])
183190
end
184191
end
185192
end

lib/pact/v2/consumer/http_interaction_builder.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ module Pact
99
module V2
1010
module Consumer
1111
class HttpInteractionBuilder
12-
DESCRIPTION_PREFIX = "http: "
1312

1413
# https://docs.rs/pact_ffi/0.4.17/pact_ffi/plugins/fn.pactffi_interaction_contents.html
1514
CREATE_INTERACTION_ERRORS = {

lib/pact/v2/consumer/message_interaction_builder.rb

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,13 @@ def with_proto_class(proto_path, message_class_name, include_dirs = [])
8686
self
8787
end
8888

89+
def with_pact_protobuf_plugin_version(version)
90+
raise InteractionBuilderError.new("version is required") if version.blank?
91+
92+
@proto_plugin_version = version
93+
self
94+
end
95+
8996
def with_proto_contents(contents_hash)
9097
@proto_contents = InteractionContents.plugin(contents_hash)
9198
self
@@ -257,11 +264,11 @@ def configure_interaction!(message_pact)
257264
end
258265

259266
def init_plugin!(pact_handle)
260-
result = PactFfi::PluginConsumer.using_plugin(pact_handle, PROTOBUF_PLUGIN_NAME, PROTOBUF_PLUGIN_VERSION)
267+
result = PactFfi::PluginConsumer.using_plugin(pact_handle, PROTOBUF_PLUGIN_NAME, @proto_plugin_version || PROTOBUF_PLUGIN_VERSION)
261268
return result if INIT_PLUGIN_ERRORS[result].blank?
262269

263270
error = INIT_PLUGIN_ERRORS[result]
264-
raise PluginInitError.new("There was an error while trying to initialize plugin #{PROTOBUF_PLUGIN_NAME}/#{PROTOBUF_PLUGIN_VERSION}", error[:reason], error[:status])
271+
raise PluginInitError.new("There was an error while trying to initialize plugin #{PROTOBUF_PLUGIN_NAME}/#{@proto_plugin_version || PROTOBUF_PLUGIN_VERSION}", error[:reason], error[:status])
265272
end
266273

267274
def serialize_metadata(metadata_hash)

lib/pact/v2/consumer/mock_server.rb

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@ class MockServer
1111
TRANSPORT_HTTP = "http"
1212
TRANSPORT_GRPC = "grpc"
1313

14-
TRANSPORTS = [TRANSPORT_HTTP, TRANSPORT_GRPC].freeze
15-
1614
class MockServerCreateError < Pact::V2::FfiError; end
1715

1816
class WritePactsError < Pact::V2::FfiError; end
@@ -41,8 +39,11 @@ def self.create_for_http!(pact:, host: "127.0.0.1", port: 0)
4139
new(pact: pact, transport: TRANSPORT_HTTP, host: host, port: port)
4240
end
4341

42+
def self.create_for_transport!(pact:, transport:, host: "127.0.0.1", port: 0)
43+
new(pact: pact, transport: transport, host: host, port: port)
44+
end
45+
4446
def initialize(pact:, transport:, host:, port:)
45-
raise "Transport #{transport} is not supported yet, available transports are: #{TRANSPORTS.join(",")}" unless TRANSPORTS.include?(transport)
4647

4748
@pact = pact
4849
@transport = transport

lib/pact/v2/consumer/pact_config.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ def self.new(transport_type, consumer_name:, provider_name:, opts: {})
1414
Grpc.new(consumer_name: consumer_name, provider_name: provider_name, opts: opts)
1515
when :message
1616
Message.new(consumer_name: consumer_name, provider_name: provider_name, opts: opts)
17+
when :plugin_sync_message
18+
PluginSyncMessage.new(consumer_name: consumer_name, provider_name: provider_name, opts: opts)
19+
when :plugin_async_message
20+
PluginAsyncMessage.new(consumer_name: consumer_name, provider_name: provider_name, opts: opts)
21+
when :plugin_http
22+
PluginHttp.new(consumer_name: consumer_name, provider_name: provider_name, opts: opts)
1723
else
1824
raise ArgumentError, "unknown transport_type: #{transport_type}"
1925
end
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "base"
4+
5+
module Pact
6+
module V2
7+
module Consumer
8+
module PactConfig
9+
class PluginAsyncMessage < Base
10+
attr_reader :mock_host, :mock_port
11+
12+
def initialize(consumer_name:, provider_name:, opts: {})
13+
super
14+
15+
@mock_host = opts[:mock_host] || "127.0.0.1"
16+
@mock_port = opts[:mock_port] || 0
17+
end
18+
19+
def new_interaction(description = nil)
20+
PluginAsyncMessageInteractionBuilder.new(self, description: description)
21+
end
22+
end
23+
end
24+
end
25+
end
26+
end
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "base"
4+
5+
module Pact
6+
module V2
7+
module Consumer
8+
module PactConfig
9+
class PluginHttp < Base
10+
attr_reader :mock_host, :mock_port
11+
12+
def initialize(consumer_name:, provider_name:, opts: {})
13+
super
14+
15+
@mock_host = opts[:mock_host] || "127.0.0.1"
16+
@mock_port = opts[:mock_port] || 0
17+
end
18+
19+
def new_interaction(description = nil)
20+
PluginHttpInteractionBuilder.new(self, description: description)
21+
end
22+
end
23+
end
24+
end
25+
end
26+
end
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "base"
4+
5+
module Pact
6+
module V2
7+
module Consumer
8+
module PactConfig
9+
class PluginSyncMessage < Base
10+
attr_reader :mock_host, :mock_port
11+
12+
def initialize(consumer_name:, provider_name:, opts: {})
13+
super
14+
15+
@mock_host = opts[:mock_host] || "127.0.0.1"
16+
@mock_port = opts[:mock_port] || 0
17+
end
18+
19+
def new_interaction(description = nil)
20+
PluginSyncMessageInteractionBuilder.new(self, description: description)
21+
end
22+
end
23+
end
24+
end
25+
end
26+
end
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
# frozen_string_literal: true
2+
3+
require "pact/ffi/async_message_pact"
4+
require "pact/ffi/plugin_consumer"
5+
require "pact/ffi/logger"
6+
7+
module Pact
8+
module V2
9+
module Consumer
10+
class PluginAsyncMessageInteractionBuilder
11+
12+
class PluginInitError < Pact::V2::FfiError; end
13+
14+
# https://docs.rs/pact_ffi/0.4.17/pact_ffi/plugins/fn.pactffi_using_plugin.html
15+
INIT_PLUGIN_ERRORS = {
16+
1 => {reason: :internal_error, status: 1, description: "A general panic was caught"},
17+
2 => {reason: :plugin_load_failed, status: 2, description: "Failed to load the plugin"},
18+
3 => {reason: :invalid_handle, status: 3, description: "Pact Handle is not valid"}
19+
}.freeze
20+
21+
# https://docs.rs/pact_ffi/0.4.17/pact_ffi/plugins/fn.pactffi_interaction_contents.html
22+
CREATE_INTERACTION_ERRORS = {
23+
1 => {reason: :internal_error, status: 1, description: "A general panic was caught"},
24+
2 => {reason: :mock_server_already_running, status: 2, description: "The mock server has already been started"},
25+
3 => {reason: :invalid_handle, status: 3, description: "The interaction handle is invalid"},
26+
4 => {reason: :invalid_content_type, status: 4, description: "The content type is not valid"},
27+
5 => {reason: :invalid_contents, status: 5, description: "The contents JSON is not valid JSON"},
28+
6 => {reason: :plugin_error, status: 6, description: "The plugin returned an error"}
29+
}.freeze
30+
31+
class CreateInteractionError < Pact::V2::FfiError; end
32+
33+
class InteractionMismatchesError < Pact::V2::Error; end
34+
35+
class InteractionBuilderError < Pact::V2::Error; end
36+
37+
def initialize(pact_config, description: nil)
38+
@pact_config = pact_config
39+
@description = description || ""
40+
@contents = nil
41+
@provider_state_meta = nil
42+
end
43+
44+
def with_plugin(plugin_name, plugin_version)
45+
raise InteractionBuilderError.new("plugin_name is required") if plugin_name.blank?
46+
raise InteractionBuilderError.new("plugin_version is required") if plugin_version.blank?
47+
48+
@plugin_name = plugin_name
49+
@plugin_version = plugin_version
50+
self
51+
end
52+
53+
def given(provider_state, metadata = {})
54+
@provider_state_meta = {provider_state => metadata}
55+
self
56+
end
57+
58+
def upon_receiving(description)
59+
@description = description
60+
self
61+
end
62+
63+
def with_contents(contents_hash)
64+
@contents = InteractionContents.plugin(contents_hash)
65+
self
66+
end
67+
68+
def with_content_type(content_type)
69+
@interaction_content_type = content_type || @content_type
70+
self
71+
end
72+
73+
def with_plugin_metadata(meta_hash)
74+
@plugin_metadata = meta_hash
75+
self
76+
end
77+
78+
def with_transport(transport)
79+
@transport = transport
80+
self
81+
end
82+
83+
def interaction_json
84+
result = {
85+
contents: @contents
86+
}
87+
result.merge!(@plugin_metadata) if @plugin_metadata.is_a?(Hash)
88+
JSON.dump(result)
89+
end
90+
91+
def validate!
92+
raise InteractionBuilderError.new("invalid contents format, should be a hash") unless @contents.is_a?(Hash)
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+
validate!
99+
100+
pact_handle = init_pact
101+
init_plugin!(pact_handle)
102+
103+
interaction = PactFfi::AsyncMessageConsumer.new(pact_handle, @description)
104+
105+
@provider_state_meta&.each_pair do |provider_state, meta|
106+
if meta.present?
107+
meta.each_pair do |k, v|
108+
if v.nil? || (v.respond_to?(:empty?) && v.empty?)
109+
PactFfi.given(interaction, provider_state)
110+
else
111+
puts "Given #{provider_state} with param #{k}: #{v}"
112+
PactFfi.given_with_param(interaction, provider_state, k.to_s, v.to_s)
113+
end
114+
end
115+
else
116+
PactFfi.given(interaction, provider_state)
117+
end
118+
end
119+
120+
result = PactFfi.with_body(interaction, 0, @interaction_content_type, interaction_json)
121+
if CREATE_INTERACTION_ERRORS[result].present?
122+
error = CREATE_INTERACTION_ERRORS[result]
123+
raise CreateInteractionError.new("There was an error while trying to add interaction \"#{@description}\"", error[:reason], error[:status])
124+
end
125+
126+
mock_server = MockServer.create_for_transport!(pact: pact_handle, transport: @transport, host: @pact_config.mock_host, port: @pact_config.mock_port)
127+
128+
yield(pact_handle, mock_server)
129+
if mock_server.matched?
130+
mock_server.write_pacts!(@pact_config.pact_dir)
131+
else
132+
msg = mismatches_error_msg(mock_server)
133+
raise InteractionMismatchesError.new(msg)
134+
end
135+
ensure
136+
@used = true
137+
mock_server&.cleanup
138+
PactFfi::PluginConsumer.cleanup_plugins(pact_handle) if pact_handle
139+
PactFfi.free_pact_handle(pact_handle) if pact_handle
140+
end
141+
142+
private
143+
144+
def mismatches_error_msg(mock_server)
145+
rspec_example_desc = RSpec.current_example&.description
146+
return "interaction for has mismatches: #{mock_server.mismatches}" if rspec_example_desc.blank?
147+
148+
"#{rspec_example_desc} has mismatches: #{mock_server.mismatches}"
149+
end
150+
151+
def init_pact
152+
handle = PactFfi.new_pact(@pact_config.consumer_name, @pact_config.provider_name)
153+
PactFfi.with_specification(handle, PactFfi::FfiSpecificationVersion["SPECIFICATION_VERSION_V4"])
154+
PactFfi.with_pact_metadata(handle, "pact-ruby-v2", "pact-ffi", PactFfi.version)
155+
156+
Pact::V2::Native::Logger.log_to_stdout(@pact_config.log_level)
157+
158+
handle
159+
end
160+
161+
def init_plugin!(pact_handle)
162+
result = PactFfi::PluginConsumer.using_plugin(pact_handle, @plugin_name, @plugin_version)
163+
return result if INIT_PLUGIN_ERRORS[result].blank?
164+
165+
error = INIT_PLUGIN_ERRORS[result]
166+
raise PluginInitError.new("There was an error while trying to initialize plugin #{@plugin_name}/#{@plugin_version}", error[:reason], error[:status])
167+
end
168+
end
169+
end
170+
end
171+
end

0 commit comments

Comments
 (0)