Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions PactNet.sln
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PactNet.Output.Xunit", "src\PactNet.Output.Xunit\PactNet.Output.Xunit.csproj", "{02E265A1-A7A2-4106-8F6A-5027FDC3FC50}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Grpc", "Grpc", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GrpcGreeter", "samples\Grpc\GrpcGreeter\GrpcGreeter.csproj", "{529F37CB-CDA0-6553-EAC9-8DAC2195ED69}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GrpcGreeterClient", "samples\Grpc\GrpcGreeterClient\GrpcGreeterClient.csproj", "{917DAC61-55B4-D721-B1ED-B0E352E4CF1A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GrpcGreeterClient.Tests", "samples\Grpc\GrpcGreeterClient.Tests\GrpcGreeterClient.Tests.csproj", "{13756BC3-0750-E2AF-E1F0-565855A3E636}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GrpcGreeter.Tests", "samples\Grpc\GrpcGreeter.Tests\GrpcGreeter.Tests.csproj", "{DC5371A0-7DE2-4CC5-D0E1-1DF6CB567FA6}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "pacts", "pacts", "{FB3A8B7F-7DA0-40A8-AFD1-FB0992FB9B2C}"
ProjectSection(SolutionItems) = preProject
samples\Grpc\pacts\grpc-greeter-client-grpc-greeter.json = samples\Grpc\pacts\grpc-greeter-client-grpc-greeter.json
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -155,6 +170,54 @@ Global
{02E265A1-A7A2-4106-8F6A-5027FDC3FC50}.Release|x64.Build.0 = Release|Any CPU
{02E265A1-A7A2-4106-8F6A-5027FDC3FC50}.Release|x86.ActiveCfg = Release|Any CPU
{02E265A1-A7A2-4106-8F6A-5027FDC3FC50}.Release|x86.Build.0 = Release|Any CPU
{529F37CB-CDA0-6553-EAC9-8DAC2195ED69}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{529F37CB-CDA0-6553-EAC9-8DAC2195ED69}.Debug|Any CPU.Build.0 = Debug|Any CPU
{529F37CB-CDA0-6553-EAC9-8DAC2195ED69}.Debug|x64.ActiveCfg = Debug|Any CPU
{529F37CB-CDA0-6553-EAC9-8DAC2195ED69}.Debug|x64.Build.0 = Debug|Any CPU
{529F37CB-CDA0-6553-EAC9-8DAC2195ED69}.Debug|x86.ActiveCfg = Debug|Any CPU
{529F37CB-CDA0-6553-EAC9-8DAC2195ED69}.Debug|x86.Build.0 = Debug|Any CPU
{529F37CB-CDA0-6553-EAC9-8DAC2195ED69}.Release|Any CPU.ActiveCfg = Release|Any CPU
{529F37CB-CDA0-6553-EAC9-8DAC2195ED69}.Release|Any CPU.Build.0 = Release|Any CPU
{529F37CB-CDA0-6553-EAC9-8DAC2195ED69}.Release|x64.ActiveCfg = Release|Any CPU
{529F37CB-CDA0-6553-EAC9-8DAC2195ED69}.Release|x64.Build.0 = Release|Any CPU
{529F37CB-CDA0-6553-EAC9-8DAC2195ED69}.Release|x86.ActiveCfg = Release|Any CPU
{529F37CB-CDA0-6553-EAC9-8DAC2195ED69}.Release|x86.Build.0 = Release|Any CPU
{917DAC61-55B4-D721-B1ED-B0E352E4CF1A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{917DAC61-55B4-D721-B1ED-B0E352E4CF1A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{917DAC61-55B4-D721-B1ED-B0E352E4CF1A}.Debug|x64.ActiveCfg = Debug|Any CPU
{917DAC61-55B4-D721-B1ED-B0E352E4CF1A}.Debug|x64.Build.0 = Debug|Any CPU
{917DAC61-55B4-D721-B1ED-B0E352E4CF1A}.Debug|x86.ActiveCfg = Debug|Any CPU
{917DAC61-55B4-D721-B1ED-B0E352E4CF1A}.Debug|x86.Build.0 = Debug|Any CPU
{917DAC61-55B4-D721-B1ED-B0E352E4CF1A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{917DAC61-55B4-D721-B1ED-B0E352E4CF1A}.Release|Any CPU.Build.0 = Release|Any CPU
{917DAC61-55B4-D721-B1ED-B0E352E4CF1A}.Release|x64.ActiveCfg = Release|Any CPU
{917DAC61-55B4-D721-B1ED-B0E352E4CF1A}.Release|x64.Build.0 = Release|Any CPU
{917DAC61-55B4-D721-B1ED-B0E352E4CF1A}.Release|x86.ActiveCfg = Release|Any CPU
{917DAC61-55B4-D721-B1ED-B0E352E4CF1A}.Release|x86.Build.0 = Release|Any CPU
{13756BC3-0750-E2AF-E1F0-565855A3E636}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{13756BC3-0750-E2AF-E1F0-565855A3E636}.Debug|Any CPU.Build.0 = Debug|Any CPU
{13756BC3-0750-E2AF-E1F0-565855A3E636}.Debug|x64.ActiveCfg = Debug|Any CPU
{13756BC3-0750-E2AF-E1F0-565855A3E636}.Debug|x64.Build.0 = Debug|Any CPU
{13756BC3-0750-E2AF-E1F0-565855A3E636}.Debug|x86.ActiveCfg = Debug|Any CPU
{13756BC3-0750-E2AF-E1F0-565855A3E636}.Debug|x86.Build.0 = Debug|Any CPU
{13756BC3-0750-E2AF-E1F0-565855A3E636}.Release|Any CPU.ActiveCfg = Release|Any CPU
{13756BC3-0750-E2AF-E1F0-565855A3E636}.Release|Any CPU.Build.0 = Release|Any CPU
{13756BC3-0750-E2AF-E1F0-565855A3E636}.Release|x64.ActiveCfg = Release|Any CPU
{13756BC3-0750-E2AF-E1F0-565855A3E636}.Release|x64.Build.0 = Release|Any CPU
{13756BC3-0750-E2AF-E1F0-565855A3E636}.Release|x86.ActiveCfg = Release|Any CPU
{13756BC3-0750-E2AF-E1F0-565855A3E636}.Release|x86.Build.0 = Release|Any CPU
{DC5371A0-7DE2-4CC5-D0E1-1DF6CB567FA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DC5371A0-7DE2-4CC5-D0E1-1DF6CB567FA6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DC5371A0-7DE2-4CC5-D0E1-1DF6CB567FA6}.Debug|x64.ActiveCfg = Debug|Any CPU
{DC5371A0-7DE2-4CC5-D0E1-1DF6CB567FA6}.Debug|x64.Build.0 = Debug|Any CPU
{DC5371A0-7DE2-4CC5-D0E1-1DF6CB567FA6}.Debug|x86.ActiveCfg = Debug|Any CPU
{DC5371A0-7DE2-4CC5-D0E1-1DF6CB567FA6}.Debug|x86.Build.0 = Debug|Any CPU
{DC5371A0-7DE2-4CC5-D0E1-1DF6CB567FA6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DC5371A0-7DE2-4CC5-D0E1-1DF6CB567FA6}.Release|Any CPU.Build.0 = Release|Any CPU
{DC5371A0-7DE2-4CC5-D0E1-1DF6CB567FA6}.Release|x64.ActiveCfg = Release|Any CPU
{DC5371A0-7DE2-4CC5-D0E1-1DF6CB567FA6}.Release|x64.Build.0 = Release|Any CPU
{DC5371A0-7DE2-4CC5-D0E1-1DF6CB567FA6}.Release|x86.ActiveCfg = Release|Any CPU
{DC5371A0-7DE2-4CC5-D0E1-1DF6CB567FA6}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -170,6 +233,12 @@ Global
{5E915D66-917B-4730-B31A-C9727C196346} = {6663C12E-9912-40D0-9310-D119D1F6B023}
{D8B75E48-6E45-468B-8049-B73823C14CB8} = {6663C12E-9912-40D0-9310-D119D1F6B023}
{02E265A1-A7A2-4106-8F6A-5027FDC3FC50} = {CF67D7A1-AE96-420B-9971-65E535B903E8}
{02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {547DB478-460A-428F-9371-1D653CE85DB5}
{529F37CB-CDA0-6553-EAC9-8DAC2195ED69} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{917DAC61-55B4-D721-B1ED-B0E352E4CF1A} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{13756BC3-0750-E2AF-E1F0-565855A3E636} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{DC5371A0-7DE2-4CC5-D0E1-1DF6CB567FA6} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{FB3A8B7F-7DA0-40A8-AFD1-FB0992FB9B2C} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {C2CBC30C-92D4-4E3A-A5B8-1E5D4E938DFC}
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,12 @@ For writing messaging pacts instead of requests/response pacts, see the [messagi

![----------](https://raw.githubusercontent.com/pactumjs/pactum/master/assets/rainbow.png)

### Plugin Interactions

For protocols handled by a Pact plugin, such as gRPC, see the [plugin interactions guide](docs/plugin-interactions.md).

![----------](https://raw.githubusercontent.com/pactumjs/pactum/master/assets/rainbow.png)

## Compatibility

### Operating System
Expand Down
2 changes: 1 addition & 1 deletion build/download-native-libs.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail

FFI_VERSION="0.4.27"
FFI_VERSION="0.5.10"
FFI_BASE_URL="https://github.com/pact-foundation/pact-reference/releases/download/libpact_ffi-v$FFI_VERSION"

GREEN="\e[32m"
Expand Down
35 changes: 35 additions & 0 deletions docs/async-plugin-interactions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Asynchronous Plugin Messages

Use `WithAsynchronousPluginInteractions` for content plugins without a transport
server. Configure a single message, then exercise the real consumer with generated
binary contents and metadata:

```csharp
using var pact = Pact.V4("consumer", "provider", config)
.WithAsynchronousPluginInteractions("avro", "0.1.3");

await pact.ExpectsToReceive("an Avro user")
.WithMetadata("topic", "users")
.WithContent("avro/binary", contents)
.VerifyAsync(message => ConsumeUserAsync(message.Contents, message.Metadata));
```

The contents dictionary follows the plugin's schema; for Avro it includes
`pact:avro`, `pact:record-name` and record field matching expressions. Message
contents are terminal. User metadata is applied after plugin configuration so
it is not replaced by the plugin's generated metadata.

`PluginMessage` contains copied binary contents, the media type and cloned JSON
metadata values. PactNet decodes the V4 base64 envelope, owns the reified native
string, writes the pact only after a successful callback, and cleans up plugins
when the pact builder is disposed. The existing JSON message API is unchanged.

This does not start Kafka/RabbitMQ or claim broker-delivery coverage. Producer
verification uses the normal verifier with a message provider or HTTP message
adapter. Review multi-message/generator/error paths and platform coverage before
publishing this prototype API.

Avro 0.1.3's single unlabelled interaction does not populate a synchronous message's
request/response fields with FFI 0.4.27. An opt-in native capability test records
that limitation. Distinct sync parts require an Avro plugin change; transport-free
binary request/response consumer verification also needs a separate PactNet API.
31 changes: 31 additions & 0 deletions docs/http-plugin-interactions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# HTTP Plugin Interactions

Content plugins can configure binary HTTP request and response bodies without
providing their own transport. The public `WithHttpPluginInteractions` entry point
creates HTTP interactions on a plugin-loaded pact and keeps native handles private.

```csharp
using var pact = Pact.V4("consumer", "provider", config)
.WithHttpPluginInteractions("avro", "0.1.3");

pact.UponReceiving("an Avro response")
.WithRequest(HttpMethod.Get, "/avro")
.WillRespondWithStatus(200)
.WithHeader("Content-Type", "avro/binary;record=User")
.WithContent("avro/binary", new Dictionary<string, object>
{
["pact:avro"] = schemaPath,
["pact:record-name"] = "User",
["id"] = "matching(integer, 1)",
["username"] = "matching(equalTo, 'alice')"
});
```

Use `Verify`/`VerifyAsync` with the real HTTP client and dispose the builder.
Request contents can be configured once before the response stage. Response
contents complete the interaction and can be configured once; later headers or
contents are rejected. The plugin owns its configuration schema and binary encoding.

PactNet still owns HTTP server startup, mismatches, logs, pact writing and plugin
cleanup. Provider verification uses the ordinary `PactVerifier`. This prototype
does not add asynchronous plugin message reification or expose FFI internals.
127 changes: 127 additions & 0 deletions docs/plugin-interactions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
Plugin Interactions
===================

Pact [plugins](https://docs.pact.io/plugins) extend Pact to protocols and content types beyond HTTP and
plain messaging, for example gRPC/protobuf or CSV. PactNet exposes a "raw" plugin API which works with any
plugin, so that a new plugin can be used before a friendlier wrapper library exists for it.

Currently only **synchronous** plugin interactions are supported: a single request with one or more
responses, such as a gRPC unary call. Asynchronous (message-style) plugin interactions are not yet exposed.

Prerequisites
-------------

The plugin itself must be installed on the machine running the tests, on both the consumer and provider side.
Plugins live under `~/.pact/plugins/<name>-<version>` and are installed with the
[plugin CLI](https://docs.pact.io/implementation_guides/pact_plugins/cli), for example:

```shell
pact-plugin-cli install https://github.com/pact-foundation/pact-protobuf-plugin/releases/tag/v-0.4.0
```

Sample
------

See the [gRPC sample](../samples/Grpc/) for a working consumer and provider using the protobuf plugin.

Consumer Tests
--------------

A plugin pact is created from a V4 pact by naming the plugin, its version and the transport the mock
server should use. Each interaction is then defined by handing the plugin a content type it understands
plus a dictionary describing the interaction. The plugin, not PactNet, decides what that dictionary must
contain and how it is split into a request and its responses.

```csharp
public class GreeterClientTests : IDisposable
{
private readonly ISynchronousPluginPactBuilderV4 pact;

public GreeterClientTests(ITestOutputHelper output)
{
IPactV4 v4 = Pact.V4("Greeter Client", "Greeter Service", new PactConfig
{
PactDir = "../../../pacts/",
Outputters = new[] { new XunitOutput(output) }
});

this.pact = v4.WithSynchronousPluginInteractions("protobuf", "0.4.0", transport: "grpc");
}

[Fact]
public async Task SayHello_ReturnsGreeting()
{
var content = new Dictionary<string, object>
{
["pact:proto"] = Path.GetFullPath("../../../Protos/greet.proto"),
["pact:proto-service"] = "Greeter/SayHello",
["pact:content-type"] = "application/protobuf",
["request"] = new { name = "matching(equalTo, 'foo')" },
["response"] = new { message = "matching(equalTo, 'Hello foo')" }
};

this.pact
.UponReceiving("a request to say hello")
.Given("the greeter is available")
.WithContent("application/grpc", content);

await this.pact.VerifyAsync(async ctx =>
{
var client = new GreeterClient(ctx.MockServerUri);

string greeting = await client.SayHello("foo");

greeting.Should().Be("Hello foo");
});
}

public void Dispose() => this.pact.Dispose();
}
```

`Verify`/`VerifyAsync` starts a mock server using the requested transport, runs your client against it, checks
every interaction was matched, writes the pact file and prints the mock server logs, exactly as for HTTP pacts.
Dispose the builder when the test finishes so the underlying plugin resources are released.

### Building an interaction

Each call to `UponReceiving` starts a new interaction and a pact may contain as many as you need. The builder
has two kinds of call:

- `Given(...)` adds provider states and may be repeated.
- `WithContent(contentType, content)` sets the plugin contents. **It completes the interaction and must be the
last call.** It returns nothing, so it cannot be chained further.

`WithContent` is deliberately single-shot. The plugin splits one contents dictionary into the request and its
responses. If contents were set a second time on the same interaction the request would be replaced but the
response would be appended, leaving the interaction with two responses, so PactNet does not allow it. Define a
second `UponReceiving` instead.

### Multiple interactions on one method

The gRPC mock server tracks whether each service method was called, not each interaction. If two interactions
target the same `Service/Method` and your client only exercises one of them, verification still passes. Give
interactions on the same method distinguishable requests and make sure your test calls each of them.

Provider Tests
--------------

Provider verification uses the normal `PactVerifier`. Point it at the running gRPC server as the HTTP endpoint;
the transport recorded in the pact file tells the verifier to hand the interaction to the plugin.

```csharp
this.verifier
.WithHttpEndpoint(new Uri("http://localhost:5000"))
.WithFileSource(new FileInfo(pactPath))
.Verify();
```

Writing a plugin wrapper
------------------------

A wrapper library gives a plugin a protocol-specific fluent API on top of the raw builders, without needing
any access to PactNet internals. Wrap `ISynchronousPluginPactBuilderV4`, have your own request/response builders
assemble the contents dictionary, and call `WithContent` once when the user has finished describing the
interaction. Delegate `Verify`, `VerifyAsync` and `Dispose` straight through. The
[`PactNet.Extensions.Grpc`](https://github.com/pact-foundation/pact-net/pull/548) package is the reference
implementation of this pattern.
23 changes: 23 additions & 0 deletions samples/Grpc/GrpcGreeter.Tests/GrpcGreeter.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="xunit" Version="2.6.6" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\PactNet.Output.Xunit\PactNet.Output.Xunit.csproj" />
<ProjectReference Include="..\..\..\src\PactNet\PactNet.csproj" />
<ProjectReference Include="..\GrpcGreeter\GrpcGreeter.csproj" />
</ItemGroup>
<ItemGroup>
<Service Include="{82a7f48d-3b50-4b1e-b82e-3ada8210c358}" />
</ItemGroup>
</Project>
52 changes: 52 additions & 0 deletions samples/Grpc/GrpcGreeter.Tests/GrpcGreeterTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
using System.IO;
using FluentAssertions;
using PactNet;
using PactNet.Exceptions;
using PactNet.Infrastructure.Outputters;
using Xunit;
using PactNet.Output.Xunit;
using PactNet.Verifier;
using Xunit.Abstractions;

namespace GrpcGreeter.Tests
{
public class GrpcGreeterTests(ITestOutputHelper output, ServerFixture serverFixture) : IClassFixture<ServerFixture>, IDisposable
{
private readonly PactVerifier verifier = new("Grpc Greeter Api", new PactVerifierConfig
{
LogLevel = PactLogLevel.Information,
Outputters = new List<IOutput>
{
new XunitOutput(output)
}
});

private readonly string pactPath = Path.Combine("..", "..", "..", "..", "..", "Grpc", "pacts",
"grpc-greeter-client-grpc-greeter.json");

[Fact]
public void VerificationThrowsExceptionWhenNoRunningProvider()
{
var source = this.verifier
.WithHttpEndpoint(new Uri("http://localhost:5060"))
.WithFileSource(new FileInfo(this.pactPath));

source.Invoking(s => s.Verify()).Should().Throw<PactVerificationFailedException>();
}

[Fact]
public void VerificationSuccessForRunningProvider()
{
verifier.WithHttpEndpoint(serverFixture.ProviderUri)
.WithFileSource(new FileInfo(pactPath))
.Verify();
}

public void Dispose()
{
this.verifier?.Dispose();
}
}
}
Loading
Loading