237 lines
8.7 KiB
C#
237 lines
8.7 KiB
C#
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<RendezvousClientResult<CreateJoinAttemptResponse>> 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<CreateJoinAttemptResponse>(
|
|
() => RendezvousHttpTransport.JsonRequest(HttpMethod.Post, "v1/join-attempts", body),
|
|
cancellationToken);
|
|
}
|
|
|
|
public async Task<RendezvousConnectionStartResult> 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<CreateJoinAttemptResponse> 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<RendezvousClientResult<bool>> CancelAsync(
|
|
CreateJoinAttemptResponse attempt,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (attempt is null)
|
|
{
|
|
throw new ArgumentNullException(nameof(attempt));
|
|
}
|
|
return _transport.SendSafeAsync<bool>(
|
|
() => HeaderRequest(
|
|
HttpMethod.Delete,
|
|
$"v1/join-attempts/{attempt.AttemptId}",
|
|
ClientPunchCapabilityHeader,
|
|
RequireHeaderValue(attempt.ClientPunchCapability, nameof(attempt))),
|
|
cancellationToken);
|
|
}
|
|
|
|
public Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> 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<BrowseHostJoinAttemptsResponse>(
|
|
() => HeaderRequest(
|
|
HttpMethod.Get,
|
|
query,
|
|
LeaseTokenHeader,
|
|
RequireHeaderValue(session.LeaseToken, nameof(session))),
|
|
cancellationToken);
|
|
}
|
|
|
|
public async Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> 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<HostJoinAttempt> attempts = [];
|
|
string? cursor = null;
|
|
for (int page = 0; page < maximumPages; page++)
|
|
{
|
|
RendezvousClientResult<BrowseHostJoinAttemptsResponse> result =
|
|
await BrowseForHostAsync(
|
|
session,
|
|
ContractLimits.BrowserPageMaxItems,
|
|
cursor,
|
|
cancellationToken).ConfigureAwait(false);
|
|
if (!result.IsSuccess || result.Value is null)
|
|
{
|
|
return RendezvousClientResult.Failure<IReadOnlyList<HostJoinAttempt>>(
|
|
result.Error,
|
|
result.Message,
|
|
result.RetryAfterSeconds);
|
|
}
|
|
|
|
attempts.AddRange(result.Value.Items);
|
|
cursor = result.Value.NextCursor;
|
|
if (string.IsNullOrEmpty(cursor))
|
|
{
|
|
return RendezvousClientResult.Success<IReadOnlyList<HostJoinAttempt>>(
|
|
attempts.AsReadOnly());
|
|
}
|
|
}
|
|
|
|
return RendezvousClientResult.Failure<IReadOnlyList<HostJoinAttempt>>(
|
|
RendezvousErrorCode.CapacityExceeded,
|
|
$"Host invitation polling exceeded the configured {maximumPages}-page limit.");
|
|
}
|
|
|
|
public Task<RendezvousClientResult<ReportConnectionOutcomeResponse>> 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<ReportConnectionOutcomeResponse>(
|
|
() => 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<T>(
|
|
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);
|
|
}
|