mishima-nexus-client-csharp/src/Robotico.Nexus.Client/NexusClient.cs

186 lines
8.6 KiB
C#

using System.Diagnostics.CodeAnalysis;
using Grpc.Core;
using Grpc.Net.Client;
using Mishima.Nexus.Wire.V1;
using Robotico.Graph;
using Robotico.Nexus.Client.Document;
using Robotico.Nexus.Client.Graph;
using Robotico.Nexus.Client.Relational;
using Robotico.Nexus.Client.Time;
using Robotico.Result.Errors;
using Robotico.Time;
namespace Robotico.Nexus.Client;
/// <summary>
/// Connects to a Nexus wire endpoint and exposes plane facades: <see cref="Graph"/> / <see cref="Time"/>
/// implement <c>Robotico.Graph</c> / <c>Robotico.Time</c> ports over gRPC, and
/// <see cref="CreateDocumentClient"/> / <see cref="CreateRelationalClient"/> return thin document/relational
/// façades (MVP — no full <c>IMishimaDatabase</c> façade; use <see cref="CreateDocumentWireClient"/> /
/// <see cref="CreateRelationalWireClient"/> for the full generated Wire.V1 RPC surface).
/// One connection is bound to a single scheme + logical database from the connection URI; open a
/// second <see cref="NexusClient"/> to talk to another plane or database.
/// </summary>
public sealed class NexusClient : IAsyncDisposable, IDisposable
{
private readonly GrpcChannel _channel;
private readonly string? _bearerToken;
private bool _disposed;
private NexusClient(GrpcChannel channel, NexusConnectionUriParts parsed, string boundDatabase, string? bearerToken)
{
_channel = channel;
_bearerToken = bearerToken;
Scheme = parsed.Scheme;
Database = boundDatabase;
Graph = new NexusGraphStore(channel, bearerToken, boundDatabase);
Time = new NexusTimeStore(channel, bearerToken, boundDatabase);
}
/// <summary>Connection scheme (<see cref="NexusUriSchemes"/>) resolved at connect time.</summary>
public string Scheme { get; }
/// <summary>Logical database bound at connect time (URI path segment, or the scheme's canonical default).</summary>
public string Database { get; }
/// <summary>
/// Graph plane store bound to <see cref="Database"/> when <see cref="Scheme"/> is
/// <see cref="NexusUriSchemes.Graph"/>; usable against any logical database name via
/// <see cref="IGraphStore.Open"/> regardless of scheme.
/// </summary>
public IGraphStore Graph { get; }
/// <summary>
/// Time plane store bound to <see cref="Database"/> when <see cref="Scheme"/> is
/// <see cref="NexusUriSchemes.Time"/>; usable against any logical database name via
/// <see cref="ITimeStore.Open"/> regardless of scheme.
/// </summary>
public ITimeStore Time { get; }
/// <summary>Connects to a Nexus wire endpoint. Validates the URI and opens a gRPC channel eagerly.</summary>
[SuppressMessage("Design", "CA1054:URI-like parameters should not be strings", Justification = "Nexus connection strings use custom nexus+* schemes and are the documented public API surface (mirrors ADO.NET / MongoDB connection-string ergonomics); a System.Uri overload adds no value here.")]
[SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "Ownership of the returned NexusClient (and its channel) transfers to the caller via Result<NexusClient>.Success; the caller is responsible for disposing it.")]
public static Robotico.Result.Result<NexusClient> Connect(string connectionUri, NexusClientOptions? options = null)
{
Robotico.Result.Result<NexusConnectionUriParts> parsedResult = NexusConnectionUri.TryParse(connectionUri);
if (!parsedResult.IsSuccess(out NexusConnectionUriParts? parsed, out IError? parseError))
{
return Robotico.Result.Result.Error<NexusClient>(parseError!);
}
options ??= new NexusClientOptions();
NexusTlsMaterial? tls = options.ResolveTls();
bool useHttps = options.ResolveUseHttps(tls);
Robotico.Result.Result<GrpcChannel> channelResult = NexusGrpcChannelFactory.CreateChannel(parsed!, tls, useHttps);
if (!channelResult.IsSuccess(out GrpcChannel? channel, out IError? channelError))
{
return Robotico.Result.Result.Error<NexusClient>(channelError!);
}
string database = string.IsNullOrWhiteSpace(parsed!.Database)
? NexusConnectionUri.DefaultDatabase(parsed.Scheme)
: parsed.Database!;
return Robotico.Result.Result.Success(new NexusClient(channel!, parsed, database, options.ResolveBearerToken()));
}
/// <summary>Calls <c>NexusHealthService.Check</c> and succeeds only when the server reports ready.</summary>
public Robotico.Result.Result CheckHealth()
{
Robotico.Result.Result disposed = EnsureNotDisposed();
if (!disposed.IsSuccess())
{
return disposed;
}
NexusHealthService.NexusHealthServiceClient client = new(_channel);
try
{
HealthCheckResponse response = client.Check(new HealthCheckRequest(), NexusCallMetadata.Build(Database, _bearerToken));
return response.Ready
? Robotico.Result.Result.Success()
: Robotico.Result.Result.Error(NexusErrors.Error("Nexus health check reports not ready.", NexusErrors.Unavailable));
}
catch (RpcException ex)
{
return Robotico.Result.Result.Error(NexusWireStatus.FromException(ex));
}
}
/// <summary>Creates a thin document-plane façade (Ping + single-document upsert/get/delete) bound to this channel.</summary>
public Robotico.Result.Result<NexusDocumentClient> CreateDocumentClient()
{
Robotico.Result.Result disposed = EnsureNotDisposed();
return disposed.IsSuccess()
? Robotico.Result.Result.Success(new NexusDocumentClient(_channel, Database, _bearerToken))
: Robotico.Result.Result.Error<NexusDocumentClient>(AsError(disposed));
}
/// <summary>Creates a thin relational-plane façade (Ping only) bound to this channel.</summary>
public Robotico.Result.Result<NexusRelationalClient> CreateRelationalClient()
{
Robotico.Result.Result disposed = EnsureNotDisposed();
return disposed.IsSuccess()
? Robotico.Result.Result.Success(new NexusRelationalClient(_channel, Database, _bearerToken))
: Robotico.Result.Result.Error<NexusRelationalClient>(AsError(disposed));
}
/// <summary>Creates the generated document-plane Wire.V1 client bound to this channel (full RPC surface).</summary>
public Robotico.Result.Result<NexusDocumentEngineService.NexusDocumentEngineServiceClient> CreateDocumentWireClient()
{
Robotico.Result.Result disposed = EnsureNotDisposed();
return disposed.IsSuccess()
? Robotico.Result.Result.Success(new NexusDocumentEngineService.NexusDocumentEngineServiceClient(_channel))
: Robotico.Result.Result.Error<NexusDocumentEngineService.NexusDocumentEngineServiceClient>(AsError(disposed));
}
/// <summary>Creates the generated relational-plane Wire.V1 client bound to this channel (full RPC surface).</summary>
public Robotico.Result.Result<NexusRelationalEngineService.NexusRelationalEngineServiceClient> CreateRelationalWireClient()
{
Robotico.Result.Result disposed = EnsureNotDisposed();
return disposed.IsSuccess()
? Robotico.Result.Result.Success(new NexusRelationalEngineService.NexusRelationalEngineServiceClient(_channel))
: Robotico.Result.Result.Error<NexusRelationalEngineService.NexusRelationalEngineServiceClient>(AsError(disposed));
}
/// <summary>Metadata headers a caller can attach to a manual RPC (document/relational clients) for this database.</summary>
public Metadata BuildCallMetadata(string? databaseNameOverride = null) =>
NexusCallMetadata.Build(string.IsNullOrWhiteSpace(databaseNameOverride) ? Database : databaseNameOverride, _bearerToken);
/// <inheritdoc />
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_channel.Dispose();
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
if (_disposed)
{
return ValueTask.CompletedTask;
}
_disposed = true;
_channel.Dispose();
return ValueTask.CompletedTask;
}
private Robotico.Result.Result EnsureNotDisposed() =>
_disposed
? Robotico.Result.Result.Error(NexusErrors.Error("NexusClient is disposed.", NexusErrors.Disposed))
: Robotico.Result.Result.Success();
private static IError AsError(Robotico.Result.Result failed)
{
failed.IsError(out IError? error);
return error ?? NexusErrors.Error("NexusClient operation failed.");
}
}