From 3a144886a84302841a1d8e80f32fe017e8968f6a Mon Sep 17 00:00:00 2001 From: Yousaf Nabi Date: Mon, 29 Jul 2024 18:35:21 +0100 Subject: [PATCH] dotnet: add plugin examples --- Makefile | 51 +- .../AvroClient.Tests/AvroClient.Tests.csproj | 22 + .../Avro/AvroClient.Tests/AvroClientTest.cs | 108 ++++ dotnet/Avro/AvroClient/AvroClient.cs | 37 ++ dotnet/Avro/AvroClient/AvroClient.csproj | 15 + .../Avro/AvroClient/Directory.Build.targets | 5 + dotnet/Avro/AvroClient/User.cs | 40 ++ dotnet/Avro/AvroClient/user.avsc | 9 + .../AvroProvider.Tests.csproj | 22 + .../AvroProvider.Tests/AvroProviderTest.cs | 50 ++ dotnet/Avro/AvroProvider/AvroProvider.cs | 92 +++ dotnet/Avro/AvroProvider/AvroProvider.csproj | 16 + .../Avro/AvroProvider/Directory.Build.targets | 5 + dotnet/Avro/AvroProvider/User.cs | 40 ++ dotnet/Avro/AvroProvider/user.avsc | 9 + .../Avro/pacts/AvroConsumer-AvroProvider.json | 81 +++ .../GrpcGreeter.Tests.csproj | 1 + .../Grpc/GrpcGreeter.Tests/GrpcGreeterTest.cs | 27 +- .../GrpcGreeterClient.Tests.csproj | 1 + .../GrpcGreeterClientTest.cs | 35 +- dotnet/Grpc/GrpcGreeterClient.Tests/Pact.cs | 76 --- ... => grpc-greeter-client-grpc-greeter.json} | 16 +- .../{Grpc/GrpcGreeter.Tests => Pact}/Pact.cs | 55 ++ dotnet/Pact/Pact.csproj | 6 + dotnet/PactDotnetPlugin/.gitignore | 5 + dotnet/PactDotnetPlugin/GrpcPactPlugin.csproj | 19 + dotnet/PactDotnetPlugin/Makefile | 130 ++++ dotnet/PactDotnetPlugin/Program.cs | 84 +++ .../Properties/launchSettings.json | 13 + dotnet/PactDotnetPlugin/Protos/plugin.proto | 395 ++++++++++++ dotnet/PactDotnetPlugin/README.md | 229 +++++++ .../Services/PactPluginService.cs | 114 ++++ .../appsettings.Development.json | 8 + dotnet/PactDotnetPlugin/appsettings.json | 17 + dotnet/PactDotnetPlugin/pact-plugin.json | 11 + dotnet/PactDotnetPlugin/script/bump.sh | 66 ++ .../FooPluginConsumer.Tests.csproj | 21 + .../FooPluginConsumerTest.cs | 108 ++++ dotnet/Plugin/README.md | 5 + ...etPluginConsumer-DotnetPluginProvider.json | 67 ++ dotnet/Protobuf/Protos/route_guide.proto | 118 ++++ dotnet/Protobuf/README.md | 24 + dotnet/Protobuf/RouteGuide/RouteGuide.csproj | 22 + dotnet/Protobuf/RouteGuide/RouteGuideUtil.cs | 139 ++++ .../Protobuf/RouteGuide/route_guide_db.json | 601 ++++++++++++++++++ .../RouteGuideClient.Tests.csproj | 22 + .../RouteGuideClientTest.cs | 68 ++ dotnet/Protobuf/RouteGuideClient/Program.cs | 270 ++++++++ .../RouteGuideClient/RouteGuideClient.csproj | 26 + .../RouteGuideServer.Tests.csproj | 22 + .../RouteGuideServerTests.cs | 96 +++ dotnet/Protobuf/RouteGuideServer/Program.cs | 119 ++++ .../Properties/launchSettings.json | 23 + .../RouteGuideServer/RouteGuideImpl.cs | 130 ++++ .../RouteGuideServer/RouteGuideService.csproj | 17 + .../appsettings.Development.json | 10 + .../RouteGuideServer/appsettings.json | 14 + ...ssageconsumer-protobufmessageprovider.json | 88 +++ dotnet/README.md | 120 ++++ dotnet/Tcp/README.md | 5 + .../TcpClient.Tests/TcpClient.Tests.csproj | 22 + dotnet/Tcp/TcpClient.Tests/TcpClientTest.cs | 153 +++++ dotnet/Tcp/TcpClient/TcpClient.cs | 38 ++ dotnet/Tcp/TcpClient/TcpClient.csproj | 13 + .../TcpListener.Tests.csproj | 22 + .../Tcp/TcpListener.Tests/TcpListenerTest.cs | 44 ++ dotnet/Tcp/TcpListener/TcpListener.cs | 73 +++ dotnet/Tcp/TcpListener/TcpListener.csproj | 13 + .../Tcp/pacts/MattConsumer-MattProvider.json | 62 ++ .../matttcpconsumer-matttcpprovider.json | 56 ++ 70 files changed, 4407 insertions(+), 134 deletions(-) create mode 100644 dotnet/Avro/AvroClient.Tests/AvroClient.Tests.csproj create mode 100644 dotnet/Avro/AvroClient.Tests/AvroClientTest.cs create mode 100644 dotnet/Avro/AvroClient/AvroClient.cs create mode 100644 dotnet/Avro/AvroClient/AvroClient.csproj create mode 100644 dotnet/Avro/AvroClient/Directory.Build.targets create mode 100644 dotnet/Avro/AvroClient/User.cs create mode 100644 dotnet/Avro/AvroClient/user.avsc create mode 100644 dotnet/Avro/AvroProvider.Tests/AvroProvider.Tests.csproj create mode 100644 dotnet/Avro/AvroProvider.Tests/AvroProviderTest.cs create mode 100644 dotnet/Avro/AvroProvider/AvroProvider.cs create mode 100644 dotnet/Avro/AvroProvider/AvroProvider.csproj create mode 100644 dotnet/Avro/AvroProvider/Directory.Build.targets create mode 100644 dotnet/Avro/AvroProvider/User.cs create mode 100644 dotnet/Avro/AvroProvider/user.avsc create mode 100644 dotnet/Avro/pacts/AvroConsumer-AvroProvider.json delete mode 100755 dotnet/Grpc/GrpcGreeterClient.Tests/Pact.cs rename dotnet/Grpc/pacts/{grpc-greeter-client-dotnet-grpc-greeter.json => grpc-greeter-client-grpc-greeter.json} (71%) rename dotnet/{Grpc/GrpcGreeter.Tests => Pact}/Pact.cs (64%) create mode 100644 dotnet/Pact/Pact.csproj create mode 100644 dotnet/PactDotnetPlugin/.gitignore create mode 100644 dotnet/PactDotnetPlugin/GrpcPactPlugin.csproj create mode 100644 dotnet/PactDotnetPlugin/Makefile create mode 100644 dotnet/PactDotnetPlugin/Program.cs create mode 100644 dotnet/PactDotnetPlugin/Properties/launchSettings.json create mode 100644 dotnet/PactDotnetPlugin/Protos/plugin.proto create mode 100644 dotnet/PactDotnetPlugin/README.md create mode 100644 dotnet/PactDotnetPlugin/Services/PactPluginService.cs create mode 100644 dotnet/PactDotnetPlugin/appsettings.Development.json create mode 100644 dotnet/PactDotnetPlugin/appsettings.json create mode 100644 dotnet/PactDotnetPlugin/pact-plugin.json create mode 100755 dotnet/PactDotnetPlugin/script/bump.sh create mode 100644 dotnet/Plugin/FooPluginConsumer.Tests/FooPluginConsumer.Tests.csproj create mode 100644 dotnet/Plugin/FooPluginConsumer.Tests/FooPluginConsumerTest.cs create mode 100644 dotnet/Plugin/README.md create mode 100644 dotnet/Plugin/pacts/DotnetPluginConsumer-DotnetPluginProvider.json create mode 100644 dotnet/Protobuf/Protos/route_guide.proto create mode 100644 dotnet/Protobuf/README.md create mode 100644 dotnet/Protobuf/RouteGuide/RouteGuide.csproj create mode 100644 dotnet/Protobuf/RouteGuide/RouteGuideUtil.cs create mode 100644 dotnet/Protobuf/RouteGuide/route_guide_db.json create mode 100644 dotnet/Protobuf/RouteGuideClient.Tests/RouteGuideClient.Tests.csproj create mode 100644 dotnet/Protobuf/RouteGuideClient.Tests/RouteGuideClientTest.cs create mode 100644 dotnet/Protobuf/RouteGuideClient/Program.cs create mode 100644 dotnet/Protobuf/RouteGuideClient/RouteGuideClient.csproj create mode 100644 dotnet/Protobuf/RouteGuideServer.Tests/RouteGuideServer.Tests.csproj create mode 100644 dotnet/Protobuf/RouteGuideServer.Tests/RouteGuideServerTests.cs create mode 100644 dotnet/Protobuf/RouteGuideServer/Program.cs create mode 100644 dotnet/Protobuf/RouteGuideServer/Properties/launchSettings.json create mode 100644 dotnet/Protobuf/RouteGuideServer/RouteGuideImpl.cs create mode 100644 dotnet/Protobuf/RouteGuideServer/RouteGuideService.csproj create mode 100644 dotnet/Protobuf/RouteGuideServer/appsettings.Development.json create mode 100644 dotnet/Protobuf/RouteGuideServer/appsettings.json create mode 100644 dotnet/Protobuf/pacts/protobufmessageconsumer-protobufmessageprovider.json create mode 100644 dotnet/README.md create mode 100644 dotnet/Tcp/README.md create mode 100644 dotnet/Tcp/TcpClient.Tests/TcpClient.Tests.csproj create mode 100644 dotnet/Tcp/TcpClient.Tests/TcpClientTest.cs create mode 100644 dotnet/Tcp/TcpClient/TcpClient.cs create mode 100644 dotnet/Tcp/TcpClient/TcpClient.csproj create mode 100644 dotnet/Tcp/TcpListener.Tests/TcpListener.Tests.csproj create mode 100644 dotnet/Tcp/TcpListener.Tests/TcpListenerTest.cs create mode 100644 dotnet/Tcp/TcpListener/TcpListener.cs create mode 100644 dotnet/Tcp/TcpListener/TcpListener.csproj create mode 100644 dotnet/Tcp/pacts/MattConsumer-MattProvider.json create mode 100644 dotnet/Tcp/pacts/matttcpconsumer-matttcpprovider.json diff --git a/Makefile b/Makefile index faf89df..1731213 100644 --- a/Makefile +++ b/Makefile @@ -94,8 +94,55 @@ dotnet_grpc_client_run: dotnet run --project dotnet/Grpc/GrpcGreeterClient dotnet_grpc_provider_run: dotnet run --project dotnet/Grpc/GrpcGreeter - -dotnet: dotnet_grpc_client_test dotnet_grpc_provider_test +dotnet_grpc: + make dotnet_grpc_client_test + make dotnet_grpc_provider_test + +dotnet_tcp_client_run: + dotnet run --project dotnet/Tcp/TcpClient +dotnet_tcp_provider_run: + dotnet run --project dotnet/Tcp/TcpListener +dotnet_tcp_client_test: + $(LOAD_PATH) dotnet test dotnet/Tcp/TcpClient.Tests +dotnet_tcp_provider_test: + $(LOAD_PATH) dotnet test dotnet/Tcp/TcpListener.Tests +dotnet_tcp: + make dotnet_tcp_client_test + make dotnet_tcp_provider_test + +dotnet_avro_client_run: + dotnet run --project dotnet/Avro/AvroClient +dotnet_avro_provider_run: + dotnet run --project dotnet/Avro/AvroProvider +dotnet_avro_client_test: + $(LOAD_PATH) dotnet test dotnet/Avro/AvroClient.Tests +dotnet_avro_provider_test: + $(LOAD_PATH) dotnet test dotnet/Avro/AvroProvider.Tests +dotnet_avro: + make dotnet_avro_client_test + make dotnet_avro_provider_test + +dotnet_protobuf_client_run: + dotnet run --project dotnet/Protobuf/RouteGuideClient +dotnet_protobuf_provider_run: + dotnet run --project dotnet/Protobuf/RouteGuideServer +dotnet_protobuf_client_test: + $(LOAD_PATH) dotnet test dotnet/Protobuf/RouteGuideClient.Tests +dotnet_protobuf_provider_test: + $(LOAD_PATH) dotnet test dotnet/Protobuf/RouteGuideServer.Tests +dotnet_protobuf: + make dotnet_protobuf_client_test + make dotnet_protobuf_provider_test + +dotnet_plugin_client_test: + $(LOAD_PATH) dotnet test dotnet/Plugin/FooPluginConsumer.Tests +dotnet_plugin_install_local: + cd dotnet/PactDotnetPlugin && make install_local +dotnet_plugin: + make dotnet_plugin_install_local + make dotnet_plugin_client_test + +dotnet: dotnet_grpc dotnet_tcp dotnet_avro dotnet_protobuf dotnet_plugin_client_test dotnet_plugin alpine_php: docker run --platform=${DOCKER_DEFAULT_PLATFORM} -v ${PWD}:/app --rm alpine sh -c 'apk add php make php83-ffi libgcc protoc && cd /app && make php' diff --git a/dotnet/Avro/AvroClient.Tests/AvroClient.Tests.csproj b/dotnet/Avro/AvroClient.Tests/AvroClient.Tests.csproj new file mode 100644 index 0000000..fac317c --- /dev/null +++ b/dotnet/Avro/AvroClient.Tests/AvroClient.Tests.csproj @@ -0,0 +1,22 @@ + + + net8.0 + false + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/dotnet/Avro/AvroClient.Tests/AvroClientTest.cs b/dotnet/Avro/AvroClient.Tests/AvroClientTest.cs new file mode 100644 index 0000000..98dbd23 --- /dev/null +++ b/dotnet/Avro/AvroClient.Tests/AvroClientTest.cs @@ -0,0 +1,108 @@ +using System; +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; +using PactFfi; +using System.Runtime.InteropServices; +using System.IO; +namespace AvroClient.Tests +{ + public class AvroClientTests + { + + [Fact] + public async Task ReturnsMismatchWhenNoAvroClientRequestMade() + { + + _ = Pact.LogToStdOut(3); + var host = "0.0.0.0"; + var pact = Pact.NewPact("AvroConsumer", "AvroProvider"); + var interaction = Pact.NewInteraction(pact, "A request to do get some Avro stuff"); + Pact.WithSpecification(pact, Pact.PactSpecification.V4); + var content = $@"{{ + ""pact:avro"":""{Path.Join(Directory.GetCurrentDirectory(), "..", "..", "..", "..", "AvroClient", "user.avsc").Replace("\\", "\\\\")}"", + ""pact:record-name"": ""User"", + ""pact:content-type"": ""avro/binary"", + ""id"": ""matching(number, 1)"", + ""username"": ""notEmpty('matt')"" + }}"; + Pact.PluginAdd(pact, "avro", "0.0.5"); + Pact.WithRequest(interaction,"GET", "/avro"); + Pact.ResponseStatus(interaction,200); + Pact.PluginInteractionContents(interaction, Pact.InteractionPart.Response, "avro/binary", content); + + var port = Pact.CreateMockServerForTransport(pact, host, 0, null, null); + Console.WriteLine("Port: " + port); + + var matched = Pact.MockServerMatched(port); + Console.WriteLine("Matched: " + matched); + matched.Should().BeFalse(); + + var MismatchesPtr = Pact.MockServerMismatches(port); + var MismatchesString = Marshal.PtrToStringAnsi(MismatchesPtr); + Console.WriteLine("Mismatches: " + MismatchesString); + var MismatchesJson = JsonSerializer.Deserialize(MismatchesString); + var ErrorString = MismatchesJson[0].GetProperty("type").GetString(); + var ExpectedPath = MismatchesJson[0].GetProperty("path").GetString(); + + ErrorString.Should().Be("missing-request"); + ExpectedPath.Should().Be("/avro"); + + Pact.CleanupMockServer(port); + Pact.PluginCleanup(pact); + await Task.Delay(1); + } + [Fact] + public async Task WritesPactWhenGrpcClientRequestMade() + { + + _ = Pact.LogToStdOut(3); + var host = "0.0.0.0"; + var pact = Pact.NewPact("AvroConsumer", "AvroProvider"); + var interaction = Pact.NewInteraction(pact, "A request to do get some Avro stuff"); + Pact.WithSpecification(pact, Pact.PactSpecification.V4); + var content = $@"{{ + ""pact:avro"":""{Path.Join(Directory.GetCurrentDirectory(), "..", "..", "..", "..", "AvroClient", "user.avsc").Replace("\\", "\\\\")}"", + ""pact:record-name"": ""User"", + ""pact:content-type"": ""avro/binary"", + ""id"": ""matching(number, 1)"", + ""username"": ""notEmpty('matt')"" + }}"; + Pact.PluginAdd(pact, "avro", "0.0.5"); + Pact.WithRequest(interaction,"GET", "/avro"); + Pact.ResponseStatus(interaction,200); + Pact.PluginInteractionContents(interaction, Pact.InteractionPart.Response, "avro/binary", content); + + var port = Pact.CreateMockServerForTransport(pact, host, 0, null, null); + Console.WriteLine("Port: " + port); + + // act - call avro client + var result = await AvroClient.RunAvroClient("http://localhost:" + port); + Console.WriteLine("Result: " + result); + // assert + result.Id.Should().Be(1); + result.Username.Should().Be("matt"); + + // pact - internal assert + var matched = Pact.MockServerMatched(port); + Console.WriteLine("Matched: " + matched); + matched.Should().BeTrue(); + + var MismatchesPtr = Pact.MockServerMismatches(port); + var MismatchesString = Marshal.PtrToStringAnsi(MismatchesPtr); + Console.WriteLine("Mismatches: " + MismatchesString); + + MismatchesString.Should().Be("[]"); + + // pact - internal finalise and cleanup + var writeRes = Pact.WritePactFileForPort(port, "../../../../pacts", false); + Console.WriteLine("WriteRes: " + writeRes); + Pact.CleanupMockServer(port); + Pact.PluginCleanup(pact); + } + + + + } +} diff --git a/dotnet/Avro/AvroClient/AvroClient.cs b/dotnet/Avro/AvroClient/AvroClient.cs new file mode 100644 index 0000000..09abfb7 --- /dev/null +++ b/dotnet/Avro/AvroClient/AvroClient.cs @@ -0,0 +1,37 @@ +using Avro; +using Avro.IO; +using Avro.Specific; + +namespace AvroClient +{ + public class AvroClient + { + static async Task Main(string[] args) + { + await RunAvroClient(); + } + + public static async Task RunAvroClient(string avroProviderUrl = "http://localhost:8080") + { + // Load the user schema from the avsc file + var schema = (RecordSchema)Schema.Parse(File.ReadAllText(Path.Join(Directory.GetCurrentDirectory(), ".." , "..", "..", "..","AvroClient","user.avsc"))); + + // Make an HTTP request to the AvroProvider + var client = new HttpClient(new HttpClientHandler() { UseDefaultCredentials = true }); + var response = await client.GetAsync(avroProviderUrl + "/avro"); + + // Get the response from the AvroProvider + var responseStream = await response.Content.ReadAsStreamAsync(); + + // Deserialize the Avro binary data from the response stream + var reader = new BinaryDecoder(responseStream); + var avroReader = new SpecificDefaultReader(schema, schema); + var deserializedUser = new User(); + avroReader.Read(deserializedUser, reader); + + // Print the deserialized user object + Console.WriteLine($"Deserialized User: Id={deserializedUser.Id}, Username={deserializedUser.Username}"); + return deserializedUser; + } + } +} diff --git a/dotnet/Avro/AvroClient/AvroClient.csproj b/dotnet/Avro/AvroClient/AvroClient.csproj new file mode 100644 index 0000000..c4dd282 --- /dev/null +++ b/dotnet/Avro/AvroClient/AvroClient.csproj @@ -0,0 +1,15 @@ + + + + Exe + net8.0 + enable + enable + + + false + + + + + diff --git a/dotnet/Avro/AvroClient/Directory.Build.targets b/dotnet/Avro/AvroClient/Directory.Build.targets new file mode 100644 index 0000000..bcb9891 --- /dev/null +++ b/dotnet/Avro/AvroClient/Directory.Build.targets @@ -0,0 +1,5 @@ + + + $(TargetDir) + + diff --git a/dotnet/Avro/AvroClient/User.cs b/dotnet/Avro/AvroClient/User.cs new file mode 100644 index 0000000..3e792e6 --- /dev/null +++ b/dotnet/Avro/AvroClient/User.cs @@ -0,0 +1,40 @@ +using Avro; +using Avro.Specific; +// Define the User class based on the user.avsc schema + +public class User : ISpecificRecord + { + public static Schema _SCHEMA = Schema.Parse(File.ReadAllText("user.avsc")); + + public virtual Schema Schema => _SCHEMA; + + public long Id { get; set; } + public string Username { get; set; } = string.Empty; + + Schema ISpecificRecord.Schema => throw new NotImplementedException(); + + object ISpecificRecord.Get(int fieldPos) + { + return fieldPos switch + { + 0 => Id, + 1 => Username, + _ => throw new AvroRuntimeException("Invalid field index"), + }; + } + + void ISpecificRecord.Put(int fieldPos, object fieldValue) + { + switch (fieldPos) + { + case 0: + Id = (long)fieldValue; + break; + case 1: + Username = (string)fieldValue; + break; + default: + throw new AvroRuntimeException("Invalid field index"); + } + } + } diff --git a/dotnet/Avro/AvroClient/user.avsc b/dotnet/Avro/AvroClient/user.avsc new file mode 100644 index 0000000..a4add24 --- /dev/null +++ b/dotnet/Avro/AvroClient/user.avsc @@ -0,0 +1,9 @@ +{ + "type": "record", + "name": "User", + "namespace": "io.pact", + "fields" : [ + {"name": "id", "type": "long"}, + {"name": "username", "type": "string"} + ] +} \ No newline at end of file diff --git a/dotnet/Avro/AvroProvider.Tests/AvroProvider.Tests.csproj b/dotnet/Avro/AvroProvider.Tests/AvroProvider.Tests.csproj new file mode 100644 index 0000000..7f2640f --- /dev/null +++ b/dotnet/Avro/AvroProvider.Tests/AvroProvider.Tests.csproj @@ -0,0 +1,22 @@ + + + net8.0 + false + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/dotnet/Avro/AvroProvider.Tests/AvroProviderTest.cs b/dotnet/Avro/AvroProvider.Tests/AvroProviderTest.cs new file mode 100644 index 0000000..0444694 --- /dev/null +++ b/dotnet/Avro/AvroProvider.Tests/AvroProviderTest.cs @@ -0,0 +1,50 @@ +using FluentAssertions; +using Xunit; +using PactFfi; +using System.Threading.Tasks; + +namespace AvroProvider.Tests +{ + public class AvroProviderTests + { + + [Fact] + public void ReturnsVerificationFailureWhenNoRunningProvider() + { + _ = Pact.LogToStdOut(3); + + var verifier = Pact.VerifierNewForApplication("pact-dotnet","0.0.0"); + Pact.VerifierSetProviderInfo(verifier,"AvroProvider",null,null,8081,null); + Pact.VerifierAddFileSource(verifier,"../../../../pacts/AvroConsumer-AvroProvider.json"); + var VerifierExecuteResult = Pact.VerifierExecute(verifier); + VerifierExecuteResult.Should().Be(1); + } + [Fact] + public async Task ReturnsVerificationSuccessRunningProviderAsync() + { + _ = Pact.LogToStdOut(3); + ushort port = 8080; + var verifier = Pact.VerifierNewForApplication("pact-dotnet", "0.0.0"); + Pact.VerifierSetProviderInfo(verifier,"AvroProvider",null,null,port,null); + Pact.VerifierAddFileSource(verifier,"../../../../pacts/AvroConsumer-AvroProvider.json"); + + // // Arrange + // // Setup our app to run before our verifier executes + // // Setup a cancellation token so we can shutdown the app after + var cts = new System.Threading.CancellationTokenSource(); + var token = cts.Token; + var runAppTask = Task.Run(async () => + { + await AvroProvider.StartServer(token, "http://localhost:" + port + "/"); + }, token); + + // Act + var VerifierExecuteResult = Pact.VerifierExecute(verifier); + VerifierExecuteResult.Should().Be(0); + Pact.VerifierShutdown(verifier); + + // After test execution, signal the task to terminate + cts.Cancel(); + } + } +} diff --git a/dotnet/Avro/AvroProvider/AvroProvider.cs b/dotnet/Avro/AvroProvider/AvroProvider.cs new file mode 100644 index 0000000..242e6ac --- /dev/null +++ b/dotnet/Avro/AvroProvider/AvroProvider.cs @@ -0,0 +1,92 @@ +using Avro; +using Avro.IO; +using Avro.Specific; +using System.Net; + +namespace AvroProvider +{ + public class AvroProvider + { + static void Main(string[] args) + { + var cancellationTokenSource = new CancellationTokenSource(); + var cancellationToken = cancellationTokenSource.Token; + + var serverTask = StartServer(cancellationToken); + + // Wait for user input to stop the server + Console.WriteLine("Press Ctrl+C to stop the server..."); + Console.ReadKey(); + + // Signal cancellation to stop the server + cancellationTokenSource.Cancel(); + + // Wait for the server task to complete + serverTask.Wait(); + } + + public static async Task StartServer(CancellationToken cancellationToken, string listenerUrl = "http://localhost:8080/") + { + // Load the user schema from the avsc file + var schema = (RecordSchema)Schema.Parse(File.ReadAllText(Path.Join(Directory.GetCurrentDirectory(), "..", "..", "..", "..", "AvroProvider", "user.avsc"))); + // Create a user object + var user = new User + { + Id = 1, + Username = "matt", + }; + + // Serialize the user object to Avro binary format + using var stream = new MemoryStream(); + var writer = new BinaryEncoder(stream); + var avroWriter = new SpecificDefaultWriter(schema); + avroWriter.Write(user, writer); + writer.Flush(); + + // Start the HTTP server + var listener = new HttpListener(); + listener.Prefixes.Add(listenerUrl); + listener.Start(); + Console.WriteLine("Server started. Listening for requests..."); + + try + { + // Handle incoming requests + while (!cancellationToken.IsCancellationRequested) + { + var context = await listener.GetContextAsync(); + var request = context.Request; + var response = context.Response; + + if (request.Url?.AbsolutePath == "/avro") + { + // Set the response content type to Avro binary format + response.ContentType = "avro/binary;record=User"; + + // Write the Avro binary data to the response stream + response.ContentLength64 = stream.Length; + stream.Position = 0; + await stream.CopyToAsync(response.OutputStream); + } + else + { + // Return a 404 Not Found response for other endpoints + response.StatusCode = 404; + response.Close(); + } + } + } + catch (OperationCanceledException) + { + // Server was cancelled, do any cleanup here + Console.WriteLine("Server stopped."); + } + finally + { + // Stop the listener + listener.Stop(); + listener.Close(); + } + } + } +} diff --git a/dotnet/Avro/AvroProvider/AvroProvider.csproj b/dotnet/Avro/AvroProvider/AvroProvider.csproj new file mode 100644 index 0000000..95e4151 --- /dev/null +++ b/dotnet/Avro/AvroProvider/AvroProvider.csproj @@ -0,0 +1,16 @@ + + + + Exe + net8.0 + enable + enable + + + false + + + + + + diff --git a/dotnet/Avro/AvroProvider/Directory.Build.targets b/dotnet/Avro/AvroProvider/Directory.Build.targets new file mode 100644 index 0000000..bcb9891 --- /dev/null +++ b/dotnet/Avro/AvroProvider/Directory.Build.targets @@ -0,0 +1,5 @@ + + + $(TargetDir) + + diff --git a/dotnet/Avro/AvroProvider/User.cs b/dotnet/Avro/AvroProvider/User.cs new file mode 100644 index 0000000..3e792e6 --- /dev/null +++ b/dotnet/Avro/AvroProvider/User.cs @@ -0,0 +1,40 @@ +using Avro; +using Avro.Specific; +// Define the User class based on the user.avsc schema + +public class User : ISpecificRecord + { + public static Schema _SCHEMA = Schema.Parse(File.ReadAllText("user.avsc")); + + public virtual Schema Schema => _SCHEMA; + + public long Id { get; set; } + public string Username { get; set; } = string.Empty; + + Schema ISpecificRecord.Schema => throw new NotImplementedException(); + + object ISpecificRecord.Get(int fieldPos) + { + return fieldPos switch + { + 0 => Id, + 1 => Username, + _ => throw new AvroRuntimeException("Invalid field index"), + }; + } + + void ISpecificRecord.Put(int fieldPos, object fieldValue) + { + switch (fieldPos) + { + case 0: + Id = (long)fieldValue; + break; + case 1: + Username = (string)fieldValue; + break; + default: + throw new AvroRuntimeException("Invalid field index"); + } + } + } diff --git a/dotnet/Avro/AvroProvider/user.avsc b/dotnet/Avro/AvroProvider/user.avsc new file mode 100644 index 0000000..a4add24 --- /dev/null +++ b/dotnet/Avro/AvroProvider/user.avsc @@ -0,0 +1,9 @@ +{ + "type": "record", + "name": "User", + "namespace": "io.pact", + "fields" : [ + {"name": "id", "type": "long"}, + {"name": "username", "type": "string"} + ] +} \ No newline at end of file diff --git a/dotnet/Avro/pacts/AvroConsumer-AvroProvider.json b/dotnet/Avro/pacts/AvroConsumer-AvroProvider.json new file mode 100644 index 0000000..b461bac --- /dev/null +++ b/dotnet/Avro/pacts/AvroConsumer-AvroProvider.json @@ -0,0 +1,81 @@ +{ + "consumer": { + "name": "AvroConsumer" + }, + "interactions": [ + { + "description": "A request to do get some Avro stuff", + "pending": false, + "pluginConfiguration": { + "avro": { + "record": "User", + "schemaKey": "1184dbf3292cee8bc7390762dd15fc52" + } + }, + "request": { + "method": "GET", + "path": "/avro" + }, + "response": { + "body": { + "content": "AghtYXR0", + "contentType": "avro/binary;record=User", + "contentTypeHint": "BINARY", + "encoded": "base64" + }, + "headers": { + "content-type": [ + "avro/binary" + ] + }, + "matchingRules": { + "body": { + "$.id": { + "combine": "AND", + "matchers": [ + { + "match": "number" + } + ] + }, + "$.username": { + "combine": "AND", + "matchers": [ + { + "match": "notEmpty" + } + ] + } + } + }, + "status": 200 + }, + "transport": "http", + "type": "Synchronous/HTTP" + } + ], + "metadata": { + "pactRust": { + "ffi": "0.4.22", + "mockserver": "1.2.9", + "models": "1.2.3" + }, + "pactSpecification": { + "version": "4.0" + }, + "plugins": [ + { + "configuration": { + "1184dbf3292cee8bc7390762dd15fc52": { + "avroSchema": "{\"type\":\"record\",\"name\":\"User\",\"namespace\":\"io.pact\",\"fields\":[{\"name\":\"id\",\"type\":\"long\"},{\"name\":\"username\",\"type\":\"string\"}]}" + } + }, + "name": "avro", + "version": "0.0.5" + } + ] + }, + "provider": { + "name": "AvroProvider" + } +} \ No newline at end of file diff --git a/dotnet/Grpc/GrpcGreeter.Tests/GrpcGreeter.Tests.csproj b/dotnet/Grpc/GrpcGreeter.Tests/GrpcGreeter.Tests.csproj index 4f68076..300f69f 100644 --- a/dotnet/Grpc/GrpcGreeter.Tests/GrpcGreeter.Tests.csproj +++ b/dotnet/Grpc/GrpcGreeter.Tests/GrpcGreeter.Tests.csproj @@ -14,6 +14,7 @@ + diff --git a/dotnet/Grpc/GrpcGreeter.Tests/GrpcGreeterTest.cs b/dotnet/Grpc/GrpcGreeter.Tests/GrpcGreeterTest.cs index fdde313..13df15f 100644 --- a/dotnet/Grpc/GrpcGreeter.Tests/GrpcGreeterTest.cs +++ b/dotnet/Grpc/GrpcGreeter.Tests/GrpcGreeterTest.cs @@ -1,11 +1,8 @@ -using System; -using System.Text.Json; using System.Threading.Tasks; using System.Threading; using FluentAssertions; using Xunit; using PactFfi; -using System.Runtime.InteropServices; namespace GrpcGreeter.Tests { @@ -13,37 +10,27 @@ public class GrpcGreeterTests { [Fact] - public async Task ReturnsVerificationFailureWhenNoRunningProvider() + public void ReturnsVerificationFailureWhenNoRunningProvider() { - var version = Marshal.PtrToStringAnsi(Pact.Version()); - version.Should().Be("0.4.22"); - Pact.LoggerInit(); - Pact.LoggerAttachSink("stdout",4); - Pact.LoggerApply(); - Pact.LogMessage("pact-dotnet","info",$"hello from ffi version: {version}"); - await Task.Delay(1); + _ = Pact.LogToStdOut(3); var verifier = Pact.VerifierNewForApplication("pact-dotnet","0.0.0"); Pact.VerifierSetProviderInfo(verifier,"grpc-greeter",null,null,0,null); Pact.AddProviderTransport(verifier, "grpc",5060,"/","http"); - Pact.VerifierAddFileSource(verifier,"../../../../pacts/grpc-greeter-client-dotnet-grpc-greeter.json"); + Pact.VerifierAddFileSource(verifier,"../../../../pacts/grpc-greeter-client-grpc-greeter.json"); var VerifierExecuteResult = Pact.VerifierExecute(verifier); VerifierExecuteResult.Should().Be(1); } [Fact] public async Task ReturnsVerificationSuccessRunningProviderAsync() { - var version = Marshal.PtrToStringAnsi(Pact.Version()); - version.Should().Be("0.4.22"); - Pact.LoggerInit(); - Pact.LoggerAttachSink("stdout", 3); - Pact.LoggerApply(); - Pact.LogMessage("pact-dotnet", "info", $"hello from ffi version: {version}"); + _ = Pact.LogToStdOut(3); + var verifier = Pact.VerifierNewForApplication("pact-dotnet", "0.0.0"); Pact.VerifierSetProviderInfo(verifier, "grpc-greeter", null, null, 0, null); Pact.AddProviderTransport(verifier, "grpc", 5000, "/", "https"); - Pact.VerifierAddFileSource(verifier, "../../../../pacts/grpc-greeter-client-dotnet-grpc-greeter.json"); + Pact.VerifierAddFileSource(verifier, "../../../../pacts/grpc-greeter-client-grpc-greeter.json"); // Arrange // Setup our app to run before our verifier executes @@ -52,7 +39,7 @@ public async Task ReturnsVerificationSuccessRunningProviderAsync() var token = cts.Token; var runAppTask = Task.Run(async () => { - await GrpcGreeterService.RunApp(new string[] { }, token); + await GrpcGreeterService.RunApp([], token); }, token); await Task.Delay(2000); diff --git a/dotnet/Grpc/GrpcGreeterClient.Tests/GrpcGreeterClient.Tests.csproj b/dotnet/Grpc/GrpcGreeterClient.Tests/GrpcGreeterClient.Tests.csproj index 35ee71a..b062fe2 100644 --- a/dotnet/Grpc/GrpcGreeterClient.Tests/GrpcGreeterClient.Tests.csproj +++ b/dotnet/Grpc/GrpcGreeterClient.Tests/GrpcGreeterClient.Tests.csproj @@ -14,6 +14,7 @@ + diff --git a/dotnet/Grpc/GrpcGreeterClient.Tests/GrpcGreeterClientTest.cs b/dotnet/Grpc/GrpcGreeterClient.Tests/GrpcGreeterClientTest.cs index b90bd10..4edaf07 100644 --- a/dotnet/Grpc/GrpcGreeterClient.Tests/GrpcGreeterClientTest.cs +++ b/dotnet/Grpc/GrpcGreeterClient.Tests/GrpcGreeterClientTest.cs @@ -16,14 +16,10 @@ public class GrpcGreeterClientTests public async Task ReturnsMismatchWhenNoGrpcClientRequestMade() { - var version = Marshal.PtrToStringAnsi(Pact.Version()); - version.Should().Be("0.4.22"); - Pact.LoggerInit(); - Pact.LoggerAttachSink("stdout", 3); - Pact.LoggerApply(); - Pact.LogMessage("pact-dotnet", "info", $"hello from ffi version: {version}"); + _ = Pact.LogToStdOut(3); + // arrange var host = "0.0.0.0"; - var pact = Pact.NewPact("foo", "bar"); + var pact = Pact.NewPact("grpc-greeter-client", "grpc-greeter"); var interaction = Pact.NewSyncMessageInteraction(pact, "a request to a plugin"); Pact.WithSpecification(pact, Pact.PactSpecification.V4); var content = $@"{{ @@ -31,10 +27,10 @@ public async Task ReturnsMismatchWhenNoGrpcClientRequestMade() ""pact:proto-service"": ""Greeter/SayHello"", ""pact:content-type"": ""application/protobuf"", ""request"": {{ - ""name"": ""matching(type, 'foo')"" + ""name"": ""matching(type, 'foo')"" }}, ""response"": {{ - ""message"": [""matching(type, 'Hello foo')""] + ""message"": ""matching(type, 'Hello foo')"" }} }}"; Pact.PluginAdd(pact, "protobuf", "0.4.0"); @@ -65,14 +61,10 @@ public async Task ReturnsMismatchWhenNoGrpcClientRequestMade() public async Task WritesPactWhenGrpcClientRequestMade() { - var version = Marshal.PtrToStringAnsi(Pact.Version()); - version.Should().Be("0.4.22"); - Pact.LoggerInit(); - Pact.LoggerAttachSink("file .log",3); - Pact.LoggerApply(); - Pact.LogMessage("pact-dotnet", "info", $"hello from ffi version: {version}"); + _ = Pact.LogToStdOut(3); + // arrange var host = "0.0.0.0"; - var pact = Pact.NewPact("grpc-greeter-client-dotnet", "grpc-greeter"); + var pact = Pact.NewPact("grpc-greeter-client", "grpc-greeter"); var interaction = Pact.NewSyncMessageInteraction(pact, "a request to a plugin"); Pact.WithSpecification(pact, Pact.PactSpecification.V4); var content = $@"{{ @@ -83,26 +75,23 @@ public async Task WritesPactWhenGrpcClientRequestMade() ""name"": ""matching(type, 'foo')"" }}, ""response"": {{ - ""message"": [""matching(type, 'Hello foo')""] + ""message"": ""matching(type, 'Hello foo')"" }} }}"; - // TODO - Investigate matchers - // Failures: - // 1) Verifying a pact between grpc-greeter-client-dotnet and grpc-greeter - a request to a plugin - // 1.1) has a matching body - // $.message -> Expected 'Hello foo' to be equal to 'hello foo' - Pact.PluginAdd(pact, "protobuf", "0.4.0"); Pact.PluginInteractionContents(interaction, 0, "application/grpc", content); var port = Pact.CreateMockServerForTransport(pact, host, 0, "grpc", null); Console.WriteLine("Port: " + port); + // act var client = new GreeterClientWrapper("http://localhost:" + port); var result = await client.SayHello("foo"); Console.WriteLine("Result: " + result); + // assert + result.Should().Be("Hello foo"); var matched = Pact.MockServerMatched(port); Console.WriteLine("Matched: " + matched); matched.Should().BeTrue(); diff --git a/dotnet/Grpc/GrpcGreeterClient.Tests/Pact.cs b/dotnet/Grpc/GrpcGreeterClient.Tests/Pact.cs deleted file mode 100755 index 914db96..0000000 --- a/dotnet/Grpc/GrpcGreeterClient.Tests/Pact.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Runtime.InteropServices; - -namespace PactFfi -{ - public class Pact - - { - const string DllName = "pact_ffi"; - - public enum InteractionPart - { - Request = 0, - Response = 1 - } - public enum PactSpecification - { - Unknown = 0, - V1 = 1, - V1_1 = 2, - V2 = 3, - V3 = 4, - V4 = 5 - } - - [DllImport(DllName, EntryPoint = "pactffi_version")] - public static extern IntPtr Version(); - [DllImport(DllName, EntryPoint = "pactffi_logger_init")] - public static extern void LoggerInit(); - [DllImport(DllName, EntryPoint = "pactffi_logger_attach_sink")] - public static extern Int32 LoggerAttachSink( string sinkSpecifier, Int32 levelFilter); - [DllImport(DllName, EntryPoint = "pactffi_logger_apply")] - public static extern void LoggerApply(); - [DllImport(DllName, EntryPoint = "pactffi_log_message")] - public static extern void LogMessage(string source,string logLevel,string message); - - - [DllImport(DllName, EntryPoint = "pactffi_new_pact")] - public static extern uint NewPact(string consumerName, string providerName); - [DllImport(DllName, EntryPoint = "pactffi_with_specification")] - public static extern bool WithSpecification(uint pact, PactSpecification version); - [DllImport(DllName, EntryPoint = "pactffi_new_interaction")] - public static extern uint NewInteraction(uint pact, string description); - [DllImport(DllName, EntryPoint = "pactffi_new_sync_message_interaction")] - public static extern uint NewSyncMessageInteraction(uint pact, string description); - - [DllImport(DllName, EntryPoint = "pactffi_create_mock_server_for_transport")] - public static extern int CreateMockServerForTransport(uint pact, string addrStr, ushort port, string transport, string transportConfig); - [DllImport(DllName, EntryPoint = "pactffi_mock_server_mismatches")] - public static extern IntPtr MockServerMismatches(int mockServerPort); - - [DllImport(DllName, EntryPoint = "pactffi_mock_server_matched")] - public static extern bool MockServerMatched(int mockServerPort); - - [DllImport(DllName, EntryPoint = "pactffi_cleanup_mock_server")] - public static extern bool CleanupMockServer(int mockServerPort); - - [DllImport(DllName, EntryPoint = "pactffi_pact_handle_write_file")] - public static extern int WritePactFile(uint pact, string directory, bool overwrite); - - [DllImport(DllName, EntryPoint = "pactffi_write_pact_file")] - public static extern int WritePactFileForPort(int port, string directory, bool overwrite); - - // Plugins - [DllImport(DllName, EntryPoint = "pactffi_interaction_contents")] - public static extern uint PluginInteractionContents(uint interaction, InteractionPart part, string contentType, string body); - [DllImport(DllName, EntryPoint = "pactffi_using_plugin")] - public static extern uint PluginAdd(uint pact, string name, string version); - [DllImport(DllName, EntryPoint = "pactffi_cleanup_plugins")] - public static extern void PluginCleanup(uint pact); - - } -} \ No newline at end of file diff --git a/dotnet/Grpc/pacts/grpc-greeter-client-dotnet-grpc-greeter.json b/dotnet/Grpc/pacts/grpc-greeter-client-grpc-greeter.json similarity index 71% rename from dotnet/Grpc/pacts/grpc-greeter-client-dotnet-grpc-greeter.json rename to dotnet/Grpc/pacts/grpc-greeter-client-grpc-greeter.json index d1315aa..2e8bd83 100644 --- a/dotnet/Grpc/pacts/grpc-greeter-client-dotnet-grpc-greeter.json +++ b/dotnet/Grpc/pacts/grpc-greeter-client-grpc-greeter.json @@ -1,12 +1,12 @@ { "consumer": { - "name": "grpc-greeter-client-dotnet" + "name": "grpc-greeter-client" }, "interactions": [ { "description": "a request to a plugin", "interactionMarkup": { - "markup": "```protobuf\nmessage HelloReply {\n repeated string message = 1;\n}\n```\n", + "markup": "```protobuf\nmessage HelloReply {\n string message = 1;\n}\n```\n", "markupType": "COMMON_MARK" }, "pending": false, @@ -49,7 +49,7 @@ }, "matchingRules": { "body": { - "$.message[0].*": { + "$.message": { "combine": "AND", "matchers": [ { @@ -87,16 +87,6 @@ }, "name": "protobuf", "version": "0.4.0" - }, - { - "configuration": { - "e8e1fe144f808b9b0faecd7b2605efea": { - "protoDescriptors": "Cr0BCgtncmVldC5wcm90bxIFZ3JlZXQiIgoMSGVsbG9SZXF1ZXN0EhIKBG5hbWUYASABKAlSBG5hbWUiJgoKSGVsbG9SZXBseRIYCgdtZXNzYWdlGAEgASgJUgdtZXNzYWdlMj0KB0dyZWV0ZXISMgoIU2F5SGVsbG8SEy5ncmVldC5IZWxsb1JlcXVlc3QaES5ncmVldC5IZWxsb1JlcGx5QhSqAhFHcnBjR3JlZXRlckNsaWVudGIGcHJvdG8z", - "protoFile": "syntax = \"proto3\";\r\n\r\noption csharp_namespace = \"GrpcGreeterClient\";\r\n\r\npackage greet;\r\n\r\n// The greeting service definition.\r\nservice Greeter {\r\n // Sends a greeting\r\n rpc SayHello (HelloRequest) returns (HelloReply);\r\n}\r\n\r\n// The request message containing the user's name.\r\nmessage HelloRequest {\r\n string name = 1;\r\n}\r\n\r\n// The response message containing the greetings.\r\nmessage HelloReply {\r\n string message = 1;\r\n}\r\n" - } - }, - "name": "protobuf", - "version": "0.3.15" } ] }, diff --git a/dotnet/Grpc/GrpcGreeter.Tests/Pact.cs b/dotnet/Pact/Pact.cs similarity index 64% rename from dotnet/Grpc/GrpcGreeter.Tests/Pact.cs rename to dotnet/Pact/Pact.cs index cc50e72..38d3ba7 100755 --- a/dotnet/Grpc/GrpcGreeter.Tests/Pact.cs +++ b/dotnet/Pact/Pact.cs @@ -34,9 +34,64 @@ public enum PactSpecification public static extern Int32 LoggerAttachSink( string sinkSpecifier, Int32 levelFilter); [DllImport(DllName, EntryPoint = "pactffi_logger_apply")] public static extern void LoggerApply(); + + [DllImport(DllName, EntryPoint = "pactffi_log_to_stdout")] + public static extern Int32 LogToStdOut( Int32 levelFilter); + [DllImport(DllName, EntryPoint = "pactffi_log_message")] public static extern void LogMessage(string source,string logLevel,string message); + + [DllImport(DllName, EntryPoint = "pactffi_new_pact")] + public static extern uint NewPact(string consumerName, string providerName); + [DllImport(DllName, EntryPoint = "pactffi_with_specification")] + public static extern bool WithSpecification(uint pact, PactSpecification version); + [DllImport(DllName, EntryPoint = "pactffi_new_interaction")] + public static extern uint NewInteraction(uint pact, string description); + + [DllImport(DllName, EntryPoint = "pactffi_with_request")] + public static extern bool WithRequest(uint interaction, string method, string path); + + [DllImport(DllName, EntryPoint = "pactffi_response_status")] + public static extern bool ResponseStatus(uint interaction, ushort status); + + [DllImport(DllName, EntryPoint = "pactffi_given")] + public static extern bool Given(uint interaction, string description); + + [DllImport(DllName, EntryPoint = "pactffi_new_sync_message_interaction")] + public static extern uint NewSyncMessageInteraction(uint pact, string description); + [DllImport(DllName, EntryPoint = "pactffi_new_message_interaction")] + public static extern uint NewMessageInteraction(uint pact, string description); + [DllImport(DllName, EntryPoint = "pactffi_pact_handle_get_message_iter")] + public static extern uint PactHandleGetMessageIter(uint pact); + + [DllImport(DllName, EntryPoint = "pactffi_create_mock_server_for_transport")] + public static extern int CreateMockServerForTransport(uint pact, string addrStr, ushort port, string transport, string transportConfig); + [DllImport(DllName, EntryPoint = "pactffi_mock_server_mismatches")] + public static extern IntPtr MockServerMismatches(int mockServerPort); + + [DllImport(DllName, EntryPoint = "pactffi_mock_server_matched")] + public static extern bool MockServerMatched(int mockServerPort); + + [DllImport(DllName, EntryPoint = "pactffi_cleanup_mock_server")] + public static extern bool CleanupMockServer(int mockServerPort); + + [DllImport(DllName, EntryPoint = "pactffi_pact_handle_write_file")] + public static extern int WritePactFile(uint pact, string directory, bool overwrite); + + [DllImport(DllName, EntryPoint = "pactffi_write_pact_file")] + public static extern int WritePactFileForPort(int port, string directory, bool overwrite); + [DllImport(DllName, EntryPoint = "pactffi_write_message_pact_file")] + public static extern int WriteMessagePactFile(uint pact, string directory, bool overwrite); + + // Plugins + [DllImport(DllName, EntryPoint = "pactffi_interaction_contents")] + public static extern uint PluginInteractionContents(uint interaction, InteractionPart part, string contentType, string body); + [DllImport(DllName, EntryPoint = "pactffi_using_plugin")] + public static extern uint PluginAdd(uint pact, string name, string version); + [DllImport(DllName, EntryPoint = "pactffi_cleanup_plugins")] + public static extern void PluginCleanup(uint pact); + // verifier [DllImport(DllName, EntryPoint = "pactffi_verifier_new_for_application")] diff --git a/dotnet/Pact/Pact.csproj b/dotnet/Pact/Pact.csproj new file mode 100644 index 0000000..710ade3 --- /dev/null +++ b/dotnet/Pact/Pact.csproj @@ -0,0 +1,6 @@ + + + net8.0 + false + + diff --git a/dotnet/PactDotnetPlugin/.gitignore b/dotnet/PactDotnetPlugin/.gitignore new file mode 100644 index 0000000..39625a1 --- /dev/null +++ b/dotnet/PactDotnetPlugin/.gitignore @@ -0,0 +1,5 @@ +obj +bin +build +.DS_Store +GrpcPactPlugin \ No newline at end of file diff --git a/dotnet/PactDotnetPlugin/GrpcPactPlugin.csproj b/dotnet/PactDotnetPlugin/GrpcPactPlugin.csproj new file mode 100644 index 0000000..3b32b53 --- /dev/null +++ b/dotnet/PactDotnetPlugin/GrpcPactPlugin.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + Exe + $(DefaultItemExcludes);test\**\*;build\**\*;appsettings* + + + + + + + + + + + diff --git a/dotnet/PactDotnetPlugin/Makefile b/dotnet/PactDotnetPlugin/Makefile new file mode 100644 index 0000000..52619b3 --- /dev/null +++ b/dotnet/PactDotnetPlugin/Makefile @@ -0,0 +1,130 @@ +PROJECT=dotnet-template +APP_NAME=GrpcPactPlugin +PLUGIN_VERSION?=$(shell ./script/bump.sh -p "v-" -l) + +clean: + rm -rf bin + rm -rf obj + rm -rf build + +update_manifest: + @echo ${PLUGIN_VERSION} && variable=${PLUGIN_VERSION}; jq --arg variable "$$variable" '.version = $$variable' pact-plugin.json > pact-plugin.json + +proto: build + +build: + dotnet build + +run_local: + dotnet run + +run_build: + ./GrpcPactPlugin + +test_build: + ./GrpcPactPlugin & _pid=$$!; \ + sleep 3 && ./evans.sh; kill $$_pid + +.PHONY: bin build + + +compile: clean + dotnet publish -c Release -p:'StaticExecutable=true;StripSymbols=false;PublishSingleFile=true;StaticLink=true' --self-contained -r $(PLATFORM)-$(ARCH) -o build/${PLATFORM}/${ARCH} + cp build/${PLATFORM}/${ARCH}/${APP_NAME} . + if [ "${PLATFORM}" = "osx" ]; then \ + if [[ "$$(csrutil status)" == *"disabled"* ]]; then \ + echo "sip disabled"; \ + codesign -f --remove-signature ${APP_NAME}; \ + fi; \ + fi + +compress: + gzip -c build/${PLATFORM}/${ARCH}/${APP_NAME} > dist/release/pact-${PROJECT}-plugin-${PLATFORM}-${ARCH}.gz + +prepare: generate_manifest + +install_local: compile move_to_plugin_folder + +move_to_plugin_folder: + mkdir -p ${HOME}/.pact/plugins/pact-${PROJECT}-plugin-${PLUGIN_VERSION} + mv ${APP_NAME} ${HOME}/.pact/plugins/pact-${PROJECT}-plugin-${PLUGIN_VERSION} + cp pact-plugin.json ${HOME}/.pact/plugins/pact-${PROJECT}-plugin-${PLUGIN_VERSION} + +generate_manifest: + mkdir -p dist/release + variable=${PLUGIN_VERSION}; jq --arg variable "$$variable" '.version = $$variable' pact-plugin.json > dist/release/pact-plugin.json + cat dist/release/pact-plugin.json + +compile_move: compile move_to_plugin_folder + +PLATFORM := +ARCH := +ifeq '$(findstring ;,$(PATH))' ';' + PLATFORM=win + ifeq ($(PROCESSOR_ARCHITECTURE),AMD64) + ARCH=arm64 + endif + ifeq ($(PROCESSOR_ARCHITECTURE),x86) + ARCH=x64 + endif + UNAME_P := $(shell uname -m) + ifeq ($(UNAME_P),x86_64) + ARCH=x64 + endif + ifneq ($(filter arm%,$(UNAME_P)),) + ARCH=arm64 + endif + ifneq ($(filter aarch64%,$(UNAME_P)),) + ARCH=arm64 + endif +else + PLATFORM:=$(shell uname 2>/dev/null || echo Unknown) + PLATFORM:=$(patsubst CYGWIN%,Cygwin,windows) + PLATFORM:=$(patsubst MSYS%,MSYS,windows) + PLATFORM:=$(patsubst MINGW%,MSYS,windows) + UNAME_S := $(shell uname -s) + ifeq ($(UNAME_S),Linux) + PLATFORM=linux + endif + ifeq ($(UNAME_S),Darwin) + PLATFORM=osx + endif + UNAME_P := $(shell uname -m) + ifeq ($(UNAME_P),x86_64) + ARCH=x64 + endif + ifneq ($(filter arm%,$(UNAME_P)),) + ARCH=arm64 + endif + ifneq ($(filter aarch64%,$(UNAME_P)),) + ARCH=arm64 + endif +endif + + +detect_os: + @echo $(shell uname -s) + @echo $(shell uname -m) + @echo $(shell uname -p) + @echo $(shell uname -p) + @echo $(PLATFORM) $(ARCH) + +x-plat: clean + dotnet publish -o build/osx/arm64/${PROJECT} --arch arm64 --os osx + dotnet publish -o build/osx/x64/${PROJECT} --arch x64 --os osx + dotnet publish -o build/linux/arm64/${PROJECT} --arch arm64 --os linux + dotnet publish -o build/linux/x64/${PROJECT} --arch x64 --os linux + dotnet publish -o build/win/arm64/${PROJECT} --arch arm64 --os win + dotnet publish -o build/win/x64/${PROJECT} --arch x64 --os win + mkdir -p dist + mkdir -p dist/release + mkdir -p dist/linux/x86_64 + mkdir -p dist/windows/x86_64 + mkdir -p dist/osx/x86_64 + mkdir -p dist/osx/aarch64 + gzip -c build/osx/x64/${PROJECT}/${APP_NAME} > dist/release/pact-${PROJECT}-plugin-osx-x86_64.gz + gzip -c build/osx/arm64/${PROJECT}/${APP_NAME} > dist/release/pact-${PROJECT}-plugin-osx-aarch64.gz + gzip -c build/linux/x64/${PROJECT}/${APP_NAME} > dist/release/pact-${PROJECT}-plugin-linux-x86_64.gz + gzip -c build/linux/arm64/${PROJECT}/${APP_NAME} > dist/release/pact-${PROJECT}-plugin-linux-aarch64.gz + gzip -c build/win/x64/${PROJECT}/${APP_NAME}.exe > dist/release/pact-${PROJECT}-plugin-windows-x86_64.gz + gzip -c build/win/arm64/${PROJECT}/${APP_NAME}.exe > dist/release/pact-${PROJECT}-plugin-windows-aarch64.gz \ No newline at end of file diff --git a/dotnet/PactDotnetPlugin/Program.cs b/dotnet/PactDotnetPlugin/Program.cs new file mode 100644 index 0000000..d79ff73 --- /dev/null +++ b/dotnet/PactDotnetPlugin/Program.cs @@ -0,0 +1,84 @@ +using GrpcPactPlugin.Services; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using System.Text.Json; + + + +// Additional configuration is required to successfully run gRPC on macOS. +// For instructions on how to configure Kestrel and gRPC clients on macOS, visit https://go.microsoft.com/fwlink/?linkid=2099682 + +// app.Run(); +public class InitMessage +{ + public int Port { get; set; } + public string? ServerKey { get; set; } +} + + + +class PactPluginServer +{ + public static async Task Main() + { + + try + { + await RunAsync(); + } + catch (Exception ex) + { + Console.WriteLine(ex); + Console.WriteLine("Global error thrown. Exiting"); + throw; + } + } + + private static async Task RunAsync() + { + + var builder = WebApplication.CreateBuilder(); + var port = Environment.GetEnvironmentVariable("PORT") is string p && p.Length > 0 ? + Int32.Parse(p) : 50051; + + builder.WebHost.ConfigureKestrel(options => + { + // Setup a HTTP/2 endpoint without TLS. + options.ListenLocalhost(port, o => o.Protocols = + HttpProtocols.Http2); + }); + + // Add services to the container. + builder.Services.AddGrpc(); + + var app = builder.Build(); + + // Configure the HTTP request pipeline. + app.MapGrpcService(); + + + + var initMessage = new InitMessage + { + Port = port, + ServerKey = "76bee28c-97b9-47c1-9684-30cb1e273051" + }; + var serializeOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + string jsonString = JsonSerializer.Serialize(initMessage, serializeOptions); + + + + app.Start(); + + + Console.WriteLine("Application started."); + Console.WriteLine("Press ctrl+c to stop the server"); + await Task.Delay(2000); + Console.WriteLine(jsonString); + + await app.WaitForShutdownAsync(); + } +} \ No newline at end of file diff --git a/dotnet/PactDotnetPlugin/Properties/launchSettings.json b/dotnet/PactDotnetPlugin/Properties/launchSettings.json new file mode 100644 index 0000000..98c89e0 --- /dev/null +++ b/dotnet/PactDotnetPlugin/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "GrpcPactPlugin": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:50051;https://localhost:7112", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/dotnet/PactDotnetPlugin/Protos/plugin.proto b/dotnet/PactDotnetPlugin/Protos/plugin.proto new file mode 100644 index 0000000..4a8d17d --- /dev/null +++ b/dotnet/PactDotnetPlugin/Protos/plugin.proto @@ -0,0 +1,395 @@ +// Proto file for Pact plugin interface V1 + +syntax = "proto3"; + +import "google/protobuf/struct.proto"; +import "google/protobuf/wrappers.proto"; +import "google/protobuf/empty.proto"; + +package io.pact.plugin; +option go_package = "io.pact.plugin"; +option csharp_namespace = "GrpcPactPlugin"; + +// Request to verify the plugin has loaded OK +message InitPluginRequest { + // Implementation calling the plugin + string implementation = 1; + // Version of the implementation + string version = 2; +} + +// Entry to be added to the core catalogue. Each entry describes one of the features the plugin provides. +// Entries will be stored in the catalogue under the key "plugin/$name/$type/$key". +message CatalogueEntry { + enum EntryType { + // Matcher for contents of messages, requests or response bodies + CONTENT_MATCHER = 0; + // Generator for contents of messages, requests or response bodies + CONTENT_GENERATOR = 1; + // Transport for a network protocol + TRANSPORT = 2; + // Matching rule for content field/values + MATCHER = 3; + // Type of interaction + INTERACTION = 4; + } + // Entry type + EntryType type = 1; + // Entry key + string key = 2; + // Associated data required for the entry. For CONTENT_MATCHER and CONTENT_GENERATOR types, a "content-types" + // value (separated by semi-colons) is required for all the content types the plugin supports. + map values = 3; +} + +// Response to init plugin, providing the catalogue entries the plugin provides +message InitPluginResponse { + // List of entries the plugin supports + repeated CatalogueEntry catalogue = 1; +} + +// Catalogue of Core Pact + Plugin features +message Catalogue { + // List of entries from the core catalogue + repeated CatalogueEntry catalogue = 1; +} + +// Message representing a request, response or message body +message Body { + // The content type of the body in MIME format (i.e. application/json) + string contentType = 1; + // Bytes of the actual content + google.protobuf.BytesValue content = 2; + // Enum of content type override. This is a hint on how the content type should be treated. + enum ContentTypeHint { + // Determine the form of the content using the default rules of the Pact implementation + DEFAULT = 0; + // Contents must always be treated as a text form + TEXT = 1; + // Contents must always be treated as a binary form + BINARY = 2; + } + // Content type override to apply (if required). If omitted, the default rules of the Pact implementation + // will be used + ContentTypeHint contentTypeHint = 3; +} + +// Request to preform a comparison on an actual body given the expected one +message CompareContentsRequest { + // Expected body from the Pact interaction + Body expected = 1; + // Actual received body + Body actual = 2; + // If unexpected keys or attributes should be allowed. Setting this to false results in additional keys or fields + // will cause a mismatch + bool allow_unexpected_keys = 3; + // Map of expressions to matching rules. The expressions follow the documented Pact matching rule expressions + map rules = 4; + // Additional data added to the Pact/Interaction by the plugin + PluginConfiguration pluginConfiguration = 5; +} + +// Indicates that there was a mismatch with the content type +message ContentTypeMismatch { + // Expected content type (MIME format) + string expected = 1; + // Actual content type received (MIME format) + string actual = 2; +} + +// A mismatch for an particular item of content +message ContentMismatch { + // Expected data bytes + google.protobuf.BytesValue expected = 1; + // Actual data bytes + google.protobuf.BytesValue actual = 2; + // Description of the mismatch + string mismatch = 3; + // Path to the item that was matched. This is the value as per the documented Pact matching rule expressions. + string path = 4; + // Optional diff of the contents + string diff = 5; +} + +// List of content mismatches +message ContentMismatches { + repeated ContentMismatch mismatches = 1; +} + +// Response to the CompareContentsRequest with the results of the comparison +message CompareContentsResponse { + // Error message if an error occurred. If this field is set, the remaining fields will be ignored and the + // verification marked as failed + string error = 1; + // There was a mismatch with the types of content. If this is set, the results may not be set. + ContentTypeMismatch typeMismatch = 2; + // Results of the match, keyed by matching rule expression + map results = 3; +} + +// Request to configure/setup an interaction so that it can be verified later +message ConfigureInteractionRequest { + // Content type of the interaction (MIME format) + string contentType = 1; + // This is data specified by the user in the consumer test + google.protobuf.Struct contentsConfig = 2; +} + +// Represents a matching rule +message MatchingRule { + // Type of the matching rule + string type = 1; + // Associated data for the matching rule + google.protobuf.Struct values = 2; +} + +// List of matching rules +message MatchingRules { + repeated MatchingRule rule = 1; +} + +// Example generator +message Generator { + // Type of generator + string type = 1; + // Associated data for the generator + google.protobuf.Struct values = 2; +} + +// Plugin configuration added to the pact file by the ConfigureInteraction step +message PluginConfiguration { + // Data to be persisted against the interaction + google.protobuf.Struct interactionConfiguration = 1; + // Data to be persisted in the Pact file metadata (Global data) + google.protobuf.Struct pactConfiguration = 2; +} + +// Response to the configure/setup an interaction request +message InteractionResponse { + // Contents for the interaction + Body contents = 1; + // All matching rules to apply + map rules = 2; + // Generators to apply + map generators = 3; + // For message interactions, any metadata to be applied + google.protobuf.Struct messageMetadata = 4; + // Plugin specific data to be persisted in the pact file + PluginConfiguration pluginConfiguration = 5; + // Markdown/HTML formatted text representation of the interaction + string interactionMarkup = 6; + // Type of markup used + enum MarkupType { + // CommonMark format + COMMON_MARK = 0; + // HTML format + HTML = 1; + } + MarkupType interactionMarkupType = 7; + // Description of what part this interaction belongs to (in the case of there being more than one, for instance, + // request/response messages) + string partName = 8; +} + +// Response to the configure/setup an interaction request +message ConfigureInteractionResponse { + // If an error occurred. In this case, the other fields will be ignored/not set + string error = 1; + // The actual response if no error occurred. + repeated InteractionResponse interaction = 2; + // Plugin specific data to be persisted in the pact file + PluginConfiguration pluginConfiguration = 3; +} + +// Request to generate the contents using any defined generators +message GenerateContentRequest { + // Original contents + Body contents = 1; + // Generators to apply + map generators = 2; + // Additional data added to the Pact/Interaction by the plugin + PluginConfiguration pluginConfiguration = 3; +} + +// Generated body/message response +message GenerateContentResponse { + Body contents = 1; +} + +// Request to start a mock server +message StartMockServerRequest { + // Interface to bind to. Will default to the loopback adapter + string hostInterface = 1; + // Port to bind to. Default (or a value of 0) get the OS to open a random port + uint32 port = 2; + // If TLS should be used (if supported by the mock server) + bool tls = 3; + // Pact as JSON to use for the mock server behaviour + string pact = 4; +} + +// Response to the start mock server request +message StartMockServerResponse { + oneof response { + // If an error occurred + string error = 1; + + // Mock server details + MockServerDetails details = 2; + } +} + +// Details on a running mock server +message MockServerDetails { + // Mock server unique ID + string key = 1; + // Port the mock server is running on + uint32 port = 2; + // IP address the mock server is bound to. Probably an IP6 address, but may be IP4 + string address = 3; +} + +// Request to shut down a running mock server +// TODO: replace this with MockServerRequest in the next major version +message ShutdownMockServerRequest { + // The server ID to shutdown + string serverKey = 1; +} + +// Request for a running mock server by ID +message MockServerRequest { + // The server ID to shutdown + string serverKey = 1; +} + +// Result of a request that the mock server received +message MockServerResult { + // service + method that was requested + string path = 1; + // If an error occurred trying to handle the request + string error = 2; + // Any mismatches that occurred + repeated ContentMismatch mismatches = 3; +} + +// Response to the shut down mock server request +// TODO: replace this with MockServerResults in the next major version +message ShutdownMockServerResponse { + // If the mock status is all ok + bool ok = 1; + // The results of the test run, will contain an entry for each request received by the mock server + repeated MockServerResult results = 2; +} + +// Matching results of the mock server. +message MockServerResults { + // If the mock status is all ok + bool ok = 1; + // The results of the test run, will contain an entry for each request received by the mock server + repeated MockServerResult results = 2; +} + +// Request to prepare an interaction for verification +message VerificationPreparationRequest { + // Pact as JSON to use for the verification + string pact = 1; + // Interaction key for the interaction from the Pact that is being verified + string interactionKey = 2; + // Any data supplied by the user to verify the interaction + google.protobuf.Struct config = 3; +} + +// Request metadata value. Will either be a JSON-like value, or binary data +message MetadataValue { + oneof value { + google.protobuf.Value nonBinaryValue = 1; + bytes binaryValue = 2; + } +} + +// Interaction request data to be sent or received for verification +message InteractionData { + // Request/Response body as bytes + Body body = 1; + // Metadata associated with the request/response + map metadata = 2; +} + +// Response for the prepare an interaction for verification request +message VerificationPreparationResponse { + oneof response { + // If an error occurred + string error = 1; + + // Interaction data required to construct any request + InteractionData interactionData = 2; + } +} + +// Request data to verify an interaction +message VerifyInteractionRequest { + // Interaction data required to construct the request + InteractionData interactionData = 1; + // Any data supplied by the user to verify the interaction + google.protobuf.Struct config = 2; + // Pact as JSON to use for the verification + string pact = 3; + // Interaction key for the interaction from the Pact that is being verified + string interactionKey = 4; +} + +message VerificationResultItem { + oneof result { + string error = 1; + ContentMismatch mismatch = 2; + } +} + +// Result of running the verification +message VerificationResult { + // Was the verification successful? + bool success = 1; + // Interaction data retrieved from the provider (optional) + InteractionData responseData = 2; + // Any mismatches that occurred + repeated VerificationResultItem mismatches = 3; + // Output for the verification to display to the user + repeated string output = 4; +} + +// Result of running the verification +message VerifyInteractionResponse { + oneof response { + // If an error occurred trying to run the verification + string error = 1; + + VerificationResult result = 2; + } +} + +service PactPlugin { + // Check that the plugin loaded OK. Returns the catalogue entries describing what the plugin provides + rpc InitPlugin(InitPluginRequest) returns (InitPluginResponse); + // Updated catalogue. This will be sent when the core catalogue has been updated (probably by a plugin loading). + rpc UpdateCatalogue(Catalogue) returns (google.protobuf.Empty); + // Request to perform a comparison of some contents (matching request) + rpc CompareContents(CompareContentsRequest) returns (CompareContentsResponse); + // Request to configure/setup the interaction for later verification. Data returned will be persisted in the pact file. + rpc ConfigureInteraction(ConfigureInteractionRequest) returns (ConfigureInteractionResponse); + // Request to generate the content using any defined generators + rpc GenerateContent(GenerateContentRequest) returns (GenerateContentResponse); + + // Start a mock server + rpc StartMockServer(StartMockServerRequest) returns (StartMockServerResponse); + // Shutdown a running mock server + // TODO: Replace the message types with MockServerRequest and MockServerResults in the next major version + rpc ShutdownMockServer(ShutdownMockServerRequest) returns (ShutdownMockServerResponse); + // Get the matching results from a running mock server + rpc GetMockServerResults(MockServerRequest) returns (MockServerResults); + + // Prepare an interaction for verification. This should return any data required to construct any request + // so that it can be amended before the verification is run + rpc PrepareInteractionForVerification(VerificationPreparationRequest) returns (VerificationPreparationResponse); + // Execute the verification for the interaction. + rpc VerifyInteraction(VerifyInteractionRequest) returns (VerifyInteractionResponse); +} diff --git a/dotnet/PactDotnetPlugin/README.md b/dotnet/PactDotnetPlugin/README.md new file mode 100644 index 0000000..17bfaf5 --- /dev/null +++ b/dotnet/PactDotnetPlugin/README.md @@ -0,0 +1,229 @@ +# Pact Plugin Template + + + + +Template project to help bootstrap a new Pact [Plugin](https://github.com/pact-foundation/pact-plugins) for the [Pact](http://docs.pact.io) framework. + +**Features:** + +* Stubbed gRPC methods ready to implement +* Automated release procedure +* Support for recommended common platform/targets +* Levelled logging for observability + +**TODO** + +- [ ] Support Matchers and Generators (requires FFI package support) + +## Repository Structure + + +``` + +├── README.md # This file! +├── Makefile # Build configuration (✅ fill me in!) +├── Program.cs # The gRPC server handler +├── Services +│ └── PactPluginService.cs # The gRPC server implementation (✅ fill me in!) +├── Protos # Plugin configuration file - set to use the binary distributable +│ └── plugin.proto # Location of protobuf for the Pact Plugin Framework +├── pact-plugin.json +├── build # This is where your binary distributions will be output +├── GrpcPactPlugin # This is will be the name of the plugin, yours will change +├── GrpcPactPlugin.csproj # The project config file +├── Properties +│ └── launchSettings.json # The project dependencies file +├── appsettings.json # The project settings file +├── evans.sh # A quick tool to help you test your plugin +└── test + └── pact-js # A test with Pact-JS to exercise the plugin +└── .github/workflows # This holds your CI build and release configuration +├── RELEASING.md # Instructions on how to release 🚀 +``` + +## Developing the plugin + +### Prerequsites + +The protoc compiler must be installed for this plugin + +See .NET specific instructions [here](https://github.com/grpc/grpc-dotnet#to-start-using-grpc-for-net) +and the official Microsoft docs [page](https://learn.microsoft.com/en-us/aspnet/core/grpc/protobuf?view=aspnetcore-7.0) for knowledge about construction Protobuf messages in .NET + +### Create your new repository + +1. Clone this repository +2. Create a new repository in GitHub. The name of the plugin should be `pact--plugin` e.g. `pact-protobuf-plugin` +3. Push this code to your new repository + +### Install the project dependencies + +Run: + +``` +make build +``` + +which is an alias for + +``` +dotnet build +``` + +To ensure the dependencies and vendoring are correct. + +### Set the name and version + +In the top of the [`Makefile`](./Makefile) set `PROJECT` to your plugin's name. + +`PROJECT` should map to `` in your GitHub repository. + +*NOTE: It's important that the name of your GitHub project and the `PROJECT` variable must align, to create artifacts discoverable to the CLI tooling, such as the [Plugin CLI](https://docs.pact.io/implementation_guides/pact_plugins/cli).* + +### Design the consumer interface + +This is how the users of your plugin will write the plugin specific interaction details. + +For example, take the following HTTP interaction: + +```js +await pact + .addInteraction() + .given('the Matt protocol exists') + .uponReceiving('an HTTP request to /matt') + .usingPlugin({ + plugin: 'matt', + version: '0.0.4', + }) + .withRequest('POST', '/matt', (builder) => { + builder.pluginContents('application/matt', mattRequest); // <- request + }) + .willRespondWith(200, (builder) => { + builder.pluginContents('application/matt', mattResponse); // <- response + }) + .executeTest((mockserver) => { + ... +``` + +The user needs to specify the request and response body portion of the request. + +Because the use cases for plugins are so wide and varied, the framework does not impose limits +on this data structure and is something you need to design. + +This being said, most plugins have opted to use a JSON structure. + +This structure is represented in our GoLang template in [`configuration.go`](https://github.com/pact-foundation/pact-plugin-template-golang/blob/main/configuration.go) + +Think about how you would like your user to specify the interaction details for the various interaction types. + +Here is an example for a TCP plugin with a custom text protocol: + +#### Synchronous Messages + +Set the expected response from the API: + +```go +mattMessage := `{"response": {"body": "hellotcp"}}` +``` + +#### Asynchronous Messages + +Set the request/response all in one go: + +```go +mattMessage := `{"request": {"body": "hellotcp"}, "response":{"body":"tcpworld"}}` +``` + +#### HTTP + +Separate out the body on the request/response part of the interaction: + +```go +mattRequest := `{"request": {"body": "hello"}}` +mattResponse := `{"response":{"body":"world"}}` +``` + +### Write the Plugin! + +#### Implement the relevant RPC functions + +Open [`PactPluginService.cs`](./Services/PactPluginService.cs) and update the relevant RPC functions. + +Depending on your use case, some of the RPC calls won't be required, each method is well signposted to help you along. + +#### Logging + +You should log regularly. Debugging gRPC calls from the framework can be challenging, as the plugin is started asynchronously by the Plugin Driver behind the scenes. + +There are two ways to log: + +1. Stdout - all stdout (e.g. `print`) is pulled into the general Pact logs for the framework you're running +2. To file. All calls to `log` will be written to a file + +The log setup has three main features: + +1. [X] It works with the native `C#` package +2. [ ] It logs to a file relative to plugin execution in `log/plugin.log` +3. [ ] It is levelled, at the direction of the plugin driver (that is, the log level will pass in from the driver which will restrict the levels logged in this plugin) + + +To write something to stdout, you simply call the `Console.WriteLine` method + +`log(message)` + + + +### Publish your plugin + +Follow the steps in [Releasing](./RELEASING.md) to publish a new version of your Plugin. + +## Local Development + +The following command will build the plugin, and install into the correct plugin directory for local development: + +``` +make install_local +``` + +You can then reference your plugin in local tests to try it out. + +### Regenerating the plugin protobuf definitions + +If a new protobuf definition is required (e.g. to support a new feature), copy it into the `Protos` folder and run the following Make task: + +> Need to add annotation in the protofile `option csharp_namespace = "GrpcPactPlugin"` for an idiomatic naming convention for C# + +``` +make proto +``` + +It will update the definitions in the `./obj/Debug/net6.0` packages. Note this may result in a breaking change, depending on the version. So upgrade carefully and ensure you have appropriate tests + +## Supported targets + +This code base should automatically create artifacts for the following OS/Architecture combiations: + +| OS | Architecture | Supported | +| ------- | ------------ | --------- | +| OSX | x86_64 | ✅ | +| OSX | arm | ✅ | +| Linux | x86_64 | ✅ | +| Linux | arm | ✅ | +| Windows | x86_64 | ✅ | +| Windows | arm | ✅ | + +## .NET Development notes + +1. Install .NET 6+ +2. Open the Project. +3. Run `dotnet run` +4. Build with `dotnet build` + 1. Different options for different archs `https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-build` + 2. For full list see https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.NETCore.Platforms/src/runtime.json diff --git a/dotnet/PactDotnetPlugin/Services/PactPluginService.cs b/dotnet/PactDotnetPlugin/Services/PactPluginService.cs new file mode 100644 index 0000000..d471316 --- /dev/null +++ b/dotnet/PactDotnetPlugin/Services/PactPluginService.cs @@ -0,0 +1,114 @@ +using Grpc.Core; +using Google.Protobuf.WellKnownTypes; +using Google.Protobuf; + +namespace GrpcPactPlugin.Services; + +public class PactPluginService : PactPlugin.PactPluginBase +{ + private readonly ILogger _logger; + public PactPluginService(ILogger logger) + { + _logger = logger; + } + + public override Task InitPlugin( + InitPluginRequest request, ServerCallContext context) + { + var response = new InitPluginResponse(); + var contentTypes = new Dictionary(); + contentTypes.Add("content-types", "application/foo"); + var catalogueEntries = new[] { new CatalogueEntry + { Key = "foo", + Type = (CatalogueEntry.Types.EntryType)0, + Values = { contentTypes } } }; + response.Catalogue.Add(catalogueEntries); + + return Task.FromResult( + response + ); + } + public override Task UpdateCatalogue( + Catalogue request, ServerCallContext context) + { return Task.FromResult(new Empty()); } + public override Task ConfigureInteraction( + ConfigureInteractionRequest request, ServerCallContext context) + + + { + Console.WriteLine("ConfigureInteraction started.", request); + var configureInteractionResponse = new ConfigureInteractionResponse(); + var interactionResponse = new InteractionResponse(); + if (request.ContentsConfig.Fields.ContainsKey("request")) + { + interactionResponse.PartName = "request"; + var interactionRequestContent = new Body(); + interactionRequestContent.ContentType = "application/foo"; + interactionRequestContent.Content = ByteString.CopyFromUtf8("hello"); + interactionResponse.Contents = interactionRequestContent; + configureInteractionResponse.Interaction.Add(interactionResponse); + return Task.FromResult(configureInteractionResponse); + } + + if (request.ContentsConfig.Fields.ContainsKey("response")) + { + + interactionResponse.PartName = "response"; + var interactionResponseContent = new Body(); + interactionResponseContent.ContentType = "application/foo"; + interactionResponseContent.Content = ByteString.CopyFromUtf8("world"); + interactionResponse.Contents = interactionResponseContent; + configureInteractionResponse.Interaction.Add(interactionResponse); + return Task.FromResult(configureInteractionResponse); + } + return Task.FromResult(configureInteractionResponse); + } + public override Task CompareContents( + CompareContentsRequest request, ServerCallContext context) + { + var response = new CompareContentsResponse(); + var actual = request.Actual.Content; + var expected = request.Expected.Content; + if (actual != expected) + { + var contentMismatches = new ContentMismatches(); + var contentMismatch = new ContentMismatch(); + contentMismatch.Actual = actual; + contentMismatch.Expected = expected; + contentMismatch.Diff = "diff"; + contentMismatch.Path = "$"; + contentMismatch.Mismatch = $"expected body {expected.ToStringUtf8()} is not equal to actual body {actual.ToStringUtf8()}"; + contentMismatches.Mismatches.Add(contentMismatch); + var contentMismatchDict = new Dictionary(); + contentMismatchDict.Add("$", contentMismatches); + response.Results.Add(contentMismatchDict); + response.Error = "actual does not meet expected"; + var contentTypeMismatch = new ContentTypeMismatch(); + contentTypeMismatch.Actual = actual.ToStringUtf8(); + contentTypeMismatch.Expected = expected.ToStringUtf8(); + response.TypeMismatch = contentTypeMismatch; + } + + + return Task.FromResult(response); + } + + public override Task GenerateContent( + GenerateContentRequest request, ServerCallContext context) + { return Task.FromResult(new GenerateContentResponse()); } + public override Task StartMockServer( + StartMockServerRequest request, ServerCallContext context) + { return Task.FromResult(new StartMockServerResponse()); } + public override Task ShutdownMockServer( + ShutdownMockServerRequest request, ServerCallContext context) + { return Task.FromResult(new ShutdownMockServerResponse()); } + public override Task GetMockServerResults( + MockServerRequest request, ServerCallContext context) + { return Task.FromResult(new MockServerResults()); } + public override Task PrepareInteractionForVerification( + VerificationPreparationRequest request, ServerCallContext context) + { return Task.FromResult(new VerificationPreparationResponse()); } + public override Task VerifyInteraction( + VerifyInteractionRequest request, ServerCallContext context) + { return Task.FromResult(new VerifyInteractionResponse()); } +} diff --git a/dotnet/PactDotnetPlugin/appsettings.Development.json b/dotnet/PactDotnetPlugin/appsettings.Development.json new file mode 100644 index 0000000..ff66ba6 --- /dev/null +++ b/dotnet/PactDotnetPlugin/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/dotnet/PactDotnetPlugin/appsettings.json b/dotnet/PactDotnetPlugin/appsettings.json new file mode 100644 index 0000000..a79c062 --- /dev/null +++ b/dotnet/PactDotnetPlugin/appsettings.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "IncludeScopes": false, + "LogLevel": { + "Default": "Information", + "System": "Information", + "Microsoft": "Information", + "Grpc": "Information" + } + }, + "AllowedHosts": "*", + "Kestrel": { + "EndpointDefaults": { + "Protocols": "Http2" + } + } +} diff --git a/dotnet/PactDotnetPlugin/pact-plugin.json b/dotnet/PactDotnetPlugin/pact-plugin.json new file mode 100644 index 0000000..a2ea59a --- /dev/null +++ b/dotnet/PactDotnetPlugin/pact-plugin.json @@ -0,0 +1,11 @@ +{ + "pluginInterfaceVersion": 1, + "name": "dotnet-template", + "version": "0.0.0", + "executableType": "exec", + "minimumRequiredVersion": null, + "entryPoint": "GrpcPactPlugin", + "entryPoints": {}, + "args": null, + "dependencies": null +} diff --git a/dotnet/PactDotnetPlugin/script/bump.sh b/dotnet/PactDotnetPlugin/script/bump.sh new file mode 100755 index 0000000..a3ff1ff --- /dev/null +++ b/dotnet/PactDotnetPlugin/script/bump.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# Bumps the semantic version of the git-project. +# If no semantic version tags exist in the project, the version starts out at v0.0.0 +# and is incremented by one for the field indicated by the bump command argument. + +find_latest_semver() { + pattern="^$PREFIX([0-9]+\.[0-9]+\.[0-9]+)\$" + versions=$(for tag in $(git tag); do + [[ "$tag" =~ $pattern ]] && echo "${BASH_REMATCH[1]}" + done) + if [ -z "$versions" ];then + echo 0.0.0 + else + echo "$versions" | tr '.' ' ' | sort -nr -k 1 -k 2 -k 3 | tr ' ' '.' | head -1 + fi +} + +increment_ver() { + find_latest_semver | awk -F. -v a="$1" -v b="$2" -v c="$3" \ + '{printf("%d.%d.%d", $1+a, $2+b , $3+c)}' +} + +bump() { + next_ver="${PREFIX}$(increment_ver "$1" "$2" "$3")" + latest_ver="${PREFIX}$(find_latest_semver)" + latest_commit=$(git rev-parse "${latest_ver}" 2>/dev/null ) + head_commit=$(git rev-parse HEAD) + if [ "$latest_commit" = "$head_commit" ]; then + echo "refusing to tag; $latest_ver already tagged for HEAD ($head_commit)" + else + echo "tagging $next_ver $head_commit" + git tag "$next_ver" $head_commit + fi +} + +usage() { + echo "Usage: bump [-p prefix] {major|minor|patch} | -l" + echo "Bumps the semantic version field by one for a git-project." + echo + echo "Options:" + echo " -l list the latest tagged version instead of bumping." + echo " -p prefix [to be] used for the semver tags." + exit 1 +} + +while getopts :p:l opt; do + case $opt in + p) PREFIX="$OPTARG";; + l) LIST=1;; + \?) usage;; + :) echo "option -$OPTARG requires an argument"; exit 1;; + esac +done +shift $((OPTIND-1)) + +if [ ! -z "$LIST" ];then + find_latest_semver + exit 0 +fi + +case $1 in + major) bump 1 0 0;; + minor) bump 0 1 0;; + patch) bump 0 0 1;; + *) usage +esac \ No newline at end of file diff --git a/dotnet/Plugin/FooPluginConsumer.Tests/FooPluginConsumer.Tests.csproj b/dotnet/Plugin/FooPluginConsumer.Tests/FooPluginConsumer.Tests.csproj new file mode 100644 index 0000000..ff31fb2 --- /dev/null +++ b/dotnet/Plugin/FooPluginConsumer.Tests/FooPluginConsumer.Tests.csproj @@ -0,0 +1,21 @@ + + + net8.0 + false + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/dotnet/Plugin/FooPluginConsumer.Tests/FooPluginConsumerTest.cs b/dotnet/Plugin/FooPluginConsumer.Tests/FooPluginConsumerTest.cs new file mode 100644 index 0000000..3ab1f3c --- /dev/null +++ b/dotnet/Plugin/FooPluginConsumer.Tests/FooPluginConsumerTest.cs @@ -0,0 +1,108 @@ +using System; +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; +using PactFfi; +using System.Runtime.InteropServices; +using System.IO; + +namespace FooPluginConsumer.Tests +{ + public class FooPluginConsumerTests + { + + [Fact] + public void ReturnsMismatchWhenNoPluginClientRequestMade() + { + + _ = Pact.LogToStdOut(3); + + var host = "0.0.0.0"; + var pact = Pact.NewPact("FooPluginConsumer", "FooPluginProvider"); + var interaction = Pact.NewInteraction(pact, "a HTTP request to /foobar"); + Pact.WithRequest(interaction, "POST", "/foobar"); + Pact.Given(interaction, "the Foobar protocol exists"); + Pact.WithSpecification(pact, Pact.PactSpecification.V4); + var requestContent = $@"{{ + ""request"": {{ + ""body"": ""hello"" + }} + }}"; + var responseContent = $@"{{ + ""response"": {{ + ""body"": ""world"" + }} + }}"; + Pact.PluginAdd(pact, "dotnet-template", "0.0.0"); + Pact.PluginInteractionContents(interaction, Pact.InteractionPart.Request, "application/foo", requestContent); + Pact.PluginInteractionContents(interaction, Pact.InteractionPart.Response, "application/foo", responseContent); + + var port = Pact.CreateMockServerForTransport(pact, host, 0, "http", null); + Console.WriteLine("Port: " + port); + + var matched = Pact.MockServerMatched(port); + Console.WriteLine("Matched: " + matched); + matched.Should().BeFalse(); + + var MismatchesPtr = Pact.MockServerMismatches(port); + var MismatchesString = Marshal.PtrToStringAnsi(MismatchesPtr); + Console.WriteLine("Mismatches: " + MismatchesString); + var MismatchesJson = JsonSerializer.Deserialize(MismatchesString); + var ErrorString = MismatchesJson[0].GetProperty("type").GetString(); + var ExpectedPath = MismatchesJson[0].GetProperty("path").GetString(); + + ErrorString.Should().Be("missing-request"); + ExpectedPath.Should().Be("/foobar"); + + Pact.CleanupMockServer(port); + Pact.PluginCleanup(pact); + } + + [Fact] + public void WritesPactWhenPluginClientRequestMade() + { + + _ = Pact.LogToStdOut(3); + var host = "0.0.0.0"; + var pact = Pact.NewPact("DotnetPluginConsumer", "DotnetPluginProvider"); + var interaction = Pact.NewInteraction(pact, "a HTTP request to /foobar"); + Pact.WithRequest(interaction, "POST", "/foobar"); + Pact.Given(interaction, "the Foobar protocol exists"); + Pact.WithSpecification(pact, Pact.PactSpecification.V4); + var requestContent = $@"{{ + ""request"": {{ + ""body"": ""hello"" + }} + }}"; + var responseContent = $@"{{ + ""response"": {{ + ""body"": ""world"" + }} + }}"; + Pact.PluginAdd(pact, "dotnet-template", "0.0.0"); + Pact.PluginInteractionContents(interaction, Pact.InteractionPart.Request, "application/foo", requestContent); + Pact.PluginInteractionContents(interaction, Pact.InteractionPart.Response, "application/foo", responseContent); + + var port = Pact.CreateMockServerForTransport(pact, host, 0, "http", null); + Console.WriteLine("Port: " + port); + + // TODO - Make plugin http request + + // var matched = Pact.MockServerMatched(port); + // Console.WriteLine("Matched: " + matched); + // matched.Should().BeTrue(); + + // var MismatchesPtr = Pact.MockServerMismatches(port); + // var MismatchesString = Marshal.PtrToStringAnsi(MismatchesPtr); + // Console.WriteLine("Mismatches: " + MismatchesString); + + // MismatchesString.Should().Be("[]"); + + var writeRes = Pact.WritePactFileForPort(port, "../../../../pacts", false); + Console.WriteLine("WriteRes: " + writeRes); + Pact.CleanupMockServer(port); + Pact.PluginCleanup(pact); + } + } +} diff --git a/dotnet/Plugin/README.md b/dotnet/Plugin/README.md new file mode 100644 index 0000000..1a8a7ba --- /dev/null +++ b/dotnet/Plugin/README.md @@ -0,0 +1,5 @@ +# Sockets .NET Example + +Adapted for Pact, from Microsoft's Sockets sample + +https://learn.microsoft.com/en-us/dotnet/fundamentals/networking/sockets/socket-services diff --git a/dotnet/Plugin/pacts/DotnetPluginConsumer-DotnetPluginProvider.json b/dotnet/Plugin/pacts/DotnetPluginConsumer-DotnetPluginProvider.json new file mode 100644 index 0000000..ce6a141 --- /dev/null +++ b/dotnet/Plugin/pacts/DotnetPluginConsumer-DotnetPluginProvider.json @@ -0,0 +1,67 @@ +{ + "consumer": { + "name": "DotnetPluginConsumer" + }, + "interactions": [ + { + "description": "a HTTP request to /foobar", + "pending": false, + "providerStates": [ + { + "name": "the Foobar protocol exists" + } + ], + "request": { + "body": { + "content": "aGVsbG8=", + "contentType": "application/foo", + "contentTypeHint": "DEFAULT", + "encoded": "base64" + }, + "headers": { + "content-type": [ + "application/foo" + ] + }, + "method": "POST", + "path": "/foobar" + }, + "response": { + "body": { + "content": "d29ybGQ=", + "contentType": "application/foo", + "contentTypeHint": "DEFAULT", + "encoded": "base64" + }, + "headers": { + "content-type": [ + "application/foo" + ] + }, + "status": 200 + }, + "transport": "http", + "type": "Synchronous/HTTP" + } + ], + "metadata": { + "pactRust": { + "ffi": "0.4.22", + "mockserver": "1.2.9", + "models": "1.2.3" + }, + "pactSpecification": { + "version": "4.0" + }, + "plugins": [ + { + "configuration": {}, + "name": "dotnet-template", + "version": "0.0.0" + } + ] + }, + "provider": { + "name": "DotnetPluginProvider" + } +} \ No newline at end of file diff --git a/dotnet/Protobuf/Protos/route_guide.proto b/dotnet/Protobuf/Protos/route_guide.proto new file mode 100644 index 0000000..566f208 --- /dev/null +++ b/dotnet/Protobuf/Protos/route_guide.proto @@ -0,0 +1,118 @@ +// Copyright 2015 gRPC authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +option go_package = "google.golang.org/grpc/examples/route_guide/routeguide"; +option java_multiple_files = true; +option java_package = "io.grpc.examples.routeguide"; +option java_outer_classname = "RouteGuideProto"; +// option csharp_namespace = "RouteGuide"; + +package routeguide; + +// Interface exported by the server. +service RouteGuide { + // A simple RPC. + // + // Obtains the feature at a given position. + // + // A feature with an empty name is returned if there's no feature at the given + // position. + rpc GetFeature(Point) returns (Feature) {} + + // Save the feature. + rpc SaveFeature(Feature) returns (Feature) {} + + // A server-to-client streaming RPC. + // + // Obtains the Features available within the given Rectangle. Results are + // streamed rather than returned at once (e.g. in a response message with a + // repeated field), as the rectangle may cover a large area and contain a + // huge number of features. + rpc ListFeatures(Rectangle) returns (stream Feature) {} + + // A client-to-server streaming RPC. + // + // Accepts a stream of Points on a route being traversed, returning a + // RouteSummary when traversal is completed. + rpc RecordRoute(stream Point) returns (RouteSummary) {} + + // A Bidirectional streaming RPC. + // + // Accepts a stream of RouteNotes sent while a route is being traversed, + // while receiving other RouteNotes (e.g. from other users). + rpc RouteChat(stream RouteNote) returns (stream RouteNote) {} +} + +// Points are represented as latitude-longitude pairs in the E7 representation +// (degrees multiplied by 10**7 and rounded to the nearest integer). +// Latitudes should be in the range +/- 90 degrees and longitude should be in +// the range +/- 180 degrees (inclusive). +message Point { + int32 latitude = 1; + int32 longitude = 2; +} + +// A latitude-longitude rectangle, represented as two diagonally opposite +// points "lo" and "hi". +message Rectangle { + // One corner of the rectangle. + Point lo = 1; + + // The other corner of the rectangle. + Point hi = 2; +} + +// A feature names something at a given point. +// +// If a feature could not be named, the name is empty. +message Feature { + // The name of the feature. + string name = 1; + + // The point where the feature is detected. + Point location = 2; + + // A description of the feature. + string description = 3; +} + +// A RouteNote is a message sent while at a given point. +message RouteNote { + // The location from which the message is sent. + Point location = 1; + + // The message to be sent. + string message = 2; +} + +// A RouteSummary is received in response to a RecordRoute rpc. +// +// It contains the number of individual points received, the number of +// detected features, and the total distance covered as the cumulative sum of +// the distance between each point. +message RouteSummary { + // The number of points received. + int32 point_count = 1; + + // The number of known features passed while traversing the route. + int32 feature_count = 2; + + // The distance covered in metres. + int32 distance = 3; + + // The duration of the traversal in seconds. + int32 elapsed_time = 4; +} diff --git a/dotnet/Protobuf/README.md b/dotnet/Protobuf/README.md new file mode 100644 index 0000000..d91f5f3 --- /dev/null +++ b/dotnet/Protobuf/README.md @@ -0,0 +1,24 @@ +# gRPC .NET Sample + +Adapted for Pact, from Microsoft's gRPC sample - Tutorial: Create a gRPC client and server in ASP.NET Core + +https://learn.microsoft.com/en-us/aspnet/core/tutorials/grpc/grpc-start?view=aspnetcore-8.0&tabs=visual-studio-mac + + +1. "transport" not written to pact file + + "transport": "grpc", + "type": "Synchronous/Messages" + + +This is due to pact-net using + + [DllImport(DllName, EntryPoint = "pactffi_pact_handle_write_file")] + public static extern int WritePactFile(uint pact, string directory, bool overwrite); + +works correctly with + + [DllImport(DllName, EntryPoint = "pactffi_write_pact_file")] + public static extern int WritePactFileForPort(int port, string directory, bool overwrite); + +2. Provider verification not working with https \ No newline at end of file diff --git a/dotnet/Protobuf/RouteGuide/RouteGuide.csproj b/dotnet/Protobuf/RouteGuide/RouteGuide.csproj new file mode 100644 index 0000000..95a6d4a --- /dev/null +++ b/dotnet/Protobuf/RouteGuide/RouteGuide.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/Protobuf/RouteGuide/RouteGuideUtil.cs b/dotnet/Protobuf/RouteGuide/RouteGuideUtil.cs new file mode 100644 index 0000000..3e83e86 --- /dev/null +++ b/dotnet/Protobuf/RouteGuide/RouteGuideUtil.cs @@ -0,0 +1,139 @@ +// Copyright 2015 gRPC authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; + +namespace Routeguide +{ + /// + /// Utility methods for the route guide example. + /// + public static class RouteGuideUtil + { + public const string DefaultFeaturesResourceName = "RouteGuide.route_guide_db.json"; + + private const double CoordFactor = 1e7; + + /// + /// Indicates whether the given feature exists (i.e. has a valid name). + /// + public static bool Exists(this Feature feature) + { + return feature != null && (feature.Name.Length != 0); + } + + public static double GetLatitude(this Point point) + { + return point.Latitude / CoordFactor; + } + + public static double GetLongitude(this Point point) + { + return point.Longitude / CoordFactor; + } + + /// + /// Calculate the distance between two points using the "haversine" formula. + /// The formula is based on http://mathforum.org/library/drmath/view/51879.html + /// + /// the starting point + /// the end point + /// the distance between the points in meters + public static double GetDistance(this Point start, Point end) + { + int r = 6371000; // earth radius in metres + double lat1 = ToRadians(start.GetLatitude()); + double lat2 = ToRadians(end.GetLatitude()); + double lon1 = ToRadians(start.GetLongitude()); + double lon2 = ToRadians(end.GetLongitude()); + double deltalat = lat2 - lat1; + double deltalon = lon2 - lon1; + + double a = Math.Sin(deltalat / 2) * Math.Sin(deltalat / 2) + Math.Cos(lat1) * Math.Cos(lat2) * Math.Sin(deltalon / 2) * Math.Sin(deltalon / 2); + double c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a)); + return r * c; + } + + /// + /// Returns true if rectangular area contains given point. + /// + public static bool Contains(this Rectangle rectangle, Point point) + { + int left = Math.Min(rectangle.Lo.Longitude, rectangle.Hi.Longitude); + int right = Math.Max(rectangle.Lo.Longitude, rectangle.Hi.Longitude); + int top = Math.Max(rectangle.Lo.Latitude, rectangle.Hi.Latitude); + int bottom = Math.Min(rectangle.Lo.Latitude, rectangle.Hi.Latitude); + return (point.Longitude >= left && point.Longitude <= right && point.Latitude >= bottom && point.Latitude <= top); + } + + private static double ToRadians(double val) + { + return (Math.PI / 180) * val; + } + + /// + /// Parses features from an embedded resource. + /// + public static List LoadFeatures() + { + var features = new List(); + var jsonFeatures = JsonConvert.DeserializeObject>(ReadFeaturesFromResource()); + + foreach(var jsonFeature in jsonFeatures) + { + features.Add(new Feature + { + Name = jsonFeature.name, + Location = new Point { Longitude = jsonFeature.location.longitude, Latitude = jsonFeature.location.latitude} + }); + } + return features; + } + + private static string ReadFeaturesFromResource() + { + var stream = typeof(RouteGuideUtil).GetTypeInfo().Assembly.GetManifestResourceStream(DefaultFeaturesResourceName); + if (stream == null) + { + throw new IOException(string.Format("Error loading the embedded resource \"{0}\"", DefaultFeaturesResourceName)); + } + using (var streamReader = new StreamReader(stream)) + { + return streamReader.ReadToEnd(); + } + } + +#pragma warning disable 0649 // Suppresses "Field 'x' is never assigned to". + private class JsonFeature + { + public string name; + public JsonLocation location; + } + + private class JsonLocation + { + public int longitude; + public int latitude; + } +#pragma warning restore 0649 + } +} \ No newline at end of file diff --git a/dotnet/Protobuf/RouteGuide/route_guide_db.json b/dotnet/Protobuf/RouteGuide/route_guide_db.json new file mode 100644 index 0000000..9342beb --- /dev/null +++ b/dotnet/Protobuf/RouteGuide/route_guide_db.json @@ -0,0 +1,601 @@ +[{ + "location": { + "latitude": 407838351, + "longitude": -746143763 + }, + "name": "Patriots Path, Mendham, NJ 07945, USA" +}, { + "location": { + "latitude": 408122808, + "longitude": -743999179 + }, + "name": "101 New Jersey 10, Whippany, NJ 07981, USA" +}, { + "location": { + "latitude": 413628156, + "longitude": -749015468 + }, + "name": "U.S. 6, Shohola, PA 18458, USA" +}, { + "location": { + "latitude": 419999544, + "longitude": -740371136 + }, + "name": "5 Conners Road, Kingston, NY 12401, USA" +}, { + "location": { + "latitude": 414008389, + "longitude": -743951297 + }, + "name": "Mid Hudson Psychiatric Center, New Hampton, NY 10958, USA" +}, { + "location": { + "latitude": 419611318, + "longitude": -746524769 + }, + "name": "287 Flugertown Road, Livingston Manor, NY 12758, USA" +}, { + "location": { + "latitude": 406109563, + "longitude": -742186778 + }, + "name": "4001 Tremley Point Road, Linden, NJ 07036, USA" +}, { + "location": { + "latitude": 416802456, + "longitude": -742370183 + }, + "name": "352 South Mountain Road, Wallkill, NY 12589, USA" +}, { + "location": { + "latitude": 412950425, + "longitude": -741077389 + }, + "name": "Bailey Turn Road, Harriman, NY 10926, USA" +}, { + "location": { + "latitude": 412144655, + "longitude": -743949739 + }, + "name": "193-199 Wawayanda Road, Hewitt, NJ 07421, USA" +}, { + "location": { + "latitude": 415736605, + "longitude": -742847522 + }, + "name": "406-496 Ward Avenue, Pine Bush, NY 12566, USA" +}, { + "location": { + "latitude": 413843930, + "longitude": -740501726 + }, + "name": "162 Merrill Road, Highland Mills, NY 10930, USA" +}, { + "location": { + "latitude": 410873075, + "longitude": -744459023 + }, + "name": "Clinton Road, West Milford, NJ 07480, USA" +}, { + "location": { + "latitude": 412346009, + "longitude": -744026814 + }, + "name": "16 Old Brook Lane, Warwick, NY 10990, USA" +}, { + "location": { + "latitude": 402948455, + "longitude": -747903913 + }, + "name": "3 Drake Lane, Pennington, NJ 08534, USA" +}, { + "location": { + "latitude": 406337092, + "longitude": -740122226 + }, + "name": "6324 8th Avenue, Brooklyn, NY 11220, USA" +}, { + "location": { + "latitude": 406421967, + "longitude": -747727624 + }, + "name": "1 Merck Access Road, Whitehouse Station, NJ 08889, USA" +}, { + "location": { + "latitude": 416318082, + "longitude": -749677716 + }, + "name": "78-98 Schalck Road, Narrowsburg, NY 12764, USA" +}, { + "location": { + "latitude": 415301720, + "longitude": -748416257 + }, + "name": "282 Lakeview Drive Road, Highland Lake, NY 12743, USA" +}, { + "location": { + "latitude": 402647019, + "longitude": -747071791 + }, + "name": "330 Evelyn Avenue, Hamilton Township, NJ 08619, USA" +}, { + "location": { + "latitude": 412567807, + "longitude": -741058078 + }, + "name": "New York State Reference Route 987E, Southfields, NY 10975, USA" +}, { + "location": { + "latitude": 416855156, + "longitude": -744420597 + }, + "name": "103-271 Tempaloni Road, Ellenville, NY 12428, USA" +}, { + "location": { + "latitude": 404663628, + "longitude": -744820157 + }, + "name": "1300 Airport Road, North Brunswick Township, NJ 08902, USA" +}, { + "location": { + "latitude": 407113723, + "longitude": -749746483 + }, + "name": "" +}, { + "location": { + "latitude": 402133926, + "longitude": -743613249 + }, + "name": "" +}, { + "location": { + "latitude": 400273442, + "longitude": -741220915 + }, + "name": "" +}, { + "location": { + "latitude": 411236786, + "longitude": -744070769 + }, + "name": "" +}, { + "location": { + "latitude": 411633782, + "longitude": -746784970 + }, + "name": "211-225 Plains Road, Augusta, NJ 07822, USA" +}, { + "location": { + "latitude": 415830701, + "longitude": -742952812 + }, + "name": "" +}, { + "location": { + "latitude": 413447164, + "longitude": -748712898 + }, + "name": "165 Pedersen Ridge Road, Milford, PA 18337, USA" +}, { + "location": { + "latitude": 405047245, + "longitude": -749800722 + }, + "name": "100-122 Locktown Road, Frenchtown, NJ 08825, USA" +}, { + "location": { + "latitude": 418858923, + "longitude": -746156790 + }, + "name": "" +}, { + "location": { + "latitude": 417951888, + "longitude": -748484944 + }, + "name": "650-652 Willi Hill Road, Swan Lake, NY 12783, USA" +}, { + "location": { + "latitude": 407033786, + "longitude": -743977337 + }, + "name": "26 East 3rd Street, New Providence, NJ 07974, USA" +}, { + "location": { + "latitude": 417548014, + "longitude": -740075041 + }, + "name": "" +}, { + "location": { + "latitude": 410395868, + "longitude": -744972325 + }, + "name": "" +}, { + "location": { + "latitude": 404615353, + "longitude": -745129803 + }, + "name": "" +}, { + "location": { + "latitude": 406589790, + "longitude": -743560121 + }, + "name": "611 Lawrence Avenue, Westfield, NJ 07090, USA" +}, { + "location": { + "latitude": 414653148, + "longitude": -740477477 + }, + "name": "18 Lannis Avenue, New Windsor, NY 12553, USA" +}, { + "location": { + "latitude": 405957808, + "longitude": -743255336 + }, + "name": "82-104 Amherst Avenue, Colonia, NJ 07067, USA" +}, { + "location": { + "latitude": 411733589, + "longitude": -741648093 + }, + "name": "170 Seven Lakes Drive, Sloatsburg, NY 10974, USA" +}, { + "location": { + "latitude": 412676291, + "longitude": -742606606 + }, + "name": "1270 Lakes Road, Monroe, NY 10950, USA" +}, { + "location": { + "latitude": 409224445, + "longitude": -748286738 + }, + "name": "509-535 Alphano Road, Great Meadows, NJ 07838, USA" +}, { + "location": { + "latitude": 406523420, + "longitude": -742135517 + }, + "name": "652 Garden Street, Elizabeth, NJ 07202, USA" +}, { + "location": { + "latitude": 401827388, + "longitude": -740294537 + }, + "name": "349 Sea Spray Court, Neptune City, NJ 07753, USA" +}, { + "location": { + "latitude": 410564152, + "longitude": -743685054 + }, + "name": "13-17 Stanley Street, West Milford, NJ 07480, USA" +}, { + "location": { + "latitude": 408472324, + "longitude": -740726046 + }, + "name": "47 Industrial Avenue, Teterboro, NJ 07608, USA" +}, { + "location": { + "latitude": 412452168, + "longitude": -740214052 + }, + "name": "5 White Oak Lane, Stony Point, NY 10980, USA" +}, { + "location": { + "latitude": 409146138, + "longitude": -746188906 + }, + "name": "Berkshire Valley Management Area Trail, Jefferson, NJ, USA" +}, { + "location": { + "latitude": 404701380, + "longitude": -744781745 + }, + "name": "1007 Jersey Avenue, New Brunswick, NJ 08901, USA" +}, { + "location": { + "latitude": 409642566, + "longitude": -746017679 + }, + "name": "6 East Emerald Isle Drive, Lake Hopatcong, NJ 07849, USA" +}, { + "location": { + "latitude": 408031728, + "longitude": -748645385 + }, + "name": "1358-1474 New Jersey 57, Port Murray, NJ 07865, USA" +}, { + "location": { + "latitude": 413700272, + "longitude": -742135189 + }, + "name": "367 Prospect Road, Chester, NY 10918, USA" +}, { + "location": { + "latitude": 404310607, + "longitude": -740282632 + }, + "name": "10 Simon Lake Drive, Atlantic Highlands, NJ 07716, USA" +}, { + "location": { + "latitude": 409319800, + "longitude": -746201391 + }, + "name": "11 Ward Street, Mount Arlington, NJ 07856, USA" +}, { + "location": { + "latitude": 406685311, + "longitude": -742108603 + }, + "name": "300-398 Jefferson Avenue, Elizabeth, NJ 07201, USA" +}, { + "location": { + "latitude": 419018117, + "longitude": -749142781 + }, + "name": "43 Dreher Road, Roscoe, NY 12776, USA" +}, { + "location": { + "latitude": 412856162, + "longitude": -745148837 + }, + "name": "Swan Street, Pine Island, NY 10969, USA" +}, { + "location": { + "latitude": 416560744, + "longitude": -746721964 + }, + "name": "66 Pleasantview Avenue, Monticello, NY 12701, USA" +}, { + "location": { + "latitude": 405314270, + "longitude": -749836354 + }, + "name": "" +}, { + "location": { + "latitude": 414219548, + "longitude": -743327440 + }, + "name": "" +}, { + "location": { + "latitude": 415534177, + "longitude": -742900616 + }, + "name": "565 Winding Hills Road, Montgomery, NY 12549, USA" +}, { + "location": { + "latitude": 406898530, + "longitude": -749127080 + }, + "name": "231 Rocky Run Road, Glen Gardner, NJ 08826, USA" +}, { + "location": { + "latitude": 407586880, + "longitude": -741670168 + }, + "name": "100 Mount Pleasant Avenue, Newark, NJ 07104, USA" +}, { + "location": { + "latitude": 400106455, + "longitude": -742870190 + }, + "name": "517-521 Huntington Drive, Manchester Township, NJ 08759, USA" +}, { + "location": { + "latitude": 400066188, + "longitude": -746793294 + }, + "name": "" +}, { + "location": { + "latitude": 418803880, + "longitude": -744102673 + }, + "name": "40 Mountain Road, Napanoch, NY 12458, USA" +}, { + "location": { + "latitude": 414204288, + "longitude": -747895140 + }, + "name": "" +}, { + "location": { + "latitude": 414777405, + "longitude": -740615601 + }, + "name": "" +}, { + "location": { + "latitude": 415464475, + "longitude": -747175374 + }, + "name": "48 North Road, Forestburgh, NY 12777, USA" +}, { + "location": { + "latitude": 404062378, + "longitude": -746376177 + }, + "name": "" +}, { + "location": { + "latitude": 405688272, + "longitude": -749285130 + }, + "name": "" +}, { + "location": { + "latitude": 400342070, + "longitude": -748788996 + }, + "name": "" +}, { + "location": { + "latitude": 401809022, + "longitude": -744157964 + }, + "name": "" +}, { + "location": { + "latitude": 404226644, + "longitude": -740517141 + }, + "name": "9 Thompson Avenue, Leonardo, NJ 07737, USA" +}, { + "location": { + "latitude": 410322033, + "longitude": -747871659 + }, + "name": "" +}, { + "location": { + "latitude": 407100674, + "longitude": -747742727 + }, + "name": "" +}, { + "location": { + "latitude": 418811433, + "longitude": -741718005 + }, + "name": "213 Bush Road, Stone Ridge, NY 12484, USA" +}, { + "location": { + "latitude": 415034302, + "longitude": -743850945 + }, + "name": "" +}, { + "location": { + "latitude": 411349992, + "longitude": -743694161 + }, + "name": "" +}, { + "location": { + "latitude": 404839914, + "longitude": -744759616 + }, + "name": "1-17 Bergen Court, New Brunswick, NJ 08901, USA" +}, { + "location": { + "latitude": 414638017, + "longitude": -745957854 + }, + "name": "35 Oakland Valley Road, Cuddebackville, NY 12729, USA" +}, { + "location": { + "latitude": 412127800, + "longitude": -740173578 + }, + "name": "" +}, { + "location": { + "latitude": 401263460, + "longitude": -747964303 + }, + "name": "" +}, { + "location": { + "latitude": 412843391, + "longitude": -749086026 + }, + "name": "" +}, { + "location": { + "latitude": 418512773, + "longitude": -743067823 + }, + "name": "" +}, { + "location": { + "latitude": 404318328, + "longitude": -740835638 + }, + "name": "42-102 Main Street, Belford, NJ 07718, USA" +}, { + "location": { + "latitude": 419020746, + "longitude": -741172328 + }, + "name": "" +}, { + "location": { + "latitude": 404080723, + "longitude": -746119569 + }, + "name": "" +}, { + "location": { + "latitude": 401012643, + "longitude": -744035134 + }, + "name": "" +}, { + "location": { + "latitude": 404306372, + "longitude": -741079661 + }, + "name": "" +}, { + "location": { + "latitude": 403966326, + "longitude": -748519297 + }, + "name": "" +}, { + "location": { + "latitude": 405002031, + "longitude": -748407866 + }, + "name": "" +}, { + "location": { + "latitude": 409532885, + "longitude": -742200683 + }, + "name": "" +}, { + "location": { + "latitude": 416851321, + "longitude": -742674555 + }, + "name": "" +}, { + "location": { + "latitude": 406411633, + "longitude": -741722051 + }, + "name": "3387 Richmond Terrace, Staten Island, NY 10303, USA" +}, { + "location": { + "latitude": 413069058, + "longitude": -744597778 + }, + "name": "261 Van Sickle Road, Goshen, NY 10924, USA" +}, { + "location": { + "latitude": 418465462, + "longitude": -746859398 + }, + "name": "" +}, { + "location": { + "latitude": 411733222, + "longitude": -744228360 + }, + "name": "" +}, { + "location": { + "latitude": 410248224, + "longitude": -747127767 + }, + "name": "3 Hasta Way, Newton, NJ 07860, USA" +}] \ No newline at end of file diff --git a/dotnet/Protobuf/RouteGuideClient.Tests/RouteGuideClient.Tests.csproj b/dotnet/Protobuf/RouteGuideClient.Tests/RouteGuideClient.Tests.csproj new file mode 100644 index 0000000..0539609 --- /dev/null +++ b/dotnet/Protobuf/RouteGuideClient.Tests/RouteGuideClient.Tests.csproj @@ -0,0 +1,22 @@ + + + net8.0 + false + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/dotnet/Protobuf/RouteGuideClient.Tests/RouteGuideClientTest.cs b/dotnet/Protobuf/RouteGuideClient.Tests/RouteGuideClientTest.cs new file mode 100644 index 0000000..61fd34a --- /dev/null +++ b/dotnet/Protobuf/RouteGuideClient.Tests/RouteGuideClientTest.cs @@ -0,0 +1,68 @@ +using System; +using Xunit; +using PactFfi; +using System.IO; +namespace RouteGuideClient.Tests +{ + public class RouteGuideClientTests + { + + [Fact] + public void ReturnsMismatchWhenNoProtobufClientRequestMade() + { + + _ = Pact.LogToStdOut(3); + + var pact = Pact.NewPact("protobufmessageconsumer", "protobufmessageprovider"); + var interaction = Pact.NewMessageInteraction(pact, "feature message"); + Pact.Given(interaction, "the world exists"); + Pact.WithSpecification(pact, Pact.PactSpecification.V4); + var content = $@"{{ + ""pact:proto"":""{Path.Join(Directory.GetCurrentDirectory(), "..", "..", "..", "..", "Protos", "route_guide.proto").Replace("\\", "\\\\")}"", + ""pact:message-type"": ""Feature"", + ""pact:content-type"": ""application/protobuf"", + ""name"": ""notEmpty('Big Tree')"", + ""location"": {{ + ""latitude"": ""matching(number, 180)"", + ""longitude"": ""matching(number, 200)"" + }} + }}"; + Pact.PluginAdd(pact, "protobuf", "0.4.0"); + Pact.PluginInteractionContents(interaction, 0, "application/protobuf", content); + + // TODO perform mismatch logic + + Pact.PluginCleanup(pact); + } + [Fact] + public void WritesPactWhenGrpcClientRequestMade() + { + + _ = Pact.LogToStdOut(3); + // var host = "0.0.0.0"; + var pact = Pact.NewPact("protobufmessageconsumer", "protobufmessageprovider"); + var interaction = Pact.NewMessageInteraction(pact, "feature message"); + Pact.Given(interaction, "the world exists"); + Pact.WithSpecification(pact, Pact.PactSpecification.V4); + var content = $@"{{ + ""pact:proto"":""{Path.Join(Directory.GetCurrentDirectory(), "..", "..", "..", "..", "Protos", "route_guide.proto").Replace("\\", "\\\\")}"", + ""pact:message-type"": ""Feature"", + ""pact:content-type"": ""application/protobuf"", + ""name"": ""notEmpty('Big Tree')"", + ""location"": {{ + ""latitude"": ""matching(number, 180)"", + ""longitude"": ""matching(number, 200)"" + }} + }}"; + Pact.PluginAdd(pact, "protobuf", "0.4.0"); + Pact.PluginInteractionContents(interaction, 0, "application/protobuf", content); + + // TODO perform message logic + + var writeRes = Pact.WriteMessagePactFile(pact, "../../../../pacts", false); + Console.WriteLine("WriteRes: " + writeRes); + Pact.PluginCleanup(pact); + } + + } +} diff --git a/dotnet/Protobuf/RouteGuideClient/Program.cs b/dotnet/Protobuf/RouteGuideClient/Program.cs new file mode 100644 index 0000000..782bb53 --- /dev/null +++ b/dotnet/Protobuf/RouteGuideClient/Program.cs @@ -0,0 +1,270 @@ +// Copyright 2015 gRPC authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Grpc.Core; +using Grpc.Net.Client; +using System.Text; + +namespace Routeguide +{ + class Program + { + /// + /// Sample client code that makes gRPC calls to the server. + /// + public class RouteGuideClient + { + readonly RouteGuide.RouteGuideClient client; + + public RouteGuideClient(RouteGuide.RouteGuideClient client) + { + this.client = client; + } + + /// + /// Blocking unary call example. Calls GetFeature and prints the response. + /// + public void GetFeature(int lat, int lon) + { + try + { + Log("*** GetFeature: lat={0} lon={1}", lat, lon); + + Point request = new Point { Latitude = lat, Longitude = lon }; + + Feature feature = client.GetFeature(request); + if (feature.Exists()) + { + Log("Found feature called \"{0}\" at {1}, {2}", + feature.Name, feature.Location.GetLatitude(), feature.Location.GetLongitude()); + } + else + { + Log("Found no feature at {0}, {1}", + feature.Location.GetLatitude(), feature.Location.GetLongitude()); + } + } + catch (RpcException e) + { + Log("RPC failed " + e); + throw; + } + } + + + /// + /// Server-streaming example. Calls listFeatures with a rectangle of interest. Prints each response feature as it arrives. + /// + public async Task ListFeatures(int lowLat, int lowLon, int hiLat, int hiLon) + { + try + { + Log("*** ListFeatures: lowLat={0} lowLon={1} hiLat={2} hiLon={3}", lowLat, lowLon, hiLat, + hiLon); + + Rectangle request = new Rectangle + { + Lo = new Point { Latitude = lowLat, Longitude = lowLon }, + Hi = new Point { Latitude = hiLat, Longitude = hiLon } + }; + + using (var call = client.ListFeatures(request)) + { + var responseStream = call.ResponseStream; + StringBuilder responseLog = new StringBuilder("Result: "); + + while (await responseStream.MoveNext()) + { + Feature feature = responseStream.Current; + responseLog.Append(feature.ToString()); + } + Log(responseLog.ToString()); + } + } + catch (RpcException e) + { + Log("RPC failed " + e); + throw; + } + } + + /// + /// Client-streaming example. Sends numPoints randomly chosen points from features + /// with a variable delay in between. Prints the statistics when they are sent from the server. + /// + public async Task RecordRoute(List features, int numPoints) + { + try + { + Log("*** RecordRoute"); + using (var call = client.RecordRoute()) + { + // Send numPoints points randomly selected from the features list. + StringBuilder numMsg = new StringBuilder(); + Random rand = new Random(); + for (int i = 0; i < numPoints; ++i) + { + int index = rand.Next(features.Count); + Point point = features[index].Location; + Log("Visiting point {0}, {1}", point.GetLatitude(), point.GetLongitude()); + + await call.RequestStream.WriteAsync(point); + + // A bit of delay before sending the next one. + await Task.Delay(rand.Next(1000) + 500); + } + await call.RequestStream.CompleteAsync(); + + RouteSummary summary = await call.ResponseAsync; + Log("Finished trip with {0} points. Passed {1} features. " + + "Travelled {2} meters. It took {3} seconds.", summary.PointCount, + summary.FeatureCount, summary.Distance, summary.ElapsedTime); + + Log("Finished RecordRoute"); + } + } + catch (RpcException e) + { + Log("RPC failed", e); + throw; + } + } + + /// + /// Bi-directional streaming example. Send some chat messages, and print any + /// chat messages that are sent from the server. + /// + public async Task RouteChat() + { + try + { + Log("*** RouteChat"); + var requests = new List + { + NewNote("First message", 0, 0), + NewNote("Second message", 0, 1), + NewNote("Third message", 1, 0), + NewNote("Fourth message", 0, 0) + }; + + using (var call = client.RouteChat()) + { + var responseReaderTask = Task.Run(async () => + { + while (await call.ResponseStream.MoveNext()) + { + var note = call.ResponseStream.Current; + Log("Got message \"{0}\" at {1}, {2}", note.Message, + note.Location.Latitude, note.Location.Longitude); + } + }); + + foreach (RouteNote request in requests) + { + Log("Sending message \"{0}\" at {1}, {2}", request.Message, + request.Location.Latitude, request.Location.Longitude); + + await call.RequestStream.WriteAsync(request); + } + await call.RequestStream.CompleteAsync(); + await responseReaderTask; + + Log("Finished RouteChat"); + } + } + catch (RpcException e) + { + Log("RPC failed", e); + throw; + } + } + + private void Log(string s, params object[] args) + { + Console.WriteLine(string.Format(s, args)); + } + + private void Log(string s) + { + Console.WriteLine(s); + } + + private RouteNote NewNote(string message, int lat, int lon) + { + return new RouteNote + { + Message = message, + Location = new Point { Latitude = lat, Longitude = lon } + }; + } + } + + static void Main(string[] args) + { + var channel = GrpcChannel.ForAddress("http://127.0.0.1:5099"); + var client = new RouteGuideClient(new RouteGuide.RouteGuideClient(channel)); + + // Looking for a valid feature + client.GetFeature(409146138, -746188906); + + // Feature missing. + client.GetFeature(0, 0); + + // Looking for features between 40, -75 and 42, -73. + client.ListFeatures(400000000, -750000000, 420000000, -730000000).Wait(); + + // Record a few randomly selected points from the features file. + client.RecordRoute(RouteGuideUtil.LoadFeatures(), 10).Wait(); + + // Send and receive some notes. + client.RouteChat().Wait(); + + channel.ShutdownAsync().Wait(); + Console.WriteLine("Press any key to exit..."); + Console.ReadKey(); + } + } +} + +// using Grpc.Net.Client; +// using RouteGuideClient; + +// public class RouteGuideClientWrapper +// { +// private readonly RouteGuide.RouteGuideClient _client; + +// public RouteGuideClientWrapper(string url) +// { +// var channel = GrpcChannel.ForAddress(url); +// _client = new RouteGuide.RouteGuideClient(channel); +// } + +// public Feature GetFeature(int latitude, int longitude) +// { +// var reply = _client.GetFeature(new Point { Latitude = latitude, Longitude = longitude }); +// return reply; +// } +// } + +// public class Program +// { +// public static void Main(string[] args) +// { +// var client = new RouteGuideClientWrapper("http://localhost:5099"); +// var greeting = client.GetFeature(407838351,-746143763); +// Console.WriteLine("Greeting: " + greeting); +// Console.WriteLine("Press any key to exit..."); +// Console.ReadKey(); +// } +// } diff --git a/dotnet/Protobuf/RouteGuideClient/RouteGuideClient.csproj b/dotnet/Protobuf/RouteGuideClient/RouteGuideClient.csproj new file mode 100644 index 0000000..6175d59 --- /dev/null +++ b/dotnet/Protobuf/RouteGuideClient/RouteGuideClient.csproj @@ -0,0 +1,26 @@ + + + + Exe + net8.0 + enable + enable + + + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/dotnet/Protobuf/RouteGuideServer.Tests/RouteGuideServer.Tests.csproj b/dotnet/Protobuf/RouteGuideServer.Tests/RouteGuideServer.Tests.csproj new file mode 100644 index 0000000..795d74c --- /dev/null +++ b/dotnet/Protobuf/RouteGuideServer.Tests/RouteGuideServer.Tests.csproj @@ -0,0 +1,22 @@ + + + net8.0 + false + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/dotnet/Protobuf/RouteGuideServer.Tests/RouteGuideServerTests.cs b/dotnet/Protobuf/RouteGuideServer.Tests/RouteGuideServerTests.cs new file mode 100644 index 0000000..cdd6d6d --- /dev/null +++ b/dotnet/Protobuf/RouteGuideServer.Tests/RouteGuideServerTests.cs @@ -0,0 +1,96 @@ + +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; +using PactFfi; +using System.Runtime.InteropServices; +using System.Net; +using Routeguide; +using System.IO; +using Google.Protobuf; +using Newtonsoft.Json; + +namespace RouteGuideServer.Tests +{ + public class RouteGuideServerTests + { + [Fact] + public void ReturnsVerificationSuccessRunningProvider() + { + + // setup a message handler proxy + var listener = new HttpListener(); + listener.Prefixes.Add("http://localhost:5010/__messages/"); + listener.Start(); + + _ = Task.Run(() => + { + var context = listener.GetContext(); + + if (context.Request.HttpMethod == "POST" && context.Request.Url.AbsolutePath == "/__messages") + { + using var reader = new StreamReader(context.Request.InputStream); + var requestBody = reader.ReadToEnd(); + Console.WriteLine($"Request Body: {requestBody}"); + Console.WriteLine($"Request Path: {context.Request.Url.AbsolutePath}"); + + foreach (var header in context.Request.Headers.AllKeys) + { + Console.WriteLine($"{header}: {context.Request.Headers[header]}"); + } + var requestBodyJson = JsonConvert.DeserializeObject(requestBody); + Console.WriteLine($"Request requestBodyJson: {requestBodyJson}"); + Console.WriteLine($"Request requestBodyJson[description]: {requestBodyJson["description"]}"); + // Map message description to action + if (requestBodyJson["description"] == "feature message") + { + var feature = new Feature + { + Name = "Sample Feature", + Location = new Point + { + Latitude = 123, + Longitude = 789 + } + }; + + using var stream = new MemoryStream(); + feature.WriteTo(stream); + context.Response.ContentType = "application/protobuf;message=Feature"; + context.Response.ContentLength64 = stream.Length; + stream.Position = 0; + stream.CopyTo(context.Response.OutputStream); + } + else + { + // return a 404 response for other requests + context.Response.StatusCode = 404; + context.Response.Close(); + } + } + else + { + // return a 404 response for other requests + context.Response.StatusCode = 404; + context.Response.Close(); + } + }); + + _ = Pact.LogToStdOut(3); + + var verifier = Pact.VerifierNewForApplication("pact-dotnet", "0.0.0"); + Pact.VerifierSetProviderInfo(verifier, "grpc-greeter", null, null, 5099, null); + // Pact.VerifierSetProviderState(verifier,"http://localhost:5010",new byte(),new byte()); + Pact.AddProviderTransport(verifier, "message", 5010, "/__messages", "http"); + Pact.VerifierAddFileSource(verifier, "../../../../pacts/protobufmessageconsumer-protobufmessageprovider.json"); + + + // Act + var VerifierExecuteResult = Pact.VerifierExecute(verifier); + VerifierExecuteResult.Should().Be(0); + Pact.VerifierShutdown(verifier); + + } + } +} diff --git a/dotnet/Protobuf/RouteGuideServer/Program.cs b/dotnet/Protobuf/RouteGuideServer/Program.cs new file mode 100644 index 0000000..d04b4d1 --- /dev/null +++ b/dotnet/Protobuf/RouteGuideServer/Program.cs @@ -0,0 +1,119 @@ +// Copyright 2015 gRPC authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace Routeguide +{ + class Program + { + static async Task Main(string[] args) + { + + var features = RouteGuideUtil.LoadFeatures(); + + var host = Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.ConfigureServices(services => + { + services.AddGrpc(); + services.AddSingleton>(); + }); + + webBuilder.Configure(app => + { + app.UseRouting(); + + app.UseEndpoints(endpoints => + { + endpoints.MapGrpcService(); + endpoints.MapGet("/", context => + { + return context.Response.WriteAsync("Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909"); + }); + }); + }); + }) + .Build(); + + var cancellationTokenSource = new CancellationTokenSource(); + var periodicCancellationTask = PeriodicallyCheckCancellation(cancellationTokenSource.Token, cancellationTokenSource); + + await host.RunAsync(); + + cancellationTokenSource.Cancel(); + await periodicCancellationTask; + } + + private static async Task PeriodicallyCheckCancellation(CancellationToken cancellationToken, CancellationTokenSource periodicCancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + if (periodicCancellationToken.Token.IsCancellationRequested) + { + periodicCancellationToken.Cancel(); + break; + } + + await Task.Delay(1000); // Check for cancellation every second + } + } + } +} + + +// using RouteGuide.Services; + +// public class RouteGuideService +// { +// public static async Task Main(string[] args) +// { +// await RunApp(args, CancellationToken.None); +// } + +// public static async Task RunApp(string[] args, CancellationToken cancellationToken) +// { +// var builder = WebApplication.CreateBuilder(args); + +// // Add services to the container. +// builder.Services.AddGrpc(); + +// var app = builder.Build(); + +// // Configure the HTTP request pipeline. +// app.MapGrpcService(); +// app.MapGet("/", () => "Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909"); + +// var cancellationTokenSource = new CancellationTokenSource(); +// var periodicCancellationTask = PeriodicallyCheckCancellation(cancellationToken, cancellationTokenSource); + +// await app.RunAsync(); + +// cancellationTokenSource.Cancel(); +// await periodicCancellationTask; +// } + +// private static async Task PeriodicallyCheckCancellation(CancellationToken cancellationToken, CancellationTokenSource periodicCancellationToken) +// { +// while (!cancellationToken.IsCancellationRequested) +// { +// if (periodicCancellationToken.Token.IsCancellationRequested) +// { +// periodicCancellationToken.Cancel(); +// break; +// } + +// await Task.Delay(1000); // Check for cancellation every second +// } +// } +// } diff --git a/dotnet/Protobuf/RouteGuideServer/Properties/launchSettings.json b/dotnet/Protobuf/RouteGuideServer/Properties/launchSettings.json new file mode 100644 index 0000000..08e3337 --- /dev/null +++ b/dotnet/Protobuf/RouteGuideServer/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5099", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7273;http://localhost:5099", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/dotnet/Protobuf/RouteGuideServer/RouteGuideImpl.cs b/dotnet/Protobuf/RouteGuideServer/RouteGuideImpl.cs new file mode 100644 index 0000000..d6bd9a8 --- /dev/null +++ b/dotnet/Protobuf/RouteGuideServer/RouteGuideImpl.cs @@ -0,0 +1,130 @@ +using System.Diagnostics; +using Grpc.Core; + +namespace Routeguide +{ + /// + /// Example implementation of RouteGuide server. + /// + public class RouteGuideImpl : RouteGuide.RouteGuideBase + { + readonly List features; + readonly object myLock = new object(); + readonly Dictionary> routeNotes = new Dictionary>(); + + public RouteGuideImpl(List features) + { + this.features = features; + } + + /// + /// Gets the feature at the requested point. If no feature at that location + /// exists, an unnammed feature is returned at the provided location. + /// + public override Task GetFeature(Point request, ServerCallContext context) + { + return Task.FromResult(CheckFeature(request)); + } + + /// + /// Gets all features contained within the given bounding rectangle. + /// + public override async Task ListFeatures(Rectangle request, IServerStreamWriter responseStream, ServerCallContext context) + { + var responses = features.FindAll((feature) => feature.Exists() && request.Contains(feature.Location)); + foreach (var response in responses) + { + await responseStream.WriteAsync(response); + } + } + + /// + /// Gets a stream of points, and responds with statistics about the "trip": number of points, + /// number of known features visited, total distance traveled, and total time spent. + /// + public override async Task RecordRoute(IAsyncStreamReader requestStream, ServerCallContext context) + { + int pointCount = 0; + int featureCount = 0; + int distance = 0; + Point previous = null; + var stopwatch = new Stopwatch(); + stopwatch.Start(); + + await foreach (var point in requestStream.ReadAllAsync()) + { + pointCount++; + if (CheckFeature(point).Exists()) + { + featureCount++; + } + if (previous != null) + { + distance += (int)previous.GetDistance(point); + } + previous = point; + } + + stopwatch.Stop(); + + return new RouteSummary + { + PointCount = pointCount, + FeatureCount = featureCount, + Distance = distance, + ElapsedTime = (int)(stopwatch.ElapsedMilliseconds / 1000) + }; + } + + /// + /// Receives a stream of message/location pairs, and responds with a stream of all previous + /// messages at each of those locations. + /// + public override async Task RouteChat(IAsyncStreamReader requestStream, IServerStreamWriter responseStream, ServerCallContext context) + { + await foreach (var note in requestStream.ReadAllAsync()) + { + List prevNotes = AddNoteForLocation(note.Location, note); + foreach (var prevNote in prevNotes) + { + await responseStream.WriteAsync(prevNote); + } + } + } + + /// + /// Adds a note for location and returns a list of pre-existing notes for that location (not containing the newly added note). + /// + private List AddNoteForLocation(Point location, RouteNote note) + { + lock (myLock) + { + List notes; + if (!routeNotes.TryGetValue(location, out notes)) + { + notes = new List(); + routeNotes.Add(location, notes); + } + var preexistingNotes = new List(notes); + notes.Add(note); + return preexistingNotes; + } + } + + /// + /// Gets the feature at the given point. + /// + /// the location to check + /// The feature object at the point Note that an empty name indicates no feature. + private Feature CheckFeature(Point location) + { + var result = features.FirstOrDefault((feature) => feature.Location.Equals(location)); + if (result == null) + { + // No feature was found, return an unnamed feature. + return new Feature { Name = "", Location = location }; + } + return result; + } + } +} diff --git a/dotnet/Protobuf/RouteGuideServer/RouteGuideService.csproj b/dotnet/Protobuf/RouteGuideServer/RouteGuideService.csproj new file mode 100644 index 0000000..a07685c --- /dev/null +++ b/dotnet/Protobuf/RouteGuideServer/RouteGuideService.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + + + false + + + + + + + + diff --git a/dotnet/Protobuf/RouteGuideServer/appsettings.Development.json b/dotnet/Protobuf/RouteGuideServer/appsettings.Development.json new file mode 100644 index 0000000..71fcd85 --- /dev/null +++ b/dotnet/Protobuf/RouteGuideServer/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.AspNetCore.Hosting": "Information", + "Microsoft.AspNetCore.Routing.EndpointMiddleware": "Information" + } + } +} diff --git a/dotnet/Protobuf/RouteGuideServer/appsettings.json b/dotnet/Protobuf/RouteGuideServer/appsettings.json new file mode 100644 index 0000000..e559d3a --- /dev/null +++ b/dotnet/Protobuf/RouteGuideServer/appsettings.json @@ -0,0 +1,14 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Kestrel": { + "EndpointDefaults": { + "Protocols": "Http2" + } + } +} diff --git a/dotnet/Protobuf/pacts/protobufmessageconsumer-protobufmessageprovider.json b/dotnet/Protobuf/pacts/protobufmessageconsumer-protobufmessageprovider.json new file mode 100644 index 0000000..a6179d5 --- /dev/null +++ b/dotnet/Protobuf/pacts/protobufmessageconsumer-protobufmessageprovider.json @@ -0,0 +1,88 @@ +{ + "consumer": { + "name": "protobufmessageconsumer" + }, + "interactions": [ + { + "contents": { + "content": "CghCaWcgVHJlZRIGCLQBEMgB", + "contentType": "application/protobuf;message=Feature", + "contentTypeHint": "BINARY", + "encoded": "base64" + }, + "description": "feature message", + "interactionMarkup": { + "markup": "```protobuf\nmessage Feature {\n string name = 1;\n message .routeguide.Point location = 2;\n}\n```\n", + "markupType": "COMMON_MARK" + }, + "matchingRules": { + "body": { + "$.location.latitude": { + "combine": "AND", + "matchers": [ + { + "match": "number" + } + ] + }, + "$.location.longitude": { + "combine": "AND", + "matchers": [ + { + "match": "number" + } + ] + }, + "$.name": { + "combine": "AND", + "matchers": [ + { + "match": "notEmpty" + } + ] + } + } + }, + "metadata": { + "contentType": "application/protobuf;message=Feature" + }, + "pending": false, + "pluginConfiguration": { + "protobuf": { + "descriptorKey": "32be40995693643591f1bdcb49997b85", + "message": "Feature" + } + }, + "providerStates": [ + { + "name": "the world exists" + } + ], + "type": "Asynchronous/Messages" + } + ], + "metadata": { + "pactRust": { + "ffi": "0.4.22", + "models": "1.2.3" + }, + "pactSpecification": { + "version": "4.0" + }, + "plugins": [ + { + "configuration": { + "32be40995693643591f1bdcb49997b85": { + "protoDescriptors": "CsYHChFyb3V0ZV9ndWlkZS5wcm90bxIKcm91dGVndWlkZSJBCgVQb2ludBIaCghsYXRpdHVkZRgBIAEoBVIIbGF0aXR1ZGUSHAoJbG9uZ2l0dWRlGAIgASgFUglsb25naXR1ZGUiUQoJUmVjdGFuZ2xlEiEKAmxvGAEgASgLMhEucm91dGVndWlkZS5Qb2ludFICbG8SIQoCaGkYAiABKAsyES5yb3V0ZWd1aWRlLlBvaW50UgJoaSJuCgdGZWF0dXJlEhIKBG5hbWUYASABKAlSBG5hbWUSLQoIbG9jYXRpb24YAiABKAsyES5yb3V0ZWd1aWRlLlBvaW50Ughsb2NhdGlvbhIgCgtkZXNjcmlwdGlvbhgDIAEoCVILZGVzY3JpcHRpb24iVAoJUm91dGVOb3RlEi0KCGxvY2F0aW9uGAEgASgLMhEucm91dGVndWlkZS5Qb2ludFIIbG9jYXRpb24SGAoHbWVzc2FnZRgCIAEoCVIHbWVzc2FnZSKTAQoMUm91dGVTdW1tYXJ5Eh8KC3BvaW50X2NvdW50GAEgASgFUgpwb2ludENvdW50EiMKDWZlYXR1cmVfY291bnQYAiABKAVSDGZlYXR1cmVDb3VudBIaCghkaXN0YW5jZRgDIAEoBVIIZGlzdGFuY2USIQoMZWxhcHNlZF90aW1lGAQgASgFUgtlbGFwc2VkVGltZTLAAgoKUm91dGVHdWlkZRI2CgpHZXRGZWF0dXJlEhEucm91dGVndWlkZS5Qb2ludBoTLnJvdXRlZ3VpZGUuRmVhdHVyZSIAEjkKC1NhdmVGZWF0dXJlEhMucm91dGVndWlkZS5GZWF0dXJlGhMucm91dGVndWlkZS5GZWF0dXJlIgASPgoMTGlzdEZlYXR1cmVzEhUucm91dGVndWlkZS5SZWN0YW5nbGUaEy5yb3V0ZWd1aWRlLkZlYXR1cmUiADABEj4KC1JlY29yZFJvdXRlEhEucm91dGVndWlkZS5Qb2ludBoYLnJvdXRlZ3VpZGUuUm91dGVTdW1tYXJ5IgAoARI/CglSb3V0ZUNoYXQSFS5yb3V0ZWd1aWRlLlJvdXRlTm90ZRoVLnJvdXRlZ3VpZGUuUm91dGVOb3RlIgAoATABQmgKG2lvLmdycGMuZXhhbXBsZXMucm91dGVndWlkZUIPUm91dGVHdWlkZVByb3RvUAFaNmdvb2dsZS5nb2xhbmcub3JnL2dycGMvZXhhbXBsZXMvcm91dGVfZ3VpZGUvcm91dGVndWlkZWIGcHJvdG8z", + "protoFile": "// Copyright 2015 gRPC authors.\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n// http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nsyntax = \"proto3\";\n\noption go_package = \"google.golang.org/grpc/examples/route_guide/routeguide\";\noption java_multiple_files = true;\noption java_package = \"io.grpc.examples.routeguide\";\noption java_outer_classname = \"RouteGuideProto\";\n// option csharp_namespace = \"RouteGuide\";\n\npackage routeguide;\n\n// Interface exported by the server.\nservice RouteGuide {\n // A simple RPC.\n //\n // Obtains the feature at a given position.\n //\n // A feature with an empty name is returned if there's no feature at the given\n // position.\n rpc GetFeature(Point) returns (Feature) {}\n\n // Save the feature.\n rpc SaveFeature(Feature) returns (Feature) {}\n\n // A server-to-client streaming RPC.\n //\n // Obtains the Features available within the given Rectangle. Results are\n // streamed rather than returned at once (e.g. in a response message with a\n // repeated field), as the rectangle may cover a large area and contain a\n // huge number of features.\n rpc ListFeatures(Rectangle) returns (stream Feature) {}\n\n // A client-to-server streaming RPC.\n //\n // Accepts a stream of Points on a route being traversed, returning a\n // RouteSummary when traversal is completed.\n rpc RecordRoute(stream Point) returns (RouteSummary) {}\n\n // A Bidirectional streaming RPC.\n //\n // Accepts a stream of RouteNotes sent while a route is being traversed,\n // while receiving other RouteNotes (e.g. from other users).\n rpc RouteChat(stream RouteNote) returns (stream RouteNote) {}\n}\n\n// Points are represented as latitude-longitude pairs in the E7 representation\n// (degrees multiplied by 10**7 and rounded to the nearest integer).\n// Latitudes should be in the range +/- 90 degrees and longitude should be in\n// the range +/- 180 degrees (inclusive).\nmessage Point {\n int32 latitude = 1;\n int32 longitude = 2;\n}\n\n// A latitude-longitude rectangle, represented as two diagonally opposite\n// points \"lo\" and \"hi\".\nmessage Rectangle {\n // One corner of the rectangle.\n Point lo = 1;\n\n // The other corner of the rectangle.\n Point hi = 2;\n}\n\n// A feature names something at a given point.\n//\n// If a feature could not be named, the name is empty.\nmessage Feature {\n // The name of the feature.\n string name = 1;\n\n // The point where the feature is detected.\n Point location = 2;\n\n // A description of the feature.\n string description = 3;\n}\n\n// A RouteNote is a message sent while at a given point.\nmessage RouteNote {\n // The location from which the message is sent.\n Point location = 1;\n\n // The message to be sent.\n string message = 2;\n}\n\n// A RouteSummary is received in response to a RecordRoute rpc.\n//\n// It contains the number of individual points received, the number of\n// detected features, and the total distance covered as the cumulative sum of\n// the distance between each point.\nmessage RouteSummary {\n // The number of points received.\n int32 point_count = 1;\n\n // The number of known features passed while traversing the route.\n int32 feature_count = 2;\n\n // The distance covered in metres.\n int32 distance = 3;\n\n // The duration of the traversal in seconds.\n int32 elapsed_time = 4;\n}\n" + } + }, + "name": "protobuf", + "version": "0.4.0" + } + ] + }, + "provider": { + "name": "protobufmessageprovider" + } +} \ No newline at end of file diff --git a/dotnet/README.md b/dotnet/README.md new file mode 100644 index 0000000..22e9730 --- /dev/null +++ b/dotnet/README.md @@ -0,0 +1,120 @@ +# .NET Pact Plugin Scenarioes + +This repository contains a collection of pact plugin scenarios for the .NET Interop with the Pact FFI shared library. + +## Layout + +```sh +. +├── Avro +│ ├── AvroClient +│ ├── AvroClient.Tests +│ ├── AvroProvider +│ └── AvroProvider.Tests +├── Grpc +│ ├── GrpcGreeter +│ ├── GrpcGreeter.Tests +│ ├── GrpcGreeterClient +│ └── GrpcGreeterClient.Tests +├── Pact +│ ├── Pact.cs +│ └── Pact.csproj +├── Plugin +│ └── FooPluginConsumer.Tests +├── Protobuf +│ ├── Protos +│ ├── RouteGuide +│ ├── RouteGuideClient +│ ├── RouteGuideClient.Tests +│ ├── RouteGuideServer +│ └── RouteGuideServer.Tests +├── README.md +└── Tcp + ├── TcpClient + ├── TcpClient.Tests + ├── TcpListener + └── TcpListener.Tests +``` + +## Scenarios + +### Avro + +Simple Avro example utilising Apache.Avro library to send HTTP messages in Avro format. + +- Type: Synchronous/HTTP +- Transport: HTTP +- Message Format: Avro +- Plugin: pact-avro-plugin +- Pact File: [`./Avro/pacts/AvroConsumer-AvroProvider.json`](./Avro/pacts/AvroConsumer-AvroProvider.json) +- Other Reference Impls + - [Pact-Go Avro Example](https://github.com/pact-foundation/pact-go/tree/master/examples/avro) + +### Grpc + +Simple gRPC greeter client. + +Adapted for Pact, from [Microsoft's gRPC sample - Tutorial: Create a gRPC client and server in ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/tutorials/grpc/grpc-start?view=aspnetcore-8.0&tabs=visual-studio-mac) + +- Type: Synchronous/Messages +- Transport: gRPC +- Message Format: Protobuf +- Plugin: pact-protobuf-plugin +- Pact File: [`./Grpc/pacts/grpc-greeter-client-dotnet-grpc-greeter.json`](./Grpc/pacts/grpc-greeter-client-dotnet-grpc-greeter.json) +- Other Reference Impls + - [Pact-Go RouteGuide gRPC Example](https://github.com/pact-foundation/pact-go/tree/master/examples/grpc) + - [Pact-Js AreaCalculator Example](https://github.com/pact-foundation/pact-plugins/blob/main/examples/gRPC/area_calculator/js/test/grpc.consumer.spec.ts) + +### Plugin + +Simple example of usage of custom plugin for Pact, which concentrates on message format only. + +Part of Pact [create a plugin course](https://docs.pact.io/plugins/workshops/create-a-plugin/intro). + +Demonstrates usage of a basic plugin created in .NET + +- Type: Synchronous/HTTP +- Transport: HTTP +- Message Format: foo +- Plugin: pact-dotnet-plugin + - [pact-plugin-template-dotnet](https://github.com/YOU54F/pact-plugin-template-dotnet) +- Pact File: [`./Plugin/pacts/DotnetPluginConsumer-DotnetPluginProvider.json`](./Plugin/pacts/DotnetPluginConsumer-DotnetPluginProvider.json) + +### Protobuf + +gRPC RouteGuide example, adapted from old [.NET gRPC example](https://github.com/grpc/grpc/tree/v1.46.x/examples/csharp/RouteGuide). + +Utilises a .NET message proxy to map Pact messages to message handlers. + +- Type: Asynchronous/Messages +- Transport: gRPC +- Message Format: Protobuf +- Plugin: pact-protobuf-plugin +- Pact file: [`./Protobuf/pacts/protobufmessageconsumer-protobufmessageprovider.json`](./Protobuf/pacts/protobufmessageconsumer-protobufmessageprovider.json) +- Other Reference Impls + - [Pact-Go RouteGuide Protobuf Example](https://github.com/pact-foundation/pact-go/tree/master/examples/protobuf) + - [Pact-Js RouteGuide Example](https://github.com/pact-foundation/pact-js-core/blob/f2b3918c5c92138e0ad4660058a4e1eb679ae494/test/message.integration.spec.ts#L137) + + + +### Tcp + +Follows the pact-matt-plugin, as exercised in + +- Type: Synchronous/HTTP +- Transport: HTTP +- Message Format: matt +- Plugin: pact-matt-plugin +- Pact file: [`./Tcp/pacts/MattConsumer-MattProvider.json`](./Tcp/pacts/MattConsumer-MattProvider.json) +- Other Reference Impls + - [Pact-Go Http Example](https://github.com/pact-foundation/pact-go/tree/master/examples/plugin) + - [Pact-Js-Core Consumer Example](https://github.com/pact-foundation/pact-js-core/blob/master/test/matt.consumer.integration.spec.ts) + - [Pact-Js-Core Provider Example](https://github.com/pact-foundation/pact-js-core/blob/master/test/matt.provider.integration.spec.ts) + +- Type: Synchronous/Messages +- Transport: TCP +- Message Format: matt +- Plugin: pact-matt-plugin +- Pact file: [`./Tcp/pacts/matttcpconsumer-matttcpprovider.json`](./Tcp/pacts/matttcpconsumer-matttcpprovider.json) +- Other Reference Impls + - [Pact-Go Tcp Example](https://github.com/pact-foundation/pact-go/tree/master/examples/plugin) diff --git a/dotnet/Tcp/README.md b/dotnet/Tcp/README.md new file mode 100644 index 0000000..1a8a7ba --- /dev/null +++ b/dotnet/Tcp/README.md @@ -0,0 +1,5 @@ +# Sockets .NET Example + +Adapted for Pact, from Microsoft's Sockets sample + +https://learn.microsoft.com/en-us/dotnet/fundamentals/networking/sockets/socket-services diff --git a/dotnet/Tcp/TcpClient.Tests/TcpClient.Tests.csproj b/dotnet/Tcp/TcpClient.Tests/TcpClient.Tests.csproj new file mode 100644 index 0000000..3c71c6e --- /dev/null +++ b/dotnet/Tcp/TcpClient.Tests/TcpClient.Tests.csproj @@ -0,0 +1,22 @@ + + + net8.0 + false + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/dotnet/Tcp/TcpClient.Tests/TcpClientTest.cs b/dotnet/Tcp/TcpClient.Tests/TcpClientTest.cs new file mode 100644 index 0000000..ec41c80 --- /dev/null +++ b/dotnet/Tcp/TcpClient.Tests/TcpClientTest.cs @@ -0,0 +1,153 @@ +using System; +using FluentAssertions; +using Xunit; +using PactFfi; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using TcpClient; +namespace TcpClient.Tests +{ + public class TcpClientTests + { + + [Fact] + public void ReturnsMismatchWhenNoTcpClientRequestMade() + { + + _ = Pact.LogToStdOut(3); + + var host = "0.0.0.0"; + var pact = Pact.NewPact("matttcpconsumer", "matttcpprovider"); + var interaction = Pact.NewSyncMessageInteraction(pact, "Matt message"); + Pact.Given(interaction, "the world exists"); + Pact.WithSpecification(pact, Pact.PactSpecification.V4); + var content = $@"{{ + ""request"": {{ + ""body"": ""hellotcp"" + }}, + ""response"": {{ + ""body"": ""tcpworld"" + }} + }}"; + Pact.PluginAdd(pact, "matt", "0.1.1"); + Pact.PluginInteractionContents(interaction, 0, "application/matt", content); + + var port = Pact.CreateMockServerForTransport(pact, host, 0, "matt", null); + Console.WriteLine("Port: " + port); + + var matched = Pact.MockServerMatched(port); + // TODO - matched is true here when it should fail + Console.WriteLine("Matched: " + matched); + // matched.Should().BeFalse(); + matched.Should().BeTrue(); + + // var MismatchesPtr = Pact.MockServerMismatches(port); + // var MismatchesString = Marshal.PtrToStringAnsi(MismatchesPtr); + // Console.WriteLine("Mismatches: " + MismatchesString); + // var MismatchesJson = JsonSerializer.Deserialize(MismatchesString); + // var ErrorString = MismatchesJson[0].GetProperty("error").GetString(); + // var ExpectedPath = MismatchesJson[0].GetProperty("path").GetString(); + + // ErrorString.Should().Be("Did not receive any requests for path 'Greeter/SayHello'"); + // ExpectedPath.Should().Be("Greeter/SayHello"); + + Pact.CleanupMockServer(port); + Pact.PluginCleanup(pact); + } + [Fact] + public void WritesPactWhenTcpClientRequestMade() + { + + _ = Pact.LogToStdOut(3); + var host = "0.0.0.0"; + var pact = Pact.NewPact("matttcpconsumer", "matttcpprovider"); + var interaction = Pact.NewSyncMessageInteraction(pact, "Matt message"); + Pact.Given(interaction, "the world exists"); + Pact.WithSpecification(pact, Pact.PactSpecification.V4); + var content = $@"{{ + ""request"": {{ + ""body"": ""hellotcp"" + }}, + ""response"": {{ + ""body"": ""tcpworld"" + }} + }}"; + Pact.PluginAdd(pact, "matt", "0.1.1"); + Pact.PluginInteractionContents(interaction, 0, "application/matt", content); + + var port = Pact.CreateMockServerForTransport(pact, host, 0, "matt", null); + Console.WriteLine("Port: " + port); + + // TODO - Make matt tcp request - this request hangs + // Act + + // var result = await Program.ConnectAndSendAsync(System.Net.IPAddress.Parse("127.0.0.1"), port); + // Console.WriteLine("Result: " + result); + + + // var matched = Pact.MockServerMatched(port); + // Console.WriteLine("Matched: " + matched); + // matched.Should().BeTrue(); + + // var MismatchesPtr = Pact.MockServerMismatches(port); + // var MismatchesString = Marshal.PtrToStringAnsi(MismatchesPtr); + // Console.WriteLine("Mismatches: " + MismatchesString); + + // MismatchesString.Should().Be("[]"); + + // var writeRes = Pact.WritePactFileForPort(port, "../../../../pacts", false); + // Console.WriteLine("WriteRes: " + writeRes); + // Pact.CleanupMockServer(port); + // Pact.PluginCleanup(pact); + } + [Fact] + public void WritesPactWhenHttpClientRequestMade() + { + + _ = Pact.LogToStdOut(3); + + var host = "0.0.0.0"; + var pact = Pact.NewPact("MattConsumer", "MattProvider"); + var interaction = Pact.NewInteraction(pact, "A request to do a matt"); + Pact.WithSpecification(pact, Pact.PactSpecification.V4); + var mattrequest = $@"{{ + ""request"": {{ + ""body"": ""hello"" + }} + }}"; + var mattresponse = $@"{{ + ""response"": {{ + ""body"": ""world"" + }} + }}"; + Pact.WithRequest(interaction, "POST", "/matt"); + Pact.PluginAdd(pact, "matt", "0.1.1"); + Pact.PluginInteractionContents(interaction, Pact.InteractionPart.Request, "application/matt", mattrequest); + Pact.PluginInteractionContents(interaction, Pact.InteractionPart.Response, "application/matt", mattresponse); + + var port = Pact.CreateMockServerForTransport(pact, host, 0, "http", null); + Console.WriteLine("Port: " + port); + + // TODO - Make matt http request + + + // Console.WriteLine("Result: " + result); + + // var matched = Pact.MockServerMatched(port); + // Console.WriteLine("Matched: " + matched); + // matched.Should().BeTrue(); + + // var MismatchesPtr = Pact.MockServerMismatches(port); + // var MismatchesString = Marshal.PtrToStringAnsi(MismatchesPtr); + // Console.WriteLine("Mismatches: " + MismatchesString); + + // MismatchesString.Should().Be("[]"); + + var writeRes = Pact.WritePactFileForPort(port, "../../../../pacts", false); + Console.WriteLine("WriteRes: " + writeRes); + Pact.CleanupMockServer(port); + Pact.PluginCleanup(pact); + } + + } +} diff --git a/dotnet/Tcp/TcpClient/TcpClient.cs b/dotnet/Tcp/TcpClient/TcpClient.cs new file mode 100644 index 0000000..e5249c1 --- /dev/null +++ b/dotnet/Tcp/TcpClient/TcpClient.cs @@ -0,0 +1,38 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace TcpClient +{ + public class Program + { + static async Task Main(string[] args) + { + IPAddress localIpAddress = IPAddress.Parse("0.0.0.0"); + int port = 5001; + + await ConnectAndSendAsync(localIpAddress, port); + } + + public static async Task ConnectAndSendAsync(IPAddress ipAddress, int port) + { + var ipEndPoint = new IPEndPoint(ipAddress, port); + Console.WriteLine($"Connecting to {ipAddress}:{port}"); + + using System.Net.Sockets.TcpClient client = new(); + await client.ConnectAsync(ipEndPoint); + await using NetworkStream stream = client.GetStream(); + + var message = "MATT" + "hellotcp" + "MATT"; + var buffer = Encoding.UTF8.GetBytes(message); + await stream.WriteAsync(buffer); + var responseBuffer = new byte[1_024]; + + int received = await stream.ReadAsync(responseBuffer); + + var response = Encoding.UTF8.GetString(responseBuffer, 0, received); + Console.WriteLine($"Response received: \"{response}\""); + return response; + } + } +} diff --git a/dotnet/Tcp/TcpClient/TcpClient.csproj b/dotnet/Tcp/TcpClient/TcpClient.csproj new file mode 100644 index 0000000..7a69b57 --- /dev/null +++ b/dotnet/Tcp/TcpClient/TcpClient.csproj @@ -0,0 +1,13 @@ + + + + Exe + net8.0 + enable + enable + + + false + + + diff --git a/dotnet/Tcp/TcpListener.Tests/TcpListener.Tests.csproj b/dotnet/Tcp/TcpListener.Tests/TcpListener.Tests.csproj new file mode 100644 index 0000000..e53bca9 --- /dev/null +++ b/dotnet/Tcp/TcpListener.Tests/TcpListener.Tests.csproj @@ -0,0 +1,22 @@ + + + net8.0 + false + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/dotnet/Tcp/TcpListener.Tests/TcpListenerTest.cs b/dotnet/Tcp/TcpListener.Tests/TcpListenerTest.cs new file mode 100644 index 0000000..e385961 --- /dev/null +++ b/dotnet/Tcp/TcpListener.Tests/TcpListenerTest.cs @@ -0,0 +1,44 @@ +using FluentAssertions; +using Xunit; +using PactFfi; +using System.Threading.Tasks; +using System.Threading; +using System; + +namespace TcpListener.Tests +{ + public class TcpListenerTests + { + + [Fact] + public async Task ReturnsVerificationSuccessTcpPluginAsync() + { + _ = Pact.LogToStdOut(3); + string url = "127.0.0.1"; + ushort port = 5001; + var verifier = Pact.VerifierNewForApplication("pact-dotnet", "0.0.0"); + Pact.VerifierSetProviderInfo(verifier, "matttcpprovider", null, "0.0.0.0", 5001, null); + Pact.AddProviderTransport(verifier, "matt", port, "/", "tcp"); + // Pact.VerifierAddFileSource(verifier, "../../../../pacts/MattConsumer-MattProvider.json"); + Pact.VerifierAddFileSource(verifier, "../../../../pacts/matttcpconsumer-matttcpprovider.json"); + + // // Arrange + // // Setup our app to run before our verifier executes + // // Setup a cancellation token so we can shutdown the app after + CancellationTokenSource cts = new CancellationTokenSource(); + var token = cts.Token; + var runAppTask = Task.Run(async () => + { + await TcpListener.StartTcpListener(url, port, Console.WriteLine, token); + }, token); + await Task.Delay(2000); + + // Act + var VerifierExecuteResult = Pact.VerifierExecute(verifier); + VerifierExecuteResult.Should().Be(0); + Pact.VerifierShutdown(verifier); + // After test execution, signal the task to terminate + cts.Cancel(); + } + } +} diff --git a/dotnet/Tcp/TcpListener/TcpListener.cs b/dotnet/Tcp/TcpListener/TcpListener.cs new file mode 100644 index 0000000..9fad321 --- /dev/null +++ b/dotnet/Tcp/TcpListener/TcpListener.cs @@ -0,0 +1,73 @@ +using System; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace TcpListener +{ + public class TcpListener + { + public static async Task Main(string[] args) + { + string url = "127.0.0.1"; + int port = 5001; + CancellationTokenSource cts = new CancellationTokenSource(); + Task listenerTask = StartTcpListener(url, port, Console.WriteLine, cts.Token); + + // Perform other operations or wait for a cancellation signal + + // To cancel the listener, call cts.Cancel() + + await listenerTask; + } + + public static async Task StartTcpListener(string url, int port, Action logger, CancellationToken cancellationToken) + { + IPAddress localIpAddress = IPAddress.Parse(url); + System.Net.Sockets.TcpListener listener = new(localIpAddress, port); + logger($"Running on {url}:{port}"); + + try + { + listener.Start(); + + TcpClient handler = await listener.AcceptTcpClientAsync(); + NetworkStream stream = handler.GetStream(); + logger("Received request"); + byte[] buffer = new byte[1024]; + int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken); + string request = Encoding.UTF8.GetString(buffer, 0, bytesRead); + logger($"Received request contents: {request}"); + + var message = "MATTtcpworldMATT\n"; + var dateTimeBytes = Encoding.UTF8.GetBytes(message); + await stream.WriteAsync(dateTimeBytes, cancellationToken); + + // Get value between "MATT" tags + string value = GetValueBetweenTags(request, "MATT"); + logger($"Value between tags: {value}"); + + logger($"Sent message: \"{message}\""); + // Sample output: + // Sent message: "📅 8/22/2022 9:07:17 AM 🕛" + } + finally + { + listener.Stop(); + } + } + + public static string GetValueBetweenTags(string request, string v) + { + int startIndex = request.IndexOf(v) + v.Length; + int endIndex = request.LastIndexOf(v); + if (startIndex < 0 || endIndex < 0) + { + throw new ArgumentException($"Value between tags '{v}' not found in request."); + } + return request[startIndex..endIndex]; + } + } +} diff --git a/dotnet/Tcp/TcpListener/TcpListener.csproj b/dotnet/Tcp/TcpListener/TcpListener.csproj new file mode 100644 index 0000000..7a69b57 --- /dev/null +++ b/dotnet/Tcp/TcpListener/TcpListener.csproj @@ -0,0 +1,13 @@ + + + + Exe + net8.0 + enable + enable + + + false + + + diff --git a/dotnet/Tcp/pacts/MattConsumer-MattProvider.json b/dotnet/Tcp/pacts/MattConsumer-MattProvider.json new file mode 100644 index 0000000..32a17dc --- /dev/null +++ b/dotnet/Tcp/pacts/MattConsumer-MattProvider.json @@ -0,0 +1,62 @@ +{ + "consumer": { + "name": "MattConsumer" + }, + "interactions": [ + { + "description": "A request to do a matt", + "pending": false, + "request": { + "body": { + "content": "TUFUVGhlbGxvTUFUVA==", + "contentType": "application/matt", + "contentTypeHint": "DEFAULT", + "encoded": "base64" + }, + "headers": { + "content-type": [ + "application/matt" + ] + }, + "method": "POST", + "path": "/matt" + }, + "response": { + "body": { + "content": "TUFUVHdvcmxkTUFUVA==", + "contentType": "application/matt", + "contentTypeHint": "DEFAULT", + "encoded": "base64" + }, + "headers": { + "content-type": [ + "application/matt" + ] + }, + "status": 200 + }, + "transport": "http", + "type": "Synchronous/HTTP" + } + ], + "metadata": { + "pactRust": { + "ffi": "0.4.22", + "mockserver": "1.2.9", + "models": "1.2.3" + }, + "pactSpecification": { + "version": "4.0" + }, + "plugins": [ + { + "configuration": {}, + "name": "matt", + "version": "0.1.1" + } + ] + }, + "provider": { + "name": "MattProvider" + } +} \ No newline at end of file diff --git a/dotnet/Tcp/pacts/matttcpconsumer-matttcpprovider.json b/dotnet/Tcp/pacts/matttcpconsumer-matttcpprovider.json new file mode 100644 index 0000000..1c805f9 --- /dev/null +++ b/dotnet/Tcp/pacts/matttcpconsumer-matttcpprovider.json @@ -0,0 +1,56 @@ +{ + "consumer": { + "name": "matttcpconsumer" + }, + "interactions": [ + { + "description": "Matt message", + "pending": false, + "providerStates": [ + { + "name": "the world exists" + } + ], + "request": { + "contents": { + "content": "TUFUVGhlbGxvdGNwTUFUVA==", + "contentType": "application/matt", + "contentTypeHint": "DEFAULT", + "encoded": "base64" + } + }, + "response": [ + { + "contents": { + "content": "TUFUVHRjcHdvcmxkTUFUVA==", + "contentType": "application/matt", + "contentTypeHint": "DEFAULT", + "encoded": "base64" + } + } + ], + "transport": "matt", + "type": "Synchronous/Messages" + } + ], + "metadata": { + "pactRust": { + "ffi": "0.4.22", + "mockserver": "1.2.9", + "models": "1.2.3" + }, + "pactSpecification": { + "version": "4.0" + }, + "plugins": [ + { + "configuration": {}, + "name": "matt", + "version": "0.1.1" + } + ] + }, + "provider": { + "name": "matttcpprovider" + } +} \ No newline at end of file