CultLib/src/GameCult.Networking/CultNetOperationServer.cs

358 lines
15 KiB
C#

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using MessagePack;
namespace GameCult.Networking
{
/// <summary>Portable failure payload for operation routing and envelope validation.</summary>
[MessagePackObject]
public sealed class CultNetOperationFailure
{
/// <summary>Gets or sets the stable machine-readable failure code.</summary>
[Key("code")] public string Code { get; set; } = "operation-error";
/// <summary>Gets or sets the human-readable failure description.</summary>
[Key("message")] public string Message { get; set; } = "CultNet operation failed.";
}
/// <summary>
/// Typed application input delivered through one CultNet operation envelope.
/// The message id is the caller's idempotency key; the application transaction
/// remains responsible for applying it exactly once.
/// </summary>
public sealed class CultNetOperationContext<TRequest>
{
internal CultNetOperationContext(CultNetOperationRequestMessage envelope, TRequest value)
{
Envelope = envelope ?? throw new ArgumentNullException(nameof(envelope));
Value = value;
}
/// <summary>Gets the decoded application request.</summary>
public TRequest Value { get; }
/// <summary>Gets the durable caller-supplied idempotency key.</summary>
public string IdempotencyKey => Envelope.MessageId;
/// <summary>Gets the calling runtime identity, when supplied.</summary>
public string? SourceRuntimeId => Envelope.SourceRuntimeId;
/// <summary>Gets the optional target runtime identity.</summary>
public string? TargetRuntimeId => Envelope.TargetRuntimeId;
/// <summary>Gets the original transport-neutral envelope for diagnostics.</summary>
public CultNetOperationRequestMessage Envelope { get; }
}
/// <summary>Provider-authored typed result for one CultNet operation.</summary>
public sealed class CultNetOperationReply<TResponse>
{
/// <summary>Creates one provider-authored typed reply.</summary>
public CultNetOperationReply(
string status,
TResponse value,
IEnumerable<string>? diagnostics = null)
{
Status = RequireNonEmpty(status, nameof(status));
Value = value;
Diagnostics = diagnostics == null ? Array.Empty<string>() : new List<string>(diagnostics).ToArray();
}
/// <summary>Gets the provider-authored operation status.</summary>
public string Status { get; }
/// <summary>Gets the typed response payload.</summary>
public TResponse Value { get; }
/// <summary>Gets provider-authored diagnostics.</summary>
public IReadOnlyList<string> Diagnostics { get; }
/// <summary>Creates an accepted typed reply.</summary>
public static CultNetOperationReply<TResponse> Accepted(TResponse value) => new("accepted", value);
/// <summary>Creates a rejected typed reply whose payload remains schema-valid.</summary>
public static CultNetOperationReply<TResponse> Rejected(
TResponse value,
params string[] diagnostics) => new("rejected", value, diagnostics);
private static string RequireNonEmpty(string value, string paramName) =>
string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("Value must be non-empty.", paramName)
: value.Trim();
}
/// <summary>
/// Transport-neutral typed dispatcher for CultNet application operations.
/// It owns envelope validation and serialization only; registered handlers own
/// domain validation, idempotency, state mutation, and durable receipts.
/// </summary>
public sealed class CultNetOperationServer : IDisposable
{
/// <summary>Schema used by correlated framework-level operation failures.</summary>
public const string FailureSchemaId = "gamecult.cultnet.operation_failure.v1";
private const string PayloadEncoding = "messagepack-base64";
private readonly ICultNetSchemaServer _server;
private readonly string? _sourceRuntimeId;
private readonly Dictionary<Route, IBinding> _bindings = new();
private readonly Func<CultNetOperationRequestMessage, ICultNetSchemaServerPeer, Task> _handler;
private bool _disposed;
/// <summary>Attaches one typed operation dispatcher to a schema server.</summary>
public CultNetOperationServer(ICultNetSchemaServer server, string? sourceRuntimeId = null)
{
_server = server ?? throw new ArgumentNullException(nameof(server));
_sourceRuntimeId = string.IsNullOrWhiteSpace(sourceRuntimeId) ? null : sourceRuntimeId!.Trim();
_handler = HandleAsync;
_server.OnCultNet(_handler);
}
/// <summary>
/// Registers one typed operation. Duplicate service/operation routes are rejected.
/// </summary>
public CultNetOperationServer Register<TRequest, TResponse>(
string serviceId,
string operation,
string requestSchema,
string responseSchema,
Func<CultNetOperationContext<TRequest>, Task<CultNetOperationReply<TResponse>>> handler)
{
ThrowIfDisposed();
var route = new Route(serviceId, operation);
if (_bindings.ContainsKey(route))
throw new InvalidOperationException($"CultNet operation '{route}' is already registered.");
_bindings.Add(route, new Binding<TRequest, TResponse>(
route,
requestSchema,
responseSchema,
handler ?? throw new ArgumentNullException(nameof(handler)),
_sourceRuntimeId));
return this;
}
/// <summary>
/// Registers one typed operation whose successful handler result is always accepted.
/// </summary>
public CultNetOperationServer Register<TRequest, TResponse>(
string serviceId,
string operation,
string requestSchema,
string responseSchema,
Func<CultNetOperationContext<TRequest>, Task<TResponse>> handler)
{
if (handler == null) throw new ArgumentNullException(nameof(handler));
return Register<TRequest, TResponse>(
serviceId,
operation,
requestSchema,
responseSchema,
async context => CultNetOperationReply<TResponse>.Accepted(await handler(context).ConfigureAwait(false)));
}
/// <inheritdoc />
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_server.RemoveCultNetMessageListener<CultNetOperationRequestMessage>(_handler);
_bindings.Clear();
}
private async Task HandleAsync(CultNetOperationRequestMessage request, ICultNetSchemaServerPeer peer)
{
if (_disposed) return;
if (request == null) throw new ArgumentNullException(nameof(request));
if (peer == null) throw new ArgumentNullException(nameof(peer));
if (_sourceRuntimeId != null &&
!string.Equals(request.TargetRuntimeId, _sourceRuntimeId, StringComparison.Ordinal))
{
peer.SendCultNet(Failure(
request,
"denied",
"target-runtime-mismatch",
$"Operation targeted runtime '{request.TargetRuntimeId ?? "(missing)"}', not '{_sourceRuntimeId}'."));
return;
}
Route route;
try
{
route = new Route(request.ServiceId, request.Operation);
}
catch (ArgumentException error)
{
peer.SendCultNet(Failure(request, "invalid", "invalid-route", error.Message));
return;
}
if (!_bindings.TryGetValue(route, out var binding))
{
peer.SendCultNet(Failure(
request,
"unsupported",
"operation-not-registered",
$"CultNet operation '{route}' is not registered."));
return;
}
try
{
peer.SendCultNet(await binding.InvokeAsync(request).ConfigureAwait(false));
}
catch (RequestException error)
{
peer.SendCultNet(Failure(request, "invalid", error.Code, error.Message));
}
catch (Exception error)
{
peer.SendCultNet(Failure(
request,
"error",
"handler-failed",
$"CultNet operation '{route}' failed: {error.Message}"));
}
}
private CultNetOperationResponseMessage Failure(
CultNetOperationRequestMessage request,
string status,
string code,
string message)
{
return new CultNetOperationResponseMessage
{
MessageId = request.MessageId ?? "",
ServiceId = request.ServiceId ?? "",
Operation = request.Operation ?? "",
Status = status,
PayloadSchema = FailureSchemaId,
PayloadEncoding = PayloadEncoding,
Payload = Convert.ToBase64String(MessagePackSerializer.Serialize(
new CultNetOperationFailure { Code = code, Message = message },
CultNetSchemaMessageSerialization.Options)),
Diagnostics = new[] { message },
SourceRuntimeId = _sourceRuntimeId
};
}
private void ThrowIfDisposed()
{
if (_disposed) throw new ObjectDisposedException(nameof(CultNetOperationServer));
}
private interface IBinding
{
Task<CultNetOperationResponseMessage> InvokeAsync(CultNetOperationRequestMessage request);
}
private sealed class Binding<TRequest, TResponse> : IBinding
{
private readonly Route _route;
private readonly string _requestSchema;
private readonly string _responseSchema;
private readonly Func<CultNetOperationContext<TRequest>, Task<CultNetOperationReply<TResponse>>> _handler;
private readonly string? _sourceRuntimeId;
public Binding(
Route route,
string requestSchema,
string responseSchema,
Func<CultNetOperationContext<TRequest>, Task<CultNetOperationReply<TResponse>>> handler,
string? sourceRuntimeId)
{
_route = route;
_requestSchema = RequireNonEmpty(requestSchema, nameof(requestSchema));
_responseSchema = RequireNonEmpty(responseSchema, nameof(responseSchema));
_handler = handler;
_sourceRuntimeId = sourceRuntimeId;
}
public async Task<CultNetOperationResponseMessage> InvokeAsync(CultNetOperationRequestMessage request)
{
if (string.IsNullOrWhiteSpace(request.MessageId))
throw new RequestException("missing-idempotency-key", "CultNet operation omitted its idempotency key.");
if (!string.Equals(request.PayloadSchema, _requestSchema, StringComparison.Ordinal))
throw new RequestException(
"request-schema-mismatch",
$"CultNet operation '{_route}' expected payload schema '{_requestSchema}', got '{request.PayloadSchema}'.");
if (!string.Equals(request.PayloadEncoding, PayloadEncoding, StringComparison.Ordinal))
throw new RequestException(
"request-encoding-mismatch",
$"CultNet operation '{_route}' expected payload encoding '{PayloadEncoding}', got '{request.PayloadEncoding}'.");
TRequest value;
try
{
value = MessagePackSerializer.Deserialize<TRequest>(
Convert.FromBase64String(request.Payload),
CultNetSchemaMessageSerialization.Options);
}
catch (FormatException error)
{
throw new RequestException("request-payload-invalid", "CultNet operation payload was not valid base64.", error);
}
catch (MessagePackSerializationException error)
{
throw new RequestException("request-payload-invalid", "CultNet operation payload was not valid MessagePack.", error);
}
if (value == null) throw new RequestException("request-payload-null", "CultNet operation payload decoded to null.");
var reply = await _handler(new CultNetOperationContext<TRequest>(request, value)).ConfigureAwait(false)
?? throw new InvalidOperationException("CultNet operation handler returned no reply.");
if (reply.Value == null) throw new InvalidOperationException("CultNet operation handler returned a null payload.");
return new CultNetOperationResponseMessage
{
MessageId = request.MessageId,
ServiceId = _route.ServiceId,
Operation = _route.Operation,
Status = reply.Status,
PayloadSchema = _responseSchema,
PayloadEncoding = PayloadEncoding,
Payload = Convert.ToBase64String(MessagePackSerializer.Serialize(
reply.Value,
CultNetSchemaMessageSerialization.Options)),
Diagnostics = new List<string>(reply.Diagnostics).ToArray(),
SourceRuntimeId = _sourceRuntimeId
};
}
}
private sealed class RequestException : Exception
{
public RequestException(string code, string message, Exception? inner = null)
: base(message, inner)
{
Code = code;
}
public string Code { get; }
}
private readonly struct Route : IEquatable<Route>
{
public Route(string serviceId, string operation)
{
ServiceId = RequireNonEmpty(serviceId, nameof(serviceId));
Operation = RequireNonEmpty(operation, nameof(operation));
}
public string ServiceId { get; }
public string Operation { get; }
public bool Equals(Route other) =>
string.Equals(ServiceId, other.ServiceId, StringComparison.Ordinal) &&
string.Equals(Operation, other.Operation, StringComparison.Ordinal);
public override bool Equals(object? value) => value is Route route && Equals(route);
public override int GetHashCode() =>
(StringComparer.Ordinal.GetHashCode(ServiceId) * 397) ^ StringComparer.Ordinal.GetHashCode(Operation);
public override string ToString() => ServiceId + "/" + Operation;
}
private static string RequireNonEmpty(string value, string paramName) =>
string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("Value must be non-empty.", paramName)
: value.Trim();
}
}