using System.Diagnostics; using FinalFactory.Rendezvous.Contracts; namespace FinalFactory.Rendezvous.Client; public sealed class RendezvousJoinClient : IRendezvousJoinClient { private const string LeaseTokenHeader = "X-Rendezvous-Lease-Token"; private const string ClientPunchCapabilityHeader = "X-Rendezvous-Client-Punch-Capability"; private readonly RendezvousHttpTransport _transport; public RendezvousJoinClient( HttpClient httpClient, RendezvousClientOptions? options = null, IRendezvousDelay? delay = null) { _transport = new(httpClient, options, delay); } public Task> CreateAsync( CreateJoinAttemptRequest request, CancellationToken cancellationToken = default) { if (request is null) { throw new ArgumentNullException(nameof(request)); } CreateJoinAttemptRequest body = new() { ContractVersion = request.ContractVersion, IdempotencyKey = request.IdempotencyKey, GameId = request.GameId, EnvironmentId = request.EnvironmentId, ListingId = request.ListingId, ProtocolVersion = request.ProtocolVersion, }; return _transport.SendSafeAsync( () => RendezvousHttpTransport.JsonRequest(HttpMethod.Post, "v1/join-attempts", body), cancellationToken); } public async Task CreateConnectionAttemptAsync( CreateJoinAttemptRequest request, NetworkEndpoint? dedicatedFallback = null, CancellationToken cancellationToken = default) { if (dedicatedFallback is not null && !ContractValidation.IsNetworkEndpointValid(dedicatedFallback)) { throw new ArgumentException("The dedicated fallback endpoint is invalid.", nameof(dedicatedFallback)); } Stopwatch elapsed = Stopwatch.StartNew(); try { RendezvousClientResult result = await CreateAsync( request, cancellationToken).ConfigureAwait(false); elapsed.Stop(); return result.IsSuccess && result.Value is not null ? RendezvousConnectionStartResult.ReadyForTraversal(result.Value) : RendezvousConnectionStartResult.Completed( RendezvousConnectionOutcome.FromServiceError( result.Error, elapsed.Elapsed, dedicatedFallback)); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { elapsed.Stop(); return RendezvousConnectionStartResult.Completed( RendezvousConnectionOutcome.Create( ConnectionOutcomeKind.Cancelled, RendezvousConnectionOutcomeSource.Caller, RendezvousConnectionFailureCategory.Lifecycle, RendezvousConnectionPhase.Authorization, elapsed.Elapsed, dedicatedFallback)); } } public Task> CancelAsync( CreateJoinAttemptResponse attempt, CancellationToken cancellationToken = default) { if (attempt is null) { throw new ArgumentNullException(nameof(attempt)); } return _transport.SendSafeAsync( () => HeaderRequest( HttpMethod.Delete, $"v1/join-attempts/{attempt.AttemptId}", ClientPunchCapabilityHeader, RequireHeaderValue(attempt.ClientPunchCapability, nameof(attempt))), cancellationToken); } public Task> BrowseForHostAsync( PublishedSession session, int pageSize = ContractLimits.BrowserPageMaxItems, string? cursor = null, CancellationToken cancellationToken = default) { if (session is null) { throw new ArgumentNullException(nameof(session)); } if (pageSize is < 1 or > ContractLimits.BrowserPageMaxItems) { throw new ArgumentOutOfRangeException(nameof(pageSize)); } string query = $"v1/sessions/{session.ListingId}/join-attempts" + $"?contractVersion={ContractLimits.ContractVersion}" + $"&pageSize={pageSize}" + (cursor is null ? string.Empty : $"&cursor={Uri.EscapeDataString(cursor)}"); return _transport.SendSafeAsync( () => HeaderRequest( HttpMethod.Get, query, LeaseTokenHeader, RequireHeaderValue(session.LeaseToken, nameof(session))), cancellationToken); } public async Task>> BrowseAllForHostAsync( PublishedSession session, int maximumPages = 100, CancellationToken cancellationToken = default) { if (session is null) { throw new ArgumentNullException(nameof(session)); } if (maximumPages is < 1 or > 1_000) { throw new ArgumentOutOfRangeException(nameof(maximumPages)); } List attempts = []; string? cursor = null; for (int page = 0; page < maximumPages; page++) { RendezvousClientResult result = await BrowseForHostAsync( session, ContractLimits.BrowserPageMaxItems, cursor, cancellationToken).ConfigureAwait(false); if (!result.IsSuccess || result.Value is null) { return RendezvousClientResult.Failure>( result.Error, result.Message, result.RetryAfterSeconds); } attempts.AddRange(result.Value.Items); cursor = result.Value.NextCursor; if (string.IsNullOrEmpty(cursor)) { return RendezvousClientResult.Success>( attempts.AsReadOnly()); } } return RendezvousClientResult.Failure>( RendezvousErrorCode.CapacityExceeded, $"Host invitation polling exceeded the configured {maximumPages}-page limit."); } public Task> ReportOutcomeAsync( CreateJoinAttemptResponse attempt, RendezvousConnectionOutcome outcome, CancellationToken cancellationToken = default) { if (attempt is null) { throw new ArgumentNullException(nameof(attempt)); } if (outcome is null) { throw new ArgumentNullException(nameof(outcome)); } if (!ContractValidation.IsReportableConnectionOutcome(outcome.Kind)) { throw new ArgumentException( "This outcome cannot be reported for an issued join attempt.", nameof(outcome)); } ReportConnectionOutcomeRequest body = new() { Outcome = outcome.Kind, ElapsedBucket = RendezvousConnectionOutcome.BucketElapsed(outcome.Elapsed), }; return _transport.SendSafeAsync( () => HeaderJsonRequest( HttpMethod.Post, $"v1/join-attempts/{attempt.AttemptId}/outcome", ClientPunchCapabilityHeader, RequireHeaderValue(attempt.ClientPunchCapability, nameof(attempt)), body), cancellationToken); } private static HttpRequestMessage HeaderRequest( HttpMethod method, string uri, string header, string value) { HttpRequestMessage request = new(method, uri); request.Headers.TryAddWithoutValidation(header, value); return request; } private static HttpRequestMessage HeaderJsonRequest( HttpMethod method, string uri, string header, string value, T body) { HttpRequestMessage request = RendezvousHttpTransport.JsonRequest(method, uri, body); request.Headers.TryAddWithoutValidation(header, value); return request; } private static string RequireHeaderValue(string value, string parameterName) => !string.IsNullOrWhiteSpace(value) ? value : throw new ArgumentException("The required capability is missing.", parameterName); }