Add XML documentation across gateway, worker, and .NET client

This commit is contained in:
Joseph Doherty
2026-04-30 11:49:58 -04:00
parent 4731ab535c
commit eed1e88a37
269 changed files with 4555 additions and 13 deletions
@@ -2,11 +2,14 @@ using System.Globalization;
namespace MxGateway.Client.Cli; namespace MxGateway.Client.Cli;
/// <summary>Parses command-line arguments into flags and named values.</summary>
internal sealed class CliArguments internal sealed class CliArguments
{ {
private readonly Dictionary<string, string> _values = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, string> _values = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _flags = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet<string> _flags = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Initializes a new instance by parsing the given command-line arguments.</summary>
/// <param name="args">Unparsed command-line arguments; flags prefixed with '--' and values follow their flag.</param>
public CliArguments(IEnumerable<string> args) public CliArguments(IEnumerable<string> args)
{ {
string? pendingName = null; string? pendingName = null;
@@ -39,11 +42,15 @@ internal sealed class CliArguments
} }
} }
/// <summary>Returns whether the named flag was present in the arguments.</summary>
/// <param name="name">The flag name (without '--' prefix).</param>
public bool HasFlag(string name) public bool HasFlag(string name)
{ {
return _flags.Contains(name); return _flags.Contains(name);
} }
/// <summary>Returns the value for a named argument, or <c>null</c> if absent.</summary>
/// <param name="name">The argument name (without '--' prefix).</param>
public string? GetOptional(string name) public string? GetOptional(string name)
{ {
return _values.TryGetValue(name, out string? value) return _values.TryGetValue(name, out string? value)
@@ -51,6 +58,8 @@ internal sealed class CliArguments
: null; : null;
} }
/// <summary>Returns the value for a required named argument, or throws if absent.</summary>
/// <param name="name">The argument name (without '--' prefix).</param>
public string GetRequired(string name) public string GetRequired(string name)
{ {
string? value = GetOptional(name); string? value = GetOptional(name);
@@ -62,6 +71,9 @@ internal sealed class CliArguments
return value; return value;
} }
/// <summary>Parses and returns an int32 argument, or the default value if absent.</summary>
/// <param name="name">The argument name (without '--' prefix).</param>
/// <param name="defaultValue">The default value if the argument is absent; if <c>null</c>, the argument is required.</param>
public int GetInt32(string name, int? defaultValue = null) public int GetInt32(string name, int? defaultValue = null)
{ {
string? value = GetOptional(name); string? value = GetOptional(name);
@@ -78,6 +90,9 @@ internal sealed class CliArguments
return int.Parse(value, CultureInfo.InvariantCulture); return int.Parse(value, CultureInfo.InvariantCulture);
} }
/// <summary>Parses and returns a uint32 argument, or the default value if absent.</summary>
/// <param name="name">The argument name (without '--' prefix).</param>
/// <param name="defaultValue">The default value if the argument is absent.</param>
public uint GetUInt32(string name, uint defaultValue) public uint GetUInt32(string name, uint defaultValue)
{ {
string? value = GetOptional(name); string? value = GetOptional(name);
@@ -86,6 +101,9 @@ internal sealed class CliArguments
: uint.Parse(value, CultureInfo.InvariantCulture); : uint.Parse(value, CultureInfo.InvariantCulture);
} }
/// <summary>Parses and returns a uint64 argument, or the default value if absent.</summary>
/// <param name="name">The argument name (without '--' prefix).</param>
/// <param name="defaultValue">The default value if the argument is absent.</param>
public ulong GetUInt64(string name, ulong defaultValue) public ulong GetUInt64(string name, ulong defaultValue)
{ {
string? value = GetOptional(name); string? value = GetOptional(name);
@@ -94,6 +112,9 @@ internal sealed class CliArguments
: ulong.Parse(value, CultureInfo.InvariantCulture); : ulong.Parse(value, CultureInfo.InvariantCulture);
} }
/// <summary>Parses and returns a TimeSpan argument, or the default value if absent. Supports "ms", "s", and standard TimeSpan format.</summary>
/// <param name="name">The argument name (without '--' prefix).</param>
/// <param name="defaultValue">The default value if the argument is absent.</param>
public TimeSpan GetDuration(string name, TimeSpan defaultValue) public TimeSpan GetDuration(string name, TimeSpan defaultValue)
{ {
string? value = GetOptional(name); string? value = GetOptional(name);
@@ -5,34 +5,82 @@ namespace MxGateway.Client.Cli;
public interface IMxGatewayCliClient : IAsyncDisposable public interface IMxGatewayCliClient : IAsyncDisposable
{ {
/// <summary>
/// Opens a new gateway session.
/// </summary>
/// <param name="request">Session open request.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The session open reply.</returns>
Task<OpenSessionReply> OpenSessionAsync( Task<OpenSessionReply> OpenSessionAsync(
OpenSessionRequest request, OpenSessionRequest request,
CancellationToken cancellationToken); CancellationToken cancellationToken);
/// <summary>
/// Closes an open gateway session.
/// </summary>
/// <param name="request">Session close request.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The session close reply.</returns>
Task<CloseSessionReply> CloseSessionAsync( Task<CloseSessionReply> CloseSessionAsync(
CloseSessionRequest request, CloseSessionRequest request,
CancellationToken cancellationToken); CancellationToken cancellationToken);
/// <summary>
/// Invokes an MXAccess command on the session.
/// </summary>
/// <param name="request">The command request.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The command reply.</returns>
Task<MxCommandReply> InvokeAsync( Task<MxCommandReply> InvokeAsync(
MxCommandRequest request, MxCommandRequest request,
CancellationToken cancellationToken); CancellationToken cancellationToken);
/// <summary>
/// Streams events from the gateway session.
/// </summary>
/// <param name="request">The stream events request.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>An async enumerable of events.</returns>
IAsyncEnumerable<MxEvent> StreamEventsAsync( IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request, StreamEventsRequest request,
CancellationToken cancellationToken); CancellationToken cancellationToken);
/// <summary>
/// Tests connection to the Galaxy Repository.
/// </summary>
/// <param name="request">The connection test request.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The connection test reply.</returns>
Task<TestConnectionReply> GalaxyTestConnectionAsync( Task<TestConnectionReply> GalaxyTestConnectionAsync(
TestConnectionRequest request, TestConnectionRequest request,
CancellationToken cancellationToken); CancellationToken cancellationToken);
/// <summary>
/// Gets the last deployment time from the Galaxy Repository.
/// </summary>
/// <param name="request">The last deploy time request.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The last deploy time reply.</returns>
Task<GetLastDeployTimeReply> GalaxyGetLastDeployTimeAsync( Task<GetLastDeployTimeReply> GalaxyGetLastDeployTimeAsync(
GetLastDeployTimeRequest request, GetLastDeployTimeRequest request,
CancellationToken cancellationToken); CancellationToken cancellationToken);
/// <summary>
/// Discovers the Galaxy Repository hierarchy.
/// </summary>
/// <param name="request">The discover hierarchy request.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The discover hierarchy reply.</returns>
Task<DiscoverHierarchyReply> GalaxyDiscoverHierarchyAsync( Task<DiscoverHierarchyReply> GalaxyDiscoverHierarchyAsync(
DiscoverHierarchyRequest request, DiscoverHierarchyRequest request,
CancellationToken cancellationToken); CancellationToken cancellationToken);
/// <summary>
/// Watches for deployment events from the Galaxy Repository.
/// </summary>
/// <param name="request">The watch deploy events request.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>An async enumerable of deployment events.</returns>
IAsyncEnumerable<DeployEvent> GalaxyWatchDeployEventsAsync( IAsyncEnumerable<DeployEvent> GalaxyWatchDeployEventsAsync(
WatchDeployEventsRequest request, WatchDeployEventsRequest request,
CancellationToken cancellationToken); CancellationToken cancellationToken);
@@ -9,6 +9,10 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
private readonly MxGatewayClient _client; private readonly MxGatewayClient _client;
private readonly Lazy<GalaxyRepositoryClient> _galaxyClient; private readonly Lazy<GalaxyRepositoryClient> _galaxyClient;
/// <summary>
/// Initializes a new instance of the <see cref="MxGatewayCliClientAdapter"/> that bridges the CLI to the gateway client.
/// </summary>
/// <param name="client">The gateway client to adapt.</param>
public MxGatewayCliClientAdapter(MxGatewayClient client) public MxGatewayCliClientAdapter(MxGatewayClient client)
{ {
_client = client; _client = client;
@@ -16,6 +20,7 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
() => GalaxyRepositoryClient.Create(_client.Options)); () => GalaxyRepositoryClient.Create(_client.Options));
} }
/// <inheritdoc />
public Task<OpenSessionReply> OpenSessionAsync( public Task<OpenSessionReply> OpenSessionAsync(
OpenSessionRequest request, OpenSessionRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -23,6 +28,7 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
return _client.OpenSessionRawAsync(request, cancellationToken); return _client.OpenSessionRawAsync(request, cancellationToken);
} }
/// <inheritdoc />
public Task<CloseSessionReply> CloseSessionAsync( public Task<CloseSessionReply> CloseSessionAsync(
CloseSessionRequest request, CloseSessionRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -30,6 +36,7 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
return _client.CloseSessionRawAsync(request, cancellationToken); return _client.CloseSessionRawAsync(request, cancellationToken);
} }
/// <inheritdoc />
public Task<MxCommandReply> InvokeAsync( public Task<MxCommandReply> InvokeAsync(
MxCommandRequest request, MxCommandRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -37,6 +44,7 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
return _client.InvokeAsync(request, cancellationToken); return _client.InvokeAsync(request, cancellationToken);
} }
/// <inheritdoc />
public IAsyncEnumerable<MxEvent> StreamEventsAsync( public IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request, StreamEventsRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -44,6 +52,7 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
return _client.StreamEventsAsync(request, cancellationToken); return _client.StreamEventsAsync(request, cancellationToken);
} }
/// <inheritdoc />
public Task<TestConnectionReply> GalaxyTestConnectionAsync( public Task<TestConnectionReply> GalaxyTestConnectionAsync(
TestConnectionRequest request, TestConnectionRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -51,6 +60,7 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
return _galaxyClient.Value.TestConnectionRawAsync(request, cancellationToken); return _galaxyClient.Value.TestConnectionRawAsync(request, cancellationToken);
} }
/// <inheritdoc />
public Task<GetLastDeployTimeReply> GalaxyGetLastDeployTimeAsync( public Task<GetLastDeployTimeReply> GalaxyGetLastDeployTimeAsync(
GetLastDeployTimeRequest request, GetLastDeployTimeRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -58,6 +68,7 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
return _galaxyClient.Value.GetLastDeployTimeRawAsync(request, cancellationToken); return _galaxyClient.Value.GetLastDeployTimeRawAsync(request, cancellationToken);
} }
/// <inheritdoc />
public Task<DiscoverHierarchyReply> GalaxyDiscoverHierarchyAsync( public Task<DiscoverHierarchyReply> GalaxyDiscoverHierarchyAsync(
DiscoverHierarchyRequest request, DiscoverHierarchyRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -65,6 +76,7 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
return _galaxyClient.Value.DiscoverHierarchyRawAsync(request, cancellationToken); return _galaxyClient.Value.DiscoverHierarchyRawAsync(request, cancellationToken);
} }
/// <inheritdoc />
public IAsyncEnumerable<DeployEvent> GalaxyWatchDeployEventsAsync( public IAsyncEnumerable<DeployEvent> GalaxyWatchDeployEventsAsync(
WatchDeployEventsRequest request, WatchDeployEventsRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -72,6 +84,7 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
return _galaxyClient.Value.WatchDeployEventsRawAsync(request, cancellationToken); return _galaxyClient.Value.WatchDeployEventsRawAsync(request, cancellationToken);
} }
/// <inheritdoc />
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
if (_galaxyClient.IsValueCreated) if (_galaxyClient.IsValueCreated)
@@ -1,7 +1,11 @@
namespace MxGateway.Client.Cli; namespace MxGateway.Client.Cli;
/// <summary>Utility to redact API keys from error messages for safe output.</summary>
internal static class MxGatewayCliSecretRedactor internal static class MxGatewayCliSecretRedactor
{ {
/// <summary>Replaces occurrences of the API key in the value with a redacted placeholder.</summary>
/// <param name="value">The message text to redact.</param>
/// <param name="apiKey">The API key to remove; no redaction if null or empty.</param>
public static string Redact(string value, string? apiKey) public static string Redact(string value, string? apiKey)
{ {
if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(apiKey)) if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(apiKey))
@@ -7,6 +7,7 @@ using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client.Cli; namespace MxGateway.Client.Cli;
/// <summary>Command-line interface for the MXAccess Gateway client, supporting session and command operations.</summary>
public static class MxGatewayClientCli public static class MxGatewayClientCli
{ {
private const uint MaxAggregateEvents = 10_000; private const uint MaxAggregateEvents = 10_000;
@@ -15,6 +16,10 @@ public static class MxGatewayClientCli
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
/// <summary>Runs the CLI synchronously with the given arguments, writing output and errors.</summary>
/// <param name="args">Command-line arguments (command name followed by options).</param>
/// <param name="standardOutput">TextWriter for command output.</param>
/// <param name="standardError">TextWriter for error messages.</param>
public static int Run( public static int Run(
string[] args, string[] args,
TextWriter standardOutput, TextWriter standardOutput,
@@ -25,6 +30,11 @@ public static class MxGatewayClientCli
.GetResult(); .GetResult();
} }
/// <summary>Runs the CLI asynchronously with the given arguments, writing output and errors.</summary>
/// <param name="args">Command-line arguments (command name followed by options).</param>
/// <param name="standardOutput">TextWriter for command output.</param>
/// <param name="standardError">TextWriter for error messages.</param>
/// <param name="clientFactory">Optional factory to create the gateway client; defaults to MxGatewayClient.Create.</param>
public static Task<int> RunAsync( public static Task<int> RunAsync(
string[] args, string[] args,
TextWriter standardOutput, TextWriter standardOutput,
@@ -3,30 +3,71 @@ using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client.Tests; namespace MxGateway.Client.Tests;
/// <summary>
/// Fake Galaxy Repository client transport for testing.
/// </summary>
internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions options) : IGalaxyRepositoryClientTransport internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions options) : IGalaxyRepositoryClientTransport
{ {
/// <summary>
/// Gets the gateway client options.
/// </summary>
public MxGatewayClientOptions Options { get; } = options; public MxGatewayClientOptions Options { get; } = options;
/// <summary>
/// Gets the raw gRPC client; always null for the fake.
/// </summary>
public GalaxyRepository.GalaxyRepositoryClient? RawClient => null; public GalaxyRepository.GalaxyRepositoryClient? RawClient => null;
/// <summary>
/// Gets the list of TestConnection RPC calls made by the client.
/// </summary>
public List<(TestConnectionRequest Request, CallOptions CallOptions)> TestConnectionCalls { get; } = []; public List<(TestConnectionRequest Request, CallOptions CallOptions)> TestConnectionCalls { get; } = [];
/// <summary>
/// Gets the list of GetLastDeployTime RPC calls made by the client.
/// </summary>
public List<(GetLastDeployTimeRequest Request, CallOptions CallOptions)> GetLastDeployTimeCalls { get; } = []; public List<(GetLastDeployTimeRequest Request, CallOptions CallOptions)> GetLastDeployTimeCalls { get; } = [];
/// <summary>
/// Gets the list of DiscoverHierarchy RPC calls made by the client.
/// </summary>
public List<(DiscoverHierarchyRequest Request, CallOptions CallOptions)> DiscoverHierarchyCalls { get; } = []; public List<(DiscoverHierarchyRequest Request, CallOptions CallOptions)> DiscoverHierarchyCalls { get; } = [];
/// <summary>
/// Gets or sets the reply to return from TestConnection; defaults to successful response.
/// </summary>
public TestConnectionReply TestConnectionReply { get; set; } = new() { Ok = true }; public TestConnectionReply TestConnectionReply { get; set; } = new() { Ok = true };
/// <summary>
/// Gets or sets the reply to return from GetLastDeployTime; defaults to no deploy time present.
/// </summary>
public GetLastDeployTimeReply GetLastDeployTimeReply { get; set; } = new() { Present = false }; public GetLastDeployTimeReply GetLastDeployTimeReply { get; set; } = new() { Present = false };
/// <summary>
/// Gets or sets the reply to return from DiscoverHierarchy; defaults to empty response.
/// </summary>
public DiscoverHierarchyReply DiscoverHierarchyReply { get; set; } = new(); public DiscoverHierarchyReply DiscoverHierarchyReply { get; set; } = new();
/// <summary>
/// Gets the queue of exceptions to throw from TestConnection; dequeued in FIFO order.
/// </summary>
public Queue<Exception> TestConnectionExceptions { get; } = new(); public Queue<Exception> TestConnectionExceptions { get; } = new();
/// <summary>
/// Gets the queue of exceptions to throw from GetLastDeployTime; dequeued in FIFO order.
/// </summary>
public Queue<Exception> GetLastDeployTimeExceptions { get; } = new(); public Queue<Exception> GetLastDeployTimeExceptions { get; } = new();
/// <summary>
/// Gets the queue of exceptions to throw from DiscoverHierarchy; dequeued in FIFO order.
/// </summary>
public Queue<Exception> DiscoverHierarchyExceptions { get; } = new(); public Queue<Exception> DiscoverHierarchyExceptions { get; } = new();
/// <summary>
/// Records the request and either throws a queued exception or returns the configured reply.
/// </summary>
/// <param name="request">The TestConnectionRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<TestConnectionReply> TestConnectionAsync( public Task<TestConnectionReply> TestConnectionAsync(
TestConnectionRequest request, TestConnectionRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -40,6 +81,11 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
return Task.FromResult(TestConnectionReply); return Task.FromResult(TestConnectionReply);
} }
/// <summary>
/// Records the request and either throws a queued exception or returns the configured reply.
/// </summary>
/// <param name="request">The GetLastDeployTimeRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<GetLastDeployTimeReply> GetLastDeployTimeAsync( public Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
GetLastDeployTimeRequest request, GetLastDeployTimeRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -53,6 +99,11 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
return Task.FromResult(GetLastDeployTimeReply); return Task.FromResult(GetLastDeployTimeReply);
} }
/// <summary>
/// Records the request and either throws a queued exception or returns the configured reply.
/// </summary>
/// <param name="request">The DiscoverHierarchyRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<DiscoverHierarchyReply> DiscoverHierarchyAsync( public Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
DiscoverHierarchyRequest request, DiscoverHierarchyRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -66,10 +117,19 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
return Task.FromResult(DiscoverHierarchyReply); return Task.FromResult(DiscoverHierarchyReply);
} }
/// <summary>
/// Gets the list of WatchDeployEvents RPC calls made by the client.
/// </summary>
public List<(WatchDeployEventsRequest Request, CallOptions CallOptions)> WatchDeployEventsCalls { get; } = []; public List<(WatchDeployEventsRequest Request, CallOptions CallOptions)> WatchDeployEventsCalls { get; } = [];
/// <summary>
/// Gets or sets the list of events to stream from WatchDeployEvents.
/// </summary>
public List<DeployEvent> WatchDeployEvents { get; } = []; public List<DeployEvent> WatchDeployEvents { get; } = [];
/// <summary>
/// Gets or sets the exception to throw from WatchDeployEvents, if any.
/// </summary>
public Exception? WatchDeployEventsException { get; set; } public Exception? WatchDeployEventsException { get; set; }
/// <summary> /// <summary>
@@ -78,6 +138,11 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
/// </summary> /// </summary>
public Func<CancellationToken, Task>? WatchDeployEventsBeforeYield { get; set; } public Func<CancellationToken, Task>? WatchDeployEventsBeforeYield { get; set; }
/// <summary>
/// Records the request and streams events, checking for queued exceptions and calling WatchDeployEventsBeforeYield before each event.
/// </summary>
/// <param name="request">The WatchDeployEventsRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync( public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
WatchDeployEventsRequest request, WatchDeployEventsRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -3,23 +3,47 @@ using MxGateway.Contracts.Proto;
namespace MxGateway.Client.Tests; namespace MxGateway.Client.Tests;
/// <summary>
/// Fake implementation of IMxGatewayClientTransport for testing.
/// </summary>
internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMxGatewayClientTransport internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMxGatewayClientTransport
{ {
private readonly Queue<MxCommandReply> _invokeReplies = new(); private readonly Queue<MxCommandReply> _invokeReplies = new();
private readonly List<MxEvent> _events = []; private readonly List<MxEvent> _events = [];
/// <summary>
/// Gets the gateway client options.
/// </summary>
public MxGatewayClientOptions Options { get; } = options; public MxGatewayClientOptions Options { get; } = options;
/// <summary>
/// Gets null, since this is a test fake without a real gRPC client.
/// </summary>
public MxAccessGateway.MxAccessGatewayClient? RawClient => null; public MxAccessGateway.MxAccessGatewayClient? RawClient => null;
/// <summary>
/// Gets the list of captured OpenSessionAsync calls.
/// </summary>
public List<(OpenSessionRequest Request, CallOptions CallOptions)> OpenSessionCalls { get; } = []; public List<(OpenSessionRequest Request, CallOptions CallOptions)> OpenSessionCalls { get; } = [];
/// <summary>
/// Gets the list of captured CloseSessionAsync calls.
/// </summary>
public List<(CloseSessionRequest Request, CallOptions CallOptions)> CloseSessionCalls { get; } = []; public List<(CloseSessionRequest Request, CallOptions CallOptions)> CloseSessionCalls { get; } = [];
/// <summary>
/// Gets the list of captured InvokeAsync calls.
/// </summary>
public List<(MxCommandRequest Request, CallOptions CallOptions)> InvokeCalls { get; } = []; public List<(MxCommandRequest Request, CallOptions CallOptions)> InvokeCalls { get; } = [];
/// <summary>
/// Gets the list of captured StreamEventsAsync calls.
/// </summary>
public List<(StreamEventsRequest Request, CallOptions CallOptions)> StreamEventsCalls { get; } = []; public List<(StreamEventsRequest Request, CallOptions CallOptions)> StreamEventsCalls { get; } = [];
/// <summary>
/// Gets or sets the reply to return from OpenSessionAsync.
/// </summary>
public OpenSessionReply OpenSessionReply { get; set; } = new() public OpenSessionReply OpenSessionReply { get; set; } = new()
{ {
SessionId = "session-fixture", SessionId = "session-fixture",
@@ -29,6 +53,9 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
}; };
/// <summary>
/// Gets or sets the reply to return from CloseSessionAsync.
/// </summary>
public CloseSessionReply CloseSessionReply { get; set; } = new() public CloseSessionReply CloseSessionReply { get; set; } = new()
{ {
SessionId = "session-fixture", SessionId = "session-fixture",
@@ -36,12 +63,26 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
}; };
/// <summary>
/// Gets the queue of exceptions to throw from OpenSessionAsync.
/// </summary>
public Queue<Exception> OpenSessionExceptions { get; } = new(); public Queue<Exception> OpenSessionExceptions { get; } = new();
/// <summary>
/// Gets the queue of exceptions to throw from CloseSessionAsync.
/// </summary>
public Queue<Exception> CloseSessionExceptions { get; } = new(); public Queue<Exception> CloseSessionExceptions { get; } = new();
/// <summary>
/// Gets the queue of exceptions to throw from InvokeAsync.
/// </summary>
public Queue<Exception> InvokeExceptions { get; } = new(); public Queue<Exception> InvokeExceptions { get; } = new();
/// <summary>
/// Verifies that the OpenSessionAsync call is recorded and returns the configured reply.
/// </summary>
/// <param name="request">The OpenSessionRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<OpenSessionReply> OpenSessionAsync( public Task<OpenSessionReply> OpenSessionAsync(
OpenSessionRequest request, OpenSessionRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -55,6 +96,11 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
return Task.FromResult(OpenSessionReply); return Task.FromResult(OpenSessionReply);
} }
/// <summary>
/// Verifies that the CloseSessionAsync call is recorded and returns the configured reply.
/// </summary>
/// <param name="request">The CloseSessionRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<CloseSessionReply> CloseSessionAsync( public Task<CloseSessionReply> CloseSessionAsync(
CloseSessionRequest request, CloseSessionRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -68,6 +114,11 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
return Task.FromResult(CloseSessionReply); return Task.FromResult(CloseSessionReply);
} }
/// <summary>
/// Verifies that the InvokeAsync call is recorded and returns the next enqueued reply.
/// </summary>
/// <param name="request">The MxCommandRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<MxCommandReply> InvokeAsync( public Task<MxCommandReply> InvokeAsync(
MxCommandRequest request, MxCommandRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -81,6 +132,11 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
return Task.FromResult(_invokeReplies.Dequeue()); return Task.FromResult(_invokeReplies.Dequeue());
} }
/// <summary>
/// Verifies that the StreamEventsAsync call is recorded and yields all enqueued events.
/// </summary>
/// <param name="request">The StreamEventsRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public async IAsyncEnumerable<MxEvent> StreamEventsAsync( public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request, StreamEventsRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -95,11 +151,19 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
} }
} }
/// <summary>
/// Enqueues a reply to be returned from the next InvokeAsync call.
/// </summary>
/// <param name="reply">The reply to enqueue.</param>
public void AddInvokeReply(MxCommandReply reply) public void AddInvokeReply(MxCommandReply reply)
{ {
_invokeReplies.Enqueue(reply); _invokeReplies.Enqueue(reply);
} }
/// <summary>
/// Enqueues an event to be yielded from StreamEventsAsync.
/// </summary>
/// <param name="gatewayEvent">The event to enqueue.</param>
public void AddEvent(MxEvent gatewayEvent) public void AddEvent(MxEvent gatewayEvent)
{ {
_events.Add(gatewayEvent); _events.Add(gatewayEvent);
@@ -6,6 +6,9 @@ namespace MxGateway.Client.Tests;
public sealed class GalaxyRepositoryClientTests public sealed class GalaxyRepositoryClientTests
{ {
/// <summary>
/// Verifies that TestConnectionAsync attaches the API key in request metadata and returns the Ok flag.
/// </summary>
[Fact] [Fact]
public async Task TestConnectionAsync_AttachesApiKeyMetadataAndReturnsOkFlag() public async Task TestConnectionAsync_AttachesApiKeyMetadataAndReturnsOkFlag()
{ {
@@ -21,6 +24,9 @@ public sealed class GalaxyRepositoryClientTests
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization")); Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
} }
/// <summary>
/// Verifies that TestConnectionAsync returns false when the server reports NotOk.
/// </summary>
[Fact] [Fact]
public async Task TestConnectionAsync_ReturnsFalseWhenServerReportsNotOk() public async Task TestConnectionAsync_ReturnsFalseWhenServerReportsNotOk()
{ {
@@ -33,6 +39,9 @@ public sealed class GalaxyRepositoryClientTests
Assert.False(ok); Assert.False(ok);
} }
/// <summary>
/// Verifies that GetLastDeployTimeAsync returns null when the server reports not present.
/// </summary>
[Fact] [Fact]
public async Task GetLastDeployTimeAsync_ReturnsNullWhenNotPresent() public async Task GetLastDeployTimeAsync_ReturnsNullWhenNotPresent()
{ {
@@ -46,6 +55,9 @@ public sealed class GalaxyRepositoryClientTests
Assert.Single(transport.GetLastDeployTimeCalls); Assert.Single(transport.GetLastDeployTimeCalls);
} }
/// <summary>
/// Verifies that GetLastDeployTimeAsync returns the timestamp when the server reports it present.
/// </summary>
[Fact] [Fact]
public async Task GetLastDeployTimeAsync_ReturnsTimestampWhenPresent() public async Task GetLastDeployTimeAsync_ReturnsTimestampWhenPresent()
{ {
@@ -64,6 +76,9 @@ public sealed class GalaxyRepositoryClientTests
Assert.Equal(expected, deployTime!.Value); Assert.Equal(expected, deployTime!.Value);
} }
/// <summary>
/// Verifies that DiscoverHierarchyAsync returns the objects from the server reply.
/// </summary>
[Fact] [Fact]
public async Task DiscoverHierarchyAsync_ReturnsObjectsFromReply() public async Task DiscoverHierarchyAsync_ReturnsObjectsFromReply()
{ {
@@ -104,6 +119,9 @@ public sealed class GalaxyRepositoryClientTests
Assert.Equal("DelmiaReceiver_001.DownloadPath", attribute.FullTagReference); Assert.Equal("DelmiaReceiver_001.DownloadPath", attribute.FullTagReference);
} }
/// <summary>
/// Verifies that DiscoverHierarchyAsync propagates cancellation tokens to the transport.
/// </summary>
[Fact] [Fact]
public async Task DiscoverHierarchyAsync_PropagatesCancellationToTransport() public async Task DiscoverHierarchyAsync_PropagatesCancellationToTransport()
{ {
@@ -121,6 +139,9 @@ public sealed class GalaxyRepositoryClientTests
Assert.False(call.CallOptions.CancellationToken.IsCancellationRequested); Assert.False(call.CallOptions.CancellationToken.IsCancellationRequested);
} }
/// <summary>
/// Verifies that TestConnectionAsync retries on transient gRPC failures.
/// </summary>
[Fact] [Fact]
public async Task TestConnectionAsync_RetriesOnTransientGrpcFailure() public async Task TestConnectionAsync_RetriesOnTransientGrpcFailure()
{ {
@@ -135,6 +156,9 @@ public sealed class GalaxyRepositoryClientTests
Assert.Equal(2, transport.TestConnectionCalls.Count); Assert.Equal(2, transport.TestConnectionCalls.Count);
} }
/// <summary>
/// Verifies that DiscoverHierarchyAsync retries on transient gRPC failures.
/// </summary>
[Fact] [Fact]
public async Task DiscoverHierarchyAsync_RetriesOnTransientGrpcFailure() public async Task DiscoverHierarchyAsync_RetriesOnTransientGrpcFailure()
{ {
@@ -148,6 +172,9 @@ public sealed class GalaxyRepositoryClientTests
Assert.Equal(2, transport.DiscoverHierarchyCalls.Count); Assert.Equal(2, transport.DiscoverHierarchyCalls.Count);
} }
/// <summary>
/// Verifies that WatchDeployEventsAsync delivers the bootstrap event.
/// </summary>
[Fact] [Fact]
public async Task WatchDeployEventsAsync_DeliversBootstrapEvent() public async Task WatchDeployEventsAsync_DeliversBootstrapEvent()
{ {
@@ -181,6 +208,9 @@ public sealed class GalaxyRepositoryClientTests
Assert.Null(call.Request.LastSeenDeployTime); Assert.Null(call.Request.LastSeenDeployTime);
} }
/// <summary>
/// Verifies that WatchDeployEventsAsync delivers multiple events in order.
/// </summary>
[Fact] [Fact]
public async Task WatchDeployEventsAsync_DeliversMultipleEventsInOrder() public async Task WatchDeployEventsAsync_DeliversMultipleEventsInOrder()
{ {
@@ -216,6 +246,9 @@ public sealed class GalaxyRepositoryClientTests
Assert.Equal(t0, call.Request.LastSeenDeployTime!.ToDateTime()); Assert.Equal(t0, call.Request.LastSeenDeployTime!.ToDateTime());
} }
/// <summary>
/// Verifies that WatchDeployEventsAsync stops iteration cleanly when cancelled.
/// </summary>
[Fact] [Fact]
public async Task WatchDeployEventsAsync_CancellationStopsIterationCleanly() public async Task WatchDeployEventsAsync_CancellationStopsIterationCleanly()
{ {
@@ -257,6 +290,9 @@ public sealed class GalaxyRepositoryClientTests
Assert.Equal(1ul, received[0].Sequence); Assert.Equal(1ul, received[0].Sequence);
} }
/// <summary>
/// Verifies that WatchDeployEventsAsync throws ObjectDisposedException after the client is disposed.
/// </summary>
[Fact] [Fact]
public async Task WatchDeployEventsAsync_ThrowsAfterDisposal() public async Task WatchDeployEventsAsync_ThrowsAfterDisposal()
{ {
@@ -269,6 +305,9 @@ public sealed class GalaxyRepositoryClientTests
client.WatchDeployEventsAsync()); client.WatchDeployEventsAsync());
} }
/// <summary>
/// Verifies that TestConnectionAsync throws ObjectDisposedException after the client is disposed.
/// </summary>
[Fact] [Fact]
public async Task TestConnectionAsync_ThrowsAfterDisposal() public async Task TestConnectionAsync_ThrowsAfterDisposal()
{ {
@@ -6,6 +6,7 @@ namespace MxGateway.Client.Tests;
public sealed class MxCommandReplyExtensionsTests public sealed class MxCommandReplyExtensionsTests
{ {
/// <summary>Verifies that successful replies pass both protocol and MxAccess success checks.</summary>
[Fact] [Fact]
public void EnsureSuccess_WithRegisterFixture_ReturnsReply() public void EnsureSuccess_WithRegisterFixture_ReturnsReply()
{ {
@@ -15,6 +16,7 @@ public sealed class MxCommandReplyExtensionsTests
Assert.Same(reply, reply.EnsureMxAccessSuccess()); Assert.Same(reply, reply.EnsureMxAccessSuccess());
} }
/// <summary>Verifies that MxAccess failures throw with preserved HResult and status details.</summary>
[Fact] [Fact]
public void EnsureMxAccessSuccess_WithFailureFixture_PreservesHResultAndStatuses() public void EnsureMxAccessSuccess_WithFailureFixture_PreservesHResultAndStatuses()
{ {
@@ -30,6 +32,7 @@ public sealed class MxCommandReplyExtensionsTests
Assert.Contains("0x80040200", exception.Message); Assert.Contains("0x80040200", exception.Message);
} }
/// <summary>Verifies that session-not-found protocol failures throw the correct gateway exception.</summary>
[Fact] [Fact]
public void EnsureProtocolSuccess_WithSessionFailure_ThrowsSessionException() public void EnsureProtocolSuccess_WithSessionFailure_ThrowsSessionException()
{ {
@@ -5,8 +5,10 @@ using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client.Tests; namespace MxGateway.Client.Tests;
/// <summary>Tests for the CLI command interface.</summary>
public sealed class MxGatewayClientCliTests public sealed class MxGatewayClientCliTests
{ {
/// <summary>Verifies that the version command prints compiled protocol versions.</summary>
[Fact] [Fact]
public void Run_Version_PrintsCompiledProtocolVersions() public void Run_Version_PrintsCompiledProtocolVersions()
{ {
@@ -21,6 +23,7 @@ public sealed class MxGatewayClientCliTests
Assert.Equal(string.Empty, error.ToString()); Assert.Equal(string.Empty, error.ToString());
} }
/// <summary>Verifies that the version command with --json flag prints JSON protocol versions.</summary>
[Fact] [Fact]
public async Task RunAsync_VersionJson_PrintsJsonProtocolVersions() public async Task RunAsync_VersionJson_PrintsJsonProtocolVersions()
{ {
@@ -34,6 +37,7 @@ public sealed class MxGatewayClientCliTests
Assert.Equal(string.Empty, error.ToString()); Assert.Equal(string.Empty, error.ToString());
} }
/// <summary>Verifies that the write command builds a write request and prints JSON reply.</summary>
[Fact] [Fact]
public async Task RunAsync_Write_BuildsWriteCommandAndPrintsJsonReply() public async Task RunAsync_Write_BuildsWriteCommandAndPrintsJsonReply()
{ {
@@ -78,6 +82,7 @@ public sealed class MxGatewayClientCliTests
Assert.Equal(string.Empty, error.ToString()); Assert.Equal(string.Empty, error.ToString());
} }
/// <summary>Verifies that error output redacts sensitive API key values.</summary>
[Fact] [Fact]
public async Task RunAsync_ErrorOutput_RedactsApiKey() public async Task RunAsync_ErrorOutput_RedactsApiKey()
{ {
@@ -101,6 +106,7 @@ public sealed class MxGatewayClientCliTests
Assert.Contains("[redacted]", error.ToString()); Assert.Contains("[redacted]", error.ToString());
} }
/// <summary>Verifies that stream-events with max-events limit stops output in non-JSON format.</summary>
[Fact] [Fact]
public async Task RunAsync_StreamEvents_WithMaxEventsStopsNonJsonOutput() public async Task RunAsync_StreamEvents_WithMaxEventsStopsNonJsonOutput()
{ {
@@ -142,6 +148,7 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary>Verifies that smoke command closes opened session when a command fails.</summary>
[Fact] [Fact]
public async Task RunAsync_Smoke_WhenCommandFails_ClosesOpenedSession() public async Task RunAsync_Smoke_WhenCommandFails_ClosesOpenedSession()
{ {
@@ -172,6 +179,7 @@ public sealed class MxGatewayClientCliTests
Assert.Equal("session-fixture", closeRequest.SessionId); Assert.Equal("session-fixture", closeRequest.SessionId);
} }
/// <summary>Verifies that galaxy-test-connection command prints JSON reply.</summary>
[Fact] [Fact]
public async Task RunAsync_GalaxyTestConnection_PrintsJsonReply() public async Task RunAsync_GalaxyTestConnection_PrintsJsonReply()
{ {
@@ -201,6 +209,7 @@ public sealed class MxGatewayClientCliTests
Assert.Equal(string.Empty, error.ToString()); Assert.Equal(string.Empty, error.ToString());
} }
/// <summary>Verifies that galaxy-discover command prints hierarchy summary.</summary>
[Fact] [Fact]
public async Task RunAsync_GalaxyDiscover_PrintsHierarchySummary() public async Task RunAsync_GalaxyDiscover_PrintsHierarchySummary()
{ {
@@ -250,6 +259,7 @@ public sealed class MxGatewayClientCliTests
Assert.Equal(string.Empty, error.ToString()); Assert.Equal(string.Empty, error.ToString());
} }
/// <summary>Verifies that galaxy-watch command prints text output for deploy events.</summary>
[Fact] [Fact]
public async Task RunAsync_GalaxyWatch_PrintsTextOutputForEvents() public async Task RunAsync_GalaxyWatch_PrintsTextOutputForEvents()
{ {
@@ -303,6 +313,7 @@ public sealed class MxGatewayClientCliTests
Assert.Equal(string.Empty, error.ToString()); Assert.Equal(string.Empty, error.ToString());
} }
/// <summary>Verifies that galaxy-watch with --json emits one JSON object per event.</summary>
[Fact] [Fact]
public async Task RunAsync_GalaxyWatch_JsonEmitsOneObjectPerEvent() public async Task RunAsync_GalaxyWatch_JsonEmitsOneObjectPerEvent()
{ {
@@ -337,23 +348,31 @@ public sealed class MxGatewayClientCliTests
Assert.Contains("\"objectCount\": 99", text); Assert.Contains("\"objectCount\": 99", text);
} }
/// <summary>Fake CLI client for testing.</summary>
private sealed class FakeCliClient : IMxGatewayCliClient private sealed class FakeCliClient : IMxGatewayCliClient
{ {
/// <summary>Queue of invoke replies to return.</summary>
public Queue<MxCommandReply> InvokeReplies { get; } = new(); public Queue<MxCommandReply> InvokeReplies { get; } = new();
/// <summary>List of received invoke requests.</summary>
public List<MxCommandRequest> InvokeRequests { get; } = []; public List<MxCommandRequest> InvokeRequests { get; } = [];
/// <summary>List of received close session requests.</summary>
public List<CloseSessionRequest> CloseSessionRequests { get; } = []; public List<CloseSessionRequest> CloseSessionRequests { get; } = [];
/// <summary>List of events to yield when streaming.</summary>
public List<MxEvent> Events { get; } = []; public List<MxEvent> Events { get; } = [];
/// <summary>Exception to throw on invoke, if any.</summary>
public Exception? InvokeFailure { get; init; } public Exception? InvokeFailure { get; init; }
/// <inheritdoc />
public ValueTask DisposeAsync() public ValueTask DisposeAsync()
{ {
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
} }
/// <inheritdoc />
public Task<OpenSessionReply> OpenSessionAsync( public Task<OpenSessionReply> OpenSessionAsync(
OpenSessionRequest request, OpenSessionRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -367,6 +386,7 @@ public sealed class MxGatewayClientCliTests
}); });
} }
/// <inheritdoc />
public Task<CloseSessionReply> CloseSessionAsync( public Task<CloseSessionReply> CloseSessionAsync(
CloseSessionRequest request, CloseSessionRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -380,6 +400,7 @@ public sealed class MxGatewayClientCliTests
}); });
} }
/// <inheritdoc />
public Task<MxCommandReply> InvokeAsync( public Task<MxCommandReply> InvokeAsync(
MxCommandRequest request, MxCommandRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -393,6 +414,7 @@ public sealed class MxGatewayClientCliTests
return Task.FromResult(InvokeReplies.Dequeue()); return Task.FromResult(InvokeReplies.Dequeue());
} }
/// <inheritdoc />
public async IAsyncEnumerable<MxEvent> StreamEventsAsync( public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request, StreamEventsRequest request,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
@@ -405,18 +427,25 @@ public sealed class MxGatewayClientCliTests
} }
} }
/// <summary>Galaxy test connection reply to return.</summary>
public TestConnectionReply GalaxyTestConnectionReply { get; set; } = new() { Ok = true }; public TestConnectionReply GalaxyTestConnectionReply { get; set; } = new() { Ok = true };
/// <summary>Galaxy get last deploy time reply to return.</summary>
public GetLastDeployTimeReply GalaxyGetLastDeployTimeReply { get; set; } = new() { Present = false }; public GetLastDeployTimeReply GalaxyGetLastDeployTimeReply { get; set; } = new() { Present = false };
/// <summary>Galaxy discover hierarchy reply to return.</summary>
public DiscoverHierarchyReply GalaxyDiscoverHierarchyReply { get; set; } = new(); public DiscoverHierarchyReply GalaxyDiscoverHierarchyReply { get; set; } = new();
/// <summary>List of received galaxy test connection requests.</summary>
public List<TestConnectionRequest> GalaxyTestConnectionRequests { get; } = []; public List<TestConnectionRequest> GalaxyTestConnectionRequests { get; } = [];
/// <summary>List of received galaxy get last deploy time requests.</summary>
public List<GetLastDeployTimeRequest> GalaxyGetLastDeployTimeRequests { get; } = []; public List<GetLastDeployTimeRequest> GalaxyGetLastDeployTimeRequests { get; } = [];
/// <summary>List of received galaxy discover hierarchy requests.</summary>
public List<DiscoverHierarchyRequest> GalaxyDiscoverHierarchyRequests { get; } = []; public List<DiscoverHierarchyRequest> GalaxyDiscoverHierarchyRequests { get; } = [];
/// <inheritdoc />
public Task<TestConnectionReply> GalaxyTestConnectionAsync( public Task<TestConnectionReply> GalaxyTestConnectionAsync(
TestConnectionRequest request, TestConnectionRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -425,6 +454,7 @@ public sealed class MxGatewayClientCliTests
return Task.FromResult(GalaxyTestConnectionReply); return Task.FromResult(GalaxyTestConnectionReply);
} }
/// <inheritdoc />
public Task<GetLastDeployTimeReply> GalaxyGetLastDeployTimeAsync( public Task<GetLastDeployTimeReply> GalaxyGetLastDeployTimeAsync(
GetLastDeployTimeRequest request, GetLastDeployTimeRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -433,6 +463,7 @@ public sealed class MxGatewayClientCliTests
return Task.FromResult(GalaxyGetLastDeployTimeReply); return Task.FromResult(GalaxyGetLastDeployTimeReply);
} }
/// <inheritdoc />
public Task<DiscoverHierarchyReply> GalaxyDiscoverHierarchyAsync( public Task<DiscoverHierarchyReply> GalaxyDiscoverHierarchyAsync(
DiscoverHierarchyRequest request, DiscoverHierarchyRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -441,10 +472,13 @@ public sealed class MxGatewayClientCliTests
return Task.FromResult(GalaxyDiscoverHierarchyReply); return Task.FromResult(GalaxyDiscoverHierarchyReply);
} }
/// <summary>List of received galaxy watch deploy events requests.</summary>
public List<WatchDeployEventsRequest> GalaxyWatchDeployEventsRequests { get; } = []; public List<WatchDeployEventsRequest> GalaxyWatchDeployEventsRequests { get; } = [];
/// <summary>List of deploy events to yield when watching.</summary>
public List<DeployEvent> GalaxyDeployEvents { get; } = []; public List<DeployEvent> GalaxyDeployEvents { get; } = [];
/// <inheritdoc />
public async IAsyncEnumerable<DeployEvent> GalaxyWatchDeployEventsAsync( public async IAsyncEnumerable<DeployEvent> GalaxyWatchDeployEventsAsync(
WatchDeployEventsRequest request, WatchDeployEventsRequest request,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
@@ -4,6 +4,7 @@ namespace MxGateway.Client.Tests;
public sealed class MxGatewayClientContractInfoTests public sealed class MxGatewayClientContractInfoTests
{ {
/// <summary>Verifies that the client's gateway protocol version matches the shared contract definition.</summary>
[Fact] [Fact]
public void GatewayProtocolVersion_MatchesSharedContract() public void GatewayProtocolVersion_MatchesSharedContract()
{ {
@@ -12,6 +13,7 @@ public sealed class MxGatewayClientContractInfoTests
MxGatewayClientContractInfo.GatewayProtocolVersion); MxGatewayClientContractInfo.GatewayProtocolVersion);
} }
/// <summary>Verifies that the client's worker protocol version matches the shared contract definition.</summary>
[Fact] [Fact]
public void WorkerProtocolVersion_MatchesSharedContract() public void WorkerProtocolVersion_MatchesSharedContract()
{ {
@@ -2,6 +2,7 @@ namespace MxGateway.Client.Tests;
public sealed class MxGatewayClientOptionsTests public sealed class MxGatewayClientOptionsTests
{ {
/// <summary>Verifies that options with valid endpoint and API key pass validation.</summary>
[Fact] [Fact]
public void Validate_WithAbsoluteEndpointAndApiKey_Succeeds() public void Validate_WithAbsoluteEndpointAndApiKey_Succeeds()
{ {
@@ -14,6 +15,7 @@ public sealed class MxGatewayClientOptionsTests
options.Validate(); options.Validate();
} }
/// <summary>Verifies that empty API key causes validation to fail.</summary>
[Fact] [Fact]
public void Validate_WithEmptyApiKey_Throws() public void Validate_WithEmptyApiKey_Throws()
{ {
@@ -26,6 +28,7 @@ public sealed class MxGatewayClientOptionsTests
Assert.Throws<ArgumentException>(options.Validate); Assert.Throws<ArgumentException>(options.Validate);
} }
/// <summary>Verifies that invalid retry options cause validation to fail.</summary>
[Fact] [Fact]
public void Validate_WithInvalidRetryOptions_Throws() public void Validate_WithInvalidRetryOptions_Throws()
{ {
@@ -3,8 +3,10 @@ using Grpc.Core;
namespace MxGateway.Client.Tests; namespace MxGateway.Client.Tests;
/// <summary>Tests for MxGatewaySession and client command behavior.</summary>
public sealed class MxGatewayClientSessionTests public sealed class MxGatewayClientSessionTests
{ {
/// <summary>Verifies that open session attaches API key metadata and cancellation token.</summary>
[Fact] [Fact]
public async Task OpenSessionRawAsync_AttachesApiKeyMetadataAndCancellation() public async Task OpenSessionRawAsync_AttachesApiKeyMetadataAndCancellation()
{ {
@@ -19,6 +21,7 @@ public sealed class MxGatewayClientSessionTests
Assert.Equal(cancellation.Token, call.CallOptions.CancellationToken); Assert.Equal(cancellation.Token, call.CallOptions.CancellationToken);
} }
/// <summary>Verifies that open session returns a session with the raw open reply.</summary>
[Fact] [Fact]
public async Task OpenSessionAsync_ReturnsSessionWithRawOpenReply() public async Task OpenSessionAsync_ReturnsSessionWithRawOpenReply()
{ {
@@ -33,6 +36,7 @@ public sealed class MxGatewayClientSessionTests
Assert.Equal(1234, session.OpenSessionReply.WorkerProcessId); Assert.Equal(1234, session.OpenSessionReply.WorkerProcessId);
} }
/// <summary>Verifies that register builds a register command and returns server handle.</summary>
[Fact] [Fact]
public async Task RegisterAsync_BuildsRegisterCommandAndReturnsServerHandle() public async Task RegisterAsync_BuildsRegisterCommandAndReturnsServerHandle()
{ {
@@ -57,6 +61,7 @@ public sealed class MxGatewayClientSessionTests
Assert.Equal("fixture-client", call.Request.Command.Register.ClientName); Assert.Equal("fixture-client", call.Request.Command.Register.ClientName);
} }
/// <summary>Verifies that add item 2 builds a command with the specified context.</summary>
[Fact] [Fact]
public async Task AddItem2Async_BuildsAddItem2CommandWithContext() public async Task AddItem2Async_BuildsAddItem2CommandWithContext()
{ {
@@ -81,6 +86,7 @@ public sealed class MxGatewayClientSessionTests
Assert.Equal("runtime", request.Command.AddItem2.ItemContext); Assert.Equal("runtime", request.Command.AddItem2.ItemContext);
} }
/// <summary>Verifies that write raw builds a write command with the raw value.</summary>
[Fact] [Fact]
public async Task WriteRawAsync_BuildsWriteCommandWithRawValue() public async Task WriteRawAsync_BuildsWriteCommandWithRawValue()
{ {
@@ -111,6 +117,7 @@ public sealed class MxGatewayClientSessionTests
Assert.Equal(56, request.Command.Write.UserId); Assert.Equal(56, request.Command.Write.UserId);
} }
/// <summary>Verifies that write 2 raw builds a write 2 command with value and timestamp.</summary>
[Fact] [Fact]
public async Task Write2RawAsync_BuildsWrite2CommandWithValueAndTimestamp() public async Task Write2RawAsync_BuildsWrite2CommandWithValueAndTimestamp()
{ {
@@ -138,6 +145,7 @@ public sealed class MxGatewayClientSessionTests
Assert.Equal(56, request.Command.Write2.UserId); Assert.Equal(56, request.Command.Write2.UserId);
} }
/// <summary>Verifies that subscribe bulk builds one command and returns per-item results.</summary>
[Fact] [Fact]
public async Task SubscribeBulkAsync_BuildsOneBulkCommandAndReturnsPerItemResults() public async Task SubscribeBulkAsync_BuildsOneBulkCommandAndReturnsPerItemResults()
{ {
@@ -176,6 +184,7 @@ public sealed class MxGatewayClientSessionTests
Assert.Equal(["Area001.Pump001.Speed"], request.Command.SubscribeBulk.TagAddresses); Assert.Equal(["Area001.Pump001.Speed"], request.Command.SubscribeBulk.TagAddresses);
} }
/// <summary>Verifies that stream events yields events in the order received from the gateway.</summary>
[Fact] [Fact]
public async Task StreamEventsAsync_YieldsEventsInGatewayOrder() public async Task StreamEventsAsync_YieldsEventsInGatewayOrder()
{ {
@@ -206,6 +215,7 @@ public sealed class MxGatewayClientSessionTests
Assert.Equal("session-fixture", request.SessionId); Assert.Equal("session-fixture", request.SessionId);
} }
/// <summary>Verifies that close is explicit and idempotent.</summary>
[Fact] [Fact]
public async Task CloseAsync_IsExplicitAndIdempotent() public async Task CloseAsync_IsExplicitAndIdempotent()
{ {
@@ -221,6 +231,7 @@ public sealed class MxGatewayClientSessionTests
Assert.Equal("session-fixture", call.Request.SessionId); Assert.Equal("session-fixture", call.Request.SessionId);
} }
/// <summary>Verifies that invoke retries safe diagnostic commands on transient RPC failure.</summary>
[Fact] [Fact]
public async Task InvokeAsync_RetriesSafeDiagnosticCommandOnTransientGrpcFailure() public async Task InvokeAsync_RetriesSafeDiagnosticCommandOnTransientGrpcFailure()
{ {
@@ -244,6 +255,7 @@ public sealed class MxGatewayClientSessionTests
Assert.Equal(2, transport.InvokeCalls.Count); Assert.Equal(2, transport.InvokeCalls.Count);
} }
/// <summary>Verifies that open session does not retry on transient RPC failure.</summary>
[Fact] [Fact]
public async Task OpenSessionAsync_DoesNotRetryTransientGrpcFailure() public async Task OpenSessionAsync_DoesNotRetryTransientGrpcFailure()
{ {
@@ -256,6 +268,7 @@ public sealed class MxGatewayClientSessionTests
Assert.Single(transport.OpenSessionCalls); Assert.Single(transport.OpenSessionCalls);
} }
/// <summary>Verifies that invoke does not retry write commands on transient RPC failure.</summary>
[Fact] [Fact]
public async Task InvokeAsync_DoesNotRetryWriteCommand() public async Task InvokeAsync_DoesNotRetryWriteCommand()
{ {
@@ -270,6 +283,7 @@ public sealed class MxGatewayClientSessionTests
Assert.Single(transport.InvokeCalls); Assert.Single(transport.InvokeCalls);
} }
/// <summary>Verifies that invoke helpers pass cancellation token to the transport.</summary>
[Fact] [Fact]
public async Task InvokeHelpers_PassCancellationTokenToTransport() public async Task InvokeHelpers_PassCancellationTokenToTransport()
{ {
@@ -2,6 +2,7 @@ namespace MxGateway.Client.Tests;
public sealed class MxGatewayGeneratedContractTests public sealed class MxGatewayGeneratedContractTests
{ {
/// <summary>Verifies that the generated gRPC client can be instantiated from the client factory.</summary>
[Fact] [Fact]
public async Task GeneratedGrpcClient_CanBeConstructedFromClientFactory() public async Task GeneratedGrpcClient_CanBeConstructedFromClientFactory()
{ {
@@ -7,6 +7,7 @@ namespace MxGateway.Client.Tests;
public sealed class MxStatusProxyExtensionsTests public sealed class MxStatusProxyExtensionsTests
{ {
/// <summary>Verifies that fixture statuses correctly project success and preserve raw integer fields.</summary>
[Fact] [Fact]
public void FixtureStatuses_ProjectSuccessAndPreserveRawFields() public void FixtureStatuses_ProjectSuccessAndPreserveRawFields()
{ {
@@ -7,6 +7,7 @@ namespace MxGateway.Client.Tests;
public sealed class MxValueExtensionsTests public sealed class MxValueExtensionsTests
{ {
/// <summary>Verifies that scalar values are converted to correctly-typed MxValue protobuf messages.</summary>
[Fact] [Fact]
public void ToMxValue_WithScalarValues_CreatesTypedProtobufValues() public void ToMxValue_WithScalarValues_CreatesTypedProtobufValues()
{ {
@@ -18,6 +19,7 @@ public sealed class MxValueExtensionsTests
Assert.Equal(MxValue.KindOneofCase.StringValue, "alpha".ToMxValue().KindCase); Assert.Equal(MxValue.KindOneofCase.StringValue, "alpha".ToMxValue().KindCase);
} }
/// <summary>Verifies that array values are converted to array-kind MxValue messages with correct element types and dimensions.</summary>
[Fact] [Fact]
public void ToMxValue_WithArrays_CreatesTypedArrayProtobufValues() public void ToMxValue_WithArrays_CreatesTypedArrayProtobufValues()
{ {
@@ -29,6 +31,7 @@ public sealed class MxValueExtensionsTests
Assert.Equal([2U], value.ArrayValue.Dimensions); Assert.Equal([2U], value.ArrayValue.Dimensions);
} }
/// <summary>Verifies that fixture test cases project to expected MxValue kinds and preserve raw type metadata.</summary>
[Fact] [Fact]
public void FixtureValues_ProjectExpectedKindsAndPreserveRawMetadata() public void FixtureValues_ProjectExpectedKindsAndPreserveRawMetadata()
{ {
@@ -23,6 +23,11 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
private readonly ResiliencePipeline _safeUnaryRetryPipeline; private readonly ResiliencePipeline _safeUnaryRetryPipeline;
private bool _disposed; private bool _disposed;
/// <summary>
/// Initializes a Galaxy Repository client with custom transport and options.
/// </summary>
/// <param name="options">Client options.</param>
/// <param name="transport">The underlying gRPC transport.</param>
internal GalaxyRepositoryClient( internal GalaxyRepositoryClient(
MxGatewayClientOptions options, MxGatewayClientOptions options,
IGalaxyRepositoryClientTransport transport) IGalaxyRepositoryClientTransport transport)
@@ -50,12 +55,23 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
Options.LoggerFactory?.CreateLogger<GalaxyRepositoryClient>()); Options.LoggerFactory?.CreateLogger<GalaxyRepositoryClient>());
} }
/// <summary>
/// Client options used to configure timeouts, authentication, and retry policy.
/// </summary>
public MxGatewayClientOptions Options { get; } public MxGatewayClientOptions Options { get; }
/// <summary>
/// The underlying generated gRPC client for advanced operations.
/// </summary>
public GalaxyRepository.GalaxyRepositoryClient RawClient => public GalaxyRepository.GalaxyRepositoryClient RawClient =>
_transport.RawClient _transport.RawClient
?? throw new InvalidOperationException("The raw generated gRPC client is not available for this client instance."); ?? throw new InvalidOperationException("The raw generated gRPC client is not available for this client instance.");
/// <summary>
/// Creates a Galaxy Repository client with the given options, establishing a new gRPC channel.
/// </summary>
/// <param name="options">Client options.</param>
/// <returns>A new client instance.</returns>
public static GalaxyRepositoryClient Create(MxGatewayClientOptions options) public static GalaxyRepositoryClient Create(MxGatewayClientOptions options)
{ {
ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(options);
@@ -81,6 +97,8 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
/// Probes the Galaxy Repository database connection. Returns true when the /// Probes the Galaxy Repository database connection. Returns true when the
/// gateway can reach the configured ZB SQL Server. /// gateway can reach the configured ZB SQL Server.
/// </summary> /// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if connection is successful, false otherwise.</returns>
public async Task<bool> TestConnectionAsync(CancellationToken cancellationToken = default) public async Task<bool> TestConnectionAsync(CancellationToken cancellationToken = default)
{ {
TestConnectionReply reply = await TestConnectionRawAsync( TestConnectionReply reply = await TestConnectionRawAsync(
@@ -91,6 +109,12 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
return reply.Ok; return reply.Ok;
} }
/// <summary>
/// Probes the Galaxy Repository database connection without result wrapping.
/// </summary>
/// <param name="request">The test connection request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<TestConnectionReply> TestConnectionRawAsync( public Task<TestConnectionReply> TestConnectionRawAsync(
TestConnectionRequest request, TestConnectionRequest request,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -107,6 +131,8 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
/// Returns the timestamp of the most recent Galaxy deployment, or /// Returns the timestamp of the most recent Galaxy deployment, or
/// <see langword="null"/> when no deployment has been recorded. /// <see langword="null"/> when no deployment has been recorded.
/// </summary> /// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The deployment timestamp, or null if not recorded.</returns>
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken cancellationToken = default) public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken cancellationToken = default)
{ {
GetLastDeployTimeReply reply = await GetLastDeployTimeRawAsync( GetLastDeployTimeReply reply = await GetLastDeployTimeRawAsync(
@@ -122,6 +148,12 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
return reply.TimeOfLastDeploy.ToDateTime(); return reply.TimeOfLastDeploy.ToDateTime();
} }
/// <summary>
/// Returns the most recent Galaxy deployment timestamp without result wrapping.
/// </summary>
/// <param name="request">The last deploy-time request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<GetLastDeployTimeReply> GetLastDeployTimeRawAsync( public Task<GetLastDeployTimeReply> GetLastDeployTimeRawAsync(
GetLastDeployTimeRequest request, GetLastDeployTimeRequest request,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -139,6 +171,8 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
/// includes its dynamic attributes so callers can determine which tag references /// includes its dynamic attributes so callers can determine which tag references
/// they may subscribe to via the MxAccessGateway service. /// they may subscribe to via the MxAccessGateway service.
/// </summary> /// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The collection of Galaxy objects in the hierarchy.</returns>
public async Task<IReadOnlyList<GalaxyObject>> DiscoverHierarchyAsync(CancellationToken cancellationToken = default) public async Task<IReadOnlyList<GalaxyObject>> DiscoverHierarchyAsync(CancellationToken cancellationToken = default)
{ {
DiscoverHierarchyReply reply = await DiscoverHierarchyRawAsync( DiscoverHierarchyReply reply = await DiscoverHierarchyRawAsync(
@@ -149,6 +183,12 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
return reply.Objects; return reply.Objects;
} }
/// <summary>
/// Enumerates the Galaxy object hierarchy without result wrapping.
/// </summary>
/// <param name="request">The discover-hierarchy request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<DiscoverHierarchyReply> DiscoverHierarchyRawAsync( public Task<DiscoverHierarchyReply> DiscoverHierarchyRawAsync(
DiscoverHierarchyRequest request, DiscoverHierarchyRequest request,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -173,6 +213,9 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
/// at-least-once delivery beyond the per-subscriber buffer (gaps in /// at-least-once delivery beyond the per-subscriber buffer (gaps in
/// <see cref="DeployEvent.Sequence"/> indicate dropped events). /// <see cref="DeployEvent.Sequence"/> indicate dropped events).
/// </remarks> /// </remarks>
/// <param name="lastSeenDeployTime">Optional timestamp to suppress the bootstrap event.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>An async enumerable of deploy events.</returns>
public IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync( public IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
DateTimeOffset? lastSeenDeployTime = null, DateTimeOffset? lastSeenDeployTime = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -188,6 +231,12 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
return WatchDeployEventsRawAsync(request, cancellationToken); return WatchDeployEventsRawAsync(request, cancellationToken);
} }
/// <summary>
/// Subscribes to Galaxy deploy events without result wrapping.
/// </summary>
/// <param name="request">The watch-deploy-events request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>An async enumerable of raw deploy events.</returns>
public IAsyncEnumerable<DeployEvent> WatchDeployEventsRawAsync( public IAsyncEnumerable<DeployEvent> WatchDeployEventsRawAsync(
WatchDeployEventsRequest request, WatchDeployEventsRequest request,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -211,6 +260,9 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
} }
} }
/// <summary>
/// Closes the gRPC channel and releases resources.
/// </summary>
public ValueTask DisposeAsync() public ValueTask DisposeAsync()
{ {
if (_disposed) if (_disposed)
@@ -223,16 +275,32 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
} }
/// <summary>
/// Creates gRPC call options with the client's default timeout and API-key authorization.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The call options.</returns>
internal CallOptions CreateCallOptions(CancellationToken cancellationToken) internal CallOptions CreateCallOptions(CancellationToken cancellationToken)
{ {
return CreateCallOptions(cancellationToken, Options.DefaultCallTimeout); return CreateCallOptions(cancellationToken, Options.DefaultCallTimeout);
} }
/// <summary>
/// Creates gRPC call options for streaming RPCs with the stream timeout and API-key authorization.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The stream call options.</returns>
internal CallOptions CreateStreamCallOptions(CancellationToken cancellationToken) internal CallOptions CreateStreamCallOptions(CancellationToken cancellationToken)
{ {
return CreateCallOptions(cancellationToken, Options.StreamTimeout); return CreateCallOptions(cancellationToken, Options.StreamTimeout);
} }
/// <summary>
/// Creates gRPC call options with the specified timeout and API-key authorization.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <param name="timeout">Optional timeout duration.</param>
/// <returns>The call options.</returns>
internal CallOptions CreateCallOptions( internal CallOptions CreateCallOptions(
CancellationToken cancellationToken, CancellationToken cancellationToken,
TimeSpan? timeout) TimeSpan? timeout)
@@ -3,16 +3,27 @@ using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client; namespace MxGateway.Client;
/// <summary>
/// gRPC implementation of IGalaxyRepositoryClientTransport.
/// </summary>
internal sealed class GrpcGalaxyRepositoryClientTransport( internal sealed class GrpcGalaxyRepositoryClientTransport(
MxGatewayClientOptions options, MxGatewayClientOptions options,
GalaxyRepository.GalaxyRepositoryClient rawClient) : IGalaxyRepositoryClientTransport GalaxyRepository.GalaxyRepositoryClient rawClient) : IGalaxyRepositoryClientTransport
{ {
/// <summary>
/// Gets the gateway client options.
/// </summary>
public MxGatewayClientOptions Options { get; } = options; public MxGatewayClientOptions Options { get; } = options;
/// <summary>
/// Gets the underlying gRPC client.
/// </summary>
public GalaxyRepository.GalaxyRepositoryClient RawClient { get; } = rawClient; public GalaxyRepository.GalaxyRepositoryClient RawClient { get; } = rawClient;
/// <inheritdoc />
GalaxyRepository.GalaxyRepositoryClient? IGalaxyRepositoryClientTransport.RawClient => RawClient; GalaxyRepository.GalaxyRepositoryClient? IGalaxyRepositoryClientTransport.RawClient => RawClient;
/// <inheritdoc />
public async Task<TestConnectionReply> TestConnectionAsync( public async Task<TestConnectionReply> TestConnectionAsync(
TestConnectionRequest request, TestConnectionRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -29,6 +40,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
} }
} }
/// <inheritdoc />
public async Task<GetLastDeployTimeReply> GetLastDeployTimeAsync( public async Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
GetLastDeployTimeRequest request, GetLastDeployTimeRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -45,6 +57,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
} }
} }
/// <inheritdoc />
public async Task<DiscoverHierarchyReply> DiscoverHierarchyAsync( public async Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
DiscoverHierarchyRequest request, DiscoverHierarchyRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -61,6 +74,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
} }
} }
/// <inheritdoc />
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync( public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
WatchDeployEventsRequest request, WatchDeployEventsRequest request,
CallOptions callOptions, CallOptions callOptions,
@@ -94,6 +108,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
} }
} }
/// <inheritdoc />
IAsyncEnumerable<DeployEvent> IGalaxyRepositoryClientTransport.WatchDeployEventsAsync( IAsyncEnumerable<DeployEvent> IGalaxyRepositoryClientTransport.WatchDeployEventsAsync(
WatchDeployEventsRequest request, WatchDeployEventsRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -3,16 +3,27 @@ using MxGateway.Contracts.Proto;
namespace MxGateway.Client; namespace MxGateway.Client;
/// <summary>
/// gRPC implementation of IMxGatewayClientTransport.
/// </summary>
internal sealed class GrpcMxGatewayClientTransport( internal sealed class GrpcMxGatewayClientTransport(
MxGatewayClientOptions options, MxGatewayClientOptions options,
MxAccessGateway.MxAccessGatewayClient rawClient) : IMxGatewayClientTransport MxAccessGateway.MxAccessGatewayClient rawClient) : IMxGatewayClientTransport
{ {
/// <summary>
/// Gets the gateway client options.
/// </summary>
public MxGatewayClientOptions Options { get; } = options; public MxGatewayClientOptions Options { get; } = options;
/// <summary>
/// Gets the underlying gRPC client.
/// </summary>
public MxAccessGateway.MxAccessGatewayClient RawClient { get; } = rawClient; public MxAccessGateway.MxAccessGatewayClient RawClient { get; } = rawClient;
/// <inheritdoc />
MxAccessGateway.MxAccessGatewayClient? IMxGatewayClientTransport.RawClient => RawClient; MxAccessGateway.MxAccessGatewayClient? IMxGatewayClientTransport.RawClient => RawClient;
/// <inheritdoc />
public async Task<OpenSessionReply> OpenSessionAsync( public async Task<OpenSessionReply> OpenSessionAsync(
OpenSessionRequest request, OpenSessionRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -29,6 +40,7 @@ internal sealed class GrpcMxGatewayClientTransport(
} }
} }
/// <inheritdoc />
public async Task<CloseSessionReply> CloseSessionAsync( public async Task<CloseSessionReply> CloseSessionAsync(
CloseSessionRequest request, CloseSessionRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -45,6 +57,7 @@ internal sealed class GrpcMxGatewayClientTransport(
} }
} }
/// <inheritdoc />
public async Task<MxCommandReply> InvokeAsync( public async Task<MxCommandReply> InvokeAsync(
MxCommandRequest request, MxCommandRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -61,6 +74,7 @@ internal sealed class GrpcMxGatewayClientTransport(
} }
} }
/// <inheritdoc />
public async IAsyncEnumerable<MxEvent> StreamEventsAsync( public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request, StreamEventsRequest request,
CallOptions callOptions, CallOptions callOptions,
@@ -94,6 +108,7 @@ internal sealed class GrpcMxGatewayClientTransport(
} }
} }
/// <inheritdoc />
IAsyncEnumerable<MxEvent> IMxGatewayClientTransport.StreamEventsAsync( IAsyncEnumerable<MxEvent> IMxGatewayClientTransport.StreamEventsAsync(
StreamEventsRequest request, StreamEventsRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -3,24 +3,39 @@ using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client; namespace MxGateway.Client;
/// <summary>Transport layer for Galaxy Repository gRPC operations.</summary>
internal interface IGalaxyRepositoryClientTransport internal interface IGalaxyRepositoryClientTransport
{ {
/// <summary>Gets the client options used to configure this transport.</summary>
MxGatewayClientOptions Options { get; } MxGatewayClientOptions Options { get; }
/// <summary>Gets the underlying gRPC client, or <c>null</c> if not yet initialized.</summary>
GalaxyRepository.GalaxyRepositoryClient? RawClient { get; } GalaxyRepository.GalaxyRepositoryClient? RawClient { get; }
/// <summary>Tests the connection to the Galaxy Repository server.</summary>
/// <param name="request">The test connection request.</param>
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
Task<TestConnectionReply> TestConnectionAsync( Task<TestConnectionReply> TestConnectionAsync(
TestConnectionRequest request, TestConnectionRequest request,
CallOptions callOptions); CallOptions callOptions);
/// <summary>Gets the last deploy time from the Galaxy Repository server.</summary>
/// <param name="request">The get last deploy time request.</param>
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
Task<GetLastDeployTimeReply> GetLastDeployTimeAsync( Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
GetLastDeployTimeRequest request, GetLastDeployTimeRequest request,
CallOptions callOptions); CallOptions callOptions);
/// <summary>Discovers the object hierarchy in the Galaxy Repository.</summary>
/// <param name="request">The discover hierarchy request.</param>
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
Task<DiscoverHierarchyReply> DiscoverHierarchyAsync( Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
DiscoverHierarchyRequest request, DiscoverHierarchyRequest request,
CallOptions callOptions); CallOptions callOptions);
/// <summary>Watches for deployment events from the Galaxy Repository server.</summary>
/// <param name="request">The watch deploy events request.</param>
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync( IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
WatchDeployEventsRequest request, WatchDeployEventsRequest request,
CallOptions callOptions); CallOptions callOptions);
@@ -5,22 +5,52 @@ namespace MxGateway.Client;
internal interface IMxGatewayClientTransport internal interface IMxGatewayClientTransport
{ {
/// <summary>
/// Gets the client configuration options.
/// </summary>
MxGatewayClientOptions Options { get; } MxGatewayClientOptions Options { get; }
/// <summary>
/// Gets the underlying gRPC client, if available.
/// </summary>
MxAccessGateway.MxAccessGatewayClient? RawClient { get; } MxAccessGateway.MxAccessGatewayClient? RawClient { get; }
/// <summary>
/// Opens a new gateway session.
/// </summary>
/// <param name="request">Session open request.</param>
/// <param name="callOptions">gRPC call options.</param>
/// <returns>The session open reply.</returns>
Task<OpenSessionReply> OpenSessionAsync( Task<OpenSessionReply> OpenSessionAsync(
OpenSessionRequest request, OpenSessionRequest request,
CallOptions callOptions); CallOptions callOptions);
/// <summary>
/// Closes an open gateway session.
/// </summary>
/// <param name="request">Session close request.</param>
/// <param name="callOptions">gRPC call options.</param>
/// <returns>The session close reply.</returns>
Task<CloseSessionReply> CloseSessionAsync( Task<CloseSessionReply> CloseSessionAsync(
CloseSessionRequest request, CloseSessionRequest request,
CallOptions callOptions); CallOptions callOptions);
/// <summary>
/// Invokes an MXAccess command on the session.
/// </summary>
/// <param name="request">The command request.</param>
/// <param name="callOptions">gRPC call options.</param>
/// <returns>The command reply.</returns>
Task<MxCommandReply> InvokeAsync( Task<MxCommandReply> InvokeAsync(
MxCommandRequest request, MxCommandRequest request,
CallOptions callOptions); CallOptions callOptions);
/// <summary>
/// Streams events from the session.
/// </summary>
/// <param name="request">The stream events request.</param>
/// <param name="callOptions">gRPC call options.</param>
/// <returns>An async enumerable of events.</returns>
IAsyncEnumerable<MxEvent> StreamEventsAsync( IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request, StreamEventsRequest request,
CallOptions callOptions); CallOptions callOptions);
@@ -2,8 +2,13 @@ using MxGateway.Contracts.Proto;
namespace MxGateway.Client; namespace MxGateway.Client;
/// <summary>Exception thrown when an MXAccess command fails with a non-zero HResult or failing status.</summary>
public sealed class MxAccessException : MxGatewayCommandException public sealed class MxAccessException : MxGatewayCommandException
{ {
/// <summary>Initializes a new instance with the given message, reply, and optional inner exception.</summary>
/// <param name="message">The error message describing the MXAccess failure.</param>
/// <param name="reply">The MxCommandReply containing the failure details (statuses, HResult, etc.).</param>
/// <param name="innerException">The underlying exception, if any.</param>
public MxAccessException( public MxAccessException(
string message, string message,
MxCommandReply reply, MxCommandReply reply,
@@ -20,5 +25,6 @@ public sealed class MxAccessException : MxGatewayCommandException
Reply = reply; Reply = reply;
} }
/// <summary>Gets the underlying MxCommandReply containing full failure details.</summary>
public MxCommandReply Reply { get; } public MxCommandReply Reply { get; }
} }
@@ -2,8 +2,11 @@ using MxGateway.Contracts.Proto;
namespace MxGateway.Client; namespace MxGateway.Client;
/// <summary>Extension methods for checking MxCommandReply success conditions.</summary>
public static class MxCommandReplyExtensions public static class MxCommandReplyExtensions
{ {
/// <summary>Validates that the reply has a successful protocol status (Ok or MxAccessFailure), throwing a gateway exception if not.</summary>
/// <param name="reply">The command reply to check.</param>
public static MxCommandReply EnsureProtocolSuccess(this MxCommandReply reply) public static MxCommandReply EnsureProtocolSuccess(this MxCommandReply reply)
{ {
ArgumentNullException.ThrowIfNull(reply); ArgumentNullException.ThrowIfNull(reply);
@@ -19,6 +22,8 @@ public static class MxCommandReplyExtensions
throw CreateProtocolException(reply, code); throw CreateProtocolException(reply, code);
} }
/// <summary>Validates that the reply indicates MXAccess success (no HResult or status failures), throwing MxAccessException if not.</summary>
/// <param name="reply">The command reply to check.</param>
public static MxCommandReply EnsureMxAccessSuccess(this MxCommandReply reply) public static MxCommandReply EnsureMxAccessSuccess(this MxCommandReply reply)
{ {
ArgumentNullException.ThrowIfNull(reply); ArgumentNullException.ThrowIfNull(reply);
@@ -2,8 +2,17 @@ using MxGateway.Contracts.Proto;
namespace MxGateway.Client; namespace MxGateway.Client;
/// <summary>Exception thrown when an API key is invalid, expired, or malformed.</summary>
public sealed class MxGatewayAuthenticationException : MxGatewayException public sealed class MxGatewayAuthenticationException : MxGatewayException
{ {
/// <summary>Initializes a new instance with the given details.</summary>
/// <param name="message">The error message describing the authentication failure.</param>
/// <param name="sessionId">The session ID, if available.</param>
/// <param name="correlationId">The correlation ID for tracing, if available.</param>
/// <param name="protocolStatus">The protocol status details, if available.</param>
/// <param name="hResult">The HResult code, if available.</param>
/// <param name="statuses">The MXAccess statuses, if available.</param>
/// <param name="innerException">The underlying exception, if any.</param>
public MxGatewayAuthenticationException( public MxGatewayAuthenticationException(
string message, string message,
string? sessionId = null, string? sessionId = null,
@@ -2,8 +2,17 @@ using MxGateway.Contracts.Proto;
namespace MxGateway.Client; namespace MxGateway.Client;
/// <summary>Exception thrown when the API key lacks required scopes for an operation.</summary>
public sealed class MxGatewayAuthorizationException : MxGatewayException public sealed class MxGatewayAuthorizationException : MxGatewayException
{ {
/// <summary>Initializes a new instance with the given details.</summary>
/// <param name="message">The error message describing the authorization failure.</param>
/// <param name="sessionId">The session ID, if available.</param>
/// <param name="correlationId">The correlation ID for tracing, if available.</param>
/// <param name="protocolStatus">The protocol status details, if available.</param>
/// <param name="hResult">The HResult code, if available.</param>
/// <param name="statuses">The MXAccess statuses, if available.</param>
/// <param name="innerException">The underlying exception, if any.</param>
public MxGatewayAuthorizationException( public MxGatewayAuthorizationException(
string message, string message,
string? sessionId = null, string? sessionId = null,
@@ -19,6 +19,11 @@ public sealed class MxGatewayClient : IAsyncDisposable
private readonly ResiliencePipeline _safeUnaryRetryPipeline; private readonly ResiliencePipeline _safeUnaryRetryPipeline;
private bool _disposed; private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="MxGatewayClient"/> with given options and transport.
/// </summary>
/// <param name="options">Client configuration options.</param>
/// <param name="transport">Transport implementation for gateway communication.</param>
internal MxGatewayClient( internal MxGatewayClient(
MxGatewayClientOptions options, MxGatewayClientOptions options,
IMxGatewayClientTransport transport) IMxGatewayClientTransport transport)
@@ -46,12 +51,23 @@ public sealed class MxGatewayClient : IAsyncDisposable
Options.LoggerFactory?.CreateLogger<MxGatewayClient>()); Options.LoggerFactory?.CreateLogger<MxGatewayClient>());
} }
/// <summary>
/// Gets the client configuration options.
/// </summary>
public MxGatewayClientOptions Options { get; } public MxGatewayClientOptions Options { get; }
/// <summary>
/// Gets the underlying generated gRPC client.
/// </summary>
public MxAccessGateway.MxAccessGatewayClient RawClient => public MxAccessGateway.MxAccessGatewayClient RawClient =>
_transport.RawClient _transport.RawClient
?? throw new InvalidOperationException("The raw generated gRPC client is not available for this client instance."); ?? throw new InvalidOperationException("The raw generated gRPC client is not available for this client instance.");
/// <summary>
/// Creates a new gateway client with the given options.
/// </summary>
/// <param name="options">Client configuration options.</param>
/// <returns>A new gateway client instance.</returns>
public static MxGatewayClient Create(MxGatewayClientOptions options) public static MxGatewayClient Create(MxGatewayClientOptions options)
{ {
ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(options);
@@ -73,6 +89,12 @@ public sealed class MxGatewayClient : IAsyncDisposable
new MxAccessGateway.MxAccessGatewayClient(channel))); new MxAccessGateway.MxAccessGatewayClient(channel)));
} }
/// <summary>
/// Opens a new gateway session.
/// </summary>
/// <param name="request">Session open request; defaults to empty request if null.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A wrapped gateway session.</returns>
public async Task<MxGatewaySession> OpenSessionAsync( public async Task<MxGatewaySession> OpenSessionAsync(
OpenSessionRequest? request = null, OpenSessionRequest? request = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -85,6 +107,12 @@ public sealed class MxGatewayClient : IAsyncDisposable
return new MxGatewaySession(this, reply); return new MxGatewaySession(this, reply);
} }
/// <summary>
/// Opens a new gateway session and returns the raw protobuf reply.
/// </summary>
/// <param name="request">Session open request.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The raw gateway session open reply.</returns>
public Task<OpenSessionReply> OpenSessionRawAsync( public Task<OpenSessionReply> OpenSessionRawAsync(
OpenSessionRequest request, OpenSessionRequest request,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -95,6 +123,12 @@ public sealed class MxGatewayClient : IAsyncDisposable
return _transport.OpenSessionAsync(request, CreateCallOptions(cancellationToken)); return _transport.OpenSessionAsync(request, CreateCallOptions(cancellationToken));
} }
/// <summary>
/// Closes an open gateway session.
/// </summary>
/// <param name="request">Session close request.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The session close reply.</returns>
public Task<CloseSessionReply> CloseSessionRawAsync( public Task<CloseSessionReply> CloseSessionRawAsync(
CloseSessionRequest request, CloseSessionRequest request,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -107,6 +141,12 @@ public sealed class MxGatewayClient : IAsyncDisposable
cancellationToken); cancellationToken);
} }
/// <summary>
/// Invokes an MXAccess command on the open session.
/// </summary>
/// <param name="request">The command request.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The command reply.</returns>
public Task<MxCommandReply> InvokeAsync( public Task<MxCommandReply> InvokeAsync(
MxCommandRequest request, MxCommandRequest request,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -124,6 +164,12 @@ public sealed class MxGatewayClient : IAsyncDisposable
return _transport.InvokeAsync(request, CreateCallOptions(cancellationToken)); return _transport.InvokeAsync(request, CreateCallOptions(cancellationToken));
} }
/// <summary>
/// Streams events from the gateway session.
/// </summary>
/// <param name="request">The stream events request.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>An async enumerable of events.</returns>
public IAsyncEnumerable<MxEvent> StreamEventsAsync( public IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request, StreamEventsRequest request,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -134,6 +180,9 @@ public sealed class MxGatewayClient : IAsyncDisposable
return _transport.StreamEventsAsync(request, CreateStreamCallOptions(cancellationToken)); return _transport.StreamEventsAsync(request, CreateStreamCallOptions(cancellationToken));
} }
/// <summary>
/// Disposes the client and releases all resources.
/// </summary>
public ValueTask DisposeAsync() public ValueTask DisposeAsync()
{ {
if (_disposed) if (_disposed)
@@ -146,16 +195,32 @@ public sealed class MxGatewayClient : IAsyncDisposable
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
} }
/// <summary>
/// Creates gRPC call options with default timeout and authorization.
/// </summary>
/// <param name="cancellationToken">Cancellation token for the call.</param>
/// <returns>Configured call options.</returns>
internal CallOptions CreateCallOptions(CancellationToken cancellationToken) internal CallOptions CreateCallOptions(CancellationToken cancellationToken)
{ {
return CreateCallOptions(cancellationToken, Options.DefaultCallTimeout); return CreateCallOptions(cancellationToken, Options.DefaultCallTimeout);
} }
/// <summary>
/// Creates gRPC call options for streaming with stream timeout and authorization.
/// </summary>
/// <param name="cancellationToken">Cancellation token for the call.</param>
/// <returns>Configured call options.</returns>
internal CallOptions CreateStreamCallOptions(CancellationToken cancellationToken) internal CallOptions CreateStreamCallOptions(CancellationToken cancellationToken)
{ {
return CreateCallOptions(cancellationToken, Options.StreamTimeout); return CreateCallOptions(cancellationToken, Options.StreamTimeout);
} }
/// <summary>
/// Creates gRPC call options with specified timeout and authorization.
/// </summary>
/// <param name="cancellationToken">Cancellation token for the call.</param>
/// <param name="timeout">Optional timeout duration; null means no timeout.</param>
/// <returns>Configured call options.</returns>
internal CallOptions CreateCallOptions( internal CallOptions CreateCallOptions(
CancellationToken cancellationToken, CancellationToken cancellationToken,
TimeSpan? timeout) TimeSpan? timeout)
@@ -7,26 +7,62 @@ namespace MxGateway.Client;
/// </summary> /// </summary>
public sealed class MxGatewayClientOptions public sealed class MxGatewayClientOptions
{ {
/// <summary>
/// Gets the gateway endpoint URI (required).
/// </summary>
public required Uri Endpoint { get; init; } public required Uri Endpoint { get; init; }
/// <summary>
/// Gets the API key for gateway authentication (required).
/// </summary>
public required string ApiKey { get; init; } public required string ApiKey { get; init; }
/// <summary>
/// Gets a value indicating whether to use TLS for the gateway connection.
/// </summary>
public bool UseTls { get; init; } public bool UseTls { get; init; }
/// <summary>
/// Gets the path to a CA certificate file for custom certificate validation.
/// </summary>
public string? CaCertificatePath { get; init; } public string? CaCertificatePath { get; init; }
/// <summary>
/// Gets the server name override for SNI during TLS handshake.
/// </summary>
public string? ServerNameOverride { get; init; } public string? ServerNameOverride { get; init; }
/// <summary>
/// Gets the timeout for establishing connection to the gateway.
/// </summary>
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10); public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10);
/// <summary>
/// Gets the default timeout for unary gRPC calls.
/// </summary>
public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30); public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Gets the optional timeout for streaming gRPC calls.
/// </summary>
public TimeSpan? StreamTimeout { get; init; } public TimeSpan? StreamTimeout { get; init; }
/// <summary>
/// Gets the retry configuration for safe unary calls.
/// </summary>
public MxGatewayClientRetryOptions Retry { get; init; } = new(); public MxGatewayClientRetryOptions Retry { get; init; } = new();
/// <summary>
/// Gets the logger factory for diagnostic logging.
/// </summary>
public ILoggerFactory? LoggerFactory { get; init; } public ILoggerFactory? LoggerFactory { get; init; }
/// <summary>
/// Validates the client options for consistency and correctness.
/// </summary>
/// <exception cref="ArgumentNullException">Endpoint is null.</exception>
/// <exception cref="ArgumentException">Options are invalid or inconsistent.</exception>
/// <exception cref="ArgumentOutOfRangeException">Timeout values are not greater than zero.</exception>
public void Validate() public void Validate()
{ {
ArgumentNullException.ThrowIfNull(Endpoint); ArgumentNullException.ThrowIfNull(Endpoint);
@@ -1,15 +1,21 @@
namespace MxGateway.Client; namespace MxGateway.Client;
/// <summary>Configuration for automatic retry behavior on transient gRPC call failures.</summary>
public sealed class MxGatewayClientRetryOptions public sealed class MxGatewayClientRetryOptions
{ {
/// <summary>Gets the maximum number of attempts (initial + retries); default is 2.</summary>
public int MaxAttempts { get; init; } = 2; public int MaxAttempts { get; init; } = 2;
/// <summary>Gets the initial delay between retry attempts; default is 200 milliseconds.</summary>
public TimeSpan Delay { get; init; } = TimeSpan.FromMilliseconds(200); public TimeSpan Delay { get; init; } = TimeSpan.FromMilliseconds(200);
/// <summary>Gets the maximum delay between retry attempts; default is 2 seconds.</summary>
public TimeSpan MaxDelay { get; init; } = TimeSpan.FromSeconds(2); public TimeSpan MaxDelay { get; init; } = TimeSpan.FromSeconds(2);
/// <summary>Gets a value indicating whether to add randomness to retry delays; default is true.</summary>
public bool UseJitter { get; init; } = true; public bool UseJitter { get; init; } = true;
/// <summary>Validates the retry options and throws if any constraint is violated.</summary>
public void Validate() public void Validate()
{ {
if (MaxAttempts <= 0) if (MaxAttempts <= 0)
@@ -6,8 +6,12 @@ using Polly.Retry;
namespace MxGateway.Client; namespace MxGateway.Client;
/// <summary>Factory and helpers for exponential-backoff retry policies on transient gRPC failures.</summary>
internal static class MxGatewayClientRetryPolicy internal static class MxGatewayClientRetryPolicy
{ {
/// <summary>Creates a Polly ResiliencePipeline that retries transient gRPC failures with exponential backoff.</summary>
/// <param name="options">Retry configuration (max attempts, delay bounds, jitter).</param>
/// <param name="logger">Optional logger for retry diagnostics.</param>
public static ResiliencePipeline Create( public static ResiliencePipeline Create(
MxGatewayClientRetryOptions options, MxGatewayClientRetryOptions options,
ILogger? logger) ILogger? logger)
@@ -36,6 +40,8 @@ internal static class MxGatewayClientRetryPolicy
.Build(); .Build();
} }
/// <summary>Returns whether a command kind is eligible for automatic retry on transient failures.</summary>
/// <param name="kind">The command kind to check.</param>
public static bool IsRetryableCommand(MxCommandKind kind) public static bool IsRetryableCommand(MxCommandKind kind)
{ {
return kind is MxCommandKind.Ping return kind is MxCommandKind.Ping
@@ -2,8 +2,17 @@ using MxGateway.Contracts.Proto;
namespace MxGateway.Client; namespace MxGateway.Client;
/// <summary>Exception thrown when a gateway command fails due to an unclassified protocol error.</summary>
public class MxGatewayCommandException : MxGatewayException public class MxGatewayCommandException : MxGatewayException
{ {
/// <summary>Initializes a new instance with the given details.</summary>
/// <param name="message">The error message describing the command failure.</param>
/// <param name="sessionId">The session ID, if available.</param>
/// <param name="correlationId">The correlation ID for tracing, if available.</param>
/// <param name="protocolStatus">The protocol status details, if available.</param>
/// <param name="hResult">The HResult code, if available.</param>
/// <param name="statuses">The MXAccess statuses, if available.</param>
/// <param name="innerException">The underlying exception, if any.</param>
public MxGatewayCommandException( public MxGatewayCommandException(
string message, string message,
string? sessionId = null, string? sessionId = null,
@@ -2,20 +2,42 @@ using MxGateway.Contracts.Proto;
namespace MxGateway.Client; namespace MxGateway.Client;
/// <summary>
/// Exception thrown when a gateway RPC call fails or returns an error status.
/// </summary>
public class MxGatewayException : Exception public class MxGatewayException : Exception
{ {
/// <summary>
/// Initializes a new instance of the MxGatewayException class with the specified message.
/// </summary>
/// <param name="message">Diagnostic message describing the failure.</param>
public MxGatewayException(string message) public MxGatewayException(string message)
: base(message) : base(message)
{ {
Statuses = []; Statuses = [];
} }
/// <summary>
/// Initializes a new instance of the MxGatewayException class with the specified message and inner exception.
/// </summary>
/// <param name="message">Diagnostic message describing the failure.</param>
/// <param name="innerException">Underlying exception that caused this failure.</param>
public MxGatewayException(string message, Exception? innerException) public MxGatewayException(string message, Exception? innerException)
: base(message, innerException) : base(message, innerException)
{ {
Statuses = []; Statuses = [];
} }
/// <summary>
/// Initializes a new instance of the MxGatewayException class with full diagnostic information.
/// </summary>
/// <param name="message">Diagnostic message describing the failure.</param>
/// <param name="sessionId">Session ID associated with the exception, if available.</param>
/// <param name="correlationId">Correlation ID associated with the exception, if available.</param>
/// <param name="protocolStatus">Protocol-level status returned by the gateway, if available.</param>
/// <param name="hResult">HRESULT code returned by the worker or MXAccess, if available.</param>
/// <param name="statuses">List of MXAccess status codes returned by the operation.</param>
/// <param name="innerException">Underlying exception that caused this failure.</param>
public MxGatewayException( public MxGatewayException(
string message, string message,
string? sessionId, string? sessionId,
@@ -33,13 +55,28 @@ public class MxGatewayException : Exception
Statuses = statuses; Statuses = statuses;
} }
/// <summary>
/// Gets the session ID associated with the exception, if available.
/// </summary>
public string? SessionId { get; } public string? SessionId { get; }
/// <summary>
/// Gets the correlation ID associated with the exception, if available.
/// </summary>
public string? CorrelationId { get; } public string? CorrelationId { get; }
/// <summary>
/// Gets the protocol-level status returned by the gateway, if available.
/// </summary>
public ProtocolStatus? ProtocolStatus { get; } public ProtocolStatus? ProtocolStatus { get; }
/// <summary>
/// Gets the HRESULT code returned by the worker or MXAccess, if available.
/// </summary>
public int? HResultCode { get; } public int? HResultCode { get; }
/// <summary>
/// Gets the list of MXAccess status codes returned by the operation.
/// </summary>
public IReadOnlyList<MxStatusProxy> Statuses { get; } public IReadOnlyList<MxStatusProxy> Statuses { get; }
} }
@@ -11,6 +11,11 @@ public sealed class MxGatewaySession : IAsyncDisposable
private readonly SemaphoreSlim _closeLock = new(1, 1); private readonly SemaphoreSlim _closeLock = new(1, 1);
private CloseSessionReply? _closeReply; private CloseSessionReply? _closeReply;
/// <summary>
/// Initializes a new session backed by the given MXAccess gateway client.
/// </summary>
/// <param name="client">The gateway client used for commands and events.</param>
/// <param name="openSessionReply">The server's session creation response.</param>
internal MxGatewaySession( internal MxGatewaySession(
MxGatewayClient client, MxGatewayClient client,
OpenSessionReply openSessionReply) OpenSessionReply openSessionReply)
@@ -19,10 +24,21 @@ public sealed class MxGatewaySession : IAsyncDisposable
OpenSessionReply = openSessionReply ?? throw new ArgumentNullException(nameof(openSessionReply)); OpenSessionReply = openSessionReply ?? throw new ArgumentNullException(nameof(openSessionReply));
} }
/// <summary>
/// The session ID assigned by the gateway.
/// </summary>
public string SessionId => OpenSessionReply.SessionId; public string SessionId => OpenSessionReply.SessionId;
/// <summary>
/// The server's session creation response containing metadata.
/// </summary>
public OpenSessionReply OpenSessionReply { get; } public OpenSessionReply OpenSessionReply { get; }
/// <summary>
/// Closes the session on the gateway. Idempotent.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The server's close-session reply.</returns>
public async Task<CloseSessionReply> CloseAsync(CancellationToken cancellationToken = default) public async Task<CloseSessionReply> CloseAsync(CancellationToken cancellationToken = default)
{ {
if (_closeReply is not null) if (_closeReply is not null)
@@ -50,6 +66,12 @@ public sealed class MxGatewaySession : IAsyncDisposable
} }
} }
/// <summary>
/// Registers a client with the MXAccess session, returning a ServerHandle.
/// </summary>
/// <param name="clientName">Name to register.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The server handle assigned to the registered client.</returns>
public async Task<int> RegisterAsync( public async Task<int> RegisterAsync(
string clientName, string clientName,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -60,6 +82,12 @@ public sealed class MxGatewaySession : IAsyncDisposable
return reply.Register?.ServerHandle ?? reply.ReturnValue.Int32Value; return reply.Register?.ServerHandle ?? reply.ReturnValue.Int32Value;
} }
/// <summary>
/// Registers a client with the MXAccess session without error checking.
/// </summary>
/// <param name="clientName">Name to register.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> RegisterRawAsync( public Task<MxCommandReply> RegisterRawAsync(
string clientName, string clientName,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -75,6 +103,13 @@ public sealed class MxGatewaySession : IAsyncDisposable
cancellationToken); cancellationToken);
} }
/// <summary>
/// Adds an item to the MXAccess session, returning an ItemHandle.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemDefinition">The item tag address.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The item handle assigned to the new item.</returns>
public async Task<int> AddItemAsync( public async Task<int> AddItemAsync(
int serverHandle, int serverHandle,
string itemDefinition, string itemDefinition,
@@ -89,6 +124,13 @@ public sealed class MxGatewaySession : IAsyncDisposable
return reply.AddItem?.ItemHandle ?? reply.ReturnValue.Int32Value; return reply.AddItem?.ItemHandle ?? reply.ReturnValue.Int32Value;
} }
/// <summary>
/// Adds an item to the MXAccess session without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemDefinition">The item tag address.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> AddItemRawAsync( public Task<MxCommandReply> AddItemRawAsync(
int serverHandle, int serverHandle,
string itemDefinition, string itemDefinition,
@@ -109,6 +151,14 @@ public sealed class MxGatewaySession : IAsyncDisposable
cancellationToken); cancellationToken);
} }
/// <summary>
/// Adds an item with context to the MXAccess session, returning an ItemHandle.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemDefinition">The item tag address.</param>
/// <param name="itemContext">Additional context for the item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The item handle assigned to the new item.</returns>
public async Task<int> AddItem2Async( public async Task<int> AddItem2Async(
int serverHandle, int serverHandle,
string itemDefinition, string itemDefinition,
@@ -125,6 +175,14 @@ public sealed class MxGatewaySession : IAsyncDisposable
return reply.AddItem2?.ItemHandle ?? reply.ReturnValue.Int32Value; return reply.AddItem2?.ItemHandle ?? reply.ReturnValue.Int32Value;
} }
/// <summary>
/// Adds an item with context to the MXAccess session without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemDefinition">The item tag address.</param>
/// <param name="itemContext">Additional context for the item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> AddItem2RawAsync( public Task<MxCommandReply> AddItem2RawAsync(
int serverHandle, int serverHandle,
string itemDefinition, string itemDefinition,
@@ -147,6 +205,12 @@ public sealed class MxGatewaySession : IAsyncDisposable
cancellationToken); cancellationToken);
} }
/// <summary>
/// Subscribes to events for an item (advises in MXAccess terminology).
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task AdviseAsync( public async Task AdviseAsync(
int serverHandle, int serverHandle,
int itemHandle, int itemHandle,
@@ -157,6 +221,13 @@ public sealed class MxGatewaySession : IAsyncDisposable
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
} }
/// <summary>
/// Subscribes to events for an item without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> AdviseRawAsync( public Task<MxCommandReply> AdviseRawAsync(
int serverHandle, int serverHandle,
int itemHandle, int itemHandle,
@@ -175,6 +246,12 @@ public sealed class MxGatewaySession : IAsyncDisposable
cancellationToken); cancellationToken);
} }
/// <summary>
/// Unsubscribes from events for an item (unadvises in MXAccess terminology).
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task UnAdviseAsync( public async Task UnAdviseAsync(
int serverHandle, int serverHandle,
int itemHandle, int itemHandle,
@@ -185,6 +262,13 @@ public sealed class MxGatewaySession : IAsyncDisposable
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
} }
/// <summary>
/// Unsubscribes from events for an item without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> UnAdviseRawAsync( public Task<MxCommandReply> UnAdviseRawAsync(
int serverHandle, int serverHandle,
int itemHandle, int itemHandle,
@@ -203,6 +287,12 @@ public sealed class MxGatewaySession : IAsyncDisposable
cancellationToken); cancellationToken);
} }
/// <summary>
/// Removes an item from the MXAccess session.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task RemoveItemAsync( public async Task RemoveItemAsync(
int serverHandle, int serverHandle,
int itemHandle, int itemHandle,
@@ -213,6 +303,13 @@ public sealed class MxGatewaySession : IAsyncDisposable
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
} }
/// <summary>
/// Removes an item from the MXAccess session without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> RemoveItemRawAsync( public Task<MxCommandReply> RemoveItemRawAsync(
int serverHandle, int serverHandle,
int itemHandle, int itemHandle,
@@ -231,6 +328,13 @@ public sealed class MxGatewaySession : IAsyncDisposable
cancellationToken); cancellationToken);
} }
/// <summary>
/// Adds multiple items to the MXAccess session in a single command.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="tagAddresses">The item tag addresses to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Per-item subscription results.</returns>
public async Task<IReadOnlyList<SubscribeResult>> AddItemBulkAsync( public async Task<IReadOnlyList<SubscribeResult>> AddItemBulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<string> tagAddresses, IReadOnlyList<string> tagAddresses,
@@ -253,6 +357,13 @@ public sealed class MxGatewaySession : IAsyncDisposable
return reply.AddItemBulk?.Results.ToArray() ?? []; return reply.AddItemBulk?.Results.ToArray() ?? [];
} }
/// <summary>
/// Advises multiple items in a single command.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandles">The ItemHandles to advise.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Per-item subscription results.</returns>
public async Task<IReadOnlyList<SubscribeResult>> AdviseItemBulkAsync( public async Task<IReadOnlyList<SubscribeResult>> AdviseItemBulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<int> itemHandles, IReadOnlyList<int> itemHandles,
@@ -275,6 +386,13 @@ public sealed class MxGatewaySession : IAsyncDisposable
return reply.AdviseItemBulk?.Results.ToArray() ?? []; return reply.AdviseItemBulk?.Results.ToArray() ?? [];
} }
/// <summary>
/// Removes multiple items in a single command.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandles">The ItemHandles to remove.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Per-item subscription results.</returns>
public async Task<IReadOnlyList<SubscribeResult>> RemoveItemBulkAsync( public async Task<IReadOnlyList<SubscribeResult>> RemoveItemBulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<int> itemHandles, IReadOnlyList<int> itemHandles,
@@ -297,6 +415,13 @@ public sealed class MxGatewaySession : IAsyncDisposable
return reply.RemoveItemBulk?.Results.ToArray() ?? []; return reply.RemoveItemBulk?.Results.ToArray() ?? [];
} }
/// <summary>
/// Unadvises multiple items in a single command.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandles">The ItemHandles to unadvise.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Per-item subscription results.</returns>
public async Task<IReadOnlyList<SubscribeResult>> UnAdviseItemBulkAsync( public async Task<IReadOnlyList<SubscribeResult>> UnAdviseItemBulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<int> itemHandles, IReadOnlyList<int> itemHandles,
@@ -319,6 +444,13 @@ public sealed class MxGatewaySession : IAsyncDisposable
return reply.UnAdviseItemBulk?.Results.ToArray() ?? []; return reply.UnAdviseItemBulk?.Results.ToArray() ?? [];
} }
/// <summary>
/// Adds and advises multiple items in a single command.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="tagAddresses">The item tag addresses to add and advise.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Per-item subscription results.</returns>
public async Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync( public async Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<string> tagAddresses, IReadOnlyList<string> tagAddresses,
@@ -341,6 +473,13 @@ public sealed class MxGatewaySession : IAsyncDisposable
return reply.SubscribeBulk?.Results.ToArray() ?? []; return reply.SubscribeBulk?.Results.ToArray() ?? [];
} }
/// <summary>
/// Unadvises and removes multiple items in a single command.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandles">The ItemHandles to unsubscribe.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Per-item subscription results.</returns>
public async Task<IReadOnlyList<SubscribeResult>> UnsubscribeBulkAsync( public async Task<IReadOnlyList<SubscribeResult>> UnsubscribeBulkAsync(
int serverHandle, int serverHandle,
IReadOnlyList<int> itemHandles, IReadOnlyList<int> itemHandles,
@@ -363,6 +502,14 @@ public sealed class MxGatewaySession : IAsyncDisposable
return reply.UnsubscribeBulk?.Results.ToArray() ?? []; return reply.UnsubscribeBulk?.Results.ToArray() ?? [];
} }
/// <summary>
/// Writes a value to an item on the MXAccess server.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="value">The value to write.</param>
/// <param name="userId">User ID context for the write.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task WriteAsync( public async Task WriteAsync(
int serverHandle, int serverHandle,
int itemHandle, int itemHandle,
@@ -375,6 +522,15 @@ public sealed class MxGatewaySession : IAsyncDisposable
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
} }
/// <summary>
/// Writes a value to an item on the MXAccess server without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="value">The value to write.</param>
/// <param name="userId">User ID context for the write.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> WriteRawAsync( public Task<MxCommandReply> WriteRawAsync(
int serverHandle, int serverHandle,
int itemHandle, int itemHandle,
@@ -399,6 +555,15 @@ public sealed class MxGatewaySession : IAsyncDisposable
cancellationToken); cancellationToken);
} }
/// <summary>
/// Writes a value and timestamp to an item on the MXAccess server.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="value">The value to write.</param>
/// <param name="timestampValue">The timestamp to write with the value.</param>
/// <param name="userId">User ID context for the write.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task Write2Async( public async Task Write2Async(
int serverHandle, int serverHandle,
int itemHandle, int itemHandle,
@@ -418,6 +583,16 @@ public sealed class MxGatewaySession : IAsyncDisposable
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
} }
/// <summary>
/// Writes a value and timestamp to an item on the MXAccess server without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="value">The value to write.</param>
/// <param name="timestampValue">The timestamp to write with the value.</param>
/// <param name="userId">User ID context for the write.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> Write2RawAsync( public Task<MxCommandReply> Write2RawAsync(
int serverHandle, int serverHandle,
int itemHandle, int itemHandle,
@@ -445,6 +620,12 @@ public sealed class MxGatewaySession : IAsyncDisposable
cancellationToken); cancellationToken);
} }
/// <summary>
/// Invokes an MXAccess command on this session.
/// </summary>
/// <param name="request">The command request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> InvokeAsync( public Task<MxCommandReply> InvokeAsync(
MxCommandRequest request, MxCommandRequest request,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -453,6 +634,12 @@ public sealed class MxGatewaySession : IAsyncDisposable
return _client.InvokeAsync(request, cancellationToken); return _client.InvokeAsync(request, cancellationToken);
} }
/// <summary>
/// Streams events from the worker for this session, optionally starting after a given sequence number.
/// </summary>
/// <param name="afterWorkerSequence">The sequence number to stream from. Defaults to 0.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>An async enumerable of events.</returns>
public IAsyncEnumerable<MxEvent> StreamEventsAsync( public IAsyncEnumerable<MxEvent> StreamEventsAsync(
ulong afterWorkerSequence = 0, ulong afterWorkerSequence = 0,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -466,6 +653,9 @@ public sealed class MxGatewaySession : IAsyncDisposable
cancellationToken); cancellationToken);
} }
/// <summary>
/// Closes the session and releases resources.
/// </summary>
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
await CloseAsync().ConfigureAwait(false); await CloseAsync().ConfigureAwait(false);
@@ -2,8 +2,17 @@ using MxGateway.Contracts.Proto;
namespace MxGateway.Client; namespace MxGateway.Client;
/// <summary>Exception thrown when a session is not found, not ready, or invalid.</summary>
public sealed class MxGatewaySessionException : MxGatewayException public sealed class MxGatewaySessionException : MxGatewayException
{ {
/// <summary>Initializes a new instance with the given details.</summary>
/// <param name="message">The error message describing the session failure.</param>
/// <param name="sessionId">The session ID, if available.</param>
/// <param name="correlationId">The correlation ID for tracing, if available.</param>
/// <param name="protocolStatus">The protocol status details, if available.</param>
/// <param name="hResult">The HResult code, if available.</param>
/// <param name="statuses">The MXAccess statuses, if available.</param>
/// <param name="innerException">The underlying exception, if any.</param>
public MxGatewaySessionException( public MxGatewaySessionException(
string message, string message,
string? sessionId = null, string? sessionId = null,
@@ -2,8 +2,17 @@ using MxGateway.Contracts.Proto;
namespace MxGateway.Client; namespace MxGateway.Client;
/// <summary>Exception thrown when the worker process is unavailable or fails to process a command.</summary>
public sealed class MxGatewayWorkerException : MxGatewayException public sealed class MxGatewayWorkerException : MxGatewayException
{ {
/// <summary>Initializes a new instance with the given details.</summary>
/// <param name="message">The error message describing the worker failure.</param>
/// <param name="sessionId">The session ID, if available.</param>
/// <param name="correlationId">The correlation ID for tracing, if available.</param>
/// <param name="protocolStatus">The protocol status details, if available.</param>
/// <param name="hResult">The HResult code, if available.</param>
/// <param name="statuses">The MXAccess statuses, if available.</param>
/// <param name="innerException">The underlying exception, if any.</param>
public MxGatewayWorkerException( public MxGatewayWorkerException(
string message, string message,
string? sessionId = null, string? sessionId = null,
@@ -2,8 +2,11 @@ using MxGateway.Contracts.Proto;
namespace MxGateway.Client; namespace MxGateway.Client;
/// <summary>Extension methods for MxStatusProxy values.</summary>
public static class MxStatusProxyExtensions public static class MxStatusProxyExtensions
{ {
/// <summary>Returns whether the status indicates success (success flag set and category is Ok).</summary>
/// <param name="status">The status to check.</param>
public static bool IsSuccess(this MxStatusProxy status) public static bool IsSuccess(this MxStatusProxy status)
{ {
ArgumentNullException.ThrowIfNull(status); ArgumentNullException.ThrowIfNull(status);
@@ -12,6 +15,8 @@ public static class MxStatusProxyExtensions
&& status.Category is MxStatusCategory.Ok; && status.Category is MxStatusCategory.Ok;
} }
/// <summary>Returns a formatted summary of the status for diagnostic output.</summary>
/// <param name="status">The status to summarize.</param>
public static string ToDiagnosticSummary(this MxStatusProxy status) public static string ToDiagnosticSummary(this MxStatusProxy status)
{ {
ArgumentNullException.ThrowIfNull(status); ArgumentNullException.ThrowIfNull(status);
@@ -10,6 +10,10 @@ namespace MxGateway.Client;
/// </summary> /// </summary>
public static class MxValueExtensions public static class MxValueExtensions
{ {
/// <summary>
/// Converts a boolean value to an MxValue with MxDataType.Boolean.
/// </summary>
/// <param name="value">Scalar boolean value to wrap.</param>
public static MxValue ToMxValue(this bool value) public static MxValue ToMxValue(this bool value)
{ {
return new MxValue return new MxValue
@@ -20,6 +24,10 @@ public static class MxValueExtensions
}; };
} }
/// <summary>
/// Converts a 32-bit integer value to an MxValue with MxDataType.Integer.
/// </summary>
/// <param name="value">32-bit integer value to wrap.</param>
public static MxValue ToMxValue(this int value) public static MxValue ToMxValue(this int value)
{ {
return new MxValue return new MxValue
@@ -30,6 +38,10 @@ public static class MxValueExtensions
}; };
} }
/// <summary>
/// Converts a 64-bit integer value to an MxValue with MxDataType.Integer.
/// </summary>
/// <param name="value">64-bit integer value to wrap.</param>
public static MxValue ToMxValue(this long value) public static MxValue ToMxValue(this long value)
{ {
return new MxValue return new MxValue
@@ -40,6 +52,10 @@ public static class MxValueExtensions
}; };
} }
/// <summary>
/// Converts a single-precision floating-point value to an MxValue with MxDataType.Float.
/// </summary>
/// <param name="value">Single-precision floating-point value to wrap.</param>
public static MxValue ToMxValue(this float value) public static MxValue ToMxValue(this float value)
{ {
return new MxValue return new MxValue
@@ -50,6 +66,10 @@ public static class MxValueExtensions
}; };
} }
/// <summary>
/// Converts a double-precision floating-point value to an MxValue with MxDataType.Double.
/// </summary>
/// <param name="value">Double-precision floating-point value to wrap.</param>
public static MxValue ToMxValue(this double value) public static MxValue ToMxValue(this double value)
{ {
return new MxValue return new MxValue
@@ -60,6 +80,10 @@ public static class MxValueExtensions
}; };
} }
/// <summary>
/// Converts a string value to an MxValue with MxDataType.String.
/// </summary>
/// <param name="value">String value to wrap.</param>
public static MxValue ToMxValue(this string value) public static MxValue ToMxValue(this string value)
{ {
ArgumentNullException.ThrowIfNull(value); ArgumentNullException.ThrowIfNull(value);
@@ -72,6 +96,10 @@ public static class MxValueExtensions
}; };
} }
/// <summary>
/// Converts a DateTimeOffset value to an MxValue with MxDataType.Time.
/// </summary>
/// <param name="value">DateTimeOffset value to wrap.</param>
public static MxValue ToMxValue(this DateTimeOffset value) public static MxValue ToMxValue(this DateTimeOffset value)
{ {
return new MxValue return new MxValue
@@ -82,6 +110,10 @@ public static class MxValueExtensions
}; };
} }
/// <summary>
/// Converts a DateTime value to an MxValue with MxDataType.Time.
/// </summary>
/// <param name="value">DateTime value to wrap.</param>
public static MxValue ToMxValue(this DateTime value) public static MxValue ToMxValue(this DateTime value)
{ {
return new DateTimeOffset( return new DateTimeOffset(
@@ -91,6 +123,10 @@ public static class MxValueExtensions
.ToMxValue(); .ToMxValue();
} }
/// <summary>
/// Converts a boolean array to an MxValue with MxDataType.Boolean.
/// </summary>
/// <param name="values">Array of boolean values to wrap.</param>
public static MxValue ToMxValue(this IReadOnlyList<bool> values) public static MxValue ToMxValue(this IReadOnlyList<bool> values)
{ {
ArgumentNullException.ThrowIfNull(values); ArgumentNullException.ThrowIfNull(values);
@@ -105,6 +141,10 @@ public static class MxValueExtensions
}); });
} }
/// <summary>
/// Converts a 32-bit integer array to an MxValue with MxDataType.Integer.
/// </summary>
/// <param name="values">Array of 32-bit integer values to wrap.</param>
public static MxValue ToMxValue(this IReadOnlyList<int> values) public static MxValue ToMxValue(this IReadOnlyList<int> values)
{ {
ArgumentNullException.ThrowIfNull(values); ArgumentNullException.ThrowIfNull(values);
@@ -119,6 +159,10 @@ public static class MxValueExtensions
}); });
} }
/// <summary>
/// Converts a 64-bit integer array to an MxValue with MxDataType.Integer.
/// </summary>
/// <param name="values">Array of 64-bit integer values to wrap.</param>
public static MxValue ToMxValue(this IReadOnlyList<long> values) public static MxValue ToMxValue(this IReadOnlyList<long> values)
{ {
ArgumentNullException.ThrowIfNull(values); ArgumentNullException.ThrowIfNull(values);
@@ -133,6 +177,10 @@ public static class MxValueExtensions
}); });
} }
/// <summary>
/// Converts a single-precision floating-point array to an MxValue with MxDataType.Float.
/// </summary>
/// <param name="values">Array of single-precision floating-point values to wrap.</param>
public static MxValue ToMxValue(this IReadOnlyList<float> values) public static MxValue ToMxValue(this IReadOnlyList<float> values)
{ {
ArgumentNullException.ThrowIfNull(values); ArgumentNullException.ThrowIfNull(values);
@@ -147,6 +195,10 @@ public static class MxValueExtensions
}); });
} }
/// <summary>
/// Converts a double-precision floating-point array to an MxValue with MxDataType.Double.
/// </summary>
/// <param name="values">Array of double-precision floating-point values to wrap.</param>
public static MxValue ToMxValue(this IReadOnlyList<double> values) public static MxValue ToMxValue(this IReadOnlyList<double> values)
{ {
ArgumentNullException.ThrowIfNull(values); ArgumentNullException.ThrowIfNull(values);
@@ -161,6 +213,10 @@ public static class MxValueExtensions
}); });
} }
/// <summary>
/// Converts a string array to an MxValue with MxDataType.String.
/// </summary>
/// <param name="values">Array of string values to wrap.</param>
public static MxValue ToMxValue(this IReadOnlyList<string> values) public static MxValue ToMxValue(this IReadOnlyList<string> values)
{ {
ArgumentNullException.ThrowIfNull(values); ArgumentNullException.ThrowIfNull(values);
@@ -175,6 +231,10 @@ public static class MxValueExtensions
}); });
} }
/// <summary>
/// Converts a DateTimeOffset array to an MxValue with MxDataType.Time.
/// </summary>
/// <param name="values">Array of DateTimeOffset values to wrap.</param>
public static MxValue ToMxValue(this IReadOnlyList<DateTimeOffset> values) public static MxValue ToMxValue(this IReadOnlyList<DateTimeOffset> values)
{ {
ArgumentNullException.ThrowIfNull(values); ArgumentNullException.ThrowIfNull(values);
@@ -189,6 +249,10 @@ public static class MxValueExtensions
}); });
} }
/// <summary>
/// Gets the projection kind (field name) of the given MxValue's current oneof value.
/// </summary>
/// <param name="value">The MxValue whose oneof projection kind is returned.</param>
public static string GetProjectionKind(this MxValue value) public static string GetProjectionKind(this MxValue value)
{ {
ArgumentNullException.ThrowIfNull(value); ArgumentNullException.ThrowIfNull(value);
@@ -208,6 +272,10 @@ public static class MxValueExtensions
}; };
} }
/// <summary>
/// Converts an MxValue to a CLR object; returns the boxed value or null for null MxValues.
/// </summary>
/// <param name="value">The MxValue to convert.</param>
public static object? ToClrValue(this MxValue value) public static object? ToClrValue(this MxValue value)
{ {
ArgumentNullException.ThrowIfNull(value); ArgumentNullException.ThrowIfNull(value);
@@ -227,6 +295,10 @@ public static class MxValueExtensions
}; };
} }
/// <summary>
/// Converts an MxArray to a CLR array; returns null if the array does not have a known element type.
/// </summary>
/// <param name="array">The MxArray to convert.</param>
public static object? ToClrArrayValue(this MxArray array) public static object? ToClrArrayValue(this MxArray array)
{ {
ArgumentNullException.ThrowIfNull(array); ArgumentNullException.ThrowIfNull(array);
@@ -249,6 +321,13 @@ public static class MxValueExtensions
}; };
} }
/// <summary>
/// Creates an MxValue with MxDataType.Unknown from raw byte data, variant type, and diagnostic info.
/// </summary>
/// <param name="value">Raw byte data representing the value.</param>
/// <param name="variantType">Variant type string (e.g., "VT_BSTR").</param>
/// <param name="rawDiagnostic">Diagnostic string describing the raw value.</param>
/// <param name="rawDataType">Optional MXAccess data type override.</param>
public static MxValue ToRawMxValue( public static MxValue ToRawMxValue(
byte[] value, byte[] value,
string variantType, string variantType,
@@ -4,6 +4,7 @@ namespace MxGateway.IntegrationTests.Galaxy;
public sealed class GalaxyRepositoryLiveTests public sealed class GalaxyRepositoryLiveTests
{ {
/// <summary>Verifies that the Galaxy Repository can establish a live connection to the ZB database.</summary>
[LiveGalaxyRepositoryFact] [LiveGalaxyRepositoryFact]
[Trait("Category", "LiveGalaxy")] [Trait("Category", "LiveGalaxy")]
public async Task TestConnection_AgainstZb_Succeeds() public async Task TestConnection_AgainstZb_Succeeds()
@@ -15,6 +16,7 @@ public sealed class GalaxyRepositoryLiveTests
Assert.True(ok, "TestConnectionAsync should return true against the ZB database."); Assert.True(ok, "TestConnectionAsync should return true against the ZB database.");
} }
/// <summary>Verifies that the last deploy time can be retrieved from the ZB database.</summary>
[LiveGalaxyRepositoryFact] [LiveGalaxyRepositoryFact]
[Trait("Category", "LiveGalaxy")] [Trait("Category", "LiveGalaxy")]
public async Task GetLastDeployTime_AgainstZb_ReturnsTimestamp() public async Task GetLastDeployTime_AgainstZb_ReturnsTimestamp()
@@ -26,6 +28,7 @@ public sealed class GalaxyRepositoryLiveTests
Assert.NotNull(lastDeploy); Assert.NotNull(lastDeploy);
} }
/// <summary>Verifies that the hierarchy can be retrieved from the ZB database.</summary>
[LiveGalaxyRepositoryFact] [LiveGalaxyRepositoryFact]
[Trait("Category", "LiveGalaxy")] [Trait("Category", "LiveGalaxy")]
public async Task GetHierarchy_AgainstZb_ReturnsObjects() public async Task GetHierarchy_AgainstZb_ReturnsObjects()
@@ -43,6 +46,7 @@ public sealed class GalaxyRepositoryLiveTests
}); });
} }
/// <summary>Verifies that object attributes can be retrieved from the ZB database.</summary>
[LiveGalaxyRepositoryFact] [LiveGalaxyRepositoryFact]
[Trait("Category", "LiveGalaxy")] [Trait("Category", "LiveGalaxy")]
public async Task GetAttributes_AgainstZb_ReturnsAtLeastOneAttribute() public async Task GetAttributes_AgainstZb_ReturnsAtLeastOneAttribute()
@@ -1,10 +1,14 @@
namespace MxGateway.IntegrationTests.Galaxy; namespace MxGateway.IntegrationTests.Galaxy;
/// <summary>Fact attribute that skips tests unless live Galaxy Repository tests are explicitly enabled.</summary>
public sealed class LiveGalaxyRepositoryFactAttribute : FactAttribute public sealed class LiveGalaxyRepositoryFactAttribute : FactAttribute
{ {
/// <summary>Environment variable name to enable live Galaxy Repository tests.</summary>
public const string EnableVariableName = "MXGATEWAY_RUN_LIVE_GALAXY_TESTS"; public const string EnableVariableName = "MXGATEWAY_RUN_LIVE_GALAXY_TESTS";
/// <summary>Environment variable name for the Galaxy Repository connection string.</summary>
public const string ConnectionStringVariableName = "MXGATEWAY_LIVE_GALAXY_CONN"; public const string ConnectionStringVariableName = "MXGATEWAY_LIVE_GALAXY_CONN";
/// <summary>Initializes a new instance of the LiveGalaxyRepositoryFactAttribute class.</summary>
public LiveGalaxyRepositoryFactAttribute() public LiveGalaxyRepositoryFactAttribute()
{ {
if (!Enabled) if (!Enabled)
@@ -13,12 +17,14 @@ public sealed class LiveGalaxyRepositoryFactAttribute : FactAttribute
} }
} }
/// <summary>Gets a value indicating whether live Galaxy Repository tests are enabled.</summary>
public static bool Enabled => public static bool Enabled =>
string.Equals( string.Equals(
Environment.GetEnvironmentVariable(EnableVariableName), Environment.GetEnvironmentVariable(EnableVariableName),
"1", "1",
StringComparison.Ordinal); StringComparison.Ordinal);
/// <summary>Gets the Galaxy Repository connection string from environment or default.</summary>
public static string ConnectionString => public static string ConnectionString =>
Environment.GetEnvironmentVariable(ConnectionStringVariableName) Environment.GetEnvironmentVariable(ConnectionStringVariableName)
?? "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;"; ?? "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;";
@@ -8,27 +8,33 @@ public static class IntegrationTestEnvironment
public const string LiveMxAccessClientNameVariableName = "MXGATEWAY_LIVE_MXACCESS_CLIENT_NAME"; public const string LiveMxAccessClientNameVariableName = "MXGATEWAY_LIVE_MXACCESS_CLIENT_NAME";
public const string LiveMxAccessEventTimeoutSecondsVariableName = "MXGATEWAY_LIVE_MXACCESS_EVENT_TIMEOUT_SECONDS"; public const string LiveMxAccessEventTimeoutSecondsVariableName = "MXGATEWAY_LIVE_MXACCESS_EVENT_TIMEOUT_SECONDS";
/// <summary>Gets whether live MXAccess tests are enabled.</summary>
public static bool LiveMxAccessTestsEnabled => public static bool LiveMxAccessTestsEnabled =>
string.Equals( string.Equals(
Environment.GetEnvironmentVariable(LiveMxAccessVariableName), Environment.GetEnvironmentVariable(LiveMxAccessVariableName),
"1", "1",
StringComparison.Ordinal); StringComparison.Ordinal);
/// <summary>Gets the MXAccess item name for live tests.</summary>
public static string LiveMxAccessItem => public static string LiveMxAccessItem =>
GetOptionalEnvironmentVariable( GetOptionalEnvironmentVariable(
LiveMxAccessItemVariableName, LiveMxAccessItemVariableName,
"TestChildObject.TestInt"); "TestChildObject.TestInt");
/// <summary>Gets the client name for live tests.</summary>
public static string LiveMxAccessClientName => public static string LiveMxAccessClientName =>
GetOptionalEnvironmentVariable( GetOptionalEnvironmentVariable(
LiveMxAccessClientNameVariableName, LiveMxAccessClientNameVariableName,
"MxGateway.IntegrationTests"); "MxGateway.IntegrationTests");
/// <summary>Gets the timeout for waiting on events in live tests.</summary>
public static TimeSpan LiveMxAccessEventTimeout => public static TimeSpan LiveMxAccessEventTimeout =>
TimeSpan.FromSeconds(GetPositiveIntegerEnvironmentVariable( TimeSpan.FromSeconds(GetPositiveIntegerEnvironmentVariable(
LiveMxAccessEventTimeoutSecondsVariableName, LiveMxAccessEventTimeoutSecondsVariableName,
defaultValue: 15)); defaultValue: 15));
/// <summary>Resolves the path to the worker executable for live tests.</summary>
/// <returns>Path to MxGateway.Worker.exe.</returns>
public static string ResolveLiveMxAccessWorkerExecutablePath() public static string ResolveLiveMxAccessWorkerExecutablePath()
{ {
string? configuredPath = Environment.GetEnvironmentVariable(LiveMxAccessWorkerExecutableVariableName); string? configuredPath = Environment.GetEnvironmentVariable(LiveMxAccessWorkerExecutableVariableName);
@@ -74,6 +80,9 @@ public static class IntegrationTestEnvironment
return defaultValue; return defaultValue;
} }
/// <summary>Resolves the root directory of the repository by searching for .git and src directories.</summary>
/// <param name="startDirectory">Starting directory to search from.</param>
/// <returns>The repository root path, or the start directory if not found.</returns>
internal static string ResolveRepositoryRoot(string startDirectory) internal static string ResolveRepositoryRoot(string startDirectory)
{ {
DirectoryInfo? directory = new(startDirectory); DirectoryInfo? directory = new(startDirectory);
@@ -2,6 +2,7 @@ namespace MxGateway.IntegrationTests;
public sealed class IntegrationTestEnvironmentTests public sealed class IntegrationTestEnvironmentTests
{ {
/// <summary>Verifies that live MXAccess tests use correct environment variable name.</summary>
[Fact] [Fact]
public void LiveMxAccessTests_AreOptInByEnvironmentVariable() public void LiveMxAccessTests_AreOptInByEnvironmentVariable()
{ {
@@ -10,6 +11,7 @@ public sealed class IntegrationTestEnvironmentTests
IntegrationTestEnvironment.LiveMxAccessVariableName); IntegrationTestEnvironment.LiveMxAccessVariableName);
} }
/// <summary>Verifies that worker executable uses correct environment variable name.</summary>
[Fact] [Fact]
public void LiveMxAccessWorkerExecutable_UsesDocumentedEnvironmentVariable() public void LiveMxAccessWorkerExecutable_UsesDocumentedEnvironmentVariable()
{ {
@@ -18,6 +20,7 @@ public sealed class IntegrationTestEnvironmentTests
IntegrationTestEnvironment.LiveMxAccessWorkerExecutableVariableName); IntegrationTestEnvironment.LiveMxAccessWorkerExecutableVariableName);
} }
/// <summary>Verifies that repository root resolution accepts git worktree files.</summary>
[Fact] [Fact]
public void ResolveRepositoryRoot_AcceptsGitWorktreeFile() public void ResolveRepositoryRoot_AcceptsGitWorktreeFile()
{ {
@@ -1,7 +1,9 @@
namespace MxGateway.IntegrationTests; namespace MxGateway.IntegrationTests;
/// <summary>Marks an xUnit test as requiring installed MXAccess COM and live provider state.</summary>
public sealed class LiveMxAccessFactAttribute : FactAttribute public sealed class LiveMxAccessFactAttribute : FactAttribute
{ {
/// <summary>Initializes the attribute, skipping the test unless the integration test environment variable is set.</summary>
public LiveMxAccessFactAttribute() public LiveMxAccessFactAttribute()
{ {
if (!IntegrationTestEnvironment.LiveMxAccessTestsEnabled) if (!IntegrationTestEnvironment.LiveMxAccessTestsEnabled)
@@ -21,6 +21,9 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(15); private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(15);
private static readonly TimeSpan StreamShutdownTimeout = TimeSpan.FromSeconds(10); private static readonly TimeSpan StreamShutdownTimeout = TimeSpan.FromSeconds(10);
/// <summary>
/// Verifies that a gateway session can register, add item, advise, and stream events from live MXAccess.
/// </summary>
[LiveMxAccessFact] [LiveMxAccessFact]
[Trait("Category", "LiveMxAccess")] [Trait("Category", "LiveMxAccess")]
public async Task GatewaySession_WithLiveWorker_RegistersAdvisesStreamsDataAndCloses() public async Task GatewaySession_WithLiveWorker_RegistersAdvisesStreamsDataAndCloses()
@@ -208,12 +211,21 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
$"Event value_type={dataChange.Value?.DataType} raw_status={dataChange.RawStatus}"); $"Event value_type={dataChange.Value?.DataType} raw_status={dataChange.RawStatus}");
} }
/// <summary>
/// Test fixture that assembles the gateway service with a worker process factory for live MXAccess testing.
/// </summary>
private sealed class GatewayServiceFixture : IAsyncDisposable private sealed class GatewayServiceFixture : IAsyncDisposable
{ {
private readonly GatewayMetrics _metrics = new(); private readonly GatewayMetrics _metrics = new();
private readonly SessionRegistry _registry = new(); private readonly SessionRegistry _registry = new();
private readonly ILoggerFactory _loggerFactory; private readonly ILoggerFactory _loggerFactory;
/// <summary>
/// Initializes the fixture with worker executable path, factory, and test output helper.
/// </summary>
/// <param name="workerExecutablePath">Path to the worker process executable.</param>
/// <param name="processFactory">Factory for creating worker processes.</param>
/// <param name="output">Test output helper for logging.</param>
public GatewayServiceFixture( public GatewayServiceFixture(
string workerExecutablePath, string workerExecutablePath,
IWorkerProcessFactory processFactory, IWorkerProcessFactory processFactory,
@@ -255,8 +267,14 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
_loggerFactory.CreateLogger<MxAccessGatewayService>()); _loggerFactory.CreateLogger<MxAccessGatewayService>());
} }
/// <summary>
/// The assembled gateway service instance.
/// </summary>
public MxAccessGatewayService Service { get; } public MxAccessGatewayService Service { get; }
/// <summary>
/// Disposes the fixture resources and closes all sessions.
/// </summary>
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
foreach (GatewaySession session in _registry.Snapshot()) foreach (GatewaySession session in _registry.Snapshot())
@@ -295,12 +313,18 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
} }
} }
/// <summary>
/// Gathers messages written to a server stream for test inspection.
/// </summary>
private sealed class RecordingServerStreamWriter<T> : IServerStreamWriter<T> private sealed class RecordingServerStreamWriter<T> : IServerStreamWriter<T>
{ {
private readonly object syncRoot = new(); private readonly object syncRoot = new();
private readonly TaskCompletionSource<T> firstMessage = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource<T> firstMessage = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly List<T> messages = []; private readonly List<T> messages = [];
/// <summary>
/// All messages that have been written to the stream.
/// </summary>
public IReadOnlyList<T> Messages public IReadOnlyList<T> Messages
{ {
get get
@@ -312,8 +336,15 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
} }
} }
/// <summary>
/// Inherited write options.
/// </summary>
public WriteOptions? WriteOptions { get; set; } public WriteOptions? WriteOptions { get; set; }
/// <summary>
/// Records the message and completes the first-message task.
/// </summary>
/// <param name="message">The message to write.</param>
public Task WriteAsync(T message) public Task WriteAsync(T message)
{ {
lock (syncRoot) lock (syncRoot)
@@ -325,12 +356,20 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
return Task.CompletedTask; return Task.CompletedTask;
} }
/// <summary>
/// Waits for the first message up to the specified timeout.
/// </summary>
/// <param name="timeout">The maximum time to wait.</param>
/// <returns>The first message written to the stream.</returns>
public async Task<T> WaitForFirstMessageAsync(TimeSpan timeout) public async Task<T> WaitForFirstMessageAsync(TimeSpan timeout)
{ {
return await firstMessage.Task.WaitAsync(timeout).ConfigureAwait(false); return await firstMessage.Task.WaitAsync(timeout).ConfigureAwait(false);
} }
} }
/// <summary>
/// Mock server call context for testing gRPC calls.
/// </summary>
private sealed class TestServerCallContext(CancellationToken cancellationToken = default) : ServerCallContext private sealed class TestServerCallContext(CancellationToken cancellationToken = default) : ServerCallContext
{ {
private readonly Metadata requestHeaders = []; private readonly Metadata requestHeaders = [];
@@ -339,43 +378,56 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
private Status status; private Status status;
private WriteOptions? writeOptions; private WriteOptions? writeOptions;
/// <inheritdoc />
protected override string MethodCore => "/mxaccess_gateway.v1.MxAccessGateway/Test"; protected override string MethodCore => "/mxaccess_gateway.v1.MxAccessGateway/Test";
/// <inheritdoc />
protected override string HostCore => "localhost"; protected override string HostCore => "localhost";
/// <inheritdoc />
protected override string PeerCore => "ipv4:127.0.0.1:5000"; protected override string PeerCore => "ipv4:127.0.0.1:5000";
/// <inheritdoc />
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1); protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
/// <inheritdoc />
protected override Metadata RequestHeadersCore => requestHeaders; protected override Metadata RequestHeadersCore => requestHeaders;
/// <inheritdoc />
protected override CancellationToken CancellationTokenCore => cancellationToken; protected override CancellationToken CancellationTokenCore => cancellationToken;
/// <inheritdoc />
protected override Metadata ResponseTrailersCore => responseTrailers; protected override Metadata ResponseTrailersCore => responseTrailers;
/// <inheritdoc />
protected override Status StatusCore protected override Status StatusCore
{ {
get => status; get => status;
set => status = value; set => status = value;
} }
/// <inheritdoc />
protected override WriteOptions? WriteOptionsCore protected override WriteOptions? WriteOptionsCore
{ {
get => writeOptions; get => writeOptions;
set => writeOptions = value; set => writeOptions = value;
} }
/// <inheritdoc />
protected override AuthContext AuthContextCore { get; } = new( protected override AuthContext AuthContextCore { get; } = new(
string.Empty, string.Empty,
new Dictionary<string, List<AuthProperty>>(StringComparer.Ordinal)); new Dictionary<string, List<AuthProperty>>(StringComparer.Ordinal));
/// <inheritdoc />
protected override IDictionary<object, object> UserStateCore => userState; protected override IDictionary<object, object> UserStateCore => userState;
/// <inheritdoc />
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders)
{ {
return Task.CompletedTask; return Task.CompletedTask;
} }
/// <inheritdoc />
protected override ContextPropagationToken CreatePropagationTokenCore( protected override ContextPropagationToken CreatePropagationTokenCore(
ContextPropagationOptions? options) ContextPropagationOptions? options)
{ {
@@ -383,10 +435,14 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
} }
} }
/// <summary>
/// Factory that launches worker processes and records their outputs for testing.
/// </summary>
private sealed class TestWorkerProcessFactory(ITestOutputHelper output) : IWorkerProcessFactory private sealed class TestWorkerProcessFactory(ITestOutputHelper output) : IWorkerProcessFactory
{ {
private readonly ConcurrentBag<TestWorkerProcess> processes = []; private readonly ConcurrentBag<TestWorkerProcess> processes = [];
/// <inheritdoc />
public IWorkerProcess Start(ProcessStartInfo startInfo) public IWorkerProcess Start(ProcessStartInfo startInfo)
{ {
startInfo.RedirectStandardError = true; startInfo.RedirectStandardError = true;
@@ -418,6 +474,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
return workerProcess; return workerProcess;
} }
/// <inheritdoc />
public async Task WaitForProcessesAsync(TimeSpan timeout) public async Task WaitForProcessesAsync(TimeSpan timeout)
{ {
foreach (TestWorkerProcess process in processes) foreach (TestWorkerProcess process in processes)
@@ -445,57 +502,77 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
} }
} }
/// <summary>
/// Adapter wrapping a System.Diagnostics.Process as IWorkerProcess for testing.
/// </summary>
private sealed class TestWorkerProcess(Process process) : IWorkerProcess private sealed class TestWorkerProcess(Process process) : IWorkerProcess
{ {
/// <inheritdoc />
public int Id => process.Id; public int Id => process.Id;
/// <inheritdoc />
public bool HasExited => process.HasExited; public bool HasExited => process.HasExited;
/// <inheritdoc />
public int? ExitCode => process.HasExited ? process.ExitCode : null; public int? ExitCode => process.HasExited ? process.ExitCode : null;
/// <inheritdoc />
public async ValueTask WaitForExitAsync(CancellationToken cancellationToken) public async ValueTask WaitForExitAsync(CancellationToken cancellationToken)
{ {
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
} }
/// <inheritdoc />
public void Kill(bool entireProcessTree) public void Kill(bool entireProcessTree)
{ {
process.Kill(entireProcessTree); process.Kill(entireProcessTree);
} }
/// <inheritdoc />
public void Dispose() public void Dispose()
{ {
process.Dispose(); process.Dispose();
} }
} }
/// <summary>
/// Logger provider that writes all output to the test output helper.
/// </summary>
private sealed class TestOutputLoggerProvider(ITestOutputHelper output) : ILoggerProvider private sealed class TestOutputLoggerProvider(ITestOutputHelper output) : ILoggerProvider
{ {
/// <inheritdoc />
public ILogger CreateLogger(string categoryName) public ILogger CreateLogger(string categoryName)
{ {
return new TestOutputLogger(output, categoryName); return new TestOutputLogger(output, categoryName);
} }
/// <inheritdoc />
public void Dispose() public void Dispose()
{ {
} }
} }
/// <summary>
/// Logger that writes messages to the test output helper.
/// </summary>
private sealed class TestOutputLogger( private sealed class TestOutputLogger(
ITestOutputHelper output, ITestOutputHelper output,
string categoryName) : ILogger string categoryName) : ILogger
{ {
/// <inheritdoc />
public IDisposable? BeginScope<TState>(TState state) public IDisposable? BeginScope<TState>(TState state)
where TState : notnull where TState : notnull
{ {
return null; return null;
} }
/// <inheritdoc />
public bool IsEnabled(LogLevel logLevel) public bool IsEnabled(LogLevel logLevel)
{ {
return logLevel >= LogLevel.Information; return logLevel >= LogLevel.Information;
} }
/// <inheritdoc />
public void Log<TState>( public void Log<TState>(
LogLevel logLevel, LogLevel logLevel,
EventId eventId, EventId eventId,
@@ -2,11 +2,15 @@ namespace MxGateway.Server.Configuration;
public sealed class AuthenticationOptions public sealed class AuthenticationOptions
{ {
/// <summary>Gets the authentication mode.</summary>
public AuthenticationMode Mode { get; init; } = AuthenticationMode.ApiKey; public AuthenticationMode Mode { get; init; } = AuthenticationMode.ApiKey;
/// <summary>Gets the SQLite database path for authentication credentials.</summary>
public string SqlitePath { get; init; } = @"C:\ProgramData\MxGateway\gateway-auth.db"; public string SqlitePath { get; init; } = @"C:\ProgramData\MxGateway\gateway-auth.db";
/// <summary>Gets the secret manager name for API key pepper.</summary>
public string PepperSecretName { get; init; } = "MxGateway:ApiKeyPepper"; public string PepperSecretName { get; init; } = "MxGateway:ApiKeyPepper";
/// <summary>Gets whether database migrations should run on startup.</summary>
public bool RunMigrationsOnStartup { get; init; } = true; public bool RunMigrationsOnStartup { get; init; } = true;
} }
@@ -2,19 +2,27 @@ namespace MxGateway.Server.Configuration;
public sealed class DashboardOptions public sealed class DashboardOptions
{ {
/// <summary>Gets whether the dashboard is enabled.</summary>
public bool Enabled { get; init; } = true; public bool Enabled { get; init; } = true;
/// <summary>Gets the dashboard URL path base.</summary>
public string PathBase { get; init; } = "/dashboard"; public string PathBase { get; init; } = "/dashboard";
/// <summary>Gets whether dashboard access requires admin scope.</summary>
public bool RequireAdminScope { get; init; } = true; public bool RequireAdminScope { get; init; } = true;
/// <summary>Gets whether anonymous localhost access to dashboard is allowed.</summary>
public bool AllowAnonymousLocalhost { get; init; } = true; public bool AllowAnonymousLocalhost { get; init; } = true;
/// <summary>Gets the dashboard snapshot update interval in milliseconds.</summary>
public int SnapshotIntervalMilliseconds { get; init; } = 1_000; public int SnapshotIntervalMilliseconds { get; init; } = 1_000;
/// <summary>Gets the maximum number of recent faults to display.</summary>
public int RecentFaultLimit { get; init; } = 100; public int RecentFaultLimit { get; init; } = 100;
/// <summary>Gets the maximum number of recent sessions to display.</summary>
public int RecentSessionLimit { get; init; } = 200; public int RecentSessionLimit { get; init; } = 200;
/// <summary>Gets whether to show full tag values in the dashboard.</summary>
public bool ShowTagValues { get; init; } public bool ShowTagValues { get; init; }
} }
@@ -2,7 +2,13 @@ namespace MxGateway.Server.Configuration;
public sealed class EventOptions public sealed class EventOptions
{ {
/// <summary>
/// Gets the event queue capacity.
/// </summary>
public int QueueCapacity { get; init; } = 10_000; public int QueueCapacity { get; init; } = 10_000;
/// <summary>
/// Gets the backpressure policy for event queue overflow.
/// </summary>
public EventBackpressurePolicy BackpressurePolicy { get; init; } = EventBackpressurePolicy.FailFast; public EventBackpressurePolicy BackpressurePolicy { get; init; } = EventBackpressurePolicy.FailFast;
} }
@@ -2,10 +2,13 @@ using Microsoft.Extensions.Options;
namespace MxGateway.Server.Configuration; namespace MxGateway.Server.Configuration;
/// <summary>Provides the effective gateway configuration with sensitive values redacted.</summary>
public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> options) : IGatewayConfigurationProvider public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> options) : IGatewayConfigurationProvider
{ {
/// <summary>Marker string for redacted sensitive configuration values.</summary>
public const string RedactedValue = "[redacted]"; public const string RedactedValue = "[redacted]";
/// <inheritdoc />
public EffectiveGatewayConfiguration GetEffectiveConfiguration() public EffectiveGatewayConfiguration GetEffectiveConfiguration()
{ {
GatewayOptions value = options.Value; GatewayOptions value = options.Value;
@@ -4,6 +4,9 @@ namespace MxGateway.Server.Configuration;
public static class GatewayConfigurationServiceCollectionExtensions public static class GatewayConfigurationServiceCollectionExtensions
{ {
/// <summary>Registers gateway configuration services in the dependency injection container.</summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddGatewayConfiguration(this IServiceCollection services) public static IServiceCollection AddGatewayConfiguration(this IServiceCollection services)
{ {
services services
@@ -4,15 +4,33 @@ public sealed class GatewayOptions
{ {
public const string SectionName = "MxGateway"; public const string SectionName = "MxGateway";
/// <summary>
/// Gets authentication configuration options.
/// </summary>
public AuthenticationOptions Authentication { get; init; } = new(); public AuthenticationOptions Authentication { get; init; } = new();
/// <summary>
/// Gets worker process configuration options.
/// </summary>
public WorkerOptions Worker { get; init; } = new(); public WorkerOptions Worker { get; init; } = new();
/// <summary>
/// Gets session management configuration options.
/// </summary>
public SessionOptions Sessions { get; init; } = new(); public SessionOptions Sessions { get; init; } = new();
/// <summary>
/// Gets event stream configuration options.
/// </summary>
public EventOptions Events { get; init; } = new(); public EventOptions Events { get; init; } = new();
/// <summary>
/// Gets dashboard configuration options.
/// </summary>
public DashboardOptions Dashboard { get; init; } = new(); public DashboardOptions Dashboard { get; init; } = new();
/// <summary>
/// Gets protocol configuration options.
/// </summary>
public ProtocolOptions Protocol { get; init; } = new(); public ProtocolOptions Protocol { get; init; } = new();
} }
@@ -8,6 +8,12 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
private const int MinimumMaxMessageBytes = 1024; private const int MinimumMaxMessageBytes = 1024;
private const int MaximumMaxMessageBytes = 256 * 1024 * 1024; private const int MaximumMaxMessageBytes = 256 * 1024 * 1024;
/// <summary>
/// Validates gateway configuration options.
/// </summary>
/// <param name="name">Options name.</param>
/// <param name="options">Gateway options to validate.</param>
/// <returns>Validation result.</returns>
public ValidateOptionsResult Validate(string? name, GatewayOptions options) public ValidateOptionsResult Validate(string? name, GatewayOptions options)
{ {
List<string> failures = []; List<string> failures = [];
@@ -1,6 +1,12 @@
namespace MxGateway.Server.Configuration; namespace MxGateway.Server.Configuration;
/// <summary>
/// Provides the effective gateway configuration, applying defaults and validations.
/// </summary>
public interface IGatewayConfigurationProvider public interface IGatewayConfigurationProvider
{ {
/// <summary>
/// Returns the validated and effective gateway configuration.
/// </summary>
EffectiveGatewayConfiguration GetEffectiveConfiguration(); EffectiveGatewayConfiguration GetEffectiveConfiguration();
} }
@@ -2,7 +2,13 @@ using MxGateway.Contracts;
namespace MxGateway.Server.Configuration; namespace MxGateway.Server.Configuration;
/// <summary>
/// Configuration options for the worker protocol version.
/// </summary>
public sealed class ProtocolOptions public sealed class ProtocolOptions
{ {
/// <summary>
/// Gets or sets the worker protocol version.
/// </summary>
public uint WorkerProtocolVersion { get; init; } = GatewayContractInfo.WorkerProtocolVersion; public uint WorkerProtocolVersion { get; init; } = GatewayContractInfo.WorkerProtocolVersion;
} }
@@ -2,11 +2,23 @@ namespace MxGateway.Server.Configuration;
public sealed class SessionOptions public sealed class SessionOptions
{ {
/// <summary>
/// Gets the default command timeout in seconds.
/// </summary>
public int DefaultCommandTimeoutSeconds { get; init; } = 30; public int DefaultCommandTimeoutSeconds { get; init; } = 30;
/// <summary>
/// Gets the maximum number of concurrent sessions.
/// </summary>
public int MaxSessions { get; init; } = 64; public int MaxSessions { get; init; } = 64;
/// <summary>
/// Gets the maximum number of pending commands per session.
/// </summary>
public int MaxPendingCommandsPerSession { get; init; } = 128; public int MaxPendingCommandsPerSession { get; init; } = 128;
/// <summary>
/// Gets a value indicating whether multiple event subscribers are allowed per session.
/// </summary>
public bool AllowMultipleEventSubscribers { get; init; } public bool AllowMultipleEventSubscribers { get; init; }
} }
@@ -2,26 +2,37 @@ namespace MxGateway.Server.Configuration;
public sealed class WorkerOptions public sealed class WorkerOptions
{ {
/// <summary>The path to the worker executable.</summary>
public string ExecutablePath { get; init; } = public string ExecutablePath { get; init; } =
@"src\MxGateway.Worker\bin\x86\Release\MxGateway.Worker.exe"; @"src\MxGateway.Worker\bin\x86\Release\MxGateway.Worker.exe";
/// <summary>The working directory for the worker process, or null to inherit.</summary>
public string? WorkingDirectory { get; init; } public string? WorkingDirectory { get; init; }
/// <summary>The required processor architecture for the worker.</summary>
public WorkerArchitecture RequiredArchitecture { get; init; } = WorkerArchitecture.X86; public WorkerArchitecture RequiredArchitecture { get; init; } = WorkerArchitecture.X86;
/// <summary>The maximum time in seconds for the worker to start.</summary>
public int StartupTimeoutSeconds { get; init; } = 30; public int StartupTimeoutSeconds { get; init; } = 30;
/// <summary>The number of retry attempts for the startup probe.</summary>
public int StartupProbeRetryAttempts { get; init; } = 3; public int StartupProbeRetryAttempts { get; init; } = 3;
/// <summary>The delay in milliseconds between startup probe retries.</summary>
public int StartupProbeRetryDelayMilliseconds { get; init; } = 250; public int StartupProbeRetryDelayMilliseconds { get; init; } = 250;
/// <summary>The timeout in milliseconds for connecting to the worker pipe.</summary>
public int PipeConnectAttemptTimeoutMilliseconds { get; init; } = 2000; public int PipeConnectAttemptTimeoutMilliseconds { get; init; } = 2000;
/// <summary>The maximum time in seconds for graceful shutdown.</summary>
public int ShutdownTimeoutSeconds { get; init; } = 10; public int ShutdownTimeoutSeconds { get; init; } = 10;
/// <summary>The interval in seconds for worker heartbeats.</summary>
public int HeartbeatIntervalSeconds { get; init; } = 5; public int HeartbeatIntervalSeconds { get; init; } = 5;
/// <summary>The grace period in seconds after a heartbeat before considering the worker unresponsive.</summary>
public int HeartbeatGraceSeconds { get; init; } = 15; public int HeartbeatGraceSeconds { get; init; } = 15;
/// <summary>The maximum message size in bytes for IPC communication.</summary>
public int MaxMessageBytes { get; init; } = 16 * 1024 * 1024; public int MaxMessageBytes { get; init; } = 16 * 1024 * 1024;
} }
@@ -2,6 +2,11 @@ namespace MxGateway.Server.Dashboard.Components;
public static class DashboardDisplay public static class DashboardDisplay
{ {
/// <summary>
/// Formats a nullable date and time value for display.
/// </summary>
/// <param name="value">The date and time to format.</param>
/// <returns>Formatted date and time string or "-" if null.</returns>
public static string DateTime(DateTimeOffset? value) public static string DateTime(DateTimeOffset? value)
{ {
return value.HasValue return value.HasValue
@@ -9,6 +14,11 @@ public static class DashboardDisplay
: "-"; : "-";
} }
/// <summary>
/// Formats a time span duration for display.
/// </summary>
/// <param name="value">The duration to format.</param>
/// <returns>Formatted duration string.</returns>
public static string Duration(TimeSpan value) public static string Duration(TimeSpan value)
{ {
return value.TotalDays >= 1 return value.TotalDays >= 1
@@ -16,16 +26,33 @@ public static class DashboardDisplay
: value.ToString(@"hh\:mm\:ss", System.Globalization.CultureInfo.InvariantCulture); : value.ToString(@"hh\:mm\:ss", System.Globalization.CultureInfo.InvariantCulture);
} }
/// <summary>
/// Formats a nullable text value for display.
/// </summary>
/// <param name="value">The text to format.</param>
/// <returns>Formatted text or "-" if null or empty.</returns>
public static string Text(string? value) public static string Text(string? value)
{ {
return string.IsNullOrWhiteSpace(value) ? "-" : value; return string.IsNullOrWhiteSpace(value) ? "-" : value;
} }
/// <summary>
/// Formats a long count value for display with thousands separator.
/// </summary>
/// <param name="value">The count to format.</param>
/// <returns>Formatted count string.</returns>
public static string Count(long value) public static string Count(long value)
{ {
return value.ToString("N0", System.Globalization.CultureInfo.InvariantCulture); return value.ToString("N0", System.Globalization.CultureInfo.InvariantCulture);
} }
/// <summary>
/// Retrieves a metric value from a snapshot by name and optional dimension.
/// </summary>
/// <param name="snapshot">Dashboard snapshot.</param>
/// <param name="name">Metric name.</param>
/// <param name="dimension">Optional metric dimension.</param>
/// <returns>Metric value or zero if not found.</returns>
public static long MetricValue(DashboardSnapshot snapshot, string name, string? dimension = null) public static long MetricValue(DashboardSnapshot snapshot, string name, string? dimension = null)
{ {
return snapshot.Metrics.FirstOrDefault(metric => return snapshot.Metrics.FirstOrDefault(metric =>
@@ -2,21 +2,32 @@ using Microsoft.AspNetCore.Components;
namespace MxGateway.Server.Dashboard.Components; namespace MxGateway.Server.Dashboard.Components;
/// <summary>
/// Base class for Blazor dashboard pages that watch gateway metrics snapshots.
/// </summary>
public abstract class DashboardPageBase : ComponentBase, IAsyncDisposable public abstract class DashboardPageBase : ComponentBase, IAsyncDisposable
{ {
private readonly CancellationTokenSource _disposeCancellation = new(); private readonly CancellationTokenSource _disposeCancellation = new();
private Task? _watchTask; private Task? _watchTask;
/// <summary>
/// Service that provides gateway metric snapshots.
/// </summary>
[Inject] [Inject]
protected IDashboardSnapshotService SnapshotService { get; set; } = null!; protected IDashboardSnapshotService SnapshotService { get; set; } = null!;
/// <summary>
/// The most recent gateway metric snapshot, updated as it changes.
/// </summary>
protected DashboardSnapshot? Snapshot { get; private set; } protected DashboardSnapshot? Snapshot { get; private set; }
/// <inheritdoc />
protected override void OnInitialized() protected override void OnInitialized()
{ {
_watchTask = WatchSnapshotsAsync(); _watchTask = WatchSnapshotsAsync();
} }
/// <inheritdoc />
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
await _disposeCancellation.CancelAsync().ConfigureAwait(false); await _disposeCancellation.CancelAsync().ConfigureAwait(false);
@@ -29,6 +40,9 @@ public abstract class DashboardPageBase : ComponentBase, IAsyncDisposable
GC.SuppressFinalize(this); GC.SuppressFinalize(this);
} }
/// <summary>
/// Watches snapshot changes and triggers component refresh.
/// </summary>
private async Task WatchSnapshotsAsync() private async Task WatchSnapshotsAsync()
{ {
try try
@@ -2,16 +2,36 @@ using System.Security.Claims;
namespace MxGateway.Server.Dashboard; namespace MxGateway.Server.Dashboard;
/// <summary>
/// Result of a dashboard authentication attempt.
/// </summary>
public sealed record DashboardAuthenticationResult( public sealed record DashboardAuthenticationResult(
/// <summary>
/// Whether authentication succeeded.
/// </summary>
bool Succeeded, bool Succeeded,
/// <summary>
/// The authenticated principal if successful; otherwise null.
/// </summary>
ClaimsPrincipal? Principal, ClaimsPrincipal? Principal,
/// <summary>
/// The failure message if authentication failed; otherwise null.
/// </summary>
string? FailureMessage) string? FailureMessage)
{ {
/// <summary>
/// Creates a successful authentication result.
/// </summary>
/// <param name="principal">Authenticated principal.</param>
public static DashboardAuthenticationResult Success(ClaimsPrincipal principal) public static DashboardAuthenticationResult Success(ClaimsPrincipal principal)
{ {
return new DashboardAuthenticationResult(true, principal, null); return new DashboardAuthenticationResult(true, principal, null);
} }
/// <summary>
/// Creates a failed authentication result.
/// </summary>
/// <param name="failureMessage">Diagnostic message describing the failure.</param>
public static DashboardAuthenticationResult Fail(string failureMessage) public static DashboardAuthenticationResult Fail(string failureMessage)
{ {
return new DashboardAuthenticationResult(false, null, failureMessage); return new DashboardAuthenticationResult(false, null, failureMessage);
@@ -12,6 +12,7 @@ public sealed class DashboardAuthenticator(
{ {
private const string GenericFailureMessage = "The API key is invalid or is not authorized for dashboard access."; private const string GenericFailureMessage = "The API key is invalid or is not authorized for dashboard access.";
/// <inheritdoc />
public async Task<DashboardAuthenticationResult> AuthenticateAsync( public async Task<DashboardAuthenticationResult> AuthenticateAsync(
string? apiKey, string? apiKey,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -10,6 +10,7 @@ public sealed class DashboardAuthorizationHandler(
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
IOptions<GatewayOptions> options) : AuthorizationHandler<DashboardAuthorizationRequirement> IOptions<GatewayOptions> options) : AuthorizationHandler<DashboardAuthorizationRequirement>
{ {
/// <inheritdoc />
protected override Task HandleRequirementAsync( protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context, AuthorizationHandlerContext context,
DashboardAuthorizationRequirement requirement) DashboardAuthorizationRequirement requirement)
@@ -7,8 +7,12 @@ using MxGateway.Server.Dashboard.Components;
namespace MxGateway.Server.Dashboard; namespace MxGateway.Server.Dashboard;
/// <summary>Endpoint extensions for registering the gateway dashboard routes.</summary>
public static class DashboardEndpointRouteBuilderExtensions public static class DashboardEndpointRouteBuilderExtensions
{ {
/// <summary>Maps all gateway dashboard routes including login, logout, and Razor components.</summary>
/// <param name="endpoints">The endpoint route builder.</param>
/// <returns>The route builder for chaining.</returns>
public static IEndpointRouteBuilder MapGatewayDashboard(this IEndpointRouteBuilder endpoints) public static IEndpointRouteBuilder MapGatewayDashboard(this IEndpointRouteBuilder endpoints)
{ {
IConfiguration configuration = endpoints.ServiceProvider.GetRequiredService<IConfiguration>(); IConfiguration configuration = endpoints.ServiceProvider.GetRequiredService<IConfiguration>();
@@ -8,6 +8,7 @@ namespace MxGateway.Server.Dashboard;
/// per-category breakdowns are computed here rather than stored on the cache so the /// per-category breakdowns are computed here rather than stored on the cache so the
/// Galaxy namespace stays free of dashboard-presentation concepts. /// Galaxy namespace stays free of dashboard-presentation concepts.
/// </summary> /// </summary>
/// <summary>Projects Galaxy Repository cache entries to dashboard presentation format.</summary>
internal static class DashboardGalaxyProjector internal static class DashboardGalaxyProjector
{ {
private const int TopTemplatesLimit = 10; private const int TopTemplatesLimit = 10;
@@ -25,6 +26,9 @@ internal static class DashboardGalaxyProjector
[26] = "OPCClient", [26] = "OPCClient",
}; };
/// <summary>Projects a Galaxy Repository cache entry to a dashboard summary.</summary>
/// <param name="entry">Galaxy cache entry to project.</param>
/// <returns>Dashboard-formatted Galaxy summary.</returns>
public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry) public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry)
{ {
DashboardGalaxyStatus status = entry.Status switch DashboardGalaxyStatus status = entry.Status switch
@@ -19,6 +19,7 @@ public sealed record DashboardGalaxySummary(
IReadOnlyList<DashboardGalaxyTemplateUsage> TopTemplates, IReadOnlyList<DashboardGalaxyTemplateUsage> TopTemplates,
IReadOnlyList<DashboardGalaxyCategoryCount> ObjectCategories) IReadOnlyList<DashboardGalaxyCategoryCount> ObjectCategories)
{ {
/// <summary>Gets the unknown Galaxy status placeholder.</summary>
public static DashboardGalaxySummary Unknown { get; } = new( public static DashboardGalaxySummary Unknown { get; } = new(
DashboardGalaxyStatus.Unknown, DashboardGalaxyStatus.Unknown,
LastQueriedAt: null, LastQueriedAt: null,
@@ -15,6 +15,11 @@ internal static class DashboardRedactor
"token", "token",
]; ];
/// <summary>
/// Redacts sensitive content from a value for dashboard display.
/// </summary>
/// <param name="value">Value to redact.</param>
/// <returns>Redacted value or original value if not sensitive.</returns>
public static string? Redact(string? value) public static string? Redact(string? value)
{ {
if (string.IsNullOrWhiteSpace(value)) if (string.IsNullOrWhiteSpace(value))
@@ -5,8 +5,15 @@ using MxGateway.Server.Configuration;
namespace MxGateway.Server.Dashboard; namespace MxGateway.Server.Dashboard;
/// <summary>
/// Extension methods for configuring the gateway dashboard services.
/// </summary>
public static class DashboardServiceCollectionExtensions public static class DashboardServiceCollectionExtensions
{ {
/// <summary>
/// Registers all dashboard services, authentication, and Razor components.
/// </summary>
/// <param name="services">Service collection to register services.</param>
public static IServiceCollection AddGatewayDashboard(this IServiceCollection services) public static IServiceCollection AddGatewayDashboard(this IServiceCollection services)
{ {
services.AddSingleton<IDashboardSnapshotService, DashboardSnapshotService>(); services.AddSingleton<IDashboardSnapshotService, DashboardSnapshotService>();
@@ -22,6 +22,13 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
private readonly int _recentFaultLimit; private readonly int _recentFaultLimit;
private readonly int _recentSessionLimit; private readonly int _recentSessionLimit;
/// <summary>Initializes a new instance of the DashboardSnapshotService class.</summary>
/// <param name="sessionRegistry">Registry of active gateway sessions.</param>
/// <param name="metrics">Gateway metrics collector.</param>
/// <param name="configurationProvider">Gateway configuration provider.</param>
/// <param name="galaxyHierarchyCache">Galaxy hierarchy cache.</param>
/// <param name="options">Gateway configuration options.</param>
/// <param name="timeProvider">Provider for current time; defaults to system time.</param>
public DashboardSnapshotService( public DashboardSnapshotService(
ISessionRegistry sessionRegistry, ISessionRegistry sessionRegistry,
GatewayMetrics metrics, GatewayMetrics metrics,
@@ -43,6 +50,10 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
_recentSessionLimit = options.Value.Dashboard.RecentSessionLimit; _recentSessionLimit = options.Value.Dashboard.RecentSessionLimit;
} }
/// <summary>
/// Gets a current dashboard snapshot of gateway state.
/// </summary>
/// <returns>Dashboard snapshot.</returns>
public DashboardSnapshot GetSnapshot() public DashboardSnapshot GetSnapshot()
{ {
DateTimeOffset generatedAt = _timeProvider.GetUtcNow(); DateTimeOffset generatedAt = _timeProvider.GetUtcNow();
@@ -73,6 +84,11 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
Galaxy: DashboardGalaxyProjector.Project(_galaxyHierarchyCache.Current)); Galaxy: DashboardGalaxyProjector.Project(_galaxyHierarchyCache.Current));
} }
/// <summary>
/// Watches dashboard snapshots at regular intervals asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of dashboard snapshots.</returns>
public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync( public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken) [EnumeratorCancellation] CancellationToken cancellationToken)
{ {
@@ -1,7 +1,15 @@
namespace MxGateway.Server.Dashboard; namespace MxGateway.Server.Dashboard;
/// <summary>
/// Authenticates dashboard access with API keys.
/// </summary>
public interface IDashboardAuthenticator public interface IDashboardAuthenticator
{ {
/// <summary>
/// Authenticates the dashboard session with an API key.
/// </summary>
/// <param name="apiKey">The API key to authenticate.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
Task<DashboardAuthenticationResult> AuthenticateAsync( Task<DashboardAuthenticationResult> AuthenticateAsync(
string? apiKey, string? apiKey,
CancellationToken cancellationToken); CancellationToken cancellationToken);
@@ -1,8 +1,18 @@
namespace MxGateway.Server.Dashboard; namespace MxGateway.Server.Dashboard;
/// <summary>
/// Provides snapshots of the dashboard state for UI updates.
/// </summary>
public interface IDashboardSnapshotService public interface IDashboardSnapshotService
{ {
/// <summary>
/// Gets the current dashboard snapshot.
/// </summary>
DashboardSnapshot GetSnapshot(); DashboardSnapshot GetSnapshot();
/// <summary>
/// Watches for changes to the dashboard state as an async enumerable.
/// </summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(CancellationToken cancellationToken); IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(CancellationToken cancellationToken);
} }
@@ -1,7 +1,11 @@
namespace MxGateway.Server.Diagnostics; namespace MxGateway.Server.Diagnostics;
/// <summary>
/// Redacts sensitive information from log entries.
/// </summary>
public static class GatewayLogRedactor public static class GatewayLogRedactor
{ {
/// <summary>Placeholder for redacted values.</summary>
public const string RedactedValue = "[redacted]"; public const string RedactedValue = "[redacted]";
private static readonly HashSet<string> SensitiveCommandMethods = new(StringComparer.OrdinalIgnoreCase) private static readonly HashSet<string> SensitiveCommandMethods = new(StringComparer.OrdinalIgnoreCase)
@@ -11,12 +15,20 @@ public static class GatewayLogRedactor
"WriteSecured2" "WriteSecured2"
}; };
/// <summary>
/// Determines whether a command method bears credentials.
/// </summary>
/// <param name="commandMethod">The command method name to check.</param>
public static bool IsCredentialBearingCommand(string? commandMethod) public static bool IsCredentialBearingCommand(string? commandMethod)
{ {
return commandMethod is not null return commandMethod is not null
&& SensitiveCommandMethods.Contains(commandMethod); && SensitiveCommandMethods.Contains(commandMethod);
} }
/// <summary>
/// Redacts the API key secret portion of a Bearer authorization header.
/// </summary>
/// <param name="authorizationHeader">The authorization header value to redact.</param>
public static string? RedactApiKey(string? authorizationHeader) public static string? RedactApiKey(string? authorizationHeader)
{ {
if (string.IsNullOrWhiteSpace(authorizationHeader)) if (string.IsNullOrWhiteSpace(authorizationHeader))
@@ -46,6 +58,10 @@ public static class GatewayLogRedactor
return $"{bearerPrefix}mxgw_{tokenParts[1]}_{RedactedValue}"; return $"{bearerPrefix}mxgw_{tokenParts[1]}_{RedactedValue}";
} }
/// <summary>
/// Redacts the client identity if it contains an API key.
/// </summary>
/// <param name="clientIdentity">The client identity string to redact.</param>
public static string? RedactClientIdentity(string? clientIdentity) public static string? RedactClientIdentity(string? clientIdentity)
{ {
if (string.IsNullOrWhiteSpace(clientIdentity)) if (string.IsNullOrWhiteSpace(clientIdentity))
@@ -58,6 +74,12 @@ public static class GatewayLogRedactor
: clientIdentity; : clientIdentity;
} }
/// <summary>
/// Redacts a command value if it contains credentials or value logging is disabled.
/// </summary>
/// <param name="commandMethod">The command method name to check for credentials.</param>
/// <param name="value">The command value to redact.</param>
/// <param name="valueLoggingEnabled">Whether value logging is enabled.</param>
public static object? RedactCommandValue( public static object? RedactCommandValue(
string? commandMethod, string? commandMethod,
object? value, object? value,
@@ -7,6 +7,7 @@ public sealed record GatewayLogScope(
string? CommandMethod = null, string? CommandMethod = null,
string? ClientIdentity = null) string? ClientIdentity = null)
{ {
/// <summary>Converts the log scope to a dictionary with redacted sensitive fields.</summary>
public IReadOnlyDictionary<string, object?> ToDictionary() public IReadOnlyDictionary<string, object?> ToDictionary()
{ {
Dictionary<string, object?> values = []; Dictionary<string, object?> values = [];
@@ -4,6 +4,10 @@ namespace MxGateway.Server.Diagnostics;
public static class GatewayLoggerExtensions public static class GatewayLoggerExtensions
{ {
/// <summary>Begins a gateway log scope with the specified scope properties.</summary>
/// <param name="logger">Logger used for diagnostic output.</param>
/// <param name="scope">Scope properties to apply.</param>
/// <returns>A disposable that ends the scope when disposed.</returns>
public static IDisposable? BeginGatewayScope( public static IDisposable? BeginGatewayScope(
this ILogger logger, this ILogger logger,
GatewayLogScope scope) GatewayLogScope scope)
@@ -2,13 +2,23 @@ using Microsoft.Extensions.Primitives;
namespace MxGateway.Server.Diagnostics; namespace MxGateway.Server.Diagnostics;
/// <summary>Middleware extensions for structured gateway request logging with correlation context.</summary>
public static class GatewayRequestLoggingMiddlewareExtensions public static class GatewayRequestLoggingMiddlewareExtensions
{ {
/// <summary>Header name for the session ID.</summary>
public const string SessionIdHeaderName = "x-session-id"; public const string SessionIdHeaderName = "x-session-id";
/// <summary>Header name for the worker process ID.</summary>
public const string WorkerProcessIdHeaderName = "x-worker-process-id"; public const string WorkerProcessIdHeaderName = "x-worker-process-id";
/// <summary>Header name for the correlation ID.</summary>
public const string CorrelationIdHeaderName = "x-correlation-id"; public const string CorrelationIdHeaderName = "x-correlation-id";
/// <summary>Header name for the command method name.</summary>
public const string CommandMethodHeaderName = "x-command-method"; public const string CommandMethodHeaderName = "x-command-method";
/// <summary>Adds gateway request logging scope middleware that reads correlation headers and redacts sensitive data.</summary>
/// <param name="app">Application builder.</param>
public static IApplicationBuilder UseGatewayRequestLoggingScope(this IApplicationBuilder app) public static IApplicationBuilder UseGatewayRequestLoggingScope(this IApplicationBuilder app)
{ {
ArgumentNullException.ThrowIfNull(app); ArgumentNullException.ThrowIfNull(app);
@@ -10,6 +10,9 @@ namespace MxGateway.Server.Galaxy;
/// other subscribers or the publisher. When a subscriber's channel is full the oldest /// other subscribers or the publisher. When a subscriber's channel is full the oldest
/// event is dropped — clients use the sequence field to detect gaps. /// event is dropped — clients use the sequence field to detect gaps.
/// </summary> /// </summary>
/// <summary>
/// Publishes Galaxy deploy events to streaming gRPC subscribers via private bounded channels.
/// </summary>
public sealed class GalaxyDeployNotifier : IGalaxyDeployNotifier public sealed class GalaxyDeployNotifier : IGalaxyDeployNotifier
{ {
private const int SubscriberQueueCapacity = 16; private const int SubscriberQueueCapacity = 16;
@@ -17,8 +20,12 @@ public sealed class GalaxyDeployNotifier : IGalaxyDeployNotifier
private readonly ConcurrentDictionary<Guid, Channel<GalaxyDeployEventInfo>> _subscribers = new(); private readonly ConcurrentDictionary<Guid, Channel<GalaxyDeployEventInfo>> _subscribers = new();
private GalaxyDeployEventInfo? _latest; private GalaxyDeployEventInfo? _latest;
/// <summary>
/// The most recent deploy event, or null if none has been published.
/// </summary>
public GalaxyDeployEventInfo? Latest => Volatile.Read(ref _latest); public GalaxyDeployEventInfo? Latest => Volatile.Read(ref _latest);
/// <inheritdoc />
public void Publish(GalaxyDeployEventInfo info) public void Publish(GalaxyDeployEventInfo info)
{ {
ArgumentNullException.ThrowIfNull(info); ArgumentNullException.ThrowIfNull(info);
@@ -33,6 +40,7 @@ public sealed class GalaxyDeployNotifier : IGalaxyDeployNotifier
} }
} }
/// <inheritdoc />
public async IAsyncEnumerable<GalaxyDeployEventInfo> SubscribeAsync( public async IAsyncEnumerable<GalaxyDeployEventInfo> SubscribeAsync(
[EnumeratorCancellation] CancellationToken cancellationToken) [EnumeratorCancellation] CancellationToken cancellationToken)
{ {
@@ -25,6 +25,11 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
private readonly SemaphoreSlim _refreshGate = new(1, 1); private readonly SemaphoreSlim _refreshGate = new(1, 1);
private GalaxyHierarchyCacheEntry _current = GalaxyHierarchyCacheEntry.Empty; private GalaxyHierarchyCacheEntry _current = GalaxyHierarchyCacheEntry.Empty;
/// <summary>Initializes a new instance of the <see cref="GalaxyHierarchyCache"/> class.</summary>
/// <param name="repository">Galaxy Repository client for SQL queries.</param>
/// <param name="notifier">Galaxy deploy event notifier.</param>
/// <param name="timeProvider">Provider for current time; defaults to system time.</param>
/// <param name="logger">Optional logger for diagnostic output.</param>
public GalaxyHierarchyCache( public GalaxyHierarchyCache(
GalaxyRepository repository, GalaxyRepository repository,
IGalaxyDeployNotifier notifier, IGalaxyDeployNotifier notifier,
@@ -37,6 +42,7 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
_logger = logger; _logger = logger;
} }
/// <summary>Gets the current Galaxy hierarchy cache entry with projected status.</summary>
public GalaxyHierarchyCacheEntry Current public GalaxyHierarchyCacheEntry Current
{ {
get get
@@ -47,6 +53,9 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
} }
} }
/// <summary>Refreshes the Galaxy hierarchy cache if the deploy time has advanced.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>Asynchronous task representing the refresh operation.</returns>
public async Task RefreshAsync(CancellationToken cancellationToken) public async Task RefreshAsync(CancellationToken cancellationToken)
{ {
await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false); await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false);
@@ -60,6 +69,9 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
} }
} }
/// <summary>Waits for the Galaxy hierarchy cache to complete its first load.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>Asynchronous task representing the wait operation.</returns>
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) public Task WaitForFirstLoadAsync(CancellationToken cancellationToken)
{ {
return _firstLoad.Task.WaitAsync(cancellationToken); return _firstLoad.Task.WaitAsync(cancellationToken);
@@ -23,6 +23,7 @@ public sealed record GalaxyHierarchyCacheEntry(
int HistorizedAttributeCount, int HistorizedAttributeCount,
int AlarmAttributeCount) int AlarmAttributeCount)
{ {
/// <summary>Gets an empty Galaxy hierarchy cache entry.</summary>
public static GalaxyHierarchyCacheEntry Empty { get; } = new( public static GalaxyHierarchyCacheEntry Empty { get; } = new(
Status: GalaxyCacheStatus.Unknown, Status: GalaxyCacheStatus.Unknown,
Sequence: 0, Sequence: 0,
@@ -39,5 +40,6 @@ public sealed record GalaxyHierarchyCacheEntry(
HistorizedAttributeCount: 0, HistorizedAttributeCount: 0,
AlarmAttributeCount: 0); AlarmAttributeCount: 0);
/// <summary>Gets a value indicating whether the cache entry contains usable data.</summary>
public bool HasData => Status is GalaxyCacheStatus.Healthy or GalaxyCacheStatus.Stale; public bool HasData => Status is GalaxyCacheStatus.Healthy or GalaxyCacheStatus.Stale;
} }
@@ -4,11 +4,7 @@ using Microsoft.Extensions.Options;
namespace MxGateway.Server.Galaxy; namespace MxGateway.Server.Galaxy;
/// <summary> /// <summary>Background service that periodically refreshes the Galaxy Repository hierarchy cache off the request path.</summary>
/// Periodically refreshes <see cref="IGalaxyHierarchyCache"/> off the request path. The
/// interval comes from <see cref="GalaxyRepositoryOptions.DashboardRefreshIntervalSeconds"/>;
/// each tick is cheap when the deploy timestamp is unchanged.
/// </summary>
public sealed class GalaxyHierarchyRefreshService( public sealed class GalaxyHierarchyRefreshService(
IGalaxyHierarchyCache cache, IGalaxyHierarchyCache cache,
IOptions<GalaxyRepositoryOptions> options, IOptions<GalaxyRepositoryOptions> options,
@@ -17,6 +13,7 @@ public sealed class GalaxyHierarchyRefreshService(
{ {
private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System; private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
TimeSpan interval = TimeSpan.FromSeconds(Math.Max(1, options.Value.DashboardRefreshIntervalSeconds)); TimeSpan interval = TimeSpan.FromSeconds(Math.Max(1, options.Value.DashboardRefreshIntervalSeconds));
@@ -6,30 +6,70 @@ namespace MxGateway.Server.Galaxy;
/// </summary> /// </summary>
public sealed class GalaxyHierarchyRow public sealed class GalaxyHierarchyRow
{ {
/// <summary>Gets the Galaxy object identifier.</summary>
public int GobjectId { get; init; } public int GobjectId { get; init; }
/// <summary>Gets the tag name.</summary>
public string TagName { get; init; } = string.Empty; public string TagName { get; init; } = string.Empty;
/// <summary>Gets the contained name.</summary>
public string ContainedName { get; init; } = string.Empty; public string ContainedName { get; init; } = string.Empty;
/// <summary>Gets the browse name.</summary>
public string BrowseName { get; init; } = string.Empty; public string BrowseName { get; init; } = string.Empty;
/// <summary>Gets the parent Galaxy object identifier.</summary>
public int ParentGobjectId { get; init; } public int ParentGobjectId { get; init; }
/// <summary>Gets a value indicating whether this is an area.</summary>
public bool IsArea { get; init; } public bool IsArea { get; init; }
/// <summary>Gets the category identifier.</summary>
public int CategoryId { get; init; } public int CategoryId { get; init; }
/// <summary>Gets the Galaxy object identifier of the host.</summary>
public int HostedByGobjectId { get; init; } public int HostedByGobjectId { get; init; }
/// <summary>Gets the template derivation chain.</summary>
public IReadOnlyList<string> TemplateChain { get; init; } = Array.Empty<string>(); public IReadOnlyList<string> TemplateChain { get; init; } = Array.Empty<string>();
} }
/// <summary>One row from <see cref="GalaxyRepository.GetAttributesAsync"/>.</summary> /// <summary>One row from <see cref="GalaxyRepository.GetAttributesAsync"/>.</summary>
public sealed class GalaxyAttributeRow public sealed class GalaxyAttributeRow
{ {
/// <summary>Gets the Galaxy object identifier.</summary>
public int GobjectId { get; init; } public int GobjectId { get; init; }
/// <summary>Gets the tag name.</summary>
public string TagName { get; init; } = string.Empty; public string TagName { get; init; } = string.Empty;
/// <summary>Gets the attribute name.</summary>
public string AttributeName { get; init; } = string.Empty; public string AttributeName { get; init; } = string.Empty;
/// <summary>Gets the full tag reference.</summary>
public string FullTagReference { get; init; } = string.Empty; public string FullTagReference { get; init; } = string.Empty;
/// <summary>Gets the MXAccess data type code.</summary>
public int MxDataType { get; init; } public int MxDataType { get; init; }
/// <summary>Gets the data type name.</summary>
public string? DataTypeName { get; init; } public string? DataTypeName { get; init; }
/// <summary>Gets a value indicating whether this is an array.</summary>
public bool IsArray { get; init; } public bool IsArray { get; init; }
/// <summary>Gets the array dimension, if applicable.</summary>
public int? ArrayDimension { get; init; } public int? ArrayDimension { get; init; }
/// <summary>Gets the MXAccess attribute category code.</summary>
public int MxAttributeCategory { get; init; } public int MxAttributeCategory { get; init; }
/// <summary>Gets the security classification code.</summary>
public int SecurityClassification { get; init; } public int SecurityClassification { get; init; }
/// <summary>Gets a value indicating whether this is historized.</summary>
public bool IsHistorized { get; init; } public bool IsHistorized { get; init; }
/// <summary>Gets a value indicating whether this is an alarm.</summary>
public bool IsAlarm { get; init; } public bool IsAlarm { get; init; }
} }
@@ -10,6 +10,8 @@ namespace MxGateway.Server.Galaxy;
/// </summary> /// </summary>
public sealed class GalaxyRepository(GalaxyRepositoryOptions options) public sealed class GalaxyRepository(GalaxyRepositoryOptions options)
{ {
/// <summary>Tests the connection to the Galaxy Repository database.</summary>
/// <param name="ct">Token to cancel the asynchronous operation.</param>
public async Task<bool> TestConnectionAsync(CancellationToken ct = default) public async Task<bool> TestConnectionAsync(CancellationToken ct = default)
{ {
try try
@@ -24,6 +26,8 @@ public sealed class GalaxyRepository(GalaxyRepositoryOptions options)
catch (InvalidOperationException) { return false; } catch (InvalidOperationException) { return false; }
} }
/// <summary>Retrieves the last deployment time from the Galaxy Repository.</summary>
/// <param name="ct">Token to cancel the asynchronous operation.</param>
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
{ {
using SqlConnection conn = new(options.ConnectionString); using SqlConnection conn = new(options.ConnectionString);
@@ -34,6 +38,8 @@ public sealed class GalaxyRepository(GalaxyRepositoryOptions options)
return result is DateTime dt ? dt : null; return result is DateTime dt ? dt : null;
} }
/// <summary>Retrieves the complete hierarchy of Galaxy objects from the repository.</summary>
/// <param name="ct">Token to cancel the asynchronous operation.</param>
public async Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default) public async Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
{ {
List<GalaxyHierarchyRow> rows = new(); List<GalaxyHierarchyRow> rows = new();
@@ -70,6 +76,8 @@ public sealed class GalaxyRepository(GalaxyRepositoryOptions options)
return rows; return rows;
} }
/// <summary>Retrieves all attributes for Galaxy objects from the repository.</summary>
/// <param name="ct">Token to cancel the asynchronous operation.</param>
public async Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default) public async Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
{ {
List<GalaxyAttributeRow> rows = new(); List<GalaxyAttributeRow> rows = new();
@@ -8,9 +8,11 @@ public sealed class GalaxyRepositoryOptions
{ {
public const string SectionName = "MxGateway:Galaxy"; public const string SectionName = "MxGateway:Galaxy";
/// <summary>The SQL Server connection string for the Galaxy Repository database.</summary>
public string ConnectionString { get; init; } = public string ConnectionString { get; init; } =
"Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;"; "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;";
/// <summary>The timeout in seconds for SQL commands executed against the Galaxy Repository.</summary>
public int CommandTimeoutSeconds { get; init; } = 60; public int CommandTimeoutSeconds { get; init; } = 60;
/// <summary> /// <summary>
@@ -4,6 +4,9 @@ namespace MxGateway.Server.Galaxy;
public static class GalaxyRepositoryServiceCollectionExtensions public static class GalaxyRepositoryServiceCollectionExtensions
{ {
/// <summary>Registers Galaxy Repository services in the dependency injection container.</summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddGalaxyRepository(this IServiceCollection services) public static IServiceCollection AddGalaxyRepository(this IServiceCollection services)
{ {
services services
@@ -1,18 +1,17 @@
namespace MxGateway.Server.Galaxy; namespace MxGateway.Server.Galaxy;
/// <summary>Publishes Galaxy repository deploy events to subscribers.</summary>
public interface IGalaxyDeployNotifier public interface IGalaxyDeployNotifier
{ {
/// <summary>The most recently published event, or <c>null</c> if no event has fired yet.</summary> /// <summary>The most recently published event, or null if no event has fired yet.</summary>
GalaxyDeployEventInfo? Latest { get; } GalaxyDeployEventInfo? Latest { get; }
/// <summary>Publishes a deploy event to all current subscribers and stores it as <see cref="Latest"/>.</summary> /// <summary>Publishes a deploy event to all current subscribers and stores it as Latest.</summary>
/// <param name="info">The deploy event to publish.</param>
void Publish(GalaxyDeployEventInfo info); void Publish(GalaxyDeployEventInfo info);
/// <summary> /// <summary>Subscribes to deploy events. The sequence yields the latest event first (if available) then streams new events as they fire.</summary>
/// Subscribe to deploy events. The async sequence yields events as they fire. If /// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <see cref="Latest"/> is set, it is yielded first so subscribers can bootstrap their /// <returns>Async enumerable of deploy events.</returns>
/// local cache without waiting for the next deploy. Pass a cancellation token to
/// unsubscribe.
/// </summary>
IAsyncEnumerable<GalaxyDeployEventInfo> SubscribeAsync(CancellationToken cancellationToken); IAsyncEnumerable<GalaxyDeployEventInfo> SubscribeAsync(CancellationToken cancellationToken);
} }
@@ -1,5 +1,6 @@
namespace MxGateway.Server.Galaxy; namespace MxGateway.Server.Galaxy;
/// <summary>Cache for Galaxy Repository hierarchy data.</summary>
public interface IGalaxyHierarchyCache public interface IGalaxyHierarchyCache
{ {
/// <summary>The latest cache entry. Status freshness is recomputed against the clock.</summary> /// <summary>The latest cache entry. Status freshness is recomputed against the clock.</summary>
@@ -11,6 +12,7 @@ public interface IGalaxyHierarchyCache
/// attributes rowsets when the deploy time has changed since the last successful /// attributes rowsets when the deploy time has changed since the last successful
/// refresh. /// refresh.
/// </summary> /// </summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
Task RefreshAsync(CancellationToken cancellationToken); Task RefreshAsync(CancellationToken cancellationToken);
/// <summary> /// <summary>
@@ -18,5 +20,6 @@ public interface IGalaxyHierarchyCache
/// gRPC handlers that want to serve from cache without returning Unavailable on the /// gRPC handlers that want to serve from cache without returning Unavailable on the
/// very first request after gateway start. /// very first request after gateway start.
/// </summary> /// </summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
Task WaitForFirstLoadAsync(CancellationToken cancellationToken); Task WaitForFirstLoadAsync(CancellationToken cancellationToken);
} }
@@ -13,10 +13,18 @@ using MxGateway.Server.Workers;
namespace MxGateway.Server; namespace MxGateway.Server;
/// <summary>
/// Configures and builds the gateway web application.
/// </summary>
public static class GatewayApplication public static class GatewayApplication
{ {
private const string StaticAssetsManifestFileName = "MxGateway.Server.staticwebassets.endpoints.json"; private const string StaticAssetsManifestFileName = "MxGateway.Server.staticwebassets.endpoints.json";
/// <summary>
/// Builds a configured web application with all gateway services and middleware.
/// </summary>
/// <param name="args">Command-line arguments passed to the application.</param>
/// <returns>A configured web application ready to run.</returns>
public static WebApplication Build(string[] args) public static WebApplication Build(string[] args)
{ {
WebApplicationBuilder builder = CreateBuilder(args); WebApplicationBuilder builder = CreateBuilder(args);
@@ -32,6 +40,11 @@ public static class GatewayApplication
return app; return app;
} }
/// <summary>
/// Creates a web application builder configured with gateway services.
/// </summary>
/// <param name="args">Command-line arguments passed to the application.</param>
/// <returns>A configured web application builder.</returns>
public static WebApplicationBuilder CreateBuilder(string[] args) public static WebApplicationBuilder CreateBuilder(string[] args)
{ {
WebApplicationBuilder builder = WebApplication.CreateBuilder(new WebApplicationOptions WebApplicationBuilder builder = WebApplication.CreateBuilder(new WebApplicationOptions
@@ -112,6 +125,11 @@ public static class GatewayApplication
&& Directory.Exists(Path.Combine(path, "wwwroot")); && Directory.Exists(Path.Combine(path, "wwwroot"));
} }
/// <summary>
/// Maps gateway endpoints including gRPC services, health checks, and the dashboard.
/// </summary>
/// <param name="endpoints">Endpoint route builder to map endpoints to.</param>
/// <returns>The same endpoint route builder for chaining.</returns>
public static IEndpointRouteBuilder MapGatewayEndpoints(this IEndpointRouteBuilder endpoints) public static IEndpointRouteBuilder MapGatewayEndpoints(this IEndpointRouteBuilder endpoints)
{ {
endpoints.MapStaticAssets(ResolveStaticAssetsManifestPath()); endpoints.MapStaticAssets(ResolveStaticAssetsManifestPath());
@@ -16,6 +16,12 @@ public sealed class EventStreamService(
GatewayMetrics metrics, GatewayMetrics metrics,
ILogger<EventStreamService> logger) : IEventStreamService ILogger<EventStreamService> logger) : IEventStreamService
{ {
/// <summary>
/// Streams events from a session to the client asynchronously.
/// </summary>
/// <param name="request">Stream events request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of MX events.</returns>
public async IAsyncEnumerable<MxEvent> StreamEventsAsync( public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request, StreamEventsRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken) [EnumeratorCancellation] CancellationToken cancellationToken)
@@ -10,6 +10,9 @@ namespace MxGateway.Server.Grpc;
/// </summary> /// </summary>
public static class GalaxyProtoMapper public static class GalaxyProtoMapper
{ {
/// <summary>Maps Galaxy hierarchy and attribute rows to Galaxy object protos.</summary>
/// <param name="hierarchy">Hierarchy rows from Galaxy Repository.</param>
/// <param name="attributes">Attribute rows from Galaxy Repository.</param>
public static IEnumerable<GalaxyObject> MapHierarchy( public static IEnumerable<GalaxyObject> MapHierarchy(
IReadOnlyList<GalaxyHierarchyRow> hierarchy, IReadOnlyList<GalaxyHierarchyRow> hierarchy,
IReadOnlyList<GalaxyAttributeRow> attributes) IReadOnlyList<GalaxyAttributeRow> attributes)
@@ -24,6 +27,9 @@ public static class GalaxyProtoMapper
} }
} }
/// <summary>Maps a Galaxy hierarchy row to a Galaxy object proto.</summary>
/// <param name="row">Hierarchy row from Galaxy Repository.</param>
/// <param name="attributesByGobjectId">Attributes indexed by gobject ID.</param>
public static GalaxyObject MapObject( public static GalaxyObject MapObject(
GalaxyHierarchyRow row, GalaxyHierarchyRow row,
IReadOnlyDictionary<int, List<GalaxyAttributeRow>> attributesByGobjectId) IReadOnlyDictionary<int, List<GalaxyAttributeRow>> attributesByGobjectId)
@@ -52,6 +58,8 @@ public static class GalaxyProtoMapper
return obj; return obj;
} }
/// <summary>Maps a Galaxy attribute row to a Galaxy attribute proto.</summary>
/// <param name="row">Attribute row from Galaxy Repository.</param>
public static GalaxyAttribute MapAttribute(GalaxyAttributeRow row) => new() public static GalaxyAttribute MapAttribute(GalaxyAttributeRow row) => new()
{ {
AttributeName = row.AttributeName, AttributeName = row.AttributeName,
@@ -22,6 +22,7 @@ public sealed class GalaxyRepositoryGrpcService(
{ {
private static readonly TimeSpan FirstLoadWaitBudget = TimeSpan.FromSeconds(5); private static readonly TimeSpan FirstLoadWaitBudget = TimeSpan.FromSeconds(5);
/// <inheritdoc />
public override async Task<TestConnectionReply> TestConnection( public override async Task<TestConnectionReply> TestConnection(
TestConnectionRequest request, TestConnectionRequest request,
ServerCallContext context) ServerCallContext context)
@@ -30,6 +31,7 @@ public sealed class GalaxyRepositoryGrpcService(
return new TestConnectionReply { Ok = ok }; return new TestConnectionReply { Ok = ok };
} }
/// <inheritdoc />
public override async Task<GetLastDeployTimeReply> GetLastDeployTime( public override async Task<GetLastDeployTimeReply> GetLastDeployTime(
GetLastDeployTimeRequest request, GetLastDeployTimeRequest request,
ServerCallContext context) ServerCallContext context)
@@ -52,6 +54,7 @@ public sealed class GalaxyRepositoryGrpcService(
return reply; return reply;
} }
/// <inheritdoc />
public override async Task<DiscoverHierarchyReply> DiscoverHierarchy( public override async Task<DiscoverHierarchyReply> DiscoverHierarchy(
DiscoverHierarchyRequest request, DiscoverHierarchyRequest request,
ServerCallContext context) ServerCallContext context)
@@ -71,6 +74,7 @@ public sealed class GalaxyRepositoryGrpcService(
return entry.Reply; return entry.Reply;
} }
/// <inheritdoc />
public override async Task WatchDeployEvents( public override async Task WatchDeployEvents(
WatchDeployEventsRequest request, WatchDeployEventsRequest request,
IServerStreamWriter<DeployEvent> responseStream, IServerStreamWriter<DeployEvent> responseStream,
@@ -2,8 +2,16 @@ using MxGateway.Contracts.Proto;
namespace MxGateway.Server.Grpc; namespace MxGateway.Server.Grpc;
/// <summary>
/// Streams MXAccess events to gRPC clients.
/// </summary>
public interface IEventStreamService public interface IEventStreamService
{ {
/// <summary>
/// Streams events for the specified session to the caller.
/// </summary>
/// <param name="request">Request payload.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
IAsyncEnumerable<MxEvent> StreamEventsAsync( IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request, StreamEventsRequest request,
CancellationToken cancellationToken); CancellationToken cancellationToken);
@@ -9,6 +9,7 @@ using MxGateway.Server.Workers;
namespace MxGateway.Server.Grpc; namespace MxGateway.Server.Grpc;
/// <summary>gRPC service implementation for MXAccess Gateway operations.</summary>
public sealed class MxAccessGatewayService( public sealed class MxAccessGatewayService(
ISessionManager sessionManager, ISessionManager sessionManager,
IGatewayRequestIdentityAccessor identityAccessor, IGatewayRequestIdentityAccessor identityAccessor,
@@ -18,6 +19,7 @@ public sealed class MxAccessGatewayService(
GatewayMetrics metrics, GatewayMetrics metrics,
ILogger<MxAccessGatewayService> logger) : MxAccessGateway.MxAccessGatewayBase ILogger<MxAccessGatewayService> logger) : MxAccessGateway.MxAccessGatewayBase
{ {
/// <inheritdoc />
public override async Task<OpenSessionReply> OpenSession( public override async Task<OpenSessionReply> OpenSession(
OpenSessionRequest request, OpenSessionRequest request,
ServerCallContext context) ServerCallContext context)
@@ -56,6 +58,7 @@ public sealed class MxAccessGatewayService(
} }
} }
/// <inheritdoc />
public override async Task<CloseSessionReply> CloseSession( public override async Task<CloseSessionReply> CloseSession(
CloseSessionRequest request, CloseSessionRequest request,
ServerCallContext context) ServerCallContext context)
@@ -80,6 +83,7 @@ public sealed class MxAccessGatewayService(
} }
} }
/// <inheritdoc />
public override async Task<MxCommandReply> Invoke( public override async Task<MxCommandReply> Invoke(
MxCommandRequest request, MxCommandRequest request,
ServerCallContext context) ServerCallContext context)
@@ -100,6 +104,7 @@ public sealed class MxAccessGatewayService(
} }
} }
/// <inheritdoc />
public override async Task StreamEvents( public override async Task StreamEvents(
StreamEventsRequest request, StreamEventsRequest request,
IServerStreamWriter<MxEvent> responseStream, IServerStreamWriter<MxEvent> responseStream,
@@ -3,15 +3,26 @@ using MxGateway.Contracts.Proto;
namespace MxGateway.Server.Grpc; namespace MxGateway.Server.Grpc;
/// <summary>
/// Maps between worker IPC types and gRPC contract types.
/// </summary>
public sealed class MxAccessGrpcMapper public sealed class MxAccessGrpcMapper
{ {
private readonly TimeProvider _timeProvider; private readonly TimeProvider _timeProvider;
/// <summary>
/// Initializes the mapper with an optional time provider.
/// </summary>
/// <param name="timeProvider">Time provider for timestamps; defaults to system time if null.</param>
public MxAccessGrpcMapper(TimeProvider? timeProvider = null) public MxAccessGrpcMapper(TimeProvider? timeProvider = null)
{ {
_timeProvider = timeProvider ?? TimeProvider.System; _timeProvider = timeProvider ?? TimeProvider.System;
} }
/// <summary>
/// Maps a gRPC MX command request to a worker command.
/// </summary>
/// <param name="request">Request payload.</param>
public WorkerCommand MapCommand(MxCommandRequest request) public WorkerCommand MapCommand(MxCommandRequest request)
{ {
ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request);
@@ -24,6 +35,10 @@ public sealed class MxAccessGrpcMapper
}; };
} }
/// <summary>
/// Maps a worker command reply to a gRPC MX command reply.
/// </summary>
/// <param name="reply">Worker command reply.</param>
public MxCommandReply MapCommandReply(WorkerCommandReply reply) public MxCommandReply MapCommandReply(WorkerCommandReply reply)
{ {
ArgumentNullException.ThrowIfNull(reply); ArgumentNullException.ThrowIfNull(reply);
@@ -39,6 +54,10 @@ public sealed class MxAccessGrpcMapper
return reply.Reply.Clone(); return reply.Reply.Clone();
} }
/// <summary>
/// Maps a worker event to a gRPC MX event.
/// </summary>
/// <param name="workerEvent">Worker event to map.</param>
public MxEvent MapEvent(WorkerEvent workerEvent) public MxEvent MapEvent(WorkerEvent workerEvent)
{ {
ArgumentNullException.ThrowIfNull(workerEvent); ArgumentNullException.ThrowIfNull(workerEvent);
@@ -50,6 +69,10 @@ public sealed class MxAccessGrpcMapper
}; };
} }
/// <summary>
/// Creates an OK protocol status.
/// </summary>
/// <param name="message">Status message.</param>
public static ProtocolStatus Ok(string message = "OK") public static ProtocolStatus Ok(string message = "OK")
{ {
return new ProtocolStatus return new ProtocolStatus
@@ -59,6 +82,10 @@ public sealed class MxAccessGrpcMapper
}; };
} }
/// <summary>
/// Creates an InvalidRequest protocol status.
/// </summary>
/// <param name="message">Status message.</param>
public static ProtocolStatus InvalidRequest(string message) public static ProtocolStatus InvalidRequest(string message)
{ {
return new ProtocolStatus return new ProtocolStatus
@@ -68,6 +95,10 @@ public sealed class MxAccessGrpcMapper
}; };
} }
/// <summary>
/// Creates a SessionNotFound protocol status.
/// </summary>
/// <param name="message">Status message.</param>
public static ProtocolStatus SessionNotFound(string message) public static ProtocolStatus SessionNotFound(string message)
{ {
return new ProtocolStatus return new ProtocolStatus
@@ -77,6 +108,10 @@ public sealed class MxAccessGrpcMapper
}; };
} }
/// <summary>
/// Creates a SessionNotReady protocol status.
/// </summary>
/// <param name="message">Status message.</param>
public static ProtocolStatus SessionNotReady(string message) public static ProtocolStatus SessionNotReady(string message)
{ {
return new ProtocolStatus return new ProtocolStatus
@@ -86,6 +121,10 @@ public sealed class MxAccessGrpcMapper
}; };
} }
/// <summary>
/// Creates a WorkerUnavailable protocol status.
/// </summary>
/// <param name="message">Status message.</param>
public static ProtocolStatus WorkerUnavailable(string message) public static ProtocolStatus WorkerUnavailable(string message)
{ {
return new ProtocolStatus return new ProtocolStatus
@@ -95,6 +134,10 @@ public sealed class MxAccessGrpcMapper
}; };
} }
/// <summary>
/// Creates a Timeout protocol status.
/// </summary>
/// <param name="message">Status message.</param>
public static ProtocolStatus Timeout(string message) public static ProtocolStatus Timeout(string message)
{ {
return new ProtocolStatus return new ProtocolStatus
@@ -104,6 +147,10 @@ public sealed class MxAccessGrpcMapper
}; };
} }
/// <summary>
/// Creates a Canceled protocol status.
/// </summary>
/// <param name="message">Status message.</param>
public static ProtocolStatus Canceled(string message) public static ProtocolStatus Canceled(string message)
{ {
return new ProtocolStatus return new ProtocolStatus
@@ -113,6 +160,10 @@ public sealed class MxAccessGrpcMapper
}; };
} }
/// <summary>
/// Creates a ProtocolViolation protocol status.
/// </summary>
/// <param name="message">Status message.</param>
public static ProtocolStatus ProtocolViolation(string message) public static ProtocolStatus ProtocolViolation(string message)
{ {
return new ProtocolStatus return new ProtocolStatus
@@ -5,6 +5,8 @@ namespace MxGateway.Server.Grpc;
public sealed class MxAccessGrpcRequestValidator public sealed class MxAccessGrpcRequestValidator
{ {
/// <summary>Validates an open session request.</summary>
/// <param name="request">The request to validate.</param>
public void ValidateOpenSession(OpenSessionRequest request) public void ValidateOpenSession(OpenSessionRequest request)
{ {
ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request);
@@ -15,18 +17,24 @@ public sealed class MxAccessGrpcRequestValidator
} }
} }
/// <summary>Validates a close session request.</summary>
/// <param name="request">The request to validate.</param>
public void ValidateCloseSession(CloseSessionRequest request) public void ValidateCloseSession(CloseSessionRequest request)
{ {
ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request);
RequireSessionId(request.SessionId); RequireSessionId(request.SessionId);
} }
/// <summary>Validates a stream events request.</summary>
/// <param name="request">The request to validate.</param>
public void ValidateStreamEvents(StreamEventsRequest request) public void ValidateStreamEvents(StreamEventsRequest request)
{ {
ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request);
RequireSessionId(request.SessionId); RequireSessionId(request.SessionId);
} }
/// <summary>Validates an invoke request with command payload.</summary>
/// <param name="request">The request to validate.</param>
public void ValidateInvoke(MxCommandRequest request) public void ValidateInvoke(MxCommandRequest request)
{ {
ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request);
@@ -49,6 +49,9 @@ public sealed class GatewayMetrics : IDisposable
private long _retryAttempts; private long _retryAttempts;
private bool _disposed; private bool _disposed;
/// <summary>
/// Initializes the gateway metrics with OpenTelemetry counters and histograms.
/// </summary>
public GatewayMetrics() public GatewayMetrics()
{ {
_meter = new Meter(MeterName, typeof(GatewayMetrics).Assembly.GetName().Version?.ToString()); _meter = new Meter(MeterName, typeof(GatewayMetrics).Assembly.GetName().Version?.ToString());
@@ -75,6 +78,9 @@ public sealed class GatewayMetrics : IDisposable
_meter.CreateObservableGauge("mxgateway.events.grpc_stream_queue.depth", GetGrpcEventStreamQueueDepth); _meter.CreateObservableGauge("mxgateway.events.grpc_stream_queue.depth", GetGrpcEventStreamQueueDepth);
} }
/// <summary>
/// Records that a session has been opened.
/// </summary>
public void SessionOpened() public void SessionOpened()
{ {
lock (_syncRoot) lock (_syncRoot)
@@ -86,6 +92,9 @@ public sealed class GatewayMetrics : IDisposable
_sessionsOpenedCounter.Add(1); _sessionsOpenedCounter.Add(1);
} }
/// <summary>
/// Records that a session has been closed.
/// </summary>
public void SessionClosed() public void SessionClosed()
{ {
lock (_syncRoot) lock (_syncRoot)
@@ -101,6 +110,9 @@ public sealed class GatewayMetrics : IDisposable
_sessionsClosedCounter.Add(1); _sessionsClosedCounter.Add(1);
} }
/// <summary>
/// Records that a session has been removed from registry.
/// </summary>
public void SessionRemoved() public void SessionRemoved()
{ {
lock (_syncRoot) lock (_syncRoot)
@@ -112,6 +124,10 @@ public sealed class GatewayMetrics : IDisposable
} }
} }
/// <summary>
/// Records that a worker process has started and its startup latency.
/// </summary>
/// <param name="startupDuration">Duration elapsed while starting the worker.</param>
public void WorkerStarted(TimeSpan startupDuration) public void WorkerStarted(TimeSpan startupDuration)
{ {
lock (_syncRoot) lock (_syncRoot)
@@ -122,6 +138,10 @@ public sealed class GatewayMetrics : IDisposable
_workerStartupLatencyHistogram.Record(startupDuration.TotalMilliseconds); _workerStartupLatencyHistogram.Record(startupDuration.TotalMilliseconds);
} }
/// <summary>
/// Records that a worker process has stopped with the given reason.
/// </summary>
/// <param name="reason">Cause of the worker stopping.</param>
public void WorkerStopped(string reason) public void WorkerStopped(string reason)
{ {
lock (_syncRoot) lock (_syncRoot)
@@ -137,6 +157,10 @@ public sealed class GatewayMetrics : IDisposable
_workerExitsCounter.Add(1, new KeyValuePair<string, object?>("reason", reason)); _workerExitsCounter.Add(1, new KeyValuePair<string, object?>("reason", reason));
} }
/// <summary>
/// Records that a worker process was killed with the given reason.
/// </summary>
/// <param name="reason">Cause of the worker termination.</param>
public void WorkerKilled(string reason) public void WorkerKilled(string reason)
{ {
lock (_syncRoot) lock (_syncRoot)
@@ -147,6 +171,10 @@ public sealed class GatewayMetrics : IDisposable
_workerKillsCounter.Add(1, new KeyValuePair<string, object?>("reason", reason)); _workerKillsCounter.Add(1, new KeyValuePair<string, object?>("reason", reason));
} }
/// <summary>
/// Records that a command has started for the given method.
/// </summary>
/// <param name="method">Name of the command method.</param>
public void CommandStarted(string method) public void CommandStarted(string method)
{ {
lock (_syncRoot) lock (_syncRoot)
@@ -157,6 +185,11 @@ public sealed class GatewayMetrics : IDisposable
_commandsStartedCounter.Add(1, new KeyValuePair<string, object?>("method", method)); _commandsStartedCounter.Add(1, new KeyValuePair<string, object?>("method", method));
} }
/// <summary>
/// Records that a command succeeded for the given method and duration.
/// </summary>
/// <param name="method">Name of the command method.</param>
/// <param name="duration">Elapsed time to complete the command.</param>
public void CommandSucceeded(string method, TimeSpan duration) public void CommandSucceeded(string method, TimeSpan duration)
{ {
lock (_syncRoot) lock (_syncRoot)
@@ -169,6 +202,12 @@ public sealed class GatewayMetrics : IDisposable
_commandLatencyHistogram.Record(duration.TotalMilliseconds, methodTag); _commandLatencyHistogram.Record(duration.TotalMilliseconds, methodTag);
} }
/// <summary>
/// Records that a command failed for the given method, category, and duration.
/// </summary>
/// <param name="method">Name of the command method.</param>
/// <param name="category">Classification of the failure.</param>
/// <param name="duration">Elapsed time before command failed.</param>
public void CommandFailed(string method, string category, TimeSpan duration) public void CommandFailed(string method, string category, TimeSpan duration)
{ {
lock (_syncRoot) lock (_syncRoot)
@@ -183,6 +222,11 @@ public sealed class GatewayMetrics : IDisposable
_commandLatencyHistogram.Record(duration.TotalMilliseconds, methodTag, categoryTag); _commandLatencyHistogram.Record(duration.TotalMilliseconds, methodTag, categoryTag);
} }
/// <summary>
/// Records that an event was received for the given session and family.
/// </summary>
/// <param name="sessionId">Identifier of the session receiving the event.</param>
/// <param name="family">Event family classification.</param>
public void EventReceived(string sessionId, string family) public void EventReceived(string sessionId, string family)
{ {
Interlocked.Increment(ref _eventsReceived); Interlocked.Increment(ref _eventsReceived);
@@ -194,6 +238,11 @@ public sealed class GatewayMetrics : IDisposable
new KeyValuePair<string, object?>("family", family)); new KeyValuePair<string, object?>("family", family));
} }
/// <summary>
/// Records the latency of sending an event to a client stream.
/// </summary>
/// <param name="family">Event family name.</param>
/// <param name="duration">Time taken to send the event.</param>
public void RecordEventStreamSend(string family, TimeSpan duration) public void RecordEventStreamSend(string family, TimeSpan duration)
{ {
_eventStreamSendLatencyHistogram.Record( _eventStreamSendLatencyHistogram.Record(
@@ -201,11 +250,19 @@ public sealed class GatewayMetrics : IDisposable
new KeyValuePair<string, object?>("family", family)); new KeyValuePair<string, object?>("family", family));
} }
/// <summary>
/// Sets the worker event queue depth; delegates to SetWorkerEventQueueDepth.
/// </summary>
/// <param name="depth">Queue depth value.</param>
public void SetEventQueueDepth(int depth) public void SetEventQueueDepth(int depth)
{ {
SetWorkerEventQueueDepth(depth); SetWorkerEventQueueDepth(depth);
} }
/// <summary>
/// Sets the worker event queue depth to the given value.
/// </summary>
/// <param name="depth">Queue depth value.</param>
public void SetWorkerEventQueueDepth(int depth) public void SetWorkerEventQueueDepth(int depth)
{ {
if (depth < 0) if (depth < 0)
@@ -219,6 +276,10 @@ public sealed class GatewayMetrics : IDisposable
} }
} }
/// <summary>
/// Adjusts the gRPC event stream queue depth by the given delta.
/// </summary>
/// <param name="delta">Amount to adjust the queue depth by.</param>
public void AdjustGrpcEventStreamQueueDepth(int delta) public void AdjustGrpcEventStreamQueueDepth(int delta)
{ {
lock (_syncRoot) lock (_syncRoot)
@@ -227,11 +288,19 @@ public sealed class GatewayMetrics : IDisposable
} }
} }
/// <summary>
/// Removes event counters for the given session.
/// </summary>
/// <param name="sessionId">Identifier of the session.</param>
public void RemoveSessionEvents(string sessionId) public void RemoveSessionEvents(string sessionId)
{ {
_eventsBySession.TryRemove(sessionId, out _); _eventsBySession.TryRemove(sessionId, out _);
} }
/// <summary>
/// Records that a queue overflow occurred for the given queue name.
/// </summary>
/// <param name="queueName">Name of the queue that overflowed.</param>
public void QueueOverflow(string queueName) public void QueueOverflow(string queueName)
{ {
lock (_syncRoot) lock (_syncRoot)
@@ -242,6 +311,10 @@ public sealed class GatewayMetrics : IDisposable
_queueOverflowsCounter.Add(1, new KeyValuePair<string, object?>("queue", queueName)); _queueOverflowsCounter.Add(1, new KeyValuePair<string, object?>("queue", queueName));
} }
/// <summary>
/// Records that a fault occurred in the given category.
/// </summary>
/// <param name="category">Category of the fault.</param>
public void Fault(string category) public void Fault(string category)
{ {
lock (_syncRoot) lock (_syncRoot)
@@ -252,6 +325,10 @@ public sealed class GatewayMetrics : IDisposable
_faultsCounter.Add(1, new KeyValuePair<string, object?>("category", category)); _faultsCounter.Add(1, new KeyValuePair<string, object?>("category", category));
} }
/// <summary>
/// Records that a heartbeat failed for the given session.
/// </summary>
/// <param name="sessionId">Identifier of the session.</param>
public void HeartbeatFailed(string sessionId) public void HeartbeatFailed(string sessionId)
{ {
lock (_syncRoot) lock (_syncRoot)
@@ -262,6 +339,10 @@ public sealed class GatewayMetrics : IDisposable
_heartbeatFailuresCounter.Add(1, new KeyValuePair<string, object?>("session_id", sessionId)); _heartbeatFailuresCounter.Add(1, new KeyValuePair<string, object?>("session_id", sessionId));
} }
/// <summary>
/// Records that an event stream was disconnected with the given reason.
/// </summary>
/// <param name="reason">Reason for the disconnection.</param>
public void StreamDisconnected(string reason) public void StreamDisconnected(string reason)
{ {
lock (_syncRoot) lock (_syncRoot)
@@ -272,6 +353,10 @@ public sealed class GatewayMetrics : IDisposable
_streamDisconnectsCounter.Add(1, new KeyValuePair<string, object?>("reason", reason)); _streamDisconnectsCounter.Add(1, new KeyValuePair<string, object?>("reason", reason));
} }
/// <summary>
/// Records that a retry was attempted in the given area.
/// </summary>
/// <param name="area">Area in which the retry was attempted.</param>
public void RetryAttempted(string area) public void RetryAttempted(string area)
{ {
lock (_syncRoot) lock (_syncRoot)
@@ -283,6 +368,9 @@ public sealed class GatewayMetrics : IDisposable
_retryAttemptsCounter.Add(1, new KeyValuePair<string, object?>("area", area)); _retryAttemptsCounter.Add(1, new KeyValuePair<string, object?>("area", area));
} }
/// <summary>
/// Returns a snapshot of all current metric values.
/// </summary>
public GatewayMetricsSnapshot GetSnapshot() public GatewayMetricsSnapshot GetSnapshot()
{ {
lock (_syncRoot) lock (_syncRoot)
@@ -312,6 +400,9 @@ public sealed class GatewayMetrics : IDisposable
} }
} }
/// <summary>
/// Disposes the underlying OpenTelemetry meter.
/// </summary>
public void Dispose() public void Dispose()
{ {
if (_disposed) if (_disposed)
@@ -2,6 +2,9 @@ using System.Text.Json;
namespace MxGateway.Server.Security.Authentication; namespace MxGateway.Server.Security.Authentication;
/// <summary>
/// Executes API key administration commands from the CLI.
/// </summary>
public sealed class ApiKeyAdminCliRunner( public sealed class ApiKeyAdminCliRunner(
IAuthStoreMigrator migrator, IAuthStoreMigrator migrator,
IApiKeyAdminStore adminStore, IApiKeyAdminStore adminStore,
@@ -13,6 +16,12 @@ public sealed class ApiKeyAdminCliRunner(
WriteIndented = true WriteIndented = true
}; };
/// <summary>
/// Runs an API key administration command and writes the output.
/// </summary>
/// <param name="command">API key administration command to execute.</param>
/// <param name="output">Text writer for command output.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public async Task<int> RunAsync( public async Task<int> RunAsync(
ApiKeyAdminCommand command, ApiKeyAdminCommand command,
TextWriter output, TextWriter output,
@@ -2,6 +2,9 @@ namespace MxGateway.Server.Security.Authentication;
public static class ApiKeyAdminCommandLineParser public static class ApiKeyAdminCommandLineParser
{ {
/// <summary>Parses command-line arguments for the API key admin subcommand.</summary>
/// <param name="args">Command-line arguments to parse.</param>
/// <returns>Parse result containing the command kind and options, or a failure message.</returns>
public static ApiKeyAdminParseResult Parse(IReadOnlyList<string> args) public static ApiKeyAdminParseResult Parse(IReadOnlyList<string> args)
{ {
if (args.Count == 0 || !string.Equals(args[0], "apikey", StringComparison.OrdinalIgnoreCase)) if (args.Count == 0 || !string.Equals(args[0], "apikey", StringComparison.OrdinalIgnoreCase))
@@ -5,16 +5,21 @@ public sealed record ApiKeyAdminParseResult(
ApiKeyAdminCommand? Command, ApiKeyAdminCommand? Command,
string? Error) string? Error)
{ {
/// <summary>Returns a result indicating the input was not an API key command.</summary>
public static ApiKeyAdminParseResult NotApiKeyCommand() public static ApiKeyAdminParseResult NotApiKeyCommand()
{ {
return new ApiKeyAdminParseResult(false, null, null); return new ApiKeyAdminParseResult(false, null, null);
} }
/// <summary>Returns a successful parse result with the parsed API key command.</summary>
/// <param name="command">Parsed API key administration command.</param>
public static ApiKeyAdminParseResult Success(ApiKeyAdminCommand command) public static ApiKeyAdminParseResult Success(ApiKeyAdminCommand command)
{ {
return new ApiKeyAdminParseResult(true, command, null); return new ApiKeyAdminParseResult(true, command, null);
} }
/// <summary>Returns a parse result with the specified error message.</summary>
/// <param name="error">Error message describing the parse failure.</param>
public static ApiKeyAdminParseResult Fail(string error) public static ApiKeyAdminParseResult Fail(string error)
{ {
return new ApiKeyAdminParseResult(true, null, error); return new ApiKeyAdminParseResult(true, null, error);
@@ -5,6 +5,10 @@ public sealed class ApiKeyParser : IApiKeyParser
private const string BearerPrefix = "Bearer "; private const string BearerPrefix = "Bearer ";
private const string TokenPrefix = "mxgw_"; private const string TokenPrefix = "mxgw_";
/// <summary>Attempts to parse a Bearer token from an Authorization header and extract the API key ID and secret.</summary>
/// <param name="authorizationHeader">Authorization header value to parse.</param>
/// <param name="apiKey">Parsed API key with ID and secret, or null if parsing failed.</param>
/// <returns>True if the header was successfully parsed; otherwise, false.</returns>
public bool TryParseAuthorizationHeader(string? authorizationHeader, out ParsedApiKey? apiKey) public bool TryParseAuthorizationHeader(string? authorizationHeader, out ParsedApiKey? apiKey)
{ {
apiKey = null; apiKey = null;
@@ -2,8 +2,12 @@ using Microsoft.Data.Sqlite;
namespace MxGateway.Server.Security.Authentication; namespace MxGateway.Server.Security.Authentication;
/// <summary>Reads API key records from SQLite query results.</summary>
public static class ApiKeyRecordReader public static class ApiKeyRecordReader
{ {
/// <summary>Deserializes a row from the API key table into an ApiKeyRecord.</summary>
/// <param name="reader">The data reader positioned at the API key row.</param>
/// <returns>The deserialized API key record.</returns>
public static ApiKeyRecord Read(SqliteDataReader reader) public static ApiKeyRecord Read(SqliteDataReader reader)
{ {
return new ApiKeyRecord( return new ApiKeyRecord(
@@ -4,11 +4,17 @@ namespace MxGateway.Server.Security.Authentication;
public static class ApiKeyScopeSerializer public static class ApiKeyScopeSerializer
{ {
/// <summary>Serializes scopes to JSON string.</summary>
/// <param name="scopes">The scopes to serialize.</param>
/// <returns>JSON string representation.</returns>
public static string Serialize(IReadOnlySet<string> scopes) public static string Serialize(IReadOnlySet<string> scopes)
{ {
return JsonSerializer.Serialize(scopes.Order(StringComparer.Ordinal)); return JsonSerializer.Serialize(scopes.Order(StringComparer.Ordinal));
} }
/// <summary>Deserializes scopes from JSON string.</summary>
/// <param name="value">The JSON string to deserialize.</param>
/// <returns>Deserialized scopes set.</returns>
public static IReadOnlySet<string> Deserialize(string value) public static IReadOnlySet<string> Deserialize(string value)
{ {
if (string.IsNullOrWhiteSpace(value)) if (string.IsNullOrWhiteSpace(value))
@@ -2,8 +2,10 @@ using System.Security.Cryptography;
namespace MxGateway.Server.Security.Authentication; namespace MxGateway.Server.Security.Authentication;
/// <summary>Generates cryptographically secure API key secrets.</summary>
public static class ApiKeySecretGenerator public static class ApiKeySecretGenerator
{ {
/// <summary>Generates a new random API key secret string.</summary>
public static string Generate() public static string Generate()
{ {
Span<byte> bytes = stackalloc byte[32]; Span<byte> bytes = stackalloc byte[32];
@@ -9,6 +9,9 @@ public sealed class ApiKeySecretHasher(
IConfiguration configuration, IConfiguration configuration,
IOptions<GatewayOptions> options) : IApiKeySecretHasher IOptions<GatewayOptions> options) : IApiKeySecretHasher
{ {
/// <summary>Hashes an API key secret with pepper using HMAC-SHA256.</summary>
/// <param name="secret">The secret to hash.</param>
/// <returns>The hashed secret.</returns>
public byte[] HashSecret(string secret) public byte[] HashSecret(string secret)
{ {
string pepper = GetPepper(); string pepper = GetPepper();
@@ -5,6 +5,11 @@ public sealed record ApiKeyVerificationResult(
ApiKeyIdentity? Identity, ApiKeyIdentity? Identity,
ApiKeyVerificationFailure Failure) ApiKeyVerificationFailure Failure)
{ {
/// <summary>
/// Creates a successful verification result.
/// </summary>
/// <param name="identity">API key identity.</param>
/// <returns>Success result.</returns>
public static ApiKeyVerificationResult Success(ApiKeyIdentity identity) public static ApiKeyVerificationResult Success(ApiKeyIdentity identity)
{ {
return new ApiKeyVerificationResult( return new ApiKeyVerificationResult(
@@ -13,6 +18,11 @@ public sealed record ApiKeyVerificationResult(
Failure: ApiKeyVerificationFailure.None); Failure: ApiKeyVerificationFailure.None);
} }
/// <summary>
/// Creates a failed verification result.
/// </summary>
/// <param name="failure">Verification failure reason.</param>
/// <returns>Failure result.</returns>
public static ApiKeyVerificationResult Fail(ApiKeyVerificationFailure failure) public static ApiKeyVerificationResult Fail(ApiKeyVerificationFailure failure)
{ {
return new ApiKeyVerificationResult( return new ApiKeyVerificationResult(
@@ -7,6 +7,12 @@ public sealed class ApiKeyVerifier(
IApiKeySecretHasher hasher, IApiKeySecretHasher hasher,
IApiKeyStore keyStore) : IApiKeyVerifier IApiKeyStore keyStore) : IApiKeyVerifier
{ {
/// <summary>
/// Verifies an API key from an authorization header asynchronously.
/// </summary>
/// <param name="authorizationHeader">Authorization header value.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Verification result.</returns>
public async Task<ApiKeyVerificationResult> VerifyAsync( public async Task<ApiKeyVerificationResult> VerifyAsync(
string? authorizationHeader, string? authorizationHeader,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -4,8 +4,14 @@ using MxGateway.Server.Configuration;
namespace MxGateway.Server.Security.Authentication; namespace MxGateway.Server.Security.Authentication;
/// <summary>
/// Factory for creating SQLite connections to the authentication store.
/// </summary>
public sealed class AuthSqliteConnectionFactory(IOptions<GatewayOptions> options) public sealed class AuthSqliteConnectionFactory(IOptions<GatewayOptions> options)
{ {
/// <summary>
/// Creates and configures a SQLite connection to the auth database.
/// </summary>
public SqliteConnection CreateConnection() public SqliteConnection CreateConnection()
{ {
string sqlitePath = options.Value.Authentication.SqlitePath; string sqlitePath = options.Value.Authentication.SqlitePath;

Some files were not shown because too many files have changed in this diff Show More