186 lines
8.6 KiB
C#
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.");
|
||
|
|
}
|
||
|
|
}
|