using Google.Protobuf.WellKnownTypes; using Grpc.Core; using Grpc.Net.Client; using Microsoft.Extensions.Logging; using MxGateway.Contracts.Proto.Galaxy; using Polly; using System.Net.Http; using System.Net.Security; using System.Runtime.CompilerServices; using System.Security.Cryptography.X509Certificates; namespace MxGateway.Client; /// /// Provides the .NET client entry point for the public Galaxy Repository gRPC API. /// All RPCs are read-only metadata calls that share the gateway's API-key auth /// interceptor and require the metadata:read scope server-side. /// public sealed class GalaxyRepositoryClient : IAsyncDisposable { private const int DiscoverHierarchyPageSize = 5000; private readonly GrpcChannel? _channel; private readonly IGalaxyRepositoryClientTransport _transport; private readonly ResiliencePipeline _safeUnaryRetryPipeline; private bool _disposed; /// /// Initializes a Galaxy Repository client with custom transport and options. /// /// Client options. /// The underlying gRPC transport. internal GalaxyRepositoryClient( MxGatewayClientOptions options, IGalaxyRepositoryClientTransport transport) { ArgumentNullException.ThrowIfNull(options); options.Validate(); Options = options; _transport = transport ?? throw new ArgumentNullException(nameof(transport)); _safeUnaryRetryPipeline = MxGatewayClientRetryPolicy.Create( options.Retry, options.LoggerFactory?.CreateLogger()); _channel = null; } private GalaxyRepositoryClient( GrpcChannel channel, IGalaxyRepositoryClientTransport transport) { _channel = channel; _transport = transport; Options = transport.Options; _safeUnaryRetryPipeline = MxGatewayClientRetryPolicy.Create( Options.Retry, Options.LoggerFactory?.CreateLogger()); } /// /// Client options used to configure timeouts, authentication, and retry policy. /// public MxGatewayClientOptions Options { get; } /// /// The underlying generated gRPC client for advanced operations. /// public GalaxyRepository.GalaxyRepositoryClient RawClient => _transport.RawClient ?? throw new InvalidOperationException("The raw generated gRPC client is not available for this client instance."); /// /// Creates a Galaxy Repository client with the given options, establishing a new gRPC channel. /// /// Client options. /// A new client instance. public static GalaxyRepositoryClient 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 GalaxyRepositoryClient( channel, new GrpcGalaxyRepositoryClientTransport( options, new GalaxyRepository.GalaxyRepositoryClient(channel))); } /// /// Probes the Galaxy Repository database connection. Returns true when the /// gateway can reach the configured ZB SQL Server. /// /// Cancellation token. /// True if connection is successful, false otherwise. public async Task TestConnectionAsync(CancellationToken cancellationToken = default) { TestConnectionReply reply = await TestConnectionRawAsync( new TestConnectionRequest(), cancellationToken) .ConfigureAwait(false); return reply.Ok; } /// /// Probes the Galaxy Repository database connection without result wrapping. /// /// The test connection request. /// Cancellation token. /// The raw server reply. public Task TestConnectionRawAsync( TestConnectionRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); ThrowIfDisposed(); return ExecuteSafeUnaryAsync( token => _transport.TestConnectionAsync(request, CreateCallOptions(token)), cancellationToken); } /// /// Returns the timestamp of the most recent Galaxy deployment, or /// when no deployment has been recorded. /// /// Cancellation token. /// The deployment timestamp, or null if not recorded. public async Task GetLastDeployTimeAsync(CancellationToken cancellationToken = default) { GetLastDeployTimeReply reply = await GetLastDeployTimeRawAsync( new GetLastDeployTimeRequest(), cancellationToken) .ConfigureAwait(false); if (!reply.Present || reply.TimeOfLastDeploy is null) { return null; } return reply.TimeOfLastDeploy.ToDateTime(); } /// /// Returns the most recent Galaxy deployment timestamp without result wrapping. /// /// The last deploy-time request. /// Cancellation token. /// The raw server reply. public Task GetLastDeployTimeRawAsync( GetLastDeployTimeRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); ThrowIfDisposed(); return ExecuteSafeUnaryAsync( token => _transport.GetLastDeployTimeAsync(request, CreateCallOptions(token)), cancellationToken); } /// /// Enumerates the deployed Galaxy object hierarchy. Each /// includes its dynamic attributes so callers can determine which tag references /// they may subscribe to via the MxAccessGateway service. /// /// Cancellation token. /// The collection of Galaxy objects in the hierarchy. public async Task> DiscoverHierarchyAsync(CancellationToken cancellationToken = default) { return await DiscoverHierarchyAsync(new DiscoverHierarchyOptions(), cancellationToken).ConfigureAwait(false); } public async Task> DiscoverHierarchyAsync( DiscoverHierarchyOptions options, CancellationToken cancellationToken = default) { List objects = []; HashSet seenPageTokens = new(StringComparer.Ordinal); string pageToken = string.Empty; do { DiscoverHierarchyRequest request = CreateDiscoverHierarchyRequest(options); request.PageSize = DiscoverHierarchyPageSize; request.PageToken = pageToken; DiscoverHierarchyReply reply = await DiscoverHierarchyRawAsync( request, cancellationToken) .ConfigureAwait(false); objects.AddRange(reply.Objects); pageToken = reply.NextPageToken; if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken)) { throw new MxGatewayException( $"Galaxy DiscoverHierarchy returned a repeated page token '{pageToken}'."); } } while (!string.IsNullOrWhiteSpace(pageToken)); return objects; } private static DiscoverHierarchyRequest CreateDiscoverHierarchyRequest(DiscoverHierarchyOptions options) { ArgumentNullException.ThrowIfNull(options); DiscoverHierarchyRequest request = new() { AlarmBearingOnly = options.AlarmBearingOnly, HistorizedOnly = options.HistorizedOnly, }; if (options.RootGobjectId.HasValue) { request.RootGobjectId = options.RootGobjectId.Value; } else if (!string.IsNullOrWhiteSpace(options.RootTagName)) { request.RootTagName = options.RootTagName; } else if (!string.IsNullOrWhiteSpace(options.RootContainedPath)) { request.RootContainedPath = options.RootContainedPath; } if (options.MaxDepth.HasValue) { request.MaxDepth = options.MaxDepth.Value; } request.CategoryIds.Add(options.CategoryIds); request.TemplateChainContains.Add(options.TemplateChainContains); if (!string.IsNullOrWhiteSpace(options.TagNameGlob)) { request.TagNameGlob = options.TagNameGlob; } if (options.IncludeAttributes.HasValue) { request.IncludeAttributes = options.IncludeAttributes.Value; } return request; } /// /// Enumerates the Galaxy object hierarchy without result wrapping. /// /// The discover-hierarchy request. /// Cancellation token. /// The raw server reply. public Task DiscoverHierarchyRawAsync( DiscoverHierarchyRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); ThrowIfDisposed(); return ExecuteSafeUnaryAsync( token => _transport.DiscoverHierarchyAsync(request, CreateCallOptions(token)), cancellationToken); } /// /// Subscribes to Galaxy deploy events. The server emits a bootstrap event with the /// current state on subscribe so callers can prime their cache, then emits one event /// per new time_of_last_deploy. Pass to /// suppress the bootstrap when the caller already holds the current deploy time. /// /// /// Streaming RPCs are not wrapped by the unary safe-read retry pipeline. If the /// stream is interrupted the caller must reopen it; the server does not guarantee /// at-least-once delivery beyond the per-subscriber buffer (gaps in /// indicate dropped events). /// /// Optional timestamp to suppress the bootstrap event. /// Cancellation token. /// An async enumerable of deploy events. public IAsyncEnumerable WatchDeployEventsAsync( DateTimeOffset? lastSeenDeployTime = null, CancellationToken cancellationToken = default) { ThrowIfDisposed(); WatchDeployEventsRequest request = new(); if (lastSeenDeployTime is { } seen) { request.LastSeenDeployTime = Timestamp.FromDateTimeOffset(seen); } return WatchDeployEventsRawAsync(request, cancellationToken); } /// /// Subscribes to Galaxy deploy events without result wrapping. /// /// The watch-deploy-events request. /// Cancellation token. /// An async enumerable of raw deploy events. public IAsyncEnumerable WatchDeployEventsRawAsync( WatchDeployEventsRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); ThrowIfDisposed(); return WatchDeployEventsCoreAsync(request, cancellationToken); } private async IAsyncEnumerable WatchDeployEventsCoreAsync( WatchDeployEventsRequest request, [EnumeratorCancellation] CancellationToken cancellationToken) { await foreach (DeployEvent deployEvent in _transport .WatchDeployEventsAsync(request, CreateStreamCallOptions(cancellationToken)) .WithCancellation(cancellationToken) .ConfigureAwait(false)) { yield return deployEvent; } } /// /// Closes the gRPC channel and releases resources. /// public ValueTask DisposeAsync() { if (_disposed) { return ValueTask.CompletedTask; } _disposed = true; _channel?.Dispose(); return ValueTask.CompletedTask; } /// /// Creates gRPC call options with the client's default timeout and API-key authorization. /// /// Cancellation token. /// The call options. internal CallOptions CreateCallOptions(CancellationToken cancellationToken) { return CreateCallOptions(cancellationToken, Options.DefaultCallTimeout); } /// /// Creates gRPC call options for streaming RPCs with the stream timeout and API-key authorization. /// /// Cancellation token. /// The stream call options. internal CallOptions CreateStreamCallOptions(CancellationToken cancellationToken) { return CreateCallOptions(cancellationToken, Options.StreamTimeout); } /// /// Creates gRPC call options with the specified timeout and API-key authorization. /// /// Cancellation token. /// Optional timeout duration. /// The call options. 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 ExecuteSafeUnaryAsync( Func> 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(_disposed, this); } }