diff --git a/clients/dotnet/MxGateway.Client.Cli/CliArguments.cs b/clients/dotnet/MxGateway.Client.Cli/CliArguments.cs new file mode 100644 index 0000000..31d6fa0 --- /dev/null +++ b/clients/dotnet/MxGateway.Client.Cli/CliArguments.cs @@ -0,0 +1,117 @@ +using System.Globalization; + +namespace MxGateway.Client.Cli; + +internal sealed class CliArguments +{ + private readonly Dictionary _values = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _flags = new(StringComparer.OrdinalIgnoreCase); + + public CliArguments(IEnumerable args) + { + string? pendingName = null; + + foreach (string arg in args) + { + if (arg.StartsWith("--", StringComparison.Ordinal)) + { + if (pendingName is not null) + { + _flags.Add(pendingName); + } + + pendingName = arg[2..]; + continue; + } + + if (pendingName is null) + { + throw new ArgumentException($"Unexpected argument '{arg}'."); + } + + _values[pendingName] = arg; + pendingName = null; + } + + if (pendingName is not null) + { + _flags.Add(pendingName); + } + } + + public bool HasFlag(string name) + { + return _flags.Contains(name); + } + + public string? GetOptional(string name) + { + return _values.TryGetValue(name, out string? value) + ? value + : null; + } + + public string GetRequired(string name) + { + string? value = GetOptional(name); + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException($"Missing required option --{name}."); + } + + return value; + } + + public int GetInt32(string name, int? defaultValue = null) + { + string? value = GetOptional(name); + if (string.IsNullOrWhiteSpace(value)) + { + if (defaultValue.HasValue) + { + return defaultValue.Value; + } + + throw new ArgumentException($"Missing required option --{name}."); + } + + return int.Parse(value, CultureInfo.InvariantCulture); + } + + public uint GetUInt32(string name, uint defaultValue) + { + string? value = GetOptional(name); + return string.IsNullOrWhiteSpace(value) + ? defaultValue + : uint.Parse(value, CultureInfo.InvariantCulture); + } + + public ulong GetUInt64(string name, ulong defaultValue) + { + string? value = GetOptional(name); + return string.IsNullOrWhiteSpace(value) + ? defaultValue + : ulong.Parse(value, CultureInfo.InvariantCulture); + } + + public TimeSpan GetDuration(string name, TimeSpan defaultValue) + { + string? value = GetOptional(name); + if (string.IsNullOrWhiteSpace(value)) + { + return defaultValue; + } + + if (value.EndsWith("ms", StringComparison.OrdinalIgnoreCase)) + { + return TimeSpan.FromMilliseconds(double.Parse(value[..^2], CultureInfo.InvariantCulture)); + } + + if (value.EndsWith('s')) + { + return TimeSpan.FromSeconds(double.Parse(value[..^1], CultureInfo.InvariantCulture)); + } + + return TimeSpan.Parse(value, CultureInfo.InvariantCulture); + } +} diff --git a/clients/dotnet/MxGateway.Client.Cli/IMxGatewayCliClient.cs b/clients/dotnet/MxGateway.Client.Cli/IMxGatewayCliClient.cs new file mode 100644 index 0000000..1804341 --- /dev/null +++ b/clients/dotnet/MxGateway.Client.Cli/IMxGatewayCliClient.cs @@ -0,0 +1,22 @@ +using MxGateway.Contracts.Proto; + +namespace MxGateway.Client.Cli; + +public interface IMxGatewayCliClient : IAsyncDisposable +{ + Task OpenSessionAsync( + OpenSessionRequest request, + CancellationToken cancellationToken); + + Task CloseSessionAsync( + CloseSessionRequest request, + CancellationToken cancellationToken); + + Task InvokeAsync( + MxCommandRequest request, + CancellationToken cancellationToken); + + IAsyncEnumerable StreamEventsAsync( + StreamEventsRequest request, + CancellationToken cancellationToken); +} diff --git a/clients/dotnet/MxGateway.Client.Cli/MxGatewayCliClientAdapter.cs b/clients/dotnet/MxGateway.Client.Cli/MxGatewayCliClientAdapter.cs new file mode 100644 index 0000000..f8e8664 --- /dev/null +++ b/clients/dotnet/MxGateway.Client.Cli/MxGatewayCliClientAdapter.cs @@ -0,0 +1,40 @@ +using MxGateway.Client; +using MxGateway.Contracts.Proto; + +namespace MxGateway.Client.Cli; + +internal sealed class MxGatewayCliClientAdapter(MxGatewayClient client) : IMxGatewayCliClient +{ + public Task OpenSessionAsync( + OpenSessionRequest request, + CancellationToken cancellationToken) + { + return client.OpenSessionRawAsync(request, cancellationToken); + } + + public Task CloseSessionAsync( + CloseSessionRequest request, + CancellationToken cancellationToken) + { + return client.CloseSessionRawAsync(request, cancellationToken); + } + + public Task InvokeAsync( + MxCommandRequest request, + CancellationToken cancellationToken) + { + return client.InvokeAsync(request, cancellationToken); + } + + public IAsyncEnumerable StreamEventsAsync( + StreamEventsRequest request, + CancellationToken cancellationToken) + { + return client.StreamEventsAsync(request, cancellationToken); + } + + public ValueTask DisposeAsync() + { + return client.DisposeAsync(); + } +} diff --git a/clients/dotnet/MxGateway.Client.Cli/MxGatewayCliSecretRedactor.cs b/clients/dotnet/MxGateway.Client.Cli/MxGatewayCliSecretRedactor.cs new file mode 100644 index 0000000..42a6a96 --- /dev/null +++ b/clients/dotnet/MxGateway.Client.Cli/MxGatewayCliSecretRedactor.cs @@ -0,0 +1,14 @@ +namespace MxGateway.Client.Cli; + +internal static class MxGatewayCliSecretRedactor +{ + public static string Redact(string value, string? apiKey) + { + if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(apiKey)) + { + return value; + } + + return value.Replace(apiKey, "[redacted]", StringComparison.Ordinal); + } +} diff --git a/clients/dotnet/MxGateway.Client.Cli/MxGatewayClientCli.cs b/clients/dotnet/MxGateway.Client.Cli/MxGatewayClientCli.cs index 8b64cd6..4ba7747 100644 --- a/clients/dotnet/MxGateway.Client.Cli/MxGatewayClientCli.cs +++ b/clients/dotnet/MxGateway.Client.Cli/MxGatewayClientCli.cs @@ -1,34 +1,702 @@ +using System.Globalization; +using System.Text.Json; +using Google.Protobuf; using MxGateway.Client; +using MxGateway.Contracts.Proto; namespace MxGateway.Client.Cli; public static class MxGatewayClientCli { + private static readonly JsonFormatter ProtobufJsonFormatter = JsonFormatter.Default; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + public static int Run( string[] args, TextWriter standardOutput, TextWriter standardError) + { + return RunAsync(args, standardOutput, standardError) + .GetAwaiter() + .GetResult(); + } + + public static Task RunAsync( + string[] args, + TextWriter standardOutput, + TextWriter standardError, + Func? clientFactory = null) { ArgumentNullException.ThrowIfNull(args); ArgumentNullException.ThrowIfNull(standardOutput); ArgumentNullException.ThrowIfNull(standardError); + return RunCoreAsync( + args, + standardOutput, + standardError, + clientFactory ?? CreateDefaultClient); + } + + private static async Task RunCoreAsync( + string[] args, + TextWriter standardOutput, + TextWriter standardError, + Func clientFactory) + { if (args.Length is 0 || IsHelp(args[0])) { WriteUsage(standardOutput); return 0; } - if (string.Equals(args[0], "version", StringComparison.OrdinalIgnoreCase)) + string command = args[0].ToLowerInvariant(); + CliArguments arguments = new(args.Skip(1)); + + try { - standardOutput.WriteLine( - $"gateway-protocol={MxGatewayClientContractInfo.GatewayProtocolVersion}"); - standardOutput.WriteLine( - $"worker-protocol={MxGatewayClientContractInfo.WorkerProtocolVersion}"); - return 0; + if (command is "version") + { + WriteVersion(arguments, standardOutput); + return 0; + } + + if (!IsKnownGatewayCommand(command)) + { + return WriteUnknownCommand(command, standardError); + } + + await using IMxGatewayCliClient client = clientFactory(CreateOptions(arguments)); + using CancellationTokenSource cancellation = CreateCancellation(arguments); + + return command switch + { + "open-session" => await OpenSessionAsync(arguments, client, standardOutput, cancellation.Token) + .ConfigureAwait(false), + "close-session" => await CloseSessionAsync(arguments, client, standardOutput, cancellation.Token) + .ConfigureAwait(false), + "ping" => await PingAsync(arguments, client, standardOutput, cancellation.Token) + .ConfigureAwait(false), + "register" => await RegisterAsync(arguments, client, standardOutput, cancellation.Token) + .ConfigureAwait(false), + "add-item" => await AddItemAsync(arguments, client, standardOutput, cancellation.Token) + .ConfigureAwait(false), + "advise" => await AdviseAsync(arguments, client, standardOutput, cancellation.Token) + .ConfigureAwait(false), + "stream-events" => await StreamEventsAsync(arguments, client, standardOutput, cancellation.Token) + .ConfigureAwait(false), + "write" => await WriteAsync(arguments, client, standardOutput, cancellation.Token) + .ConfigureAwait(false), + "write2" => await Write2Async(arguments, client, standardOutput, cancellation.Token) + .ConfigureAwait(false), + "smoke" => await SmokeAsync(arguments, client, standardOutput, cancellation.Token) + .ConfigureAwait(false), + _ => WriteUnknownCommand(command, standardError), + }; + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + string? apiKey = arguments.GetOptional("api-key"); + string message = MxGatewayCliSecretRedactor.Redact(exception.Message, apiKey); + + if (arguments.HasFlag("json")) + { + standardError.WriteLine(JsonSerializer.Serialize( + new { error = message, type = exception.GetType().Name }, + JsonOptions)); + } + else + { + standardError.WriteLine(message); + } + + return 1; + } + } + + private static IMxGatewayCliClient CreateDefaultClient(MxGatewayClientOptions options) + { + return new MxGatewayCliClientAdapter(MxGatewayClient.Create(options)); + } + + private static MxGatewayClientOptions CreateOptions(CliArguments arguments) + { + string endpoint = arguments.GetOptional("endpoint") + ?? Environment.GetEnvironmentVariable("MXGATEWAY_ENDPOINT") + ?? "http://localhost:5000"; + + string apiKey = ResolveApiKey(arguments); + + return new MxGatewayClientOptions + { + Endpoint = new Uri(endpoint, UriKind.Absolute), + ApiKey = apiKey, + UseTls = arguments.HasFlag("tls") + || endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase), + DefaultCallTimeout = arguments.GetDuration("timeout", TimeSpan.FromSeconds(30)), + ConnectTimeout = arguments.GetDuration("connect-timeout", TimeSpan.FromSeconds(10)), + CaCertificatePath = arguments.GetOptional("ca-file"), + ServerNameOverride = arguments.GetOptional("server-name"), + }; + } + + private static string ResolveApiKey(CliArguments arguments) + { + string? apiKey = arguments.GetOptional("api-key"); + if (!string.IsNullOrWhiteSpace(apiKey)) + { + return apiKey; } - standardError.WriteLine($"Unknown command '{args[0]}'."); + string apiKeyEnvironmentName = arguments.GetOptional("api-key-env") + ?? "MXGATEWAY_API_KEY"; + + apiKey = Environment.GetEnvironmentVariable(apiKeyEnvironmentName); + if (!string.IsNullOrWhiteSpace(apiKey)) + { + return apiKey; + } + + throw new ArgumentException( + $"Gateway API key is required. Pass --api-key or set {apiKeyEnvironmentName}."); + } + + private static CancellationTokenSource CreateCancellation(CliArguments arguments) + { + var cancellation = new CancellationTokenSource(); + TimeSpan timeout = arguments.GetDuration("timeout", TimeSpan.FromSeconds(30)); + cancellation.CancelAfter(timeout); + return cancellation; + } + + private static Task OpenSessionAsync( + CliArguments arguments, + IMxGatewayCliClient client, + TextWriter output, + CancellationToken cancellationToken) + { + return WriteReplyAsync( + client.OpenSessionAsync( + new OpenSessionRequest + { + ClientSessionName = arguments.GetOptional("client-name") ?? "mxgw-dotnet-cli", + ClientCorrelationId = CreateCorrelationId(), + RequestedBackend = arguments.GetOptional("backend") ?? string.Empty, + }, + cancellationToken), + arguments, + output); + } + + private static Task CloseSessionAsync( + CliArguments arguments, + IMxGatewayCliClient client, + TextWriter output, + CancellationToken cancellationToken) + { + return WriteReplyAsync( + client.CloseSessionAsync( + new CloseSessionRequest + { + SessionId = arguments.GetRequired("session-id"), + ClientCorrelationId = CreateCorrelationId(), + }, + cancellationToken), + arguments, + output); + } + + private static Task PingAsync( + CliArguments arguments, + IMxGatewayCliClient client, + TextWriter output, + CancellationToken cancellationToken) + { + return InvokeAndWriteAsync( + arguments, + client, + output, + new MxCommand + { + Kind = MxCommandKind.Ping, + Ping = new PingCommand { Message = arguments.GetOptional("message") ?? "ping" }, + }, + cancellationToken); + } + + private static Task RegisterAsync( + CliArguments arguments, + IMxGatewayCliClient client, + TextWriter output, + CancellationToken cancellationToken) + { + return InvokeAndWriteAsync( + arguments, + client, + output, + new MxCommand + { + Kind = MxCommandKind.Register, + Register = new RegisterCommand { ClientName = arguments.GetOptional("client-name") ?? "mxgw-dotnet-cli" }, + }, + cancellationToken); + } + + private static Task AddItemAsync( + CliArguments arguments, + IMxGatewayCliClient client, + TextWriter output, + CancellationToken cancellationToken) + { + return InvokeAndWriteAsync( + arguments, + client, + output, + new MxCommand + { + Kind = MxCommandKind.AddItem, + AddItem = new AddItemCommand + { + ServerHandle = arguments.GetInt32("server-handle"), + ItemDefinition = arguments.GetRequired("item"), + }, + }, + cancellationToken); + } + + private static Task AdviseAsync( + CliArguments arguments, + IMxGatewayCliClient client, + TextWriter output, + CancellationToken cancellationToken) + { + return InvokeAndWriteAsync( + arguments, + client, + output, + new MxCommand + { + Kind = MxCommandKind.Advise, + Advise = new AdviseCommand + { + ServerHandle = arguments.GetInt32("server-handle"), + ItemHandle = arguments.GetInt32("item-handle"), + }, + }, + cancellationToken); + } + + private static Task WriteAsync( + CliArguments arguments, + IMxGatewayCliClient client, + TextWriter output, + CancellationToken cancellationToken) + { + return InvokeAndWriteAsync( + arguments, + client, + output, + new MxCommand + { + Kind = MxCommandKind.Write, + Write = new WriteCommand + { + ServerHandle = arguments.GetInt32("server-handle"), + ItemHandle = arguments.GetInt32("item-handle"), + UserId = arguments.GetInt32("user-id", 0), + Value = ParseValue(arguments), + }, + }, + cancellationToken); + } + + private static Task Write2Async( + CliArguments arguments, + IMxGatewayCliClient client, + TextWriter output, + CancellationToken cancellationToken) + { + return InvokeAndWriteAsync( + arguments, + client, + output, + new MxCommand + { + Kind = MxCommandKind.Write2, + Write2 = new Write2Command + { + ServerHandle = arguments.GetInt32("server-handle"), + ItemHandle = arguments.GetInt32("item-handle"), + UserId = arguments.GetInt32("user-id", 0), + Value = ParseValue(arguments), + TimestampValue = ParseTimestampValue(arguments), + }, + }, + cancellationToken); + } + + private static async Task StreamEventsAsync( + CliArguments arguments, + IMxGatewayCliClient client, + TextWriter output, + CancellationToken cancellationToken) + { + var events = new List(); + uint maxEvents = arguments.GetUInt32("max-events", 0); + uint eventCount = 0; + var request = new StreamEventsRequest + { + SessionId = arguments.GetRequired("session-id"), + AfterWorkerSequence = arguments.GetUInt64("after-worker-sequence", 0), + }; + + await foreach (MxEvent gatewayEvent in client.StreamEventsAsync(request, cancellationToken) + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + if (arguments.HasFlag("json")) + { + events.Add(gatewayEvent); + } + else + { + output.WriteLine(ProtobufJsonFormatter.Format(gatewayEvent)); + } + + eventCount++; + if (maxEvents > 0 && eventCount >= maxEvents) + { + break; + } + } + + if (arguments.HasFlag("json")) + { + output.WriteLine(JsonSerializer.Serialize( + new { events = events.Select(EventToJsonElement).ToArray() }, + JsonOptions)); + } + + return 0; + } + + private static async Task SmokeAsync( + CliArguments arguments, + IMxGatewayCliClient client, + TextWriter output, + CancellationToken cancellationToken) + { + OpenSessionReply? openReply = null; + CloseSessionReply? closeReply = null; + var commandReplies = new List(); + var events = new List(); + + try + { + openReply = await client.OpenSessionAsync( + new OpenSessionRequest + { + ClientSessionName = arguments.GetOptional("client-name") ?? "mxgw-dotnet-smoke", + ClientCorrelationId = CreateCorrelationId(), + }, + cancellationToken) + .ConfigureAwait(false); + + int serverHandle = await InvokeForHandleAsync( + arguments, + client, + openReply.SessionId, + new MxCommand + { + Kind = MxCommandKind.Register, + Register = new RegisterCommand { ClientName = arguments.GetOptional("client-name") ?? "mxgw-dotnet-smoke" }, + }, + reply => reply.Register?.ServerHandle ?? reply.ReturnValue.Int32Value, + commandReplies, + cancellationToken) + .ConfigureAwait(false); + + int itemHandle = await InvokeForHandleAsync( + arguments, + client, + openReply.SessionId, + new MxCommand + { + Kind = MxCommandKind.AddItem, + AddItem = new AddItemCommand + { + ServerHandle = serverHandle, + ItemDefinition = arguments.GetRequired("item"), + }, + }, + reply => reply.AddItem?.ItemHandle ?? reply.ReturnValue.Int32Value, + commandReplies, + cancellationToken) + .ConfigureAwait(false); + + commandReplies.Add(await InvokeAndEnsureAsync( + client, + CreateCommandRequest( + openReply.SessionId, + new MxCommand + { + Kind = MxCommandKind.Advise, + Advise = new AdviseCommand + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + }, + }), + cancellationToken) + .ConfigureAwait(false)); + + if (arguments.GetOptional("value") is not null) + { + commandReplies.Add(await InvokeAndEnsureAsync( + client, + CreateCommandRequest( + openReply.SessionId, + new MxCommand + { + Kind = MxCommandKind.Write, + Write = new WriteCommand + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + UserId = arguments.GetInt32("user-id", 0), + Value = ParseValue(arguments), + }, + }), + cancellationToken) + .ConfigureAwait(false)); + } + + using CancellationTokenSource streamCancellation = CancellationTokenSource + .CreateLinkedTokenSource(cancellationToken); + streamCancellation.CancelAfter(arguments.GetDuration( + "event-timeout", + TimeSpan.FromSeconds(2))); + + try + { + await foreach (MxEvent gatewayEvent in client.StreamEventsAsync( + new StreamEventsRequest { SessionId = openReply.SessionId }, + streamCancellation.Token) + .WithCancellation(streamCancellation.Token) + .ConfigureAwait(false)) + { + events.Add(gatewayEvent); + if (events.Count >= arguments.GetUInt32("max-events", 1)) + { + break; + } + } + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + } + } + finally + { + if (openReply is not null) + { + closeReply = await client.CloseSessionAsync( + new CloseSessionRequest + { + SessionId = openReply.SessionId, + ClientCorrelationId = CreateCorrelationId(), + }, + CancellationToken.None) + .ConfigureAwait(false); + } + } + + WriteSmokeResult(arguments, output, openReply, closeReply, commandReplies, events); + return 0; + } + + private static async Task InvokeAndWriteAsync( + CliArguments arguments, + IMxGatewayCliClient client, + TextWriter output, + MxCommand command, + CancellationToken cancellationToken) + { + MxCommandReply reply = await InvokeAndEnsureAsync( + client, + CreateCommandRequest(arguments.GetRequired("session-id"), command), + cancellationToken) + .ConfigureAwait(false); + + WriteMessage(arguments, output, reply); + return 0; + } + + private static async Task InvokeForHandleAsync( + CliArguments arguments, + IMxGatewayCliClient client, + string sessionId, + MxCommand command, + Func handleSelector, + List replies, + CancellationToken cancellationToken) + { + MxCommandReply reply = await InvokeAndEnsureAsync( + client, + CreateCommandRequest(sessionId, command), + cancellationToken) + .ConfigureAwait(false); + replies.Add(reply); + return handleSelector(reply); + } + + private static async Task InvokeAndEnsureAsync( + IMxGatewayCliClient client, + MxCommandRequest request, + CancellationToken cancellationToken) + { + MxCommandReply reply = await client.InvokeAsync(request, cancellationToken) + .ConfigureAwait(false); + reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); + return reply; + } + + private static MxCommandRequest CreateCommandRequest( + string sessionId, + MxCommand command) + { + return new MxCommandRequest + { + SessionId = sessionId, + ClientCorrelationId = CreateCorrelationId(), + Command = command, + }; + } + + private static async Task WriteReplyAsync( + Task replyTask, + CliArguments arguments, + TextWriter output) + where TReply : IMessage + { + TReply reply = await replyTask.ConfigureAwait(false); + WriteMessage(arguments, output, reply); + return 0; + } + + private static void WriteVersion(CliArguments arguments, TextWriter output) + { + if (arguments.HasFlag("json")) + { + output.WriteLine(JsonSerializer.Serialize( + new + { + gatewayProtocolVersion = MxGatewayClientContractInfo.GatewayProtocolVersion, + workerProtocolVersion = MxGatewayClientContractInfo.WorkerProtocolVersion, + }, + JsonOptions)); + return; + } + + output.WriteLine( + $"gateway-protocol={MxGatewayClientContractInfo.GatewayProtocolVersion}"); + output.WriteLine( + $"worker-protocol={MxGatewayClientContractInfo.WorkerProtocolVersion}"); + } + + private static void WriteMessage( + CliArguments arguments, + TextWriter output, + IMessage message) + { + output.WriteLine(arguments.HasFlag("json") + ? ProtobufJsonFormatter.Format(message) + : message.ToString()); + } + + private static void WriteSmokeResult( + CliArguments arguments, + TextWriter output, + OpenSessionReply? openReply, + CloseSessionReply? closeReply, + IReadOnlyList commandReplies, + IReadOnlyList events) + { + if (!arguments.HasFlag("json")) + { + output.WriteLine($"session-id={openReply?.SessionId}"); + output.WriteLine($"commands={commandReplies.Count}"); + output.WriteLine($"events={events.Count}"); + output.WriteLine($"closed={closeReply is not null}"); + return; + } + + output.WriteLine(JsonSerializer.Serialize( + new + { + sessionId = openReply?.SessionId, + closed = closeReply is not null, + commandReplies = commandReplies.Select(CommandReplyToJsonElement).ToArray(), + events = events.Select(EventToJsonElement).ToArray(), + }, + JsonOptions)); + } + + private static JsonElement CommandReplyToJsonElement(MxCommandReply reply) + { + return JsonDocument.Parse(ProtobufJsonFormatter.Format(reply)).RootElement.Clone(); + } + + private static JsonElement EventToJsonElement(MxEvent gatewayEvent) + { + return JsonDocument.Parse(ProtobufJsonFormatter.Format(gatewayEvent)).RootElement.Clone(); + } + + private static MxValue ParseValue(CliArguments arguments) + { + string type = arguments.GetRequired("type").ToLowerInvariant(); + string value = arguments.GetRequired("value"); + string[] values = value.Split(',', StringSplitOptions.TrimEntries); + + return type switch + { + "bool" or "boolean" => bool.Parse(value).ToMxValue(), + "bool-array" or "boolean-array" => values.Select(bool.Parse).ToArray().ToMxValue(), + "int32" or "integer" => int.Parse(value, CultureInfo.InvariantCulture).ToMxValue(), + "int32-array" or "integer-array" => values.Select(item => int.Parse(item, CultureInfo.InvariantCulture)).ToArray().ToMxValue(), + "int64" => long.Parse(value, CultureInfo.InvariantCulture).ToMxValue(), + "int64-array" => values.Select(item => long.Parse(item, CultureInfo.InvariantCulture)).ToArray().ToMxValue(), + "float" => float.Parse(value, CultureInfo.InvariantCulture).ToMxValue(), + "float-array" => values.Select(item => float.Parse(item, CultureInfo.InvariantCulture)).ToArray().ToMxValue(), + "double" => double.Parse(value, CultureInfo.InvariantCulture).ToMxValue(), + "double-array" => values.Select(item => double.Parse(item, CultureInfo.InvariantCulture)).ToArray().ToMxValue(), + "string" => value.ToMxValue(), + "string-array" => values.ToMxValue(), + "time" or "timestamp" => DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal).ToMxValue(), + "time-array" or "timestamp-array" => values + .Select(item => DateTimeOffset.Parse(item, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal)) + .ToArray() + .ToMxValue(), + _ => throw new ArgumentException($"Unsupported MX value type '{type}'."), + }; + } + + private static MxValue ParseTimestampValue(CliArguments arguments) + { + string timestamp = arguments.GetOptional("timestamp") + ?? DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture); + + return DateTimeOffset.Parse( + timestamp, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal) + .ToMxValue(); + } + + private static int WriteUnknownCommand(string command, TextWriter standardError) + { + standardError.WriteLine($"Unknown command '{command}'."); WriteUsage(standardError); return 2; } @@ -40,9 +708,37 @@ public static class MxGatewayClientCli || string.Equals(value, "help", StringComparison.OrdinalIgnoreCase); } + private static bool IsKnownGatewayCommand(string command) + { + return command is "open-session" + or "close-session" + or "ping" + or "register" + or "add-item" + or "advise" + or "stream-events" + or "write" + or "write2" + or "smoke"; + } + + private static string CreateCorrelationId() + { + return Guid.NewGuid().ToString("N"); + } + private static void WriteUsage(TextWriter writer) { - writer.WriteLine("mxgw-dotnet version"); - writer.WriteLine("mxgw-dotnet --help"); + writer.WriteLine("mxgw-dotnet version [--json]"); + writer.WriteLine("mxgw-dotnet ping --session-id [--json]"); + writer.WriteLine("mxgw-dotnet open-session [--client-name ] [--json]"); + writer.WriteLine("mxgw-dotnet close-session --session-id [--json]"); + writer.WriteLine("mxgw-dotnet register --session-id --client-name [--json]"); + writer.WriteLine("mxgw-dotnet add-item --session-id --server-handle --item [--json]"); + writer.WriteLine("mxgw-dotnet advise --session-id --server-handle --item-handle [--json]"); + writer.WriteLine("mxgw-dotnet stream-events --session-id [--max-events ] [--json]"); + writer.WriteLine("mxgw-dotnet write --session-id --server-handle --item-handle --type --value [--json]"); + writer.WriteLine("mxgw-dotnet write2 --session-id --server-handle --item-handle --type --value [--timestamp ] [--json]"); + writer.WriteLine("mxgw-dotnet smoke --item [--value --type ] [--json]"); } } diff --git a/clients/dotnet/MxGateway.Client.Cli/Program.cs b/clients/dotnet/MxGateway.Client.Cli/Program.cs index 3f1db75..fbea43f 100644 --- a/clients/dotnet/MxGateway.Client.Cli/Program.cs +++ b/clients/dotnet/MxGateway.Client.Cli/Program.cs @@ -1,3 +1,3 @@ using MxGateway.Client.Cli; -return MxGatewayClientCli.Run(args, Console.Out, Console.Error); +return await MxGatewayClientCli.RunAsync(args, Console.Out, Console.Error); diff --git a/clients/dotnet/MxGateway.Client.Tests/MxCommandReplyExtensionsTests.cs b/clients/dotnet/MxGateway.Client.Tests/MxCommandReplyExtensionsTests.cs new file mode 100644 index 0000000..686c1e4 --- /dev/null +++ b/clients/dotnet/MxGateway.Client.Tests/MxCommandReplyExtensionsTests.cs @@ -0,0 +1,78 @@ +using Google.Protobuf; +using MxGateway.Client; +using MxGateway.Contracts.Proto; + +namespace MxGateway.Client.Tests; + +public sealed class MxCommandReplyExtensionsTests +{ + [Fact] + public void EnsureSuccess_WithRegisterFixture_ReturnsReply() + { + MxCommandReply reply = ReadReplyFixture("register.ok.reply.json"); + + Assert.Same(reply, reply.EnsureProtocolSuccess()); + Assert.Same(reply, reply.EnsureMxAccessSuccess()); + } + + [Fact] + public void EnsureMxAccessSuccess_WithFailureFixture_PreservesHResultAndStatuses() + { + MxCommandReply reply = ReadReplyFixture("write.mxaccess-failure.reply.json"); + + reply.EnsureProtocolSuccess(); + MxAccessException exception = Assert.Throws( + reply.EnsureMxAccessSuccess); + + Assert.Equal(-2147220992, exception.HResultCode); + Assert.Equal(reply.Statuses.Count, exception.Statuses.Count); + Assert.Equal(reply, exception.Reply); + Assert.Contains("0x80040200", exception.Message); + } + + [Fact] + public void EnsureProtocolSuccess_WithSessionFailure_ThrowsSessionException() + { + MxCommandReply reply = new() + { + SessionId = "session-missing", + CorrelationId = "correlation", + ProtocolStatus = new ProtocolStatus + { + Code = ProtocolStatusCode.SessionNotFound, + Message = "Session was not found.", + }, + }; + + MxGatewaySessionException exception = Assert.Throws( + reply.EnsureProtocolSuccess); + + Assert.Equal("session-missing", exception.SessionId); + Assert.Equal(ProtocolStatusCode.SessionNotFound, exception.ProtocolStatus?.Code); + } + + private static MxCommandReply ReadReplyFixture(string fileName) + { + DirectoryInfo directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + string path = Path.Combine( + directory.FullName, + "clients", + "proto", + "fixtures", + "behavior", + "command-replies", + fileName); + + if (File.Exists(path)) + { + return JsonParser.Default.Parse(File.ReadAllText(path)); + } + + directory = directory.Parent!; + } + + throw new FileNotFoundException(fileName); + } +} diff --git a/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientCliTests.cs b/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientCliTests.cs index 5ee9ebc..cddc1d1 100644 --- a/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientCliTests.cs +++ b/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientCliTests.cs @@ -1,4 +1,5 @@ using MxGateway.Client.Cli; +using MxGateway.Contracts.Proto; namespace MxGateway.Client.Tests; @@ -17,4 +18,224 @@ public sealed class MxGatewayClientCliTests Assert.Contains("worker-protocol=1", output.ToString()); Assert.Equal(string.Empty, error.ToString()); } + + [Fact] + public async Task RunAsync_VersionJson_PrintsJsonProtocolVersions() + { + using var output = new StringWriter(); + using var error = new StringWriter(); + + int exitCode = await MxGatewayClientCli.RunAsync(["version", "--json"], output, error); + + Assert.Equal(0, exitCode); + Assert.Contains("\"gatewayProtocolVersion\":1", output.ToString()); + Assert.Equal(string.Empty, error.ToString()); + } + + [Fact] + public async Task RunAsync_Write_BuildsWriteCommandAndPrintsJsonReply() + { + using var output = new StringWriter(); + using var error = new StringWriter(); + FakeCliClient fakeClient = new(); + fakeClient.InvokeReplies.Enqueue(new MxCommandReply + { + SessionId = "session-fixture", + Kind = MxCommandKind.Write, + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + }); + + int exitCode = await MxGatewayClientCli.RunAsync( + [ + "write", + "--endpoint", + "http://localhost:5000", + "--api-key", + "test-api-key", + "--session-id", + "session-fixture", + "--server-handle", + "12", + "--item-handle", + "34", + "--type", + "int32", + "--value", + "123", + "--json", + ], + output, + error, + _ => fakeClient); + + Assert.Equal(0, exitCode); + MxCommandRequest request = Assert.Single(fakeClient.InvokeRequests); + Assert.Equal(MxCommandKind.Write, request.Command.Kind); + Assert.Equal(123, request.Command.Write.Value.Int32Value); + Assert.Contains("MX_COMMAND_KIND_WRITE", output.ToString()); + Assert.Equal(string.Empty, error.ToString()); + } + + [Fact] + public async Task RunAsync_ErrorOutput_RedactsApiKey() + { + using var output = new StringWriter(); + using var error = new StringWriter(); + + int exitCode = await MxGatewayClientCli.RunAsync( + [ + "open-session", + "--endpoint", + "http://localhost:5000", + "--api-key", + "secret-api-key", + ], + output, + error, + _ => throw new InvalidOperationException("boom secret-api-key")); + + Assert.Equal(1, exitCode); + Assert.DoesNotContain("secret-api-key", error.ToString()); + Assert.Contains("[redacted]", error.ToString()); + } + + [Fact] + public async Task RunAsync_StreamEvents_WithMaxEventsStopsNonJsonOutput() + { + using var output = new StringWriter(); + using var error = new StringWriter(); + FakeCliClient fakeClient = new(); + fakeClient.Events.Add(new MxEvent + { + SessionId = "session-fixture", + Family = MxEventFamily.OnDataChange, + WorkerSequence = 1, + }); + fakeClient.Events.Add(new MxEvent + { + SessionId = "session-fixture", + Family = MxEventFamily.OnWriteComplete, + WorkerSequence = 2, + }); + + int exitCode = await MxGatewayClientCli.RunAsync( + [ + "stream-events", + "--endpoint", + "http://localhost:5000", + "--api-key", + "test-api-key", + "--session-id", + "session-fixture", + "--max-events", + "1", + ], + output, + error, + _ => fakeClient); + + Assert.Equal(0, exitCode); + Assert.Contains("workerSequence", output.ToString()); + Assert.DoesNotContain("ON_WRITE_COMPLETE", output.ToString()); + } + + + [Fact] + public async Task RunAsync_Smoke_WhenCommandFails_ClosesOpenedSession() + { + using var output = new StringWriter(); + using var error = new StringWriter(); + FakeCliClient fakeClient = new() + { + InvokeFailure = new InvalidOperationException("register failed"), + }; + + int exitCode = await MxGatewayClientCli.RunAsync( + [ + "smoke", + "--endpoint", + "http://localhost:5000", + "--api-key", + "test-api-key", + "--item", + "Area001.Pump001.Speed", + "--json", + ], + output, + error, + _ => fakeClient); + + Assert.Equal(1, exitCode); + CloseSessionRequest closeRequest = Assert.Single(fakeClient.CloseSessionRequests); + Assert.Equal("session-fixture", closeRequest.SessionId); + } + + private sealed class FakeCliClient : IMxGatewayCliClient + { + public Queue InvokeReplies { get; } = new(); + + public List InvokeRequests { get; } = []; + + public List CloseSessionRequests { get; } = []; + + public List Events { get; } = []; + + public Exception? InvokeFailure { get; init; } + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } + + public Task OpenSessionAsync( + OpenSessionRequest request, + CancellationToken cancellationToken) + { + return Task.FromResult(new OpenSessionReply + { + SessionId = "session-fixture", + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + GatewayProtocolVersion = 1, + WorkerProtocolVersion = 1, + }); + } + + public Task CloseSessionAsync( + CloseSessionRequest request, + CancellationToken cancellationToken) + { + CloseSessionRequests.Add(request); + return Task.FromResult(new CloseSessionReply + { + SessionId = request.SessionId, + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + FinalState = SessionState.Closed, + }); + } + + public Task InvokeAsync( + MxCommandRequest request, + CancellationToken cancellationToken) + { + InvokeRequests.Add(request); + if (InvokeFailure is not null) + { + throw InvokeFailure; + } + + return Task.FromResult(InvokeReplies.Dequeue()); + } + + public async IAsyncEnumerable StreamEventsAsync( + StreamEventsRequest request, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + foreach (MxEvent gatewayEvent in Events) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Yield(); + yield return gatewayEvent; + } + } + } } diff --git a/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientSessionTests.cs b/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientSessionTests.cs index 3248c51..2cb36d3 100644 --- a/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientSessionTests.cs +++ b/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientSessionTests.cs @@ -110,6 +110,33 @@ public sealed class MxGatewayClientSessionTests Assert.Equal(56, request.Command.Write.UserId); } + [Fact] + public async Task Write2RawAsync_BuildsWrite2CommandWithValueAndTimestamp() + { + FakeGatewayTransport transport = CreateTransport(); + transport.AddInvokeReply(new MxCommandReply + { + SessionId = "session-fixture", + Kind = MxCommandKind.Write2, + ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, + }); + await using MxGatewayClient client = CreateClient(transport); + MxGatewaySession session = await client.OpenSessionAsync(); + MxValue value = 123.ToMxValue(); + MxValue timestampValue = DateTimeOffset.Parse("2026-01-01T00:00:00Z").ToMxValue(); + + MxCommandReply reply = await session.Write2RawAsync(12, 34, value, timestampValue, 56); + + Assert.Equal(MxCommandKind.Write2, reply.Kind); + MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request; + Assert.Equal(MxCommandKind.Write2, request.Command.Kind); + Assert.Equal(12, request.Command.Write2.ServerHandle); + Assert.Equal(34, request.Command.Write2.ItemHandle); + Assert.Same(value, request.Command.Write2.Value); + Assert.Same(timestampValue, request.Command.Write2.TimestampValue); + Assert.Equal(56, request.Command.Write2.UserId); + } + [Fact] public async Task StreamEventsAsync_YieldsEventsInGatewayOrder() { diff --git a/clients/dotnet/MxGateway.Client.Tests/MxStatusProxyExtensionsTests.cs b/clients/dotnet/MxGateway.Client.Tests/MxStatusProxyExtensionsTests.cs new file mode 100644 index 0000000..76ecca8 --- /dev/null +++ b/clients/dotnet/MxGateway.Client.Tests/MxStatusProxyExtensionsTests.cs @@ -0,0 +1,57 @@ +using System.Text.Json; +using Google.Protobuf; +using MxGateway.Client; +using MxGateway.Contracts.Proto; + +namespace MxGateway.Client.Tests; + +public sealed class MxStatusProxyExtensionsTests +{ + [Fact] + public void FixtureStatuses_ProjectSuccessAndPreserveRawFields() + { + using JsonDocument document = JsonDocument.Parse(ReadFixture( + "statuses", + "status-conversion-cases.json")); + + foreach (JsonElement testCase in document.RootElement.GetProperty("cases").EnumerateArray()) + { + MxStatusProxy status = JsonParser.Default.Parse( + testCase.GetProperty("status").GetRawText()); + int success = testCase.GetProperty("status").GetProperty("success").GetInt32(); + + Assert.Equal(success != 0 && status.Category is MxStatusCategory.Ok, status.IsSuccess()); + Assert.Equal( + testCase.GetProperty("status").GetProperty("rawCategory").GetInt32(), + status.RawCategory); + Assert.Equal( + testCase.GetProperty("status").GetProperty("rawDetectedBy").GetInt32(), + status.RawDetectedBy); + } + } + + private static string ReadFixture(string category, string fileName) + { + DirectoryInfo directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + string path = Path.Combine( + directory.FullName, + "clients", + "proto", + "fixtures", + "behavior", + category, + fileName); + + if (File.Exists(path)) + { + return File.ReadAllText(path); + } + + directory = directory.Parent!; + } + + throw new FileNotFoundException(fileName); + } +} diff --git a/clients/dotnet/MxGateway.Client.Tests/MxValueExtensionsTests.cs b/clients/dotnet/MxGateway.Client.Tests/MxValueExtensionsTests.cs new file mode 100644 index 0000000..2fbde41 --- /dev/null +++ b/clients/dotnet/MxGateway.Client.Tests/MxValueExtensionsTests.cs @@ -0,0 +1,79 @@ +using System.Text.Json; +using Google.Protobuf; +using MxGateway.Client; +using MxGateway.Contracts.Proto; + +namespace MxGateway.Client.Tests; + +public sealed class MxValueExtensionsTests +{ + [Fact] + public void ToMxValue_WithScalarValues_CreatesTypedProtobufValues() + { + Assert.Equal(MxValue.KindOneofCase.BoolValue, true.ToMxValue().KindCase); + Assert.Equal(MxValue.KindOneofCase.Int32Value, 123.ToMxValue().KindCase); + Assert.Equal(MxValue.KindOneofCase.Int64Value, 123L.ToMxValue().KindCase); + Assert.Equal(MxValue.KindOneofCase.FloatValue, 1.25F.ToMxValue().KindCase); + Assert.Equal(MxValue.KindOneofCase.DoubleValue, 2.5D.ToMxValue().KindCase); + Assert.Equal(MxValue.KindOneofCase.StringValue, "alpha".ToMxValue().KindCase); + } + + [Fact] + public void ToMxValue_WithArrays_CreatesTypedArrayProtobufValues() + { + MxValue value = new[] { "alpha", "beta" }.ToMxValue(); + + Assert.Equal(MxValue.KindOneofCase.ArrayValue, value.KindCase); + Assert.Equal(MxArray.ValuesOneofCase.StringValues, value.ArrayValue.ValuesCase); + Assert.Equal(["alpha", "beta"], value.ArrayValue.StringValues.Values); + Assert.Equal([2U], value.ArrayValue.Dimensions); + } + + [Fact] + public void FixtureValues_ProjectExpectedKindsAndPreserveRawMetadata() + { + using JsonDocument document = JsonDocument.Parse(ReadFixture( + "values", + "value-conversion-cases.json")); + + foreach (JsonElement testCase in document.RootElement.GetProperty("cases").EnumerateArray()) + { + string expectedKind = testCase.GetProperty("expectedKind").GetString()!; + MxValue value = JsonParser.Default.Parse( + testCase.GetProperty("value").GetRawText()); + + Assert.Equal(expectedKind, value.GetProjectionKind()); + + if (testCase.GetProperty("id").GetString() is "raw-fallback.variant") + { + Assert.Equal(32767, value.RawDataType); + Assert.Equal([1, 2, 3, 4, 5], Assert.IsType(value.ToClrValue())); + } + } + } + + private static string ReadFixture(string category, string fileName) + { + DirectoryInfo directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + string path = Path.Combine( + directory.FullName, + "clients", + "proto", + "fixtures", + "behavior", + category, + fileName); + + if (File.Exists(path)) + { + return File.ReadAllText(path); + } + + directory = directory.Parent!; + } + + throw new FileNotFoundException(fileName); + } +} diff --git a/clients/dotnet/MxGateway.Client/GrpcMxGatewayClientTransport.cs b/clients/dotnet/MxGateway.Client/GrpcMxGatewayClientTransport.cs index 3bc9e85..6db23ed 100644 --- a/clients/dotnet/MxGateway.Client/GrpcMxGatewayClientTransport.cs +++ b/clients/dotnet/MxGateway.Client/GrpcMxGatewayClientTransport.cs @@ -17,27 +17,48 @@ internal sealed class GrpcMxGatewayClientTransport( OpenSessionRequest request, CallOptions callOptions) { - return await RawClient.OpenSessionAsync(request, callOptions) - .ResponseAsync - .ConfigureAwait(false); + try + { + return await RawClient.OpenSessionAsync(request, callOptions) + .ResponseAsync + .ConfigureAwait(false); + } + catch (RpcException exception) + { + throw MapRpcException(exception); + } } public async Task CloseSessionAsync( CloseSessionRequest request, CallOptions callOptions) { - return await RawClient.CloseSessionAsync(request, callOptions) - .ResponseAsync - .ConfigureAwait(false); + try + { + return await RawClient.CloseSessionAsync(request, callOptions) + .ResponseAsync + .ConfigureAwait(false); + } + catch (RpcException exception) + { + throw MapRpcException(exception); + } } public async Task InvokeAsync( MxCommandRequest request, CallOptions callOptions) { - return await RawClient.InvokeAsync(request, callOptions) - .ResponseAsync - .ConfigureAwait(false); + try + { + return await RawClient.InvokeAsync(request, callOptions) + .ResponseAsync + .ConfigureAwait(false); + } + catch (RpcException exception) + { + throw MapRpcException(exception); + } } public async IAsyncEnumerable StreamEventsAsync( @@ -51,10 +72,24 @@ internal sealed class GrpcMxGatewayClientTransport( using AsyncServerStreamingCall call = RawClient.StreamEvents(request, callOptions); - await foreach (MxEvent gatewayEvent in call.ResponseStream - .ReadAllAsync(effectiveCancellationToken) - .ConfigureAwait(false)) + IAsyncStreamReader responseStream = call.ResponseStream; + while (true) { + MxEvent? gatewayEvent; + try + { + if (!await responseStream.MoveNext(effectiveCancellationToken).ConfigureAwait(false)) + { + break; + } + + gatewayEvent = responseStream.Current; + } + catch (RpcException exception) + { + throw MapRpcException(exception); + } + yield return gatewayEvent; } } @@ -65,4 +100,18 @@ internal sealed class GrpcMxGatewayClientTransport( { return StreamEventsAsync(request, callOptions); } + + private static MxGatewayException MapRpcException(RpcException exception) + { + return exception.StatusCode switch + { + StatusCode.Unauthenticated => new MxGatewayAuthenticationException( + exception.Status.Detail, + innerException: exception), + StatusCode.PermissionDenied => new MxGatewayAuthorizationException( + exception.Status.Detail, + innerException: exception), + _ => new MxGatewayException(exception.Status.Detail, exception), + }; + } } diff --git a/clients/dotnet/MxGateway.Client/MxAccessException.cs b/clients/dotnet/MxGateway.Client/MxAccessException.cs new file mode 100644 index 0000000..a2a14ca --- /dev/null +++ b/clients/dotnet/MxGateway.Client/MxAccessException.cs @@ -0,0 +1,24 @@ +using MxGateway.Contracts.Proto; + +namespace MxGateway.Client; + +public sealed class MxAccessException : MxGatewayCommandException +{ + public MxAccessException( + string message, + MxCommandReply reply, + Exception? innerException = null) + : base( + message, + reply.SessionId, + reply.CorrelationId, + reply.ProtocolStatus, + reply.HasHresult ? reply.Hresult : null, + reply.Statuses.ToArray(), + innerException) + { + Reply = reply; + } + + public MxCommandReply Reply { get; } +} diff --git a/clients/dotnet/MxGateway.Client/MxCommandReplyExtensions.cs b/clients/dotnet/MxGateway.Client/MxCommandReplyExtensions.cs new file mode 100644 index 0000000..ce9f2b8 --- /dev/null +++ b/clients/dotnet/MxGateway.Client/MxCommandReplyExtensions.cs @@ -0,0 +1,96 @@ +using MxGateway.Contracts.Proto; + +namespace MxGateway.Client; + +public static class MxCommandReplyExtensions +{ + public static MxCommandReply EnsureProtocolSuccess(this MxCommandReply reply) + { + ArgumentNullException.ThrowIfNull(reply); + + ProtocolStatusCode code = reply.ProtocolStatus?.Code + ?? ProtocolStatusCode.Unspecified; + + if (code is ProtocolStatusCode.Ok or ProtocolStatusCode.MxaccessFailure) + { + return reply; + } + + throw CreateProtocolException(reply, code); + } + + public static MxCommandReply EnsureMxAccessSuccess(this MxCommandReply reply) + { + ArgumentNullException.ThrowIfNull(reply); + + bool mxAccessFailure = reply.ProtocolStatus?.Code is ProtocolStatusCode.MxaccessFailure; + bool hResultFailure = reply.HasHresult && reply.Hresult != 0; + bool statusFailure = reply.Statuses.Any(status => !status.IsSuccess()); + + if (!mxAccessFailure && !hResultFailure && !statusFailure) + { + return reply; + } + + throw new MxAccessException(CreateMxAccessMessage(reply), reply); + } + + private static MxGatewayException CreateProtocolException( + MxCommandReply reply, + ProtocolStatusCode code) + { + string message = CreateProtocolMessage(reply); + int? hResult = reply.HasHresult ? reply.Hresult : null; + MxStatusProxy[] statuses = reply.Statuses.ToArray(); + + return code switch + { + ProtocolStatusCode.SessionNotFound or ProtocolStatusCode.SessionNotReady + => new MxGatewaySessionException( + message, + reply.SessionId, + reply.CorrelationId, + reply.ProtocolStatus, + hResult, + statuses), + ProtocolStatusCode.WorkerUnavailable + => new MxGatewayWorkerException( + message, + reply.SessionId, + reply.CorrelationId, + reply.ProtocolStatus, + hResult, + statuses), + _ + => new MxGatewayCommandException( + message, + reply.SessionId, + reply.CorrelationId, + reply.ProtocolStatus, + hResult, + statuses), + }; + } + + private static string CreateProtocolMessage(MxCommandReply reply) + { + string statusMessage = string.IsNullOrWhiteSpace(reply.ProtocolStatus?.Message) + ? "Gateway protocol failure." + : reply.ProtocolStatus.Message; + + return $"{statusMessage} code={reply.ProtocolStatus?.Code}; session={reply.SessionId}; correlation={reply.CorrelationId}"; + } + + private static string CreateMxAccessMessage(MxCommandReply reply) + { + string statusSummary = reply.Statuses.Count is 0 + ? "no MXSTATUS_PROXY entries" + : string.Join("; ", reply.Statuses.Select(status => status.ToDiagnosticSummary())); + + string hResult = reply.HasHresult + ? $"0x{reply.Hresult:X8}" + : "none"; + + return $"MXAccess command failed. kind={reply.Kind}; hresult={hResult}; statuses={statusSummary}"; + } +} diff --git a/clients/dotnet/MxGateway.Client/MxGatewayAuthenticationException.cs b/clients/dotnet/MxGateway.Client/MxGatewayAuthenticationException.cs new file mode 100644 index 0000000..e1164fb --- /dev/null +++ b/clients/dotnet/MxGateway.Client/MxGatewayAuthenticationException.cs @@ -0,0 +1,25 @@ +using MxGateway.Contracts.Proto; + +namespace MxGateway.Client; + +public sealed class MxGatewayAuthenticationException : MxGatewayException +{ + public MxGatewayAuthenticationException( + string message, + string? sessionId = null, + string? correlationId = null, + ProtocolStatus? protocolStatus = null, + int? hResult = null, + IReadOnlyList? statuses = null, + Exception? innerException = null) + : base( + message, + sessionId, + correlationId, + protocolStatus, + hResult, + statuses ?? [], + innerException) + { + } +} diff --git a/clients/dotnet/MxGateway.Client/MxGatewayAuthorizationException.cs b/clients/dotnet/MxGateway.Client/MxGatewayAuthorizationException.cs new file mode 100644 index 0000000..1c1c67c --- /dev/null +++ b/clients/dotnet/MxGateway.Client/MxGatewayAuthorizationException.cs @@ -0,0 +1,25 @@ +using MxGateway.Contracts.Proto; + +namespace MxGateway.Client; + +public sealed class MxGatewayAuthorizationException : MxGatewayException +{ + public MxGatewayAuthorizationException( + string message, + string? sessionId = null, + string? correlationId = null, + ProtocolStatus? protocolStatus = null, + int? hResult = null, + IReadOnlyList? statuses = null, + Exception? innerException = null) + : base( + message, + sessionId, + correlationId, + protocolStatus, + hResult, + statuses ?? [], + innerException) + { + } +} diff --git a/clients/dotnet/MxGateway.Client/MxGatewayCommandException.cs b/clients/dotnet/MxGateway.Client/MxGatewayCommandException.cs new file mode 100644 index 0000000..83baf90 --- /dev/null +++ b/clients/dotnet/MxGateway.Client/MxGatewayCommandException.cs @@ -0,0 +1,25 @@ +using MxGateway.Contracts.Proto; + +namespace MxGateway.Client; + +public class MxGatewayCommandException : MxGatewayException +{ + public MxGatewayCommandException( + string message, + string? sessionId = null, + string? correlationId = null, + ProtocolStatus? protocolStatus = null, + int? hResult = null, + IReadOnlyList? statuses = null, + Exception? innerException = null) + : base( + message, + sessionId, + correlationId, + protocolStatus, + hResult, + statuses ?? [], + innerException) + { + } +} diff --git a/clients/dotnet/MxGateway.Client/MxGatewayException.cs b/clients/dotnet/MxGateway.Client/MxGatewayException.cs new file mode 100644 index 0000000..eb5b59e --- /dev/null +++ b/clients/dotnet/MxGateway.Client/MxGatewayException.cs @@ -0,0 +1,45 @@ +using MxGateway.Contracts.Proto; + +namespace MxGateway.Client; + +public class MxGatewayException : Exception +{ + public MxGatewayException(string message) + : base(message) + { + Statuses = []; + } + + public MxGatewayException(string message, Exception? innerException) + : base(message, innerException) + { + Statuses = []; + } + + public MxGatewayException( + string message, + string? sessionId, + string? correlationId, + ProtocolStatus? protocolStatus, + int? hResult, + IReadOnlyList statuses, + Exception? innerException = null) + : base(message, innerException) + { + SessionId = sessionId; + CorrelationId = correlationId; + ProtocolStatus = protocolStatus; + HResultCode = hResult; + Statuses = statuses; + } + + public string? SessionId { get; } + + public string? CorrelationId { get; } + + public ProtocolStatus? ProtocolStatus { get; } + + public int? HResultCode { get; } + + public IReadOnlyList Statuses { get; } +} diff --git a/clients/dotnet/MxGateway.Client/MxGatewaySession.cs b/clients/dotnet/MxGateway.Client/MxGatewaySession.cs index 2081593..6dcaa38 100644 --- a/clients/dotnet/MxGateway.Client/MxGatewaySession.cs +++ b/clients/dotnet/MxGateway.Client/MxGatewaySession.cs @@ -56,6 +56,7 @@ public sealed class MxGatewaySession : IAsyncDisposable { MxCommandReply reply = await RegisterRawAsync(clientName, cancellationToken) .ConfigureAwait(false); + reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); return reply.Register?.ServerHandle ?? reply.ReturnValue.Int32Value; } @@ -84,6 +85,7 @@ public sealed class MxGatewaySession : IAsyncDisposable itemDefinition, cancellationToken) .ConfigureAwait(false); + reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); return reply.AddItem?.ItemHandle ?? reply.ReturnValue.Int32Value; } @@ -119,6 +121,7 @@ public sealed class MxGatewaySession : IAsyncDisposable itemContext, cancellationToken) .ConfigureAwait(false); + reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); return reply.AddItem2?.ItemHandle ?? reply.ReturnValue.Int32Value; } @@ -149,8 +152,9 @@ public sealed class MxGatewaySession : IAsyncDisposable int itemHandle, CancellationToken cancellationToken = default) { - await AdviseRawAsync(serverHandle, itemHandle, cancellationToken) + MxCommandReply reply = await AdviseRawAsync(serverHandle, itemHandle, cancellationToken) .ConfigureAwait(false); + reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); } public Task AdviseRawAsync( @@ -178,8 +182,9 @@ public sealed class MxGatewaySession : IAsyncDisposable int userId, CancellationToken cancellationToken = default) { - await WriteRawAsync(serverHandle, itemHandle, value, userId, cancellationToken) + MxCommandReply reply = await WriteRawAsync(serverHandle, itemHandle, value, userId, cancellationToken) .ConfigureAwait(false); + reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); } public Task WriteRawAsync( @@ -206,6 +211,52 @@ public sealed class MxGatewaySession : IAsyncDisposable cancellationToken); } + public async Task Write2Async( + int serverHandle, + int itemHandle, + MxValue value, + MxValue timestampValue, + int userId, + CancellationToken cancellationToken = default) + { + MxCommandReply reply = await Write2RawAsync( + serverHandle, + itemHandle, + value, + timestampValue, + userId, + cancellationToken) + .ConfigureAwait(false); + reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); + } + + public Task Write2RawAsync( + int serverHandle, + int itemHandle, + MxValue value, + MxValue timestampValue, + int userId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(timestampValue); + + return InvokeCommandAsync( + new MxCommand + { + Kind = MxCommandKind.Write2, + Write2 = new Write2Command + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + Value = value, + TimestampValue = timestampValue, + UserId = userId, + }, + }, + cancellationToken); + } + public Task InvokeAsync( MxCommandRequest request, CancellationToken cancellationToken = default) diff --git a/clients/dotnet/MxGateway.Client/MxGatewaySessionException.cs b/clients/dotnet/MxGateway.Client/MxGatewaySessionException.cs new file mode 100644 index 0000000..f7ae5db --- /dev/null +++ b/clients/dotnet/MxGateway.Client/MxGatewaySessionException.cs @@ -0,0 +1,25 @@ +using MxGateway.Contracts.Proto; + +namespace MxGateway.Client; + +public sealed class MxGatewaySessionException : MxGatewayException +{ + public MxGatewaySessionException( + string message, + string? sessionId = null, + string? correlationId = null, + ProtocolStatus? protocolStatus = null, + int? hResult = null, + IReadOnlyList? statuses = null, + Exception? innerException = null) + : base( + message, + sessionId, + correlationId, + protocolStatus, + hResult, + statuses ?? [], + innerException) + { + } +} diff --git a/clients/dotnet/MxGateway.Client/MxGatewayWorkerException.cs b/clients/dotnet/MxGateway.Client/MxGatewayWorkerException.cs new file mode 100644 index 0000000..794a10b --- /dev/null +++ b/clients/dotnet/MxGateway.Client/MxGatewayWorkerException.cs @@ -0,0 +1,25 @@ +using MxGateway.Contracts.Proto; + +namespace MxGateway.Client; + +public sealed class MxGatewayWorkerException : MxGatewayException +{ + public MxGatewayWorkerException( + string message, + string? sessionId = null, + string? correlationId = null, + ProtocolStatus? protocolStatus = null, + int? hResult = null, + IReadOnlyList? statuses = null, + Exception? innerException = null) + : base( + message, + sessionId, + correlationId, + protocolStatus, + hResult, + statuses ?? [], + innerException) + { + } +} diff --git a/clients/dotnet/MxGateway.Client/MxStatusProxyExtensions.cs b/clients/dotnet/MxGateway.Client/MxStatusProxyExtensions.cs new file mode 100644 index 0000000..a3086b8 --- /dev/null +++ b/clients/dotnet/MxGateway.Client/MxStatusProxyExtensions.cs @@ -0,0 +1,25 @@ +using MxGateway.Contracts.Proto; + +namespace MxGateway.Client; + +public static class MxStatusProxyExtensions +{ + public static bool IsSuccess(this MxStatusProxy status) + { + ArgumentNullException.ThrowIfNull(status); + + return status.Success != 0 + && status.Category is MxStatusCategory.Ok; + } + + public static string ToDiagnosticSummary(this MxStatusProxy status) + { + ArgumentNullException.ThrowIfNull(status); + + string diagnosticText = string.IsNullOrWhiteSpace(status.DiagnosticText) + ? "no diagnostic text" + : status.DiagnosticText; + + return $"{status.Category} by {status.DetectedBy}; detail={status.Detail}; {diagnosticText}"; + } +} diff --git a/clients/dotnet/MxGateway.Client/MxValueExtensions.cs b/clients/dotnet/MxGateway.Client/MxValueExtensions.cs new file mode 100644 index 0000000..5b75d2c --- /dev/null +++ b/clients/dotnet/MxGateway.Client/MxValueExtensions.cs @@ -0,0 +1,284 @@ +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using MxGateway.Contracts.Proto; + +namespace MxGateway.Client; + +/// +/// Creates and projects gateway MXAccess values without hiding the raw +/// protobuf value carried by command replies and events. +/// +public static class MxValueExtensions +{ + public static MxValue ToMxValue(this bool value) + { + return new MxValue + { + DataType = MxDataType.Boolean, + VariantType = "VT_BOOL", + BoolValue = value, + }; + } + + public static MxValue ToMxValue(this int value) + { + return new MxValue + { + DataType = MxDataType.Integer, + VariantType = "VT_I4", + Int32Value = value, + }; + } + + public static MxValue ToMxValue(this long value) + { + return new MxValue + { + DataType = MxDataType.Integer, + VariantType = "VT_I8", + Int64Value = value, + }; + } + + public static MxValue ToMxValue(this float value) + { + return new MxValue + { + DataType = MxDataType.Float, + VariantType = "VT_R4", + FloatValue = value, + }; + } + + public static MxValue ToMxValue(this double value) + { + return new MxValue + { + DataType = MxDataType.Double, + VariantType = "VT_R8", + DoubleValue = value, + }; + } + + public static MxValue ToMxValue(this string value) + { + ArgumentNullException.ThrowIfNull(value); + + return new MxValue + { + DataType = MxDataType.String, + VariantType = "VT_BSTR", + StringValue = value, + }; + } + + public static MxValue ToMxValue(this DateTimeOffset value) + { + return new MxValue + { + DataType = MxDataType.Time, + VariantType = "VT_DATE", + TimestampValue = Timestamp.FromDateTimeOffset(value), + }; + } + + public static MxValue ToMxValue(this DateTime value) + { + return new DateTimeOffset( + value.Kind == DateTimeKind.Unspecified + ? DateTime.SpecifyKind(value, DateTimeKind.Utc) + : value.ToUniversalTime()) + .ToMxValue(); + } + + public static MxValue ToMxValue(this IReadOnlyList values) + { + ArgumentNullException.ThrowIfNull(values); + + var array = new BoolArray(); + array.Values.Add(values); + return CreateArrayValue(MxDataType.Boolean, "VT_ARRAY|VT_BOOL", values.Count, new MxArray + { + ElementDataType = MxDataType.Boolean, + VariantType = "VT_ARRAY|VT_BOOL", + BoolValues = array, + }); + } + + public static MxValue ToMxValue(this IReadOnlyList values) + { + ArgumentNullException.ThrowIfNull(values); + + var array = new Int32Array(); + array.Values.Add(values); + return CreateArrayValue(MxDataType.Integer, "VT_ARRAY|VT_I4", values.Count, new MxArray + { + ElementDataType = MxDataType.Integer, + VariantType = "VT_ARRAY|VT_I4", + Int32Values = array, + }); + } + + public static MxValue ToMxValue(this IReadOnlyList values) + { + ArgumentNullException.ThrowIfNull(values); + + var array = new Int64Array(); + array.Values.Add(values); + return CreateArrayValue(MxDataType.Integer, "VT_ARRAY|VT_I8", values.Count, new MxArray + { + ElementDataType = MxDataType.Integer, + VariantType = "VT_ARRAY|VT_I8", + Int64Values = array, + }); + } + + public static MxValue ToMxValue(this IReadOnlyList values) + { + ArgumentNullException.ThrowIfNull(values); + + var array = new FloatArray(); + array.Values.Add(values); + return CreateArrayValue(MxDataType.Float, "VT_ARRAY|VT_R4", values.Count, new MxArray + { + ElementDataType = MxDataType.Float, + VariantType = "VT_ARRAY|VT_R4", + FloatValues = array, + }); + } + + public static MxValue ToMxValue(this IReadOnlyList values) + { + ArgumentNullException.ThrowIfNull(values); + + var array = new DoubleArray(); + array.Values.Add(values); + return CreateArrayValue(MxDataType.Double, "VT_ARRAY|VT_R8", values.Count, new MxArray + { + ElementDataType = MxDataType.Double, + VariantType = "VT_ARRAY|VT_R8", + DoubleValues = array, + }); + } + + public static MxValue ToMxValue(this IReadOnlyList values) + { + ArgumentNullException.ThrowIfNull(values); + + var array = new StringArray(); + array.Values.Add(values); + return CreateArrayValue(MxDataType.String, "VT_ARRAY|VT_BSTR", values.Count, new MxArray + { + ElementDataType = MxDataType.String, + VariantType = "VT_ARRAY|VT_BSTR", + StringValues = array, + }); + } + + public static MxValue ToMxValue(this IReadOnlyList values) + { + ArgumentNullException.ThrowIfNull(values); + + var array = new TimestampArray(); + array.Values.Add(values.Select(Timestamp.FromDateTimeOffset)); + return CreateArrayValue(MxDataType.Time, "VT_ARRAY|VT_DATE", values.Count, new MxArray + { + ElementDataType = MxDataType.Time, + VariantType = "VT_ARRAY|VT_DATE", + TimestampValues = array, + }); + } + + public static string GetProjectionKind(this MxValue value) + { + ArgumentNullException.ThrowIfNull(value); + + return value.KindCase switch + { + MxValue.KindOneofCase.BoolValue => "boolValue", + MxValue.KindOneofCase.Int32Value => "int32Value", + MxValue.KindOneofCase.Int64Value => "int64Value", + MxValue.KindOneofCase.FloatValue => "floatValue", + MxValue.KindOneofCase.DoubleValue => "doubleValue", + MxValue.KindOneofCase.StringValue => "stringValue", + MxValue.KindOneofCase.TimestampValue => "timestampValue", + MxValue.KindOneofCase.ArrayValue => "arrayValue", + MxValue.KindOneofCase.RawValue => "rawValue", + _ => value.IsNull ? "nullValue" : "unspecified", + }; + } + + public static object? ToClrValue(this MxValue value) + { + ArgumentNullException.ThrowIfNull(value); + + return value.KindCase switch + { + MxValue.KindOneofCase.BoolValue => value.BoolValue, + MxValue.KindOneofCase.Int32Value => value.Int32Value, + MxValue.KindOneofCase.Int64Value => value.Int64Value, + MxValue.KindOneofCase.FloatValue => value.FloatValue, + MxValue.KindOneofCase.DoubleValue => value.DoubleValue, + MxValue.KindOneofCase.StringValue => value.StringValue, + MxValue.KindOneofCase.TimestampValue => value.TimestampValue.ToDateTimeOffset(), + MxValue.KindOneofCase.ArrayValue => value.ArrayValue.ToClrArrayValue(), + MxValue.KindOneofCase.RawValue => value.RawValue.ToByteArray(), + _ => value.IsNull ? null : value, + }; + } + + public static object? ToClrArrayValue(this MxArray array) + { + ArgumentNullException.ThrowIfNull(array); + + return array.ValuesCase switch + { + MxArray.ValuesOneofCase.BoolValues => array.BoolValues.Values.ToArray(), + MxArray.ValuesOneofCase.Int32Values => array.Int32Values.Values.ToArray(), + MxArray.ValuesOneofCase.Int64Values => array.Int64Values.Values.ToArray(), + MxArray.ValuesOneofCase.FloatValues => array.FloatValues.Values.ToArray(), + MxArray.ValuesOneofCase.DoubleValues => array.DoubleValues.Values.ToArray(), + MxArray.ValuesOneofCase.StringValues => array.StringValues.Values.ToArray(), + MxArray.ValuesOneofCase.TimestampValues => array.TimestampValues.Values + .Select(timestamp => timestamp.ToDateTimeOffset()) + .ToArray(), + MxArray.ValuesOneofCase.RawValues => array.RawValues.Values + .Select(value => value.ToByteArray()) + .ToArray(), + _ => null, + }; + } + + public static MxValue ToRawMxValue( + byte[] value, + string variantType, + string rawDiagnostic, + int rawDataType = 0) + { + ArgumentNullException.ThrowIfNull(value); + + return new MxValue + { + DataType = MxDataType.Unknown, + VariantType = variantType, + RawDiagnostic = rawDiagnostic, + RawDataType = rawDataType, + RawValue = ByteString.CopyFrom(value), + }; + } + + private static MxValue CreateArrayValue( + MxDataType dataType, + string variantType, + int length, + MxArray array) + { + array.Dimensions.Add((uint)length); + return new MxValue + { + DataType = dataType, + VariantType = variantType, + ArrayValue = array, + }; + } +} diff --git a/clients/dotnet/README.md b/clients/dotnet/README.md index d77e0c6..89150da 100644 --- a/clients/dotnet/README.md +++ b/clients/dotnet/README.md @@ -63,3 +63,49 @@ complete `MxCommandReply`. `MxGatewaySession.CloseAsync` is explicit and idempotent. Repeated calls return the first `CloseSessionReply` instead of sending another close request. + +## Values, Status, And Errors + +The client provides extension helpers for generated protobuf values. Use +`ToMxValue()` on .NET scalar values and typed arrays to create `MxValue` +instances for `Write` and `Write2`. Use `ToClrValue()` and +`GetProjectionKind()` when test or diagnostic code needs to inspect generated +`MxValue` replies while preserving `rawDiagnostic`, raw data type fields, and +raw byte payloads. + +`MxStatusProxy.IsSuccess()` and `ToDiagnosticSummary()` expose MXAccess status +arrays without collapsing them into a single gateway success flag. Command +reply helpers follow the same split: + +```csharp +reply.EnsureProtocolSuccess(); +reply.EnsureMxAccessSuccess(); +``` + +`EnsureProtocolSuccess()` raises gateway, session, worker, or command +exceptions for gateway-level failures. It leaves +`PROTOCOL_STATUS_CODE_MXACCESS_FAILURE` to `EnsureMxAccessSuccess()` so callers +can keep the full `MxCommandReply`, HRESULT, and status array when MXAccess +itself rejects a command. `MxAccessException.Reply` contains the raw generated +reply. + +## CLI Usage + +The test CLI supports deterministic JSON output for automation: + +```powershell +dotnet run --project clients/dotnet/MxGateway.Client.Cli -- version --json +dotnet run --project clients/dotnet/MxGateway.Client.Cli -- open-session --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json +dotnet run --project clients/dotnet/MxGateway.Client.Cli -- register --session-id --client-name mxgw-dotnet-cli --json +dotnet run --project clients/dotnet/MxGateway.Client.Cli -- add-item --session-id --server-handle 1 --item Area001.Pump001.Speed --json +dotnet run --project clients/dotnet/MxGateway.Client.Cli -- advise --session-id --server-handle 1 --item-handle 1 --json +dotnet run --project clients/dotnet/MxGateway.Client.Cli -- write --session-id --server-handle 1 --item-handle 1 --type int32 --value 123 --json +dotnet run --project clients/dotnet/MxGateway.Client.Cli -- write2 --session-id --server-handle 1 --item-handle 1 --type int32 --value 123 --timestamp 2026-01-01T00:00:00Z --json +dotnet run --project clients/dotnet/MxGateway.Client.Cli -- stream-events --session-id --max-events 1 --json +dotnet run --project clients/dotnet/MxGateway.Client.Cli -- smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item Area001.Pump001.Speed --json +``` + +`smoke` opens a session, registers a client, adds one item, advises it, +optionally writes a value when `--type` and `--value` are supplied, reads a +bounded event stream, and closes the session in a `finally` block. CLI error +output redacts API keys supplied through `--api-key`.