From 06c3973ce7aad1157ad7a191acb46ef520e35ae9 Mon Sep 17 00:00:00 2001 From: KyuubiYoru Date: Thu, 16 Jul 2026 06:27:44 +0200 Subject: [PATCH] feat: add publisher and browser client SDK (#9) Closes #9 --- .../0007-caller-owned-dotnet-client-sdk.md | 53 +++ docs/architecture/README.md | 1 + .../FinalFactory.Rendezvous.Client.csproj | 2 + src/FinalFactory.Rendezvous.Client/README.md | 61 ++++ .../RendezvousClientAbstractions.cs | 141 ++++++++ .../RendezvousHttpTransport.cs | 242 +++++++++++++ .../RendezvousPublisherClient.cs | 151 ++++++++ .../RendezvousSessionBrowserClient.cs | 110 ++++++ .../SessionLeaseMaintainer.cs | 150 ++++++++ .../Client/RendezvousClientBehaviorTests.cs | 342 ++++++++++++++++++ .../RendezvousClientIntegrationTests.cs | 195 ++++++++++ .../Contracts/PublicApiCompatibilityTests.cs | 27 +- .../Contracts/v1/client-public-api.txt | 60 +++ 13 files changed, 1534 insertions(+), 1 deletion(-) create mode 100644 docs/architecture/0007-caller-owned-dotnet-client-sdk.md create mode 100644 src/FinalFactory.Rendezvous.Client/README.md create mode 100644 src/FinalFactory.Rendezvous.Client/RendezvousClientAbstractions.cs create mode 100644 src/FinalFactory.Rendezvous.Client/RendezvousHttpTransport.cs create mode 100644 src/FinalFactory.Rendezvous.Client/RendezvousPublisherClient.cs create mode 100644 src/FinalFactory.Rendezvous.Client/RendezvousSessionBrowserClient.cs create mode 100644 src/FinalFactory.Rendezvous.Client/SessionLeaseMaintainer.cs create mode 100644 tests/FinalFactory.Rendezvous.Tests/Client/RendezvousClientBehaviorTests.cs create mode 100644 tests/FinalFactory.Rendezvous.Tests/Client/RendezvousClientIntegrationTests.cs create mode 100644 tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/client-public-api.txt diff --git a/docs/architecture/0007-caller-owned-dotnet-client-sdk.md b/docs/architecture/0007-caller-owned-dotnet-client-sdk.md new file mode 100644 index 0000000..28afd0d --- /dev/null +++ b/docs/architecture/0007-caller-owned-dotnet-client-sdk.md @@ -0,0 +1,53 @@ +# ADR 0007: caller-owned .NET publisher and browser SDK + +- Status: Accepted +- Date: 2026-07-16 +- Tracking: #9 + +## Decision + +The .NET client package exposes separate publisher and browser interfaces plus +concrete clients over a caller-supplied `HttpClient`. The caller owns that client, +its handler, base address, connection pool, proxy, and lifetime. SDK operations +dispose every request, response, and response body they create, but never dispose +the supplied client. The package targets `netstandard2.1`, depends only on the +wire-contract package and LiteNetLib, and contains no Godot types, global client, +service URL, publisher secret, or embedded game credential. + +Every operation returns `RendezvousClientResult` with a stable error code, +message, and optional retry guidance. Cancellation remains exceptional through +the caller's `CancellationToken`; transport failures become `ServiceUnavailable`. +Response bodies are streamed under the contract's 256 KiB browser ceiling before +deserialization. Invalid or oversized success bodies become `InternalError` and +never escape as partially trusted contract objects. + +The SDK retries only operations whose duplicate execution is safe: scoped reads, +idempotency-keyed registration, lease renewal with the same lease token, complete +resource update, and lease-token deregistration. It honors bounded server retry +guidance and otherwise uses capped exponential backoff with jitter. Each retry +creates a fresh HTTP request while preserving the caller's registration +idempotency key. Configuration is copied on construction so later option mutation +cannot change an in-flight client's behavior. + +`PublishedSession` holds the server-issued lease and presence capabilities needed +by the host. Its string representation always redacts them. Update requests are +copied before the lease token is attached, so the SDK never mutates caller-owned +DTOs. The browser exposes one-page calls and bounded cursor traversal; cursor +values remain opaque and caller requests remain unchanged. + +Lease maintenance is explicit. Creating a `SessionLeaseMaintainer` starts no task; +the game chooses when to call `RunAsync`, owns cancellation, and awaits +`DisposeAsync`. The loop uses the latest server-provided renewal interval and +returns a distinct cancelled, disposed, lost-lease, or failed result. Terminal +authorization, expiry, and missing-lease responses also raise `LeaseLost` so the +host can stop advertising or re-register deliberately. + +## Consequences + +- SpaceGame and Unscouted can inject the publisher/browser interfaces in tests + without an engine runtime or real network. +- Games must configure an absolute `HttpClient.BaseAddress` (or equivalent + handler routing), obtain publisher credentials from their deployment boundary, + and explicitly run and dispose lease maintenance. +- The versioned client public-API snapshot and live-server integration tests fail + together when SDK and HTTP contracts drift. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index f8d93a0..48e0ab7 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -9,6 +9,7 @@ decision requires a superseding ADR and corresponding contract/test updates. - [ADR 0004: atomic ephemeral state and single-active availability](0004-atomic-ephemeral-state.md) - [ADR 0005: authenticated session lease and presence lifecycle](0005-session-lease-lifecycle.md) - [ADR 0006: bounded compatible session browser](0006-compatible-session-browser.md) +- [ADR 0007: caller-owned .NET publisher and browser SDK](0007-caller-owned-dotnet-client-sdk.md) - [Threat model](../security/threat-model.md) - [Security promise and test matrix](../security/control-matrix.md) - [Versioned HTTP and UDP contracts](../contracts/README.md) diff --git a/src/FinalFactory.Rendezvous.Client/FinalFactory.Rendezvous.Client.csproj b/src/FinalFactory.Rendezvous.Client/FinalFactory.Rendezvous.Client.csproj index b1d411c..f18e326 100644 --- a/src/FinalFactory.Rendezvous.Client/FinalFactory.Rendezvous.Client.csproj +++ b/src/FinalFactory.Rendezvous.Client/FinalFactory.Rendezvous.Client.csproj @@ -5,10 +5,12 @@ FinalFactory.Rendezvous.Client true FinalFactory.Rendezvous.Client + README.md Godot-independent client SDK for Final Factory Rendezvous. + diff --git a/src/FinalFactory.Rendezvous.Client/README.md b/src/FinalFactory.Rendezvous.Client/README.md new file mode 100644 index 0000000..bf523ff --- /dev/null +++ b/src/FinalFactory.Rendezvous.Client/README.md @@ -0,0 +1,61 @@ +# FinalFactory.Rendezvous.Client + +Godot-independent .NET publisher and session-browser SDK for Rendezvous v1. +The package targets `netstandard2.1` and uses a caller-owned `HttpClient`. + +```csharp +using FinalFactory.Rendezvous.Client; +using FinalFactory.Rendezvous.Contracts; + +using HttpClient http = new() +{ + BaseAddress = new Uri("https://rendezvous.example/"), +}; + +string publisherCredential = Environment.GetEnvironmentVariable( + "RENDEZVOUS_PUBLISHER_CREDENTIAL") + ?? throw new InvalidOperationException("Publisher credential is not configured."); +CancellationToken cancellationToken = default; +RendezvousPublisherClient publisher = new(http); +RendezvousClientResult registered = await publisher.RegisterAsync( + new RegisterSessionRequest + { + IdempotencyKey = Guid.NewGuid().ToString("N"), + GameId = new("space-game"), + EnvironmentId = new("production"), + RegionId = new("eu-central"), + ProtocolVersion = 7, + BuildVersion = "1.0.0", + DisplayName = "My server", + Visibility = ListingVisibility.Public, + Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 8 }, + }, + publisherCredential, + cancellationToken); +if (!registered.IsSuccess || registered.Value is null) +{ + throw new InvalidOperationException( + $"Registration failed: {registered.Error} ({registered.Message})"); +} +``` + +Load `publisherCredential` from the game's deployment secret boundary; never +embed it in a client build or source control. A successful registration returns a +`PublishedSession` containing the lease and host-presence capabilities. + +Lease renewal is explicit and caller-controlled: + +```csharp +PublishedSession session = registered.Value; +await using SessionLeaseMaintainer maintainer = publisher.CreateLeaseMaintainer( + session, + publisherCredential); +LeaseMaintenanceResult stopped = await maintainer.RunAsync(cancellationToken); +``` + +Creating the maintainer does not start background work. Await its run and dispose +it when hosting stops. Use `IRendezvousPublisherClient` and +`IRendezvousSessionBrowserClient` as injection seams in game tests. The SDK disposes +the requests and responses it creates but never disposes the supplied `HttpClient`. + +See the repository's ADR 0007 for retry, paging, ownership, and failure semantics. diff --git a/src/FinalFactory.Rendezvous.Client/RendezvousClientAbstractions.cs b/src/FinalFactory.Rendezvous.Client/RendezvousClientAbstractions.cs new file mode 100644 index 0000000..0a9e70a --- /dev/null +++ b/src/FinalFactory.Rendezvous.Client/RendezvousClientAbstractions.cs @@ -0,0 +1,141 @@ +using FinalFactory.Rendezvous.Contracts; + +namespace FinalFactory.Rendezvous.Client; + +public sealed class RendezvousClientResult +{ + internal RendezvousClientResult( + RendezvousErrorCode error, + T? value, + string message, + int? retryAfterSeconds) + { + Error = error; + Value = value; + Message = message; + RetryAfterSeconds = retryAfterSeconds; + } + + public bool IsSuccess => Error == RendezvousErrorCode.None; + public RendezvousErrorCode Error { get; } + public T? Value { get; } + public string Message { get; } + public int? RetryAfterSeconds { get; } + +} + +public static class RendezvousClientResult +{ + public static RendezvousClientResult Success(T value) => + value is null + ? throw new ArgumentNullException(nameof(value)) + : new(RendezvousErrorCode.None, value, string.Empty, null); + + public static RendezvousClientResult Failure( + RendezvousErrorCode error, + string message, + int? retryAfterSeconds = null) => + error == RendezvousErrorCode.None + ? throw new ArgumentException("A failure requires a non-success error.", nameof(error)) + : new(error, default, message ?? string.Empty, retryAfterSeconds); +} + +public sealed class PublishedSession +{ + internal PublishedSession(RegisterSessionResponse response) + { + ListingId = response.ListingId; + LeaseId = response.LeaseId; + LeaseToken = response.LeaseToken; + HostPresenceHandle = response.HostPresenceHandle; + HostPresenceCapability = response.HostPresenceCapability; + ExpiresAt = response.ExpiresAt; + LeaseRenewAfterSeconds = response.LeaseRenewAfterSeconds; + HostPresenceRefreshAfterSeconds = response.HostPresenceRefreshAfterSeconds; + } + + public SessionListingId ListingId { get; } + public LeaseId LeaseId { get; } + public string LeaseToken { get; } + public MediationHandle HostPresenceHandle { get; } + public string HostPresenceCapability { get; } + public DateTimeOffset ExpiresAt { get; internal set; } + public int LeaseRenewAfterSeconds { get; internal set; } + public int HostPresenceRefreshAfterSeconds { get; } + + public override string ToString() => $"[PublishedSession {ListingId}; credentials redacted]"; +} + +public interface IRendezvousPublisherClient +{ + Task> RegisterAsync( + RegisterSessionRequest request, + string publisherCredential, + CancellationToken cancellationToken = default); + + Task> RenewAsync( + PublishedSession session, + string publisherCredential, + CancellationToken cancellationToken = default); + + Task> UpdateAsync( + PublishedSession session, + UpdateSessionRequest request, + string publisherCredential, + CancellationToken cancellationToken = default); + + Task> DeregisterAsync( + PublishedSession session, + string publisherCredential, + CancellationToken cancellationToken = default); +} + +public interface IRendezvousSessionBrowserClient +{ + Task> BrowseAsync( + BrowseSessionsRequest request, + CancellationToken cancellationToken = default); + + Task>> BrowseAllAsync( + BrowseSessionsRequest request, + int maximumPages = 100, + CancellationToken cancellationToken = default); + + Task> GetAsync( + SessionListingId listingId, + GameId gameId, + EnvironmentId environmentId, + uint protocolVersion, + CancellationToken cancellationToken = default); +} + +public interface IRendezvousDelay +{ + Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken); +} + +public sealed class RendezvousClientOptions +{ + public int MaximumSafeRetries { get; set; } = 2; + public TimeSpan InitialRetryDelay { get; set; } = TimeSpan.FromMilliseconds(200); + public TimeSpan MaximumRetryDelay { get; set; } = TimeSpan.FromSeconds(2); + public double JitterRatio { get; set; } = 0.2; + + internal void Validate() + { + if (MaximumSafeRetries is < 0 or > 5 + || InitialRetryDelay < TimeSpan.Zero + || MaximumRetryDelay < InitialRetryDelay + || MaximumRetryDelay > TimeSpan.FromSeconds(30) + || JitterRatio is < 0 or > 1) + { + throw new ArgumentOutOfRangeException(nameof(RendezvousClientOptions)); + } + } +} + +internal sealed class SystemRendezvousDelay : IRendezvousDelay +{ + public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) => + Task.Delay(delay, cancellationToken); +} diff --git a/src/FinalFactory.Rendezvous.Client/RendezvousHttpTransport.cs b/src/FinalFactory.Rendezvous.Client/RendezvousHttpTransport.cs new file mode 100644 index 0000000..ffeb9a7 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Client/RendezvousHttpTransport.cs @@ -0,0 +1,242 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using FinalFactory.Rendezvous.Contracts; + +namespace FinalFactory.Rendezvous.Client; + +internal sealed class RendezvousHttpTransport +{ + private readonly HttpClient _httpClient; + private readonly RendezvousClientOptions _options; + private readonly IRendezvousDelay _delay; + + internal RendezvousHttpTransport( + HttpClient httpClient, + RendezvousClientOptions? options, + IRendezvousDelay? delay) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + RendezvousClientOptions suppliedOptions = options ?? new RendezvousClientOptions(); + suppliedOptions.Validate(); + _options = new RendezvousClientOptions + { + MaximumSafeRetries = suppliedOptions.MaximumSafeRetries, + InitialRetryDelay = suppliedOptions.InitialRetryDelay, + MaximumRetryDelay = suppliedOptions.MaximumRetryDelay, + JitterRatio = suppliedOptions.JitterRatio, + }; + _delay = delay ?? new SystemRendezvousDelay(); + } + + internal async Task> SendSafeAsync( + Func requestFactory, + CancellationToken cancellationToken) + { + for (int attempt = 0; ; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + using HttpRequestMessage request = requestFactory(); + using HttpResponseMessage response = await _httpClient + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + if (response.IsSuccessStatusCode) + { + if (typeof(T) == typeof(bool) && response.StatusCode == HttpStatusCode.NoContent) + { + return RendezvousClientResult.Success((T)(object)true); + } + + byte[] payload; + try + { + payload = await ReadBoundedAsync(response.Content, cancellationToken) + .ConfigureAwait(false); + } + catch (InvalidDataException) + { + return RendezvousClientResult.Failure( + RendezvousErrorCode.InternalError, + "The service returned an oversized success response."); + } + + T? value; + try + { + value = JsonSerializer.Deserialize(payload, ContractJson.Options); + } + catch (JsonException) + { + value = default; + } + + return value is null + ? RendezvousClientResult.Failure( + RendezvousErrorCode.InternalError, + "The service returned an invalid success response.") + : RendezvousClientResult.Success(value); + } + + ApiError error = await ReadErrorAsync(response, cancellationToken).ConfigureAwait(false); + int? retryAfter = error.RetryAfterSeconds ?? GetRetryAfterSeconds(response.Headers.RetryAfter); + if (attempt < _options.MaximumSafeRetries && IsTransient(error.Code)) + { + await _delay.DelayAsync( + GetRetryDelay(attempt, retryAfter), + cancellationToken).ConfigureAwait(false); + continue; + } + + return RendezvousClientResult.Failure(error.Code, error.Message, retryAfter); + } + catch (Exception exception) when ( + IsTransientTransportFailure(exception, cancellationToken) + && attempt < _options.MaximumSafeRetries) + { + await _delay.DelayAsync(GetRetryDelay(attempt, null), cancellationToken) + .ConfigureAwait(false); + } + catch (Exception exception) when (IsTransientTransportFailure(exception, cancellationToken)) + { + return RendezvousClientResult.Failure( + RendezvousErrorCode.ServiceUnavailable, + "The Rendezvous service did not return a valid response."); + } + } + } + + internal static HttpRequestMessage JsonRequest( + HttpMethod method, + string uri, + T body, + string? publisherCredential = null) + { + HttpRequestMessage request = new(method, uri) + { + Content = new StringContent( + JsonSerializer.Serialize(body, ContractJson.Options), + Encoding.UTF8, + "application/json"), + }; + if (publisherCredential is not null) + { + request.Headers.Authorization = new AuthenticationHeaderValue( + "Bearer", + RequireCredential(publisherCredential)); + } + + return request; + } + + internal static string RequireCredential(string credential) => + !string.IsNullOrWhiteSpace(credential) + ? credential + : throw new ArgumentException("A publisher credential is required.", nameof(credential)); + + private static async Task ReadErrorAsync( + HttpResponseMessage response, + CancellationToken cancellationToken) + { + try + { + byte[] payload = await ReadBoundedAsync(response.Content, cancellationToken) + .ConfigureAwait(false); + ApiError? error = JsonSerializer.Deserialize(payload, ContractJson.Options); + return error is not null && error.Code != RendezvousErrorCode.None + ? error + : FallbackError(response.StatusCode); + } + catch (Exception exception) when (exception is JsonException or InvalidDataException) + { + return FallbackError(response.StatusCode); + } + } + + private static async Task ReadBoundedAsync( + HttpContent content, + CancellationToken cancellationToken) + { + using Stream source = await content.ReadAsStreamAsync().ConfigureAwait(false); + using MemoryStream destination = new(); + byte[] buffer = new byte[8192]; + while (true) + { + int read = await source.ReadAsync(buffer.AsMemory(), cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + return destination.ToArray(); + } + + if (destination.Length + read > ContractLimits.BrowserResponseMaxBytes) + { + throw new InvalidDataException("The service response exceeded the SDK limit."); + } + + await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken) + .ConfigureAwait(false); + } + } + + private TimeSpan GetRetryDelay(int attempt, int? retryAfterSeconds) + { + TimeSpan basis = retryAfterSeconds.HasValue + ? TimeSpan.FromSeconds(Math.Max(0, retryAfterSeconds.Value)) + : TimeSpan.FromMilliseconds( + _options.InitialRetryDelay.TotalMilliseconds * Math.Pow(2, attempt)); + double bounded = Math.Min(basis.TotalMilliseconds, _options.MaximumRetryDelay.TotalMilliseconds); + if (_options.JitterRatio == 0 || bounded == 0) + { + return TimeSpan.FromMilliseconds(bounded); + } + + byte[] random = new byte[1]; + RandomNumberGenerator.Fill(random); + double unit = random[0] / 255d; + double multiplier = 1 - _options.JitterRatio + (2 * _options.JitterRatio * unit); + return TimeSpan.FromMilliseconds(Math.Min( + bounded * multiplier, + _options.MaximumRetryDelay.TotalMilliseconds)); + } + + private static bool IsTransient(RendezvousErrorCode code) => code is + RendezvousErrorCode.RateLimited + or RendezvousErrorCode.CapacityExceeded + or RendezvousErrorCode.ServiceUnavailable; + + private static bool IsTransientTransportFailure( + Exception exception, + CancellationToken callerCancellation) => + exception is HttpRequestException + or IOException + || exception is OperationCanceledException && !callerCancellation.IsCancellationRequested; + + private static int? GetRetryAfterSeconds(RetryConditionHeaderValue? retryAfter) => + retryAfter?.Delta is TimeSpan delta + ? Math.Max(0, (int)Math.Ceiling(delta.TotalSeconds)) + : null; + + private static ApiError FallbackError(HttpStatusCode statusCode) => new() + { + Code = statusCode switch + { + HttpStatusCode.BadRequest => RendezvousErrorCode.InvalidRequest, + HttpStatusCode.Unauthorized => RendezvousErrorCode.AuthenticationRequired, + HttpStatusCode.Forbidden => RendezvousErrorCode.Forbidden, + HttpStatusCode.NotFound => RendezvousErrorCode.NotFound, + HttpStatusCode.Conflict => RendezvousErrorCode.Conflict, + HttpStatusCode.Gone => RendezvousErrorCode.Expired, + HttpStatusCode.TooManyRequests => RendezvousErrorCode.RateLimited, + HttpStatusCode.RequestTimeout => RendezvousErrorCode.ServiceUnavailable, + HttpStatusCode.BadGateway => RendezvousErrorCode.ServiceUnavailable, + HttpStatusCode.ServiceUnavailable => RendezvousErrorCode.ServiceUnavailable, + HttpStatusCode.GatewayTimeout => RendezvousErrorCode.ServiceUnavailable, + _ => RendezvousErrorCode.InternalError, + }, + Message = "The service returned an error without a valid Rendezvous envelope.", + }; +} diff --git a/src/FinalFactory.Rendezvous.Client/RendezvousPublisherClient.cs b/src/FinalFactory.Rendezvous.Client/RendezvousPublisherClient.cs new file mode 100644 index 0000000..8cab5d7 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Client/RendezvousPublisherClient.cs @@ -0,0 +1,151 @@ +using FinalFactory.Rendezvous.Contracts; + +namespace FinalFactory.Rendezvous.Client; + +public sealed class RendezvousPublisherClient : IRendezvousPublisherClient +{ + private readonly RendezvousHttpTransport _transport; + private readonly IRendezvousDelay _delay; + + public RendezvousPublisherClient( + HttpClient httpClient, + RendezvousClientOptions? options = null, + IRendezvousDelay? delay = null) + { + _delay = delay ?? new SystemRendezvousDelay(); + _transport = new(httpClient, options, _delay); + } + + public async Task> RegisterAsync( + RegisterSessionRequest request, + string publisherCredential, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + RegisterSessionRequest body = CopyRegistration(request); + RendezvousClientResult result = await _transport.SendSafeAsync( + () => RendezvousHttpTransport.JsonRequest(HttpMethod.Post, "v1/sessions", body, publisherCredential), + cancellationToken).ConfigureAwait(false); + return result.IsSuccess && result.Value is not null + ? RendezvousClientResult.Success(new PublishedSession(result.Value)) + : RendezvousClientResult.Failure( + result.Error, + result.Message, + result.RetryAfterSeconds); + } + + public async Task> RenewAsync( + PublishedSession session, + string publisherCredential, + CancellationToken cancellationToken = default) + { + if (session is null) + { + throw new ArgumentNullException(nameof(session)); + } + + RendezvousClientResult result = await _transport.SendSafeAsync( + () => RendezvousHttpTransport.JsonRequest( + HttpMethod.Post, + $"v1/sessions/{session.ListingId}/renew", + new RenewLeaseRequest { LeaseToken = session.LeaseToken }, + publisherCredential), + cancellationToken).ConfigureAwait(false); + if (result.IsSuccess && result.Value is not null) + { + session.ExpiresAt = result.Value.ExpiresAt; + session.LeaseRenewAfterSeconds = result.Value.RenewAfterSeconds; + } + + return result; + } + + public Task> UpdateAsync( + PublishedSession session, + UpdateSessionRequest request, + string publisherCredential, + CancellationToken cancellationToken = default) + { + if (session is null) + { + throw new ArgumentNullException(nameof(session)); + } + + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + UpdateSessionRequest body = new() + { + ContractVersion = request.ContractVersion, + LeaseToken = session.LeaseToken, + BuildVersion = request.BuildVersion, + DisplayName = request.DisplayName, + Capacity = CopyCapacity(request.Capacity), + Metadata = CopyMetadata(request.Metadata), + }; + return _transport.SendSafeAsync( + () => RendezvousHttpTransport.JsonRequest( + HttpMethod.Put, + $"v1/sessions/{session.ListingId}", + body, + publisherCredential), + cancellationToken); + } + + public Task> DeregisterAsync( + PublishedSession session, + string publisherCredential, + CancellationToken cancellationToken = default) + { + if (session is null) + { + throw new ArgumentNullException(nameof(session)); + } + + return _transport.SendSafeAsync( + () => RendezvousHttpTransport.JsonRequest( + HttpMethod.Delete, + $"v1/sessions/{session.ListingId}", + new DeleteSessionRequest { LeaseToken = session.LeaseToken }, + publisherCredential), + cancellationToken); + } + + public SessionLeaseMaintainer CreateLeaseMaintainer( + PublishedSession session, + string publisherCredential) => new( + this, + session ?? throw new ArgumentNullException(nameof(session)), + RendezvousHttpTransport.RequireCredential(publisherCredential), + _delay); + + private static RegisterSessionRequest CopyRegistration(RegisterSessionRequest request) => new() + { + ContractVersion = request.ContractVersion, + IdempotencyKey = request.IdempotencyKey, + GameId = request.GameId, + EnvironmentId = request.EnvironmentId, + RegionId = request.RegionId, + ProtocolVersion = request.ProtocolVersion, + BuildVersion = request.BuildVersion, + DisplayName = request.DisplayName, + Visibility = request.Visibility, + Capacity = CopyCapacity(request.Capacity), + Metadata = CopyMetadata(request.Metadata), + }; + + private static SessionCapacity CopyCapacity(SessionCapacity capacity) => new() + { + CurrentPlayers = capacity.CurrentPlayers, + MaximumPlayers = capacity.MaximumPlayers, + }; + + private static Dictionary CopyMetadata(Dictionary metadata) => + new(metadata, StringComparer.Ordinal); +} diff --git a/src/FinalFactory.Rendezvous.Client/RendezvousSessionBrowserClient.cs b/src/FinalFactory.Rendezvous.Client/RendezvousSessionBrowserClient.cs new file mode 100644 index 0000000..7b7f185 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Client/RendezvousSessionBrowserClient.cs @@ -0,0 +1,110 @@ +using FinalFactory.Rendezvous.Contracts; + +namespace FinalFactory.Rendezvous.Client; + +public sealed class RendezvousSessionBrowserClient : IRendezvousSessionBrowserClient +{ + private readonly RendezvousHttpTransport _transport; + + public RendezvousSessionBrowserClient( + HttpClient httpClient, + RendezvousClientOptions? options = null, + IRendezvousDelay? delay = null) + { + _transport = new(httpClient, options, delay); + } + + public Task> BrowseAsync( + BrowseSessionsRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + string query = $"v1/sessions?contractVersion={request.ContractVersion}" + + $"&gameId={Escape(request.GameId.Value)}" + + $"&environmentId={Escape(request.EnvironmentId.Value)}" + + $"&protocolVersion={request.ProtocolVersion}" + + $"&pageSize={request.PageSize}" + + $"&excludeFull={request.ExcludeFull.ToString().ToLowerInvariant()}" + + (request.RegionId.HasValue ? $"®ionId={Escape(request.RegionId.Value.Value)}" : string.Empty) + + (request.Cursor is not null ? $"&cursor={Escape(request.Cursor)}" : string.Empty); + return _transport.SendSafeAsync( + () => new HttpRequestMessage(HttpMethod.Get, query), + cancellationToken); + } + + public async Task>> BrowseAllAsync( + BrowseSessionsRequest request, + int maximumPages = 100, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (maximumPages is < 1 or > 1000) + { + throw new ArgumentOutOfRangeException(nameof(maximumPages)); + } + + List items = []; + string? cursor = request.Cursor; + for (int page = 0; page < maximumPages; page++) + { + BrowseSessionsRequest pageRequest = new() + { + ContractVersion = request.ContractVersion, + GameId = request.GameId, + EnvironmentId = request.EnvironmentId, + ProtocolVersion = request.ProtocolVersion, + RegionId = request.RegionId, + PageSize = request.PageSize, + ExcludeFull = request.ExcludeFull, + Cursor = cursor, + }; + RendezvousClientResult result = await BrowseAsync( + pageRequest, + cancellationToken).ConfigureAwait(false); + if (!result.IsSuccess || result.Value is null) + { + return RendezvousClientResult.Failure>( + result.Error, + result.Message, + result.RetryAfterSeconds); + } + + items.AddRange(result.Value.Items); + cursor = result.Value.NextCursor; + if (string.IsNullOrEmpty(cursor)) + { + return RendezvousClientResult.Success>(items.AsReadOnly()); + } + } + + return RendezvousClientResult.Failure>( + RendezvousErrorCode.CapacityExceeded, + $"Browsing exceeded the configured {maximumPages}-page limit."); + } + + public Task> GetAsync( + SessionListingId listingId, + GameId gameId, + EnvironmentId environmentId, + uint protocolVersion, + CancellationToken cancellationToken = default) + { + string query = $"v1/sessions/{listingId}?contractVersion={ContractLimits.ContractVersion}" + + $"&gameId={Escape(gameId.Value)}" + + $"&environmentId={Escape(environmentId.Value)}" + + $"&protocolVersion={protocolVersion}"; + return _transport.SendSafeAsync( + () => new HttpRequestMessage(HttpMethod.Get, query), + cancellationToken); + } + + private static string Escape(string value) => Uri.EscapeDataString(value ?? string.Empty); +} diff --git a/src/FinalFactory.Rendezvous.Client/SessionLeaseMaintainer.cs b/src/FinalFactory.Rendezvous.Client/SessionLeaseMaintainer.cs new file mode 100644 index 0000000..5ab6480 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Client/SessionLeaseMaintainer.cs @@ -0,0 +1,150 @@ +using FinalFactory.Rendezvous.Contracts; + +namespace FinalFactory.Rendezvous.Client; + +public enum LeaseMaintenanceStopReason +{ + Cancelled = 1, + Disposed = 2, + LeaseLost = 3, + Failed = 4, +} + +public sealed class LeaseMaintenanceResult +{ + internal LeaseMaintenanceResult(LeaseMaintenanceStopReason reason, RendezvousErrorCode error) + { + Reason = reason; + Error = error; + } + + public LeaseMaintenanceStopReason Reason { get; } + public RendezvousErrorCode Error { get; } +} + +public sealed class SessionLeaseMaintainer : IAsyncDisposable +{ + private readonly object _gate = new(); + private readonly IRendezvousPublisherClient _publisher; + private readonly PublishedSession _session; + private readonly string _publisherCredential; + private readonly IRendezvousDelay _delay; + private readonly CancellationTokenSource _disposeCancellation = new(); + private Task? _activeRun; + private Task? _disposeTask; + private bool _disposed; + + internal SessionLeaseMaintainer( + IRendezvousPublisherClient publisher, + PublishedSession session, + string publisherCredential, + IRendezvousDelay? delay = null) + { + _publisher = publisher; + _session = session; + _publisherCredential = publisherCredential; + _delay = delay ?? new SystemRendezvousDelay(); + } + + public event EventHandler? LeaseLost; + + public Task RunAsync(CancellationToken cancellationToken = default) + { + lock (_gate) + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(SessionLeaseMaintainer)); + } + if (_activeRun is not null) + { + throw new InvalidOperationException("Lease maintenance is already running."); + } + + _activeRun = RunCoreAsync(cancellationToken); + return _activeRun; + } + } + + public ValueTask DisposeAsync() + { + lock (_gate) + { + if (_disposeTask is not null) + { + return new(_disposeTask); + } + + _disposed = true; + _disposeCancellation.Cancel(); + _disposeTask = FinishDisposeAsync(_activeRun); + return new(_disposeTask); + } + } + + private async Task FinishDisposeAsync(Task? active) + { + try + { + if (active is not null) + { + await active.ConfigureAwait(false); + } + } + finally + { + _disposeCancellation.Dispose(); + } + } + + private async Task RunCoreAsync(CancellationToken cancellationToken) + { + await Task.Yield(); + using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + _disposeCancellation.Token); + try + { + while (true) + { + await _delay.DelayAsync( + TimeSpan.FromSeconds(Math.Max(1, _session.LeaseRenewAfterSeconds)), + linked.Token).ConfigureAwait(false); + RendezvousClientResult renewed = await _publisher.RenewAsync( + _session, + _publisherCredential, + linked.Token).ConfigureAwait(false); + if (renewed.IsSuccess) + { + continue; + } + + if (renewed.Error is RendezvousErrorCode.NotFound + or RendezvousErrorCode.Expired + or RendezvousErrorCode.Forbidden + or RendezvousErrorCode.AuthenticationRequired) + { + LeaseLost?.Invoke(this, EventArgs.Empty); + return new(LeaseMaintenanceStopReason.LeaseLost, renewed.Error); + } + + return new(LeaseMaintenanceStopReason.Failed, renewed.Error); + } + } + catch (OperationCanceledException) when (linked.IsCancellationRequested) + { + return new( + _disposeCancellation.IsCancellationRequested + ? LeaseMaintenanceStopReason.Disposed + : LeaseMaintenanceStopReason.Cancelled, + RendezvousErrorCode.None); + } + finally + { + lock (_gate) + { + _activeRun = null; + } + } + } +} diff --git a/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousClientBehaviorTests.cs b/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousClientBehaviorTests.cs new file mode 100644 index 0000000..4fb5310 --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousClientBehaviorTests.cs @@ -0,0 +1,342 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using FinalFactory.Rendezvous.Client; +using FinalFactory.Rendezvous.Contracts; + +namespace FinalFactory.Rendezvous.Tests.Client; + +public sealed class RendezvousClientBehaviorTests +{ + [Fact] + public async Task RegistrationRetriesWithTheSameIdempotentPayloadAndDisposesResponses() + { + TrackingContent unavailable = JsonContent(new ApiError + { + Code = RendezvousErrorCode.ServiceUnavailable, + Message = "try later", + RetryAfterSeconds = 1, + }); + TrackingContent created = JsonContent(CreateRegistrationResponse()); + ScriptedHandler handler = new( + Response(HttpStatusCode.ServiceUnavailable, unavailable), + Response(HttpStatusCode.Created, created), + new HttpResponseMessage(HttpStatusCode.NoContent)); + using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") }; + RegisterSessionRequest request = CreateRegistrationRequest("stable-idempotency-key"); + RecordingDelay delay = new(() => request.DisplayName = "mutated during retry delay"); + RendezvousPublisherClient publisher = new( + httpClient, + new RendezvousClientOptions { JitterRatio = 0 }, + delay); + + RendezvousClientResult result = await publisher.RegisterAsync( + request, + "publisher-credential"); + + Assert.True(result.IsSuccess); + Assert.Equal(2, handler.RequestBodies.Count); + Assert.Equal(handler.RequestBodies[0], handler.RequestBodies[1]); + Assert.Contains("stable-idempotency-key", handler.RequestBodies[0], StringComparison.Ordinal); + Assert.Equal(TimeSpan.FromSeconds(1), Assert.Single(delay.Delays)); + Assert.True(unavailable.IsDisposed); + Assert.True(created.IsDisposed); + + using HttpResponseMessage stillOwnedByCaller = await httpClient.GetAsync("health"); + Assert.Equal(HttpStatusCode.NoContent, stillOwnedByCaller.StatusCode); + } + + [Fact] + public async Task UpdateUsesTheLeaseWithoutMutatingTheCallersRequest() + { + ScriptedHandler handler = new( + Response(HttpStatusCode.Created, JsonContent(CreateRegistrationResponse())), + new HttpResponseMessage(HttpStatusCode.NoContent)); + using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") }; + RendezvousPublisherClient publisher = new(httpClient); + PublishedSession session = AssertSuccess(await publisher.RegisterAsync( + CreateRegistrationRequest("update-idempotency-key"), + "publisher-credential")); + UpdateSessionRequest update = new() + { + LeaseToken = "caller-placeholder", + BuildVersion = "2.0.0", + DisplayName = "updated", + Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 4 }, + Metadata = new() { ["mode"] = "online-coop" }, + }; + + RendezvousClientResult result = await publisher.UpdateAsync( + session, + update, + "publisher-credential"); + + Assert.True(result.IsSuccess); + Assert.Equal("caller-placeholder", update.LeaseToken); + Assert.Contains("lease-token", handler.RequestBodies[1], StringComparison.Ordinal); + Assert.DoesNotContain("caller-placeholder", handler.RequestBodies[1], StringComparison.Ordinal); + } + + [Fact] + public async Task GatewayFailureIsRetriedForSafeBrowserReads() + { + ScriptedHandler handler = new( + new HttpResponseMessage(HttpStatusCode.BadGateway), + Response(HttpStatusCode.OK, JsonContent(new BrowseSessionsResponse()))); + using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") }; + RecordingDelay delay = new(); + RendezvousSessionBrowserClient browser = new( + httpClient, + new RendezvousClientOptions { JitterRatio = 0 }, + delay); + + RendezvousClientResult result = await browser.BrowseAsync(new() + { + GameId = new("space-game"), + EnvironmentId = new("production"), + ProtocolVersion = 7, + }); + + Assert.True(result.IsSuccess, result.Message); + Assert.Equal(2, handler.RequestUris.Count); + Assert.Equal(TimeSpan.FromMilliseconds(200), Assert.Single(delay.Delays)); + } + + [Fact] + public void SuccessResultRequiresAValue() + { + Assert.Throws(() => RendezvousClientResult.Success(null!)); + } + + [Fact] + public async Task BrowseAllFollowsCursorsWithoutMutatingTheCallersRequest() + { + ScriptedHandler handler = new( + Response(HttpStatusCode.OK, JsonContent(new BrowseSessionsResponse + { + Items = [CreateListing("00000000-0000-0000-0000-000000000001")], + NextCursor = "next page+token", + })), + Response(HttpStatusCode.OK, JsonContent(new BrowseSessionsResponse + { + Items = [CreateListing("00000000-0000-0000-0000-000000000002")], + }))); + using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") }; + RendezvousSessionBrowserClient browser = new(httpClient); + BrowseSessionsRequest request = new() + { + GameId = new("space-game"), + EnvironmentId = new("production"), + ProtocolVersion = 7, + PageSize = 1, + }; + + RendezvousClientResult> result = await browser.BrowseAllAsync(request); + + Assert.True(result.IsSuccess); + Assert.Equal(2, result.Value!.Count); + Assert.Null(request.Cursor); + Assert.DoesNotContain("cursor=", handler.RequestUris[0].Query, StringComparison.Ordinal); + Assert.Contains("cursor=next%20page%2Btoken", handler.RequestUris[1].Query, StringComparison.Ordinal); + Assert.Contains("gameId=space-game", handler.RequestUris[0].Query, StringComparison.Ordinal); + } + + [Fact] + public async Task LeaseMaintainerReportsLeaseLoss() + { + ScriptedHandler handler = new( + Response(HttpStatusCode.Created, JsonContent(CreateRegistrationResponse())), + Response(HttpStatusCode.Gone, JsonContent(new ApiError + { + Code = RendezvousErrorCode.Expired, + Message = "lease expired", + }))); + using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") }; + RecordingDelay delay = new(); + RendezvousPublisherClient publisher = new(httpClient, delay: delay); + PublishedSession session = AssertSuccess(await publisher.RegisterAsync( + CreateRegistrationRequest("lease-loss-key"), + "publisher-credential")); + await using SessionLeaseMaintainer maintainer = publisher.CreateLeaseMaintainer( + session, + "publisher-credential"); + bool eventRaised = false; + maintainer.LeaseLost += (_, _) => eventRaised = true; + + LeaseMaintenanceResult result = await maintainer.RunAsync(); + + Assert.Equal(LeaseMaintenanceStopReason.LeaseLost, result.Reason); + Assert.Equal(RendezvousErrorCode.Expired, result.Error); + Assert.True(eventRaised); + Assert.Equal(TimeSpan.FromSeconds(15), Assert.Single(delay.Delays)); + } + + [Fact] + public async Task DisposingLeaseMaintainerCancelsItsWaitAndDoesNotRenew() + { + ScriptedHandler handler = new( + Response(HttpStatusCode.Created, JsonContent(CreateRegistrationResponse()))); + using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") }; + BlockingDelay delay = new(); + RendezvousPublisherClient publisher = new(httpClient, delay: delay); + PublishedSession session = AssertSuccess(await publisher.RegisterAsync( + CreateRegistrationRequest("dispose-key"), + "publisher-credential")); + SessionLeaseMaintainer maintainer = publisher.CreateLeaseMaintainer( + session, + "publisher-credential"); + Task active = maintainer.RunAsync(); + await delay.Started.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + await maintainer.DisposeAsync(); + LeaseMaintenanceResult result = await active; + + Assert.Equal(LeaseMaintenanceStopReason.Disposed, result.Reason); + Assert.Single(handler.RequestUris); + await Assert.ThrowsAsync(() => maintainer.RunAsync()); + } + + [Fact] + public async Task CallerCancellationStopsLeaseMaintenanceWithoutRenewing() + { + ScriptedHandler handler = new( + Response(HttpStatusCode.Created, JsonContent(CreateRegistrationResponse()))); + using HttpClient httpClient = new(handler) { BaseAddress = new("http://rendezvous.test/") }; + BlockingDelay delay = new(); + RendezvousPublisherClient publisher = new(httpClient, delay: delay); + PublishedSession session = AssertSuccess(await publisher.RegisterAsync( + CreateRegistrationRequest("cancel-key"), + "publisher-credential")); + await using SessionLeaseMaintainer maintainer = publisher.CreateLeaseMaintainer( + session, + "publisher-credential"); + using CancellationTokenSource cancellation = new(); + Task active = maintainer.RunAsync(cancellation.Token); + await delay.Started.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + await cancellation.CancelAsync(); + LeaseMaintenanceResult result = await active; + + Assert.Equal(LeaseMaintenanceStopReason.Cancelled, result.Reason); + Assert.Single(handler.RequestUris); + } + + private static T AssertSuccess(RendezvousClientResult result) + { + Assert.True(result.IsSuccess, result.Message); + return Assert.IsType(result.Value); + } + + private static RegisterSessionRequest CreateRegistrationRequest(string idempotencyKey) => new() + { + IdempotencyKey = idempotencyKey, + GameId = new("space-game"), + EnvironmentId = new("production"), + RegionId = new("eu-central"), + ProtocolVersion = 7, + BuildVersion = "1.0.0", + DisplayName = "SDK host", + Visibility = ListingVisibility.Public, + Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 4 }, + }; + + private static RegisterSessionResponse CreateRegistrationResponse() => new() + { + ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000010")), + LeaseId = new(Guid.Parse("00000000-0000-0000-0000-000000000011")), + LeaseToken = "lease-token", + HostPresenceHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000012")), + HostPresenceCapability = "presence-capability", + ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero), + LeaseRenewAfterSeconds = 15, + HostPresenceRefreshAfterSeconds = 10, + }; + + private static SessionListing CreateListing(string id) => new() + { + ListingId = new(Guid.Parse(id)), + GameId = new("space-game"), + EnvironmentId = new("production"), + RegionId = new("eu-central"), + ProtocolVersion = 7, + BuildVersion = "1.0.0", + DisplayName = "host", + Visibility = ListingVisibility.Public, + PublisherTrustMode = PublisherTrustMode.ManagedDedicated, + Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 4 }, + }; + + private static TrackingContent JsonContent(T value) => new( + JsonSerializer.SerializeToUtf8Bytes(value, ContractJson.Options)); + + private static HttpResponseMessage Response(HttpStatusCode status, HttpContent content) => new(status) + { + Content = content, + }; + + private sealed class ScriptedHandler(params HttpResponseMessage[] responses) : HttpMessageHandler + { + private readonly Queue _responses = new(responses); + + internal List RequestBodies { get; } = []; + internal List RequestUris { get; } = []; + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + RequestUris.Add(request.RequestUri!); + RequestBodies.Add(request.Content is null + ? string.Empty + : await request.Content.ReadAsStringAsync(cancellationToken)); + return _responses.Count > 0 + ? _responses.Dequeue() + : throw new InvalidOperationException("No scripted response remains."); + } + } + + private sealed class TrackingContent(byte[] bytes) : HttpContent + { + internal bool IsDisposed { get; private set; } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => + stream.WriteAsync(bytes).AsTask(); + + protected override bool TryComputeLength(out long length) + { + length = bytes.Length; + return true; + } + + protected override void Dispose(bool disposing) + { + IsDisposed = true; + base.Dispose(disposing); + } + } + + private sealed class RecordingDelay(Action? onDelay = null) : IRendezvousDelay + { + internal List Delays { get; } = []; + + public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Delays.Add(delay); + onDelay?.Invoke(); + return Task.CompletedTask; + } + } + + private sealed class BlockingDelay : IRendezvousDelay + { + internal TaskCompletionSource Started { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) + { + Started.TrySetResult(); + return Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + } +} diff --git a/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousClientIntegrationTests.cs b/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousClientIntegrationTests.cs new file mode 100644 index 0000000..84cdb94 --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousClientIntegrationTests.cs @@ -0,0 +1,195 @@ +using FinalFactory.Rendezvous.Client; +using FinalFactory.Rendezvous.Contracts; +using FinalFactory.Rendezvous.Server.Browser; +using FinalFactory.Rendezvous.Server.Http; +using FinalFactory.Rendezvous.Server.Provisioning; +using FinalFactory.Rendezvous.Server.Sessions; +using FinalFactory.Rendezvous.Server.State; +using FinalFactory.Rendezvous.Tests.Provisioning; +using FinalFactory.Rendezvous.Tests.State; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace FinalFactory.Rendezvous.Tests.Client; + +public sealed class RendezvousClientIntegrationTests +{ + [Fact] + public async Task PublisherAndBrowserClientsCompleteTheRealSessionLifecycleAndPaging() + { + await using ClientTestHost host = await ClientTestHost.StartAsync(); + RendezvousPublisherClient publisher = new(host.HttpClient); + RendezvousSessionBrowserClient browser = new(host.HttpClient); + List sessions = []; + + for (int index = 0; index < 3; index++) + { + RendezvousClientResult registered = await publisher.RegisterAsync( + CreateRegistration(index), + host.PublisherCredential); + PublishedSession session = AssertSuccess(registered); + sessions.Add(session); + Assert.True(host.Capabilities.TryFingerprint( + session.HostPresenceCapability, + out SecretFingerprint fingerprint)); + StoreResult bound = host.Store.BindHostPresence(new( + session.HostPresenceHandle, + fingerprint, + new(AddressFamilyKind.Ipv4, $"203.0.113.{80 + index}", 41_000 + index), + null)); + Assert.Equal(StoreResultCode.Success, bound.Code); + } + + PublishedSession first = sessions[0]; + RendezvousClientResult renewed = await publisher.RenewAsync( + first, + host.PublisherCredential); + Assert.True(renewed.IsSuccess, renewed.Message); + Assert.Equal(renewed.Value!.ExpiresAt, first.ExpiresAt); + + UpdateSessionRequest update = new() + { + BuildVersion = "2.0.0", + DisplayName = "SDK host updated", + Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 8 }, + Metadata = new() { ["mode"] = "online-coop" }, + }; + RendezvousClientResult updated = await publisher.UpdateAsync( + first, + update, + host.PublisherCredential); + Assert.True(updated.IsSuccess, updated.Message); + + BrowseSessionsRequest browseRequest = new() + { + GameId = new("space-game"), + EnvironmentId = new("production"), + RegionId = new("eu-central"), + ProtocolVersion = 7, + PageSize = 1, + ExcludeFull = true, + }; + IReadOnlyList listings = AssertSuccess( + await browser.BrowseAllAsync(browseRequest)); + Assert.Equal(3, listings.Count); + Assert.Equal("SDK host updated", listings.Single(item => item.ListingId == first.ListingId).DisplayName); + + GetSessionResponse direct = AssertSuccess(await browser.GetAsync( + first.ListingId, + new("space-game"), + new("production"), + 7)); + Assert.Equal("2.0.0", direct.Session.BuildVersion); + + foreach (PublishedSession session in sessions) + { + RendezvousClientResult deregistered = await publisher.DeregisterAsync( + session, + host.PublisherCredential); + Assert.True(deregistered.IsSuccess, deregistered.Message); + Assert.Equal(StoreResultCode.NotFound, host.Store.GetListing(session.ListingId, false).Code); + } + } + + private static T AssertSuccess(RendezvousClientResult result) + { + Assert.True(result.IsSuccess, result.Message); + return Assert.IsAssignableFrom(result.Value); + } + + private static RegisterSessionRequest CreateRegistration(int index) => new() + { + IdempotencyKey = $"sdk-integration-{index}", + GameId = new("space-game"), + EnvironmentId = new("production"), + RegionId = new("eu-central"), + ProtocolVersion = 7, + BuildVersion = "1.0.0", + DisplayName = $"SDK host {index}", + Visibility = ListingVisibility.Public, + Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 8 }, + Metadata = new() { ["mode"] = "online-coop" }, + }; + + private sealed class ClientTestHost : IAsyncDisposable + { + private readonly WebApplication _application; + + private ClientTestHost( + WebApplication application, + HttpClient httpClient, + InMemoryEphemeralRendezvousStore store, + EphemeralCapabilityIssuer capabilities, + string publisherCredential) + { + _application = application; + HttpClient = httpClient; + Store = store; + Capabilities = capabilities; + PublisherCredential = publisherCredential; + } + + internal HttpClient HttpClient { get; } + internal InMemoryEphemeralRendezvousStore Store { get; } + internal EphemeralCapabilityIssuer Capabilities { get; } + internal string PublisherCredential { get; } + + internal static async Task StartAsync() + { + ManualRendezvousClock clock = new(ProvisioningTestData.Now); + EphemeralStoreOptions stateOptions = new(); + InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock); + EphemeralCapabilityIssuer capabilities = new(); + ProvisioningRuntime provisioning = ProvisioningRuntime.Create( + ProvisioningTestData.CreateOptions(), + ProvisioningTestData.CreateSecrets("secret-1"), + clock.UtcNow); + DedicatedPublisherPrincipal principal = ProvisioningTestData.CreateDedicatedPublisher(); + string credential = provisioning.Credentials.Issue(principal, clock.UtcNow); + + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Services.ConfigureHttpJsonOptions(static options => + ContractJson.Configure(options.SerializerOptions)); + builder.Services.Configure(static options => + options.ThrowOnBadRequest = true); + builder.Services.AddProblemDetails(); + builder.Services.AddExceptionHandler(); + builder.Services.AddSingleton(provisioning); + builder.Services.AddSingleton(provisioning.Credentials); + builder.Services.AddSingleton(provisioning.PublisherAuthorization); + builder.Services.AddSingleton(store); + builder.Services.AddSingleton(clock); + builder.Services.AddSingleton(capabilities); + builder.Services.AddSingleton(capabilities); + builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions)); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + WebApplication app = builder.Build(); + app.UseExceptionHandler(); + app.MapRendezvousContractEndpoints(); + await app.StartAsync(); + IServer server = app.Services.GetRequiredService(); + string address = Assert.Single(server.Features.Get()!.Addresses); + return new( + app, + new HttpClient { BaseAddress = new Uri(address) }, + store, + capabilities, + credential); + } + + public async ValueTask DisposeAsync() + { + HttpClient.Dispose(); + await _application.StopAsync(); + await _application.DisposeAsync(); + } + } +} diff --git a/tests/FinalFactory.Rendezvous.Tests/Contracts/PublicApiCompatibilityTests.cs b/tests/FinalFactory.Rendezvous.Tests/Contracts/PublicApiCompatibilityTests.cs index 76da70d..e0890c6 100644 --- a/tests/FinalFactory.Rendezvous.Tests/Contracts/PublicApiCompatibilityTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/Contracts/PublicApiCompatibilityTests.cs @@ -1,4 +1,5 @@ using System.Reflection; +using FinalFactory.Rendezvous.Client; using FinalFactory.Rendezvous.Contracts; namespace FinalFactory.Rendezvous.Tests.Contracts; @@ -23,6 +24,24 @@ public sealed class PublicApiCompatibilityTests Assert.Equal(expected, snapshot); } + [Fact] + public void ClientPublicApiMatchesTheV1Snapshot() + { + string snapshot = CreateSnapshot(typeof(RendezvousPublisherClient).Assembly); + string expected = ContractTestFiles.Read("client-public-api.txt"); + if (expected == "SNAPSHOT_PENDING" + && Environment.GetEnvironmentVariable("RENDEZVOUS_UPDATE_CONTRACT_SNAPSHOT") == "1") + { + string snapshotPath = Path.Combine( + ContractTestFiles.Directory, + "client-public-api.txt"); + File.WriteAllText(snapshotPath, snapshot + Environment.NewLine); + expected = snapshot; + } + + Assert.Equal(expected, snapshot); + } + private static string CreateSnapshot(Assembly assembly) { List lines = []; @@ -58,10 +77,16 @@ public sealed class PublicApiCompatibilityTests foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly) .OrderBy(static property => property.Name, StringComparer.Ordinal)) { - string accessors = $"{(property.CanRead ? "get;" : string.Empty)}{(property.CanWrite ? "set;" : string.Empty)}"; + string accessors = $"{(property.GetMethod?.IsPublic == true ? "get;" : string.Empty)}{(property.SetMethod?.IsPublic == true ? "set;" : string.Empty)}"; lines.Add($" PROP {FormatType(property.PropertyType)} {property.Name} {{{accessors}}}"); } + foreach (EventInfo eventInfo in type.GetEvents(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly) + .OrderBy(static eventInfo => eventInfo.Name, StringComparer.Ordinal)) + { + lines.Add($" EVENT {FormatType(eventInfo.EventHandlerType!)} {eventInfo.Name}"); + } + foreach (MethodInfo method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly) .Where(static method => !method.IsSpecialName || method.Name.StartsWith("op_", StringComparison.Ordinal)) .OrderBy(static method => method.Name, StringComparer.Ordinal) diff --git a/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/client-public-api.txt b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/client-public-api.txt new file mode 100644 index 0000000..b9d75e8 --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/client-public-api.txt @@ -0,0 +1,60 @@ +TYPE FinalFactory.Rendezvous.Client.IRendezvousDelay + METHOD System.Threading.Tasks.Task DelayAsync(System.TimeSpan delay, System.Threading.CancellationToken cancellationToken) +TYPE FinalFactory.Rendezvous.Client.IRendezvousPublisherClient + METHOD System.Threading.Tasks.Task> DeregisterAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential, System.Threading.CancellationToken cancellationToken) + METHOD System.Threading.Tasks.Task> RegisterAsync(FinalFactory.Rendezvous.Contracts.RegisterSessionRequest request, System.String publisherCredential, System.Threading.CancellationToken cancellationToken) + METHOD System.Threading.Tasks.Task> RenewAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential, System.Threading.CancellationToken cancellationToken) + METHOD System.Threading.Tasks.Task> UpdateAsync(FinalFactory.Rendezvous.Client.PublishedSession session, FinalFactory.Rendezvous.Contracts.UpdateSessionRequest request, System.String publisherCredential, System.Threading.CancellationToken cancellationToken) +TYPE FinalFactory.Rendezvous.Client.IRendezvousSessionBrowserClient + METHOD System.Threading.Tasks.Task>> BrowseAllAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken) + METHOD System.Threading.Tasks.Task> BrowseAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Threading.CancellationToken cancellationToken) + METHOD System.Threading.Tasks.Task> GetAsync(FinalFactory.Rendezvous.Contracts.SessionListingId listingId, FinalFactory.Rendezvous.Contracts.GameId gameId, FinalFactory.Rendezvous.Contracts.EnvironmentId environmentId, System.UInt32 protocolVersion, System.Threading.CancellationToken cancellationToken) +TYPE FinalFactory.Rendezvous.Client.LeaseMaintenanceResult + PROP FinalFactory.Rendezvous.Contracts.RendezvousErrorCode Error {get;} + PROP FinalFactory.Rendezvous.Client.LeaseMaintenanceStopReason Reason {get;} +TYPE FinalFactory.Rendezvous.Client.LeaseMaintenanceStopReason + ENUM Cancelled=1 + ENUM Disposed=2 + ENUM LeaseLost=3 + ENUM Failed=4 +TYPE FinalFactory.Rendezvous.Client.PublishedSession + PROP System.DateTimeOffset ExpiresAt {get;} + PROP System.String HostPresenceCapability {get;} + PROP FinalFactory.Rendezvous.Contracts.MediationHandle HostPresenceHandle {get;} + PROP System.Int32 HostPresenceRefreshAfterSeconds {get;} + PROP FinalFactory.Rendezvous.Contracts.LeaseId LeaseId {get;} + PROP System.Int32 LeaseRenewAfterSeconds {get;} + PROP System.String LeaseToken {get;} + PROP FinalFactory.Rendezvous.Contracts.SessionListingId ListingId {get;} + METHOD System.String ToString() +TYPE FinalFactory.Rendezvous.Client.RendezvousClientOptions + CTOR () + PROP System.TimeSpan InitialRetryDelay {get;set;} + PROP System.Double JitterRatio {get;set;} + PROP System.TimeSpan MaximumRetryDelay {get;set;} + PROP System.Int32 MaximumSafeRetries {get;set;} +TYPE FinalFactory.Rendezvous.Client.RendezvousClientResult + METHOD FinalFactory.Rendezvous.Client.RendezvousClientResult Failure(FinalFactory.Rendezvous.Contracts.RendezvousErrorCode error, System.String message, System.Nullable retryAfterSeconds) + METHOD FinalFactory.Rendezvous.Client.RendezvousClientResult Success(T value) +TYPE FinalFactory.Rendezvous.Client.RendezvousClientResult + PROP FinalFactory.Rendezvous.Contracts.RendezvousErrorCode Error {get;} + PROP System.Boolean IsSuccess {get;} + PROP System.String Message {get;} + PROP System.Nullable RetryAfterSeconds {get;} + PROP T Value {get;} +TYPE FinalFactory.Rendezvous.Client.RendezvousPublisherClient + CTOR (System.Net.Http.HttpClient httpClient, FinalFactory.Rendezvous.Client.RendezvousClientOptions options, FinalFactory.Rendezvous.Client.IRendezvousDelay delay) + METHOD FinalFactory.Rendezvous.Client.SessionLeaseMaintainer CreateLeaseMaintainer(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential) + METHOD System.Threading.Tasks.Task> DeregisterAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential, System.Threading.CancellationToken cancellationToken) + METHOD System.Threading.Tasks.Task> RegisterAsync(FinalFactory.Rendezvous.Contracts.RegisterSessionRequest request, System.String publisherCredential, System.Threading.CancellationToken cancellationToken) + METHOD System.Threading.Tasks.Task> RenewAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential, System.Threading.CancellationToken cancellationToken) + METHOD System.Threading.Tasks.Task> UpdateAsync(FinalFactory.Rendezvous.Client.PublishedSession session, FinalFactory.Rendezvous.Contracts.UpdateSessionRequest request, System.String publisherCredential, System.Threading.CancellationToken cancellationToken) +TYPE FinalFactory.Rendezvous.Client.RendezvousSessionBrowserClient + CTOR (System.Net.Http.HttpClient httpClient, FinalFactory.Rendezvous.Client.RendezvousClientOptions options, FinalFactory.Rendezvous.Client.IRendezvousDelay delay) + METHOD System.Threading.Tasks.Task>> BrowseAllAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken) + METHOD System.Threading.Tasks.Task> BrowseAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Threading.CancellationToken cancellationToken) + METHOD System.Threading.Tasks.Task> GetAsync(FinalFactory.Rendezvous.Contracts.SessionListingId listingId, FinalFactory.Rendezvous.Contracts.GameId gameId, FinalFactory.Rendezvous.Contracts.EnvironmentId environmentId, System.UInt32 protocolVersion, System.Threading.CancellationToken cancellationToken) +TYPE FinalFactory.Rendezvous.Client.SessionLeaseMaintainer + EVENT System.EventHandler LeaseLost + METHOD System.Threading.Tasks.ValueTask DisposeAsync() + METHOD System.Threading.Tasks.Task RunAsync(System.Threading.CancellationToken cancellationToken)