Skip to content

Commit 36f0376

Browse files
committed
feat(grpc): Add PactNet.Extensions.Grpc library
1 parent 12b9663 commit 36f0376

File tree

11 files changed

+398
-33
lines changed

11 files changed

+398
-33
lines changed

PactNet.sln

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,15 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GrpcGreeterClient.Tests", "
4747
EndProject
4848
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GrpcGreeter.Tests", "samples\Grpc\GrpcGreeter.Tests\GrpcGreeter.Tests.csproj", "{DC5371A0-7DE2-4CC5-D0E1-1DF6CB567FA6}"
4949
EndProject
50-
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "pacts", "pacts", "{FB3A8B7F-7DA0-40A8-AFD1-FB0992FB9B2C}"
50+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PactNet.Extensions.Grpc", "src\PactNet.Extensions.Grpc\PactNet.Extensions.Grpc.csproj", "{30EF8B05-ACE6-482B-97D3-B8EE45F1DE10}"
51+
EndProject
52+
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "pacts", "pacts", "{AF3752A7-877C-4958-8438-222D2C842D45}"
5153
ProjectSection(SolutionItems) = preProject
5254
samples\Grpc\pacts\grpc-greeter-client-grpc-greeter.json = samples\Grpc\pacts\grpc-greeter-client-grpc-greeter.json
5355
EndProjectSection
5456
EndProject
57+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PactNet.Extensions.Grpc.Tests", "tests\PactNet.Extensions.Grpc.Tests\PactNet.Extensions.Grpc.Tests.csproj", "{F4C6BFE1-003A-4BFB-B855-6F5E72AB98CA}"
58+
EndProject
5559
Global
5660
GlobalSection(SolutionConfigurationPlatforms) = preSolution
5761
Debug|Any CPU = Debug|Any CPU
@@ -218,6 +222,30 @@ Global
218222
{DC5371A0-7DE2-4CC5-D0E1-1DF6CB567FA6}.Release|x64.Build.0 = Release|Any CPU
219223
{DC5371A0-7DE2-4CC5-D0E1-1DF6CB567FA6}.Release|x86.ActiveCfg = Release|Any CPU
220224
{DC5371A0-7DE2-4CC5-D0E1-1DF6CB567FA6}.Release|x86.Build.0 = Release|Any CPU
225+
{30EF8B05-ACE6-482B-97D3-B8EE45F1DE10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
226+
{30EF8B05-ACE6-482B-97D3-B8EE45F1DE10}.Debug|Any CPU.Build.0 = Debug|Any CPU
227+
{30EF8B05-ACE6-482B-97D3-B8EE45F1DE10}.Debug|x64.ActiveCfg = Debug|Any CPU
228+
{30EF8B05-ACE6-482B-97D3-B8EE45F1DE10}.Debug|x64.Build.0 = Debug|Any CPU
229+
{30EF8B05-ACE6-482B-97D3-B8EE45F1DE10}.Debug|x86.ActiveCfg = Debug|Any CPU
230+
{30EF8B05-ACE6-482B-97D3-B8EE45F1DE10}.Debug|x86.Build.0 = Debug|Any CPU
231+
{30EF8B05-ACE6-482B-97D3-B8EE45F1DE10}.Release|Any CPU.ActiveCfg = Release|Any CPU
232+
{30EF8B05-ACE6-482B-97D3-B8EE45F1DE10}.Release|Any CPU.Build.0 = Release|Any CPU
233+
{30EF8B05-ACE6-482B-97D3-B8EE45F1DE10}.Release|x64.ActiveCfg = Release|Any CPU
234+
{30EF8B05-ACE6-482B-97D3-B8EE45F1DE10}.Release|x64.Build.0 = Release|Any CPU
235+
{30EF8B05-ACE6-482B-97D3-B8EE45F1DE10}.Release|x86.ActiveCfg = Release|Any CPU
236+
{30EF8B05-ACE6-482B-97D3-B8EE45F1DE10}.Release|x86.Build.0 = Release|Any CPU
237+
{F4C6BFE1-003A-4BFB-B855-6F5E72AB98CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
238+
{F4C6BFE1-003A-4BFB-B855-6F5E72AB98CA}.Debug|Any CPU.Build.0 = Debug|Any CPU
239+
{F4C6BFE1-003A-4BFB-B855-6F5E72AB98CA}.Debug|x64.ActiveCfg = Debug|Any CPU
240+
{F4C6BFE1-003A-4BFB-B855-6F5E72AB98CA}.Debug|x64.Build.0 = Debug|Any CPU
241+
{F4C6BFE1-003A-4BFB-B855-6F5E72AB98CA}.Debug|x86.ActiveCfg = Debug|Any CPU
242+
{F4C6BFE1-003A-4BFB-B855-6F5E72AB98CA}.Debug|x86.Build.0 = Debug|Any CPU
243+
{F4C6BFE1-003A-4BFB-B855-6F5E72AB98CA}.Release|Any CPU.ActiveCfg = Release|Any CPU
244+
{F4C6BFE1-003A-4BFB-B855-6F5E72AB98CA}.Release|Any CPU.Build.0 = Release|Any CPU
245+
{F4C6BFE1-003A-4BFB-B855-6F5E72AB98CA}.Release|x64.ActiveCfg = Release|Any CPU
246+
{F4C6BFE1-003A-4BFB-B855-6F5E72AB98CA}.Release|x64.Build.0 = Release|Any CPU
247+
{F4C6BFE1-003A-4BFB-B855-6F5E72AB98CA}.Release|x86.ActiveCfg = Release|Any CPU
248+
{F4C6BFE1-003A-4BFB-B855-6F5E72AB98CA}.Release|x86.Build.0 = Release|Any CPU
221249
EndGlobalSection
222250
GlobalSection(SolutionProperties) = preSolution
223251
HideSolutionNode = FALSE
@@ -238,7 +266,9 @@ Global
238266
{917DAC61-55B4-D721-B1ED-B0E352E4CF1A} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
239267
{13756BC3-0750-E2AF-E1F0-565855A3E636} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
240268
{DC5371A0-7DE2-4CC5-D0E1-1DF6CB567FA6} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
241-
{FB3A8B7F-7DA0-40A8-AFD1-FB0992FB9B2C} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
269+
{30EF8B05-ACE6-482B-97D3-B8EE45F1DE10} = {CF67D7A1-AE96-420B-9971-65E535B903E8}
270+
{AF3752A7-877C-4958-8438-222D2C842D45} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
271+
{F4C6BFE1-003A-4BFB-B855-6F5E72AB98CA} = {A117BBC6-60BB-4282-BF10-E616DE0AFAD0}
242272
EndGlobalSection
243273
GlobalSection(ExtensibilityGlobals) = postSolution
244274
SolutionGuid = {C2CBC30C-92D4-4E3A-A5B8-1E5D4E938DFC}

samples/Grpc/GrpcGreeterClient.Tests/GrpcGreeterClient.Tests.csproj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
</PackageReference>
1414
</ItemGroup>
1515
<ItemGroup>
16-
<ProjectReference Include="..\..\..\src\PactNet.Output.Xunit\PactNet.Output.Xunit.csproj" />
1716
<ProjectReference Include="..\..\..\src\PactNet\PactNet.csproj" />
17+
<ProjectReference Include="..\..\..\src\PactNet.Extensions.Grpc\PactNet.Extensions.Grpc.csproj" />
18+
<ProjectReference Include="..\..\..\src\PactNet.Output.Xunit\PactNet.Output.Xunit.csproj" />
1819
<ProjectReference Include="..\GrpcGreeterClient\GrpcGreeterClient.csproj" />
1920
</ItemGroup>
2021
<ItemGroup>

samples/Grpc/GrpcGreeterClient.Tests/GrpcGreeterClientTest.cs

Lines changed: 20 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using System;
2-
using System.Collections.Generic;
32
using System.Text.Json;
43
using System.Threading.Tasks;
54
using FluentAssertions;
@@ -8,21 +7,25 @@
87
using System.Text.Json.Serialization;
98
using PactNet;
109
using PactNet.Exceptions;
10+
using PactNet.Extensions.Grpc;
1111
using PactNet.Output.Xunit;
1212
using Xunit.Abstractions;
1313

1414
namespace GrpcGreeterClient.Tests
1515
{
1616
public class GrpcGreeterClientTests : IDisposable
1717
{
18-
private readonly ISynchronousPluginPactBuilderV4 pact;
18+
private readonly IGrpcPactBuilderV4 pact;
1919

2020
public GrpcGreeterClientTests(ITestOutputHelper output)
2121
{
2222
var config = new PactConfig
2323
{
2424
PactDir = "../../../../pacts/",
25-
Outputters = new[] { new XunitOutput(output) },
25+
Outputters = new[]
26+
{
27+
new XunitOutput(output)
28+
},
2629
DefaultJsonSettings = new JsonSerializerOptions
2730
{
2831
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
@@ -32,26 +35,19 @@ public GrpcGreeterClientTests(ITestOutputHelper output)
3235
LogLevel = PactLogLevel.Information
3336
};
3437

35-
this.pact = Pact.V4("grpc-greeter-client", "grpc-greeter", config)
36-
.WithSynchronousPluginInteractions("protobuf", "0.4.0", transport: "grpc");
38+
this.pact = Pact.V4("grpc-greeter-client", "grpc-greeter", config).WithGrpcInteractions();
3739
}
3840

3941
[Fact]
4042
public void ThrowsExceptionWhenNoGrpcClientRequestMade()
4143
{
4244
string protoFilePath = Path.Join(Directory.GetCurrentDirectory(), "..", "..", "..", "..", "GrpcGreeterClient", "Protos", "greet.proto");
43-
var content = new Dictionary<string, object>
44-
{
45-
{
46-
"pact:proto", protoFilePath
47-
},
48-
{ "pact:proto-service", "Greeter/SayHello" },
49-
{ "pact:content-type", "application/protobuf" },
50-
{ "request", new { name = "matching(equalTo, 'foo')" } },
51-
{ "response", new { message = "matching(equalTo, 'Hello foo')" } }
52-
};
53-
54-
this.pact.UponReceiving("A greeting request to say hello.").WithContent("application/grpc", content);
45+
this.pact
46+
.UponReceiving("A greeting request to say hello.")
47+
.WithRequest(protoFilePath, nameof(Greeter), "SayHello",
48+
new { name = "matching(equalTo, 'foo')" })
49+
.WillRespond()
50+
.WithBody(new { message = "matching(equalTo, 'Hello foo')" });
5551

5652
Assert.Throws<PactFailureException>(() =>
5753
this.pact.Verify(_ =>
@@ -63,20 +59,14 @@ public void ThrowsExceptionWhenNoGrpcClientRequestMade()
6359
[Fact]
6460
public async Task WritesPactForGreeterSayHelloRequest()
6561
{
62+
// Arrange
6663
string protoFilePath = Path.Join(Directory.GetCurrentDirectory(), "..", "..", "..", "..", "GrpcGreeterClient", "Protos", "greet.proto");
67-
var content = new Dictionary<string, object>
68-
{
69-
{
70-
"pact:proto", protoFilePath
71-
},
72-
{ "pact:proto-service", "Greeter/SayHello" },
73-
{ "pact:content-type", "application/protobuf" },
74-
{ "request", new { name = "matching(equalTo, 'foo')" } },
75-
{ "response", new { message = "matching(equalTo, 'Hello foo')" } }
76-
};
77-
78-
79-
this.pact.UponReceiving("A greeting request to say hello.").WithContent("application/grpc", content);
64+
this.pact
65+
.UponReceiving("A greeting request to say hello.")
66+
.WithRequest(protoFilePath, nameof(Greeter), "SayHello",
67+
new { name = "matching(equalTo, 'foo')" })
68+
.WillRespond()
69+
.WithBody(new { message = "matching(equalTo, 'Hello foo')" });
8070

8171
await this.pact.VerifyAsync(async ctx =>
8272
{
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("PactNet.Extensions.Grpc.Tests")]
2+
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("DynamicProxyGenAssembly2")]
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using PactNet.Models;
2+
3+
4+
namespace PactNet.Extensions.Grpc;
5+
6+
/// <summary>
7+
/// Grpc extensions for Pact V4
8+
/// </summary>
9+
public static class GrpcExtensions
10+
{
11+
/// <summary>
12+
/// Add asynchronous message (i.e. consumer/producer) interactions to the pact
13+
/// </summary>
14+
/// <param name="pact">Pact details</param>
15+
/// <param name="port">Port for the mock server. If null, one will be assigned automatically</param>
16+
/// <param name="host">Host for the mock server</param>
17+
/// <returns>Pact builder</returns>
18+
public static IGrpcPactBuilderV4 WithGrpcInteractions(this IPactV4 pact, int? port = null, IPAddress host = IPAddress.Loopback)
19+
{
20+
var pluginBuilder = pact.WithSynchronousPluginInteractions("protobuf", "0.4.0", transport: "grpc", port, host);
21+
var builder = new GrpcPactBuilder(pluginBuilder, pact.Config, port, host);
22+
return builder;
23+
}
24+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
using System;
2+
using System.Threading.Tasks;
3+
using PactNet.Models;
4+
5+
namespace PactNet.Extensions.Grpc;
6+
7+
8+
/// <summary>
9+
/// Grpc pact v4 builder
10+
/// </summary>
11+
public interface IGrpcPactBuilderV4: IPactBuilder, IDisposable
12+
{
13+
/// <summary>
14+
/// Add a new interaction to the pact
15+
/// </summary>
16+
/// <param name="description">Interaction description</param>
17+
/// <returns>Fluent builder</returns>
18+
IGrpcRequestBuilderV4 UponReceiving(string description);
19+
}
20+
21+
internal class GrpcPactBuilder : AbstractPactBuilder, IGrpcPactBuilderV4
22+
{
23+
private readonly ISynchronousPluginPactBuilderV4 pactBuilder;
24+
private ISynchronousPluginRequestBuilderV4 synchronousPluginRequestBuilder;
25+
private GrpcRequestBuilder requestBuilder;
26+
private bool interactionInitialized;
27+
28+
/// <summary>
29+
/// Initialises a new instance of the <see cref="GrpcPactBuilder"/> class.
30+
/// </summary>
31+
internal GrpcPactBuilder(ISynchronousPluginPactBuilderV4 pactBuilder,
32+
PactConfig config, int? port, IPAddress host) : base(pactBuilder.CompletedPactDriver, config, port, host, "grpc")
33+
{
34+
this.pactBuilder = pactBuilder;
35+
}
36+
37+
/// <summary>
38+
/// Create a new request/response interaction
39+
/// </summary>
40+
/// <param name="description">Interaction description</param>
41+
/// <returns>Fluent builder</returns>
42+
public IGrpcRequestBuilderV4 UponReceiving(string description)
43+
{
44+
if (interactionInitialized)
45+
{
46+
throw new InvalidOperationException("An interaction has already been initialized for this pact.");
47+
}
48+
49+
interactionInitialized = true;
50+
synchronousPluginRequestBuilder = pactBuilder.UponReceiving(description);
51+
requestBuilder = new GrpcRequestBuilder(synchronousPluginRequestBuilder);
52+
return requestBuilder;
53+
}
54+
55+
/// <summary>
56+
/// <inheritdoc cref="Verify"/>
57+
/// </summary>
58+
public override void Verify(Action<IConsumerContext> interact)
59+
{
60+
if (!interactionInitialized)
61+
{
62+
throw new InvalidOperationException("No pact has been initialized.");
63+
}
64+
65+
this.synchronousPluginRequestBuilder.WithContent("application/grpc", requestBuilder.InteractionContents);
66+
base.Verify(interact);
67+
}
68+
69+
/// <summary>
70+
/// <inheritdoc cref="VerifyAsync"/>
71+
/// </summary>
72+
public override async Task VerifyAsync(Func<IConsumerContext, Task> interact)
73+
{
74+
if (!interactionInitialized)
75+
{
76+
throw new InvalidOperationException("No pact has been initialized.");
77+
}
78+
79+
this.synchronousPluginRequestBuilder.WithContent("application/grpc", requestBuilder.InteractionContents);
80+
await base.VerifyAsync(interact);
81+
}
82+
83+
public void Dispose() => this.pactBuilder.Dispose();
84+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
using System;
2+
using System.Collections.Generic;
3+
4+
namespace PactNet.Extensions.Grpc;
5+
6+
/// <summary>
7+
/// Grpc request builder
8+
/// </summary>
9+
public interface IGrpcRequestBuilderV4
10+
{
11+
/// <summary>
12+
/// Add a provider state
13+
/// </summary>
14+
/// <param name="providerState">Provider state description</param>
15+
/// <returns>Fluent builder</returns>
16+
IGrpcRequestBuilderV4 Given(string providerState);
17+
18+
/// <summary>
19+
/// Add a provider state with a parameter to the interaction
20+
/// </summary>
21+
/// <param name="description">Provider state description</param>
22+
/// <param name="name">Parameter name</param>
23+
/// <param name="value">Parameter value</param>
24+
/// <returns>Fluent builder</returns>
25+
IGrpcRequestBuilderV4 Given(string description, string name, string value);
26+
27+
/// <summary>
28+
/// Define the response to this request
29+
/// </summary>
30+
/// <returns>Response builder</returns>
31+
IGrpcResponseBuilderV4 WillRespond();
32+
33+
/// <summary>
34+
/// Configure grpc request
35+
/// </summary>
36+
/// <param name="protoFilePath"></param>
37+
/// <param name="serviceName"></param>
38+
/// <param name="methodName"></param>
39+
/// <param name="body"></param>
40+
/// <returns></returns>
41+
IGrpcRequestBuilderV4 WithRequest(string protoFilePath, string serviceName, string methodName, dynamic body);
42+
}
43+
44+
internal class GrpcRequestBuilder(ISynchronousPluginRequestBuilderV4 requestBuilder) : IGrpcRequestBuilderV4
45+
{
46+
private const string PactProtoKey = "pact:proto";
47+
private const string PactProtoServiceKey = "pact:proto-service";
48+
private const string RequestKey = "request";
49+
private const string PactContentType = "pact:content-type";
50+
private bool requestConfigured;
51+
52+
internal readonly Dictionary<string, object> InteractionContents = new();
53+
54+
/// <summary>
55+
/// <inheritdoc cref="Given(string)"/>
56+
/// </summary>
57+
public IGrpcRequestBuilderV4 Given(string providerState)
58+
{
59+
requestBuilder.Given(providerState);
60+
return this;
61+
}
62+
63+
/// <summary>
64+
/// <inheritdoc cref="Given(string, string, string)"/>
65+
/// </summary>
66+
public IGrpcRequestBuilderV4 Given(string description, string name, string value)
67+
{
68+
requestBuilder.Given(description, name, value);
69+
return this;
70+
}
71+
72+
/// <summary>
73+
/// Configure grpc request
74+
/// </summary>
75+
/// <param name="protoFilePath"></param>
76+
/// <param name="serviceName"></param>
77+
/// <param name="methodName"></param>
78+
/// <param name="body"></param>
79+
/// <returns></returns>
80+
public IGrpcRequestBuilderV4 WithRequest(string protoFilePath, string serviceName, string methodName, dynamic body)
81+
{
82+
if (this.requestConfigured)
83+
{
84+
throw new InvalidOperationException("Request has already been configured.");
85+
}
86+
87+
this.InteractionContents.Add(PactProtoKey, protoFilePath);
88+
this.InteractionContents.Add(PactProtoServiceKey, $"{serviceName}/{methodName}");
89+
this.InteractionContents.Add(PactContentType, "application/protobuf");
90+
this.InteractionContents.Add(RequestKey, body);
91+
this.requestConfigured = true;
92+
return this;
93+
}
94+
95+
/// <summary>
96+
/// Define the response to this request
97+
/// </summary>
98+
/// <returns>Response builder</returns>
99+
public IGrpcResponseBuilderV4 WillRespond()
100+
{
101+
if (!this.requestConfigured)
102+
{
103+
throw new InvalidOperationException("You must configure the request before defining the response");
104+
}
105+
106+
var builder = new GrpcResponseBuilder(this.InteractionContents);
107+
return builder;
108+
}
109+
}

0 commit comments

Comments
 (0)