89043cb2b6
Client.Dotnet-004: documented DefaultCallTimeout as both the per-attempt deadline and the shared retry budget, and removed DeadlineExceeded from the transient-retry set (a client-imposed deadline cannot be helped by retrying). Client.Dotnet-005: RegisterAsync/AddItemAsync/AddItem2Async silently returned 0 when a successful reply lacked the typed payload. They now throw a descriptive MxGatewayException. Client.Dotnet-006: added XML docs to the previously undocumented public members MaxGrpcMessageBytes, GatewayProtocolVersion, WorkerProtocolVersion. Client.Dotnet-007: corrected the AcknowledgeAlarmAsync XML comment — the RPC requires the admin scope, not a non-existent invoke:alarm-ack sub-scope. Client.Dotnet-008: the CLI redactor missed env-var-sourced keys because the caller passed only the --api-key option. Redaction now uses the same resolver, stripping env-var keys too. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
341 lines
13 KiB
C#
341 lines
13 KiB
C#
using Grpc.Core;
|
|
using Grpc.Net.Client;
|
|
using Microsoft.Extensions.Logging;
|
|
using MxGateway.Contracts.Proto;
|
|
using Polly;
|
|
using System.Net.Http;
|
|
using System.Net.Security;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
|
|
namespace MxGateway.Client;
|
|
|
|
/// <summary>
|
|
/// Provides the .NET client entry point for the public MXAccess Gateway gRPC API.
|
|
/// </summary>
|
|
public sealed class MxGatewayClient : IAsyncDisposable
|
|
{
|
|
private readonly GrpcChannel _channel;
|
|
private readonly IMxGatewayClientTransport _transport;
|
|
private readonly ResiliencePipeline _safeUnaryRetryPipeline;
|
|
private int _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(
|
|
MxGatewayClientOptions options,
|
|
IMxGatewayClientTransport transport)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
options.Validate();
|
|
|
|
Options = options;
|
|
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
|
_safeUnaryRetryPipeline = MxGatewayClientRetryPolicy.Create(
|
|
options.Retry,
|
|
options.LoggerFactory?.CreateLogger<MxGatewayClient>());
|
|
_channel = null!;
|
|
}
|
|
|
|
private MxGatewayClient(
|
|
GrpcChannel channel,
|
|
IMxGatewayClientTransport transport)
|
|
{
|
|
_channel = channel;
|
|
_transport = transport;
|
|
Options = transport.Options;
|
|
_safeUnaryRetryPipeline = MxGatewayClientRetryPolicy.Create(
|
|
Options.Retry,
|
|
Options.LoggerFactory?.CreateLogger<MxGatewayClient>());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the client configuration options.
|
|
/// </summary>
|
|
public MxGatewayClientOptions Options { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the underlying generated gRPC client.
|
|
/// </summary>
|
|
public MxAccessGateway.MxAccessGatewayClient RawClient =>
|
|
_transport.RawClient
|
|
?? 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)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
options.Validate();
|
|
|
|
HttpMessageHandler handler = CreateHttpHandler(options);
|
|
var channel = GrpcChannel.ForAddress(
|
|
options.Endpoint,
|
|
new GrpcChannelOptions
|
|
{
|
|
HttpHandler = handler,
|
|
LoggerFactory = options.LoggerFactory,
|
|
MaxReceiveMessageSize = options.MaxGrpcMessageBytes,
|
|
MaxSendMessageSize = options.MaxGrpcMessageBytes,
|
|
});
|
|
|
|
return new MxGatewayClient(
|
|
channel,
|
|
new GrpcMxGatewayClientTransport(
|
|
options,
|
|
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(
|
|
OpenSessionRequest? request = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
OpenSessionReply reply = await OpenSessionRawAsync(
|
|
request ?? new OpenSessionRequest(),
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
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(
|
|
OpenSessionRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
ThrowIfDisposed();
|
|
|
|
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(
|
|
CloseSessionRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
ThrowIfDisposed();
|
|
|
|
return ExecuteSafeUnaryAsync(
|
|
token => _transport.CloseSessionAsync(request, CreateCallOptions(token)),
|
|
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(
|
|
MxCommandRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
ThrowIfDisposed();
|
|
|
|
if (MxGatewayClientRetryPolicy.IsRetryableCommand(request.Command?.Kind ?? MxCommandKind.Unspecified))
|
|
{
|
|
return ExecuteSafeUnaryAsync(
|
|
token => _transport.InvokeAsync(request, CreateCallOptions(token)),
|
|
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(
|
|
StreamEventsRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
ThrowIfDisposed();
|
|
|
|
return _transport.StreamEventsAsync(request, CreateStreamCallOptions(cancellationToken));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Acknowledges an active MXAccess alarm condition through the gateway. The
|
|
/// gateway authorizes <see cref="AcknowledgeAlarmRequest"/> against the API
|
|
/// key's <c>admin</c> scope (there is no finer-grained alarm-ack sub-scope)
|
|
/// and forwards the acknowledge to the worker's MXAccess session; the
|
|
/// resulting <see cref="MxStatusProxy"/> is returned in the reply.
|
|
/// </summary>
|
|
/// <param name="request">The acknowledge request — alarm reference, comment, operator user.</param>
|
|
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
|
/// <returns>The acknowledge reply with protocol + native MxStatus.</returns>
|
|
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
|
AcknowledgeAlarmRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
ThrowIfDisposed();
|
|
|
|
return ExecuteSafeUnaryAsync(
|
|
token => _transport.AcknowledgeAlarmAsync(request, CreateCallOptions(token)),
|
|
cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Streams a snapshot of all alarms currently Active or ActiveAcked — the gateway's
|
|
/// ConditionRefresh equivalent. Used after reconnect to seed the local Part 9 state
|
|
/// machine, or to reconcile alarms that may have been missed during a transport
|
|
/// blip. Optionally scoped by alarm-reference prefix
|
|
/// (<see cref="QueryActiveAlarmsRequest.AlarmFilterPrefix"/>) so a partial refresh
|
|
/// can target an equipment sub-tree.
|
|
/// </summary>
|
|
/// <param name="request">The query request, optionally scoped by alarm-reference prefix.</param>
|
|
/// <param name="cancellationToken">Cancellation token for the stream.</param>
|
|
/// <returns>An async enumerable of active-alarm snapshots.</returns>
|
|
public IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
|
|
QueryActiveAlarmsRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
ThrowIfDisposed();
|
|
|
|
return _transport.QueryActiveAlarmsAsync(request, CreateStreamCallOptions(cancellationToken));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Disposes the client and releases all resources.
|
|
/// </summary>
|
|
public ValueTask DisposeAsync()
|
|
{
|
|
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
|
{
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
_channel?.Dispose();
|
|
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)
|
|
{
|
|
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)
|
|
{
|
|
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(
|
|
CancellationToken cancellationToken,
|
|
TimeSpan? timeout)
|
|
{
|
|
Metadata headers = new()
|
|
{
|
|
{ "authorization", $"Bearer {Options.ApiKey}" },
|
|
};
|
|
|
|
return new CallOptions(
|
|
headers,
|
|
timeout is null ? null : DateTime.UtcNow.Add(timeout.Value),
|
|
cancellationToken);
|
|
}
|
|
|
|
private async Task<T> ExecuteSafeUnaryAsync<T>(
|
|
Func<CancellationToken, Task<T>> call,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
timeout.CancelAfter(Options.DefaultCallTimeout);
|
|
|
|
return await _safeUnaryRetryPipeline.ExecuteAsync(
|
|
async token => await call(token).ConfigureAwait(false),
|
|
timeout.Token)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options)
|
|
{
|
|
SocketsHttpHandler handler = new()
|
|
{
|
|
ConnectTimeout = options.ConnectTimeout,
|
|
};
|
|
|
|
if (options.UseTls)
|
|
{
|
|
handler.SslOptions = new SslClientAuthenticationOptions();
|
|
if (!string.IsNullOrWhiteSpace(options.ServerNameOverride))
|
|
{
|
|
handler.SslOptions.TargetHost = options.ServerNameOverride;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(options.CaCertificatePath))
|
|
{
|
|
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
|
|
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
|
|
{
|
|
if (certificate is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
using X509Chain customChain = new();
|
|
customChain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
|
customChain.ChainPolicy.CustomTrustStore.Add(trustedRoot);
|
|
customChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
|
customChain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
|
|
X509Certificate2 certificateToValidate = certificate as X509Certificate2
|
|
?? X509CertificateLoader.LoadCertificate(certificate.Export(X509ContentType.Cert));
|
|
return customChain.Build(certificateToValidate);
|
|
};
|
|
}
|
|
}
|
|
|
|
return handler;
|
|
}
|
|
|
|
private void ThrowIfDisposed()
|
|
{
|
|
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
|
|
}
|
|
}
|