From 7331c6157a21099ce31cc88787a66abab8127f4e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 19:25:07 -0400 Subject: [PATCH] Scaffold .NET client projects --- .../MxGateway.Client.Cli.csproj | 14 ++++ .../MxGatewayClientCli.cs | 48 ++++++++++++ .../dotnet/MxGateway.Client.Cli/Program.cs | 3 + .../MxGateway.Client.Tests.csproj | 26 +++++++ .../MxGatewayClientCliTests.cs | 20 +++++ .../MxGatewayClientContractInfoTests.cs | 22 ++++++ .../MxGatewayClientOptionsTests.cs | 28 +++++++ .../MxGatewayGeneratedContractTests.cs | 18 +++++ clients/dotnet/MxGateway.Client.sln | 76 +++++++++++++++++++ .../MxGateway.Client/MxGateway.Client.csproj | 18 +++++ .../MxGateway.Client/MxGatewayClient.cs | 41 ++++++++++ .../MxGatewayClientContractInfo.cs | 15 ++++ .../MxGatewayClientOptions.cs | 58 ++++++++++++++ clients/dotnet/README.md | 24 ++++++ docs/clients-dotnet-csharp-design.md | 7 ++ 15 files changed, 418 insertions(+) create mode 100644 clients/dotnet/MxGateway.Client.Cli/MxGateway.Client.Cli.csproj create mode 100644 clients/dotnet/MxGateway.Client.Cli/MxGatewayClientCli.cs create mode 100644 clients/dotnet/MxGateway.Client.Cli/Program.cs create mode 100644 clients/dotnet/MxGateway.Client.Tests/MxGateway.Client.Tests.csproj create mode 100644 clients/dotnet/MxGateway.Client.Tests/MxGatewayClientCliTests.cs create mode 100644 clients/dotnet/MxGateway.Client.Tests/MxGatewayClientContractInfoTests.cs create mode 100644 clients/dotnet/MxGateway.Client.Tests/MxGatewayClientOptionsTests.cs create mode 100644 clients/dotnet/MxGateway.Client.Tests/MxGatewayGeneratedContractTests.cs create mode 100644 clients/dotnet/MxGateway.Client.sln create mode 100644 clients/dotnet/MxGateway.Client/MxGateway.Client.csproj create mode 100644 clients/dotnet/MxGateway.Client/MxGatewayClient.cs create mode 100644 clients/dotnet/MxGateway.Client/MxGatewayClientContractInfo.cs create mode 100644 clients/dotnet/MxGateway.Client/MxGatewayClientOptions.cs create mode 100644 clients/dotnet/README.md diff --git a/clients/dotnet/MxGateway.Client.Cli/MxGateway.Client.Cli.csproj b/clients/dotnet/MxGateway.Client.Cli/MxGateway.Client.Cli.csproj new file mode 100644 index 0000000..b04f285 --- /dev/null +++ b/clients/dotnet/MxGateway.Client.Cli/MxGateway.Client.Cli.csproj @@ -0,0 +1,14 @@ + + + + + + + + Exe + net10.0 + enable + enable + + + diff --git a/clients/dotnet/MxGateway.Client.Cli/MxGatewayClientCli.cs b/clients/dotnet/MxGateway.Client.Cli/MxGatewayClientCli.cs new file mode 100644 index 0000000..8b64cd6 --- /dev/null +++ b/clients/dotnet/MxGateway.Client.Cli/MxGatewayClientCli.cs @@ -0,0 +1,48 @@ +using MxGateway.Client; + +namespace MxGateway.Client.Cli; + +public static class MxGatewayClientCli +{ + public static int Run( + string[] args, + TextWriter standardOutput, + TextWriter standardError) + { + ArgumentNullException.ThrowIfNull(args); + ArgumentNullException.ThrowIfNull(standardOutput); + ArgumentNullException.ThrowIfNull(standardError); + + if (args.Length is 0 || IsHelp(args[0])) + { + WriteUsage(standardOutput); + return 0; + } + + if (string.Equals(args[0], "version", StringComparison.OrdinalIgnoreCase)) + { + standardOutput.WriteLine( + $"gateway-protocol={MxGatewayClientContractInfo.GatewayProtocolVersion}"); + standardOutput.WriteLine( + $"worker-protocol={MxGatewayClientContractInfo.WorkerProtocolVersion}"); + return 0; + } + + standardError.WriteLine($"Unknown command '{args[0]}'."); + WriteUsage(standardError); + return 2; + } + + private static bool IsHelp(string value) + { + return string.Equals(value, "-h", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "--help", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "help", StringComparison.OrdinalIgnoreCase); + } + + private static void WriteUsage(TextWriter writer) + { + writer.WriteLine("mxgw-dotnet version"); + writer.WriteLine("mxgw-dotnet --help"); + } +} diff --git a/clients/dotnet/MxGateway.Client.Cli/Program.cs b/clients/dotnet/MxGateway.Client.Cli/Program.cs new file mode 100644 index 0000000..3f1db75 --- /dev/null +++ b/clients/dotnet/MxGateway.Client.Cli/Program.cs @@ -0,0 +1,3 @@ +using MxGateway.Client.Cli; + +return MxGatewayClientCli.Run(args, Console.Out, Console.Error); diff --git a/clients/dotnet/MxGateway.Client.Tests/MxGateway.Client.Tests.csproj b/clients/dotnet/MxGateway.Client.Tests/MxGateway.Client.Tests.csproj new file mode 100644 index 0000000..d02ed8e --- /dev/null +++ b/clients/dotnet/MxGateway.Client.Tests/MxGateway.Client.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientCliTests.cs b/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientCliTests.cs new file mode 100644 index 0000000..5ee9ebc --- /dev/null +++ b/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientCliTests.cs @@ -0,0 +1,20 @@ +using MxGateway.Client.Cli; + +namespace MxGateway.Client.Tests; + +public sealed class MxGatewayClientCliTests +{ + [Fact] + public void Run_Version_PrintsCompiledProtocolVersions() + { + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = MxGatewayClientCli.Run(["version"], output, error); + + Assert.Equal(0, exitCode); + Assert.Contains("gateway-protocol=1", output.ToString()); + Assert.Contains("worker-protocol=1", output.ToString()); + Assert.Equal(string.Empty, error.ToString()); + } +} diff --git a/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientContractInfoTests.cs b/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientContractInfoTests.cs new file mode 100644 index 0000000..1458347 --- /dev/null +++ b/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientContractInfoTests.cs @@ -0,0 +1,22 @@ +using MxGateway.Contracts; + +namespace MxGateway.Client.Tests; + +public sealed class MxGatewayClientContractInfoTests +{ + [Fact] + public void GatewayProtocolVersion_MatchesSharedContract() + { + Assert.Equal( + GatewayContractInfo.GatewayProtocolVersion, + MxGatewayClientContractInfo.GatewayProtocolVersion); + } + + [Fact] + public void WorkerProtocolVersion_MatchesSharedContract() + { + Assert.Equal( + GatewayContractInfo.WorkerProtocolVersion, + MxGatewayClientContractInfo.WorkerProtocolVersion); + } +} diff --git a/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientOptionsTests.cs b/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientOptionsTests.cs new file mode 100644 index 0000000..8c91227 --- /dev/null +++ b/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientOptionsTests.cs @@ -0,0 +1,28 @@ +namespace MxGateway.Client.Tests; + +public sealed class MxGatewayClientOptionsTests +{ + [Fact] + public void Validate_WithAbsoluteEndpointAndApiKey_Succeeds() + { + var options = new MxGatewayClientOptions + { + Endpoint = new Uri("http://localhost:5000"), + ApiKey = "test-api-key", + }; + + options.Validate(); + } + + [Fact] + public void Validate_WithEmptyApiKey_Throws() + { + var options = new MxGatewayClientOptions + { + Endpoint = new Uri("http://localhost:5000"), + ApiKey = "", + }; + + Assert.Throws(options.Validate); + } +} diff --git a/clients/dotnet/MxGateway.Client.Tests/MxGatewayGeneratedContractTests.cs b/clients/dotnet/MxGateway.Client.Tests/MxGatewayGeneratedContractTests.cs new file mode 100644 index 0000000..846d9e3 --- /dev/null +++ b/clients/dotnet/MxGateway.Client.Tests/MxGatewayGeneratedContractTests.cs @@ -0,0 +1,18 @@ +namespace MxGateway.Client.Tests; + +public sealed class MxGatewayGeneratedContractTests +{ + [Fact] + public async Task GeneratedGrpcClient_CanBeConstructedFromClientFactory() + { + var options = new MxGatewayClientOptions + { + Endpoint = new Uri("http://localhost:5000"), + ApiKey = "test-api-key", + }; + + await using var client = MxGatewayClient.Create(options); + + Assert.NotNull(client.RawClient); + } +} diff --git a/clients/dotnet/MxGateway.Client.sln b/clients/dotnet/MxGateway.Client.sln new file mode 100644 index 0000000..07a6a8c --- /dev/null +++ b/clients/dotnet/MxGateway.Client.sln @@ -0,0 +1,76 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Client", "MxGateway.Client\MxGateway.Client.csproj", "{7CF9ED88-1F32-4040-BEB1-D0902E304C70}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Contracts", "..\..\src\MxGateway.Contracts\MxGateway.Contracts.csproj", "{9AB807A8-0469-40F7-A000-D240F36B6E5D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Client.Cli", "MxGateway.Client.Cli\MxGateway.Client.Cli.csproj", "{EB061E77-2475-4322-9257-3F2456DD141C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Client.Tests", "MxGateway.Client.Tests\MxGateway.Client.Tests.csproj", "{B77B5A8E-0C53-4419-9BCD-227C9753A074}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x64.ActiveCfg = Debug|Any CPU + {7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x64.Build.0 = Debug|Any CPU + {7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x86.ActiveCfg = Debug|Any CPU + {7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x86.Build.0 = Debug|Any CPU + {7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|Any CPU.Build.0 = Release|Any CPU + {7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x64.ActiveCfg = Release|Any CPU + {7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x64.Build.0 = Release|Any CPU + {7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x86.ActiveCfg = Release|Any CPU + {7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x86.Build.0 = Release|Any CPU + {9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x64.ActiveCfg = Debug|Any CPU + {9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x64.Build.0 = Debug|Any CPU + {9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x86.ActiveCfg = Debug|Any CPU + {9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x86.Build.0 = Debug|Any CPU + {9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|Any CPU.Build.0 = Release|Any CPU + {9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x64.ActiveCfg = Release|Any CPU + {9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x64.Build.0 = Release|Any CPU + {9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x86.ActiveCfg = Release|Any CPU + {9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x86.Build.0 = Release|Any CPU + {EB061E77-2475-4322-9257-3F2456DD141C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EB061E77-2475-4322-9257-3F2456DD141C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x64.ActiveCfg = Debug|Any CPU + {EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x64.Build.0 = Debug|Any CPU + {EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x86.ActiveCfg = Debug|Any CPU + {EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x86.Build.0 = Debug|Any CPU + {EB061E77-2475-4322-9257-3F2456DD141C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EB061E77-2475-4322-9257-3F2456DD141C}.Release|Any CPU.Build.0 = Release|Any CPU + {EB061E77-2475-4322-9257-3F2456DD141C}.Release|x64.ActiveCfg = Release|Any CPU + {EB061E77-2475-4322-9257-3F2456DD141C}.Release|x64.Build.0 = Release|Any CPU + {EB061E77-2475-4322-9257-3F2456DD141C}.Release|x86.ActiveCfg = Release|Any CPU + {EB061E77-2475-4322-9257-3F2456DD141C}.Release|x86.Build.0 = Release|Any CPU + {B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x64.ActiveCfg = Debug|Any CPU + {B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x64.Build.0 = Debug|Any CPU + {B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x86.ActiveCfg = Debug|Any CPU + {B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x86.Build.0 = Debug|Any CPU + {B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|Any CPU.Build.0 = Release|Any CPU + {B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x64.ActiveCfg = Release|Any CPU + {B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x64.Build.0 = Release|Any CPU + {B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x86.ActiveCfg = Release|Any CPU + {B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/clients/dotnet/MxGateway.Client/MxGateway.Client.csproj b/clients/dotnet/MxGateway.Client/MxGateway.Client.csproj new file mode 100644 index 0000000..d505aa4 --- /dev/null +++ b/clients/dotnet/MxGateway.Client/MxGateway.Client.csproj @@ -0,0 +1,18 @@ + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/clients/dotnet/MxGateway.Client/MxGatewayClient.cs b/clients/dotnet/MxGateway.Client/MxGatewayClient.cs new file mode 100644 index 0000000..efa8ce8 --- /dev/null +++ b/clients/dotnet/MxGateway.Client/MxGatewayClient.cs @@ -0,0 +1,41 @@ +using Grpc.Net.Client; +using MxGateway.Contracts.Proto; + +namespace MxGateway.Client; + +/// +/// Provides the initial .NET client entry point and raw generated gRPC client. +/// +public sealed class MxGatewayClient : IAsyncDisposable +{ + private readonly GrpcChannel _channel; + + private MxGatewayClient(GrpcChannel channel) + { + _channel = channel; + RawClient = new MxAccessGateway.MxAccessGatewayClient(channel); + } + + public MxAccessGateway.MxAccessGatewayClient RawClient { get; } + + public static MxGatewayClient Create(MxGatewayClientOptions options) + { + ArgumentNullException.ThrowIfNull(options); + options.Validate(); + + var channel = GrpcChannel.ForAddress( + options.Endpoint, + new GrpcChannelOptions + { + LoggerFactory = options.LoggerFactory, + }); + + return new MxGatewayClient(channel); + } + + public ValueTask DisposeAsync() + { + _channel.Dispose(); + return ValueTask.CompletedTask; + } +} diff --git a/clients/dotnet/MxGateway.Client/MxGatewayClientContractInfo.cs b/clients/dotnet/MxGateway.Client/MxGatewayClientContractInfo.cs new file mode 100644 index 0000000..4869faa --- /dev/null +++ b/clients/dotnet/MxGateway.Client/MxGatewayClientContractInfo.cs @@ -0,0 +1,15 @@ +using MxGateway.Contracts; + +namespace MxGateway.Client; + +/// +/// Exposes the protocol versions compiled into this client package. +/// +public static class MxGatewayClientContractInfo +{ + public const uint GatewayProtocolVersion = + GatewayContractInfo.GatewayProtocolVersion; + + public const uint WorkerProtocolVersion = + GatewayContractInfo.WorkerProtocolVersion; +} diff --git a/clients/dotnet/MxGateway.Client/MxGatewayClientOptions.cs b/clients/dotnet/MxGateway.Client/MxGatewayClientOptions.cs new file mode 100644 index 0000000..b6863c5 --- /dev/null +++ b/clients/dotnet/MxGateway.Client/MxGatewayClientOptions.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.Logging; + +namespace MxGateway.Client; + +/// +/// Configures the gRPC channel used by the .NET MXAccess Gateway client. +/// +public sealed class MxGatewayClientOptions +{ + public required Uri Endpoint { get; init; } + + public required string ApiKey { get; init; } + + public bool UseTls { get; init; } + + public string? CaCertificatePath { get; init; } + + public string? ServerNameOverride { get; init; } + + public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10); + + public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30); + + public ILoggerFactory? LoggerFactory { get; init; } + + public void Validate() + { + ArgumentNullException.ThrowIfNull(Endpoint); + + if (!Endpoint.IsAbsoluteUri) + { + throw new ArgumentException( + "The gateway endpoint must be an absolute URI.", + nameof(Endpoint)); + } + + if (string.IsNullOrWhiteSpace(ApiKey)) + { + throw new ArgumentException( + "The gateway API key must not be empty.", + nameof(ApiKey)); + } + + if (ConnectTimeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException( + nameof(ConnectTimeout), + "The connect timeout must be greater than zero."); + } + + if (DefaultCallTimeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException( + nameof(DefaultCallTimeout), + "The default call timeout must be greater than zero."); + } + } +} diff --git a/clients/dotnet/README.md b/clients/dotnet/README.md new file mode 100644 index 0000000..a606185 --- /dev/null +++ b/clients/dotnet/README.md @@ -0,0 +1,24 @@ +# .NET Client Projects + +The .NET client workspace contains the MXAccess Gateway client library, test +CLI, and unit tests. + +## Projects + +| Project | Purpose | +|---------|---------| +| `MxGateway.Client` | .NET 10 library entry point and raw gRPC client access. | +| `MxGateway.Client.Cli` | Test CLI for smoke and diagnostic commands. | +| `MxGateway.Client.Tests` | Unit tests for the scaffold and generated contract wiring. | + +The projects reference `src/MxGateway.Contracts/MxGateway.Contracts.csproj` so +the client compiles against the same generated protobuf and gRPC types as the +gateway. `clients/dotnet/generated` remains reserved for generator output if a +future client build switches to client-local `Grpc.Tools` generation. + +## Build And Test + +```powershell +dotnet build clients/dotnet/MxGateway.Client.sln +dotnet test clients/dotnet/MxGateway.Client.sln --no-build +``` diff --git a/docs/clients-dotnet-csharp-design.md b/docs/clients-dotnet-csharp-design.md index 900b63c..8b35036 100644 --- a/docs/clients-dotnet-csharp-design.md +++ b/docs/clients-dotnet-csharp-design.md @@ -16,6 +16,7 @@ Recommended layout: ```text clients/dotnet/ + MxGateway.Client.sln MxGateway.Client/ MxGateway.Client.csproj GatewayClient.cs @@ -41,6 +42,12 @@ Target framework: net10.0 ``` +The scaffold uses a project reference to +`src/MxGateway.Contracts/MxGateway.Contracts.csproj` for generated protobuf and +gRPC types. `clients/dotnet/generated` remains reserved for client-local +generator output if the .NET client later needs to decouple from the contracts +project. + Expected packages: - `Grpc.Net.Client` -- 2.52.0