Files
Rendezvous/src/FinalFactory.Rendezvous.Server/Http/ContractEndpoints.cs
T
KyuubiYoru 1baa1055dc
quality-gate / quality (push) Successful in 1m1s
feat: implement scoped join attempts and tickets (#10)
Closes #10
2026-07-16 06:56:30 +02:00

416 lines
18 KiB
C#

using System.Net;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State;
using Microsoft.AspNetCore.Mvc;
namespace FinalFactory.Rendezvous.Server.Http;
internal static class ContractEndpoints
{
private const int NotImplementedStatus = StatusCodes.Status501NotImplemented;
public static IEndpointRouteBuilder MapRendezvousContractEndpoints(
this IEndpointRouteBuilder endpoints)
{
RouteGroupBuilder sessions = endpoints.MapGroup("/v1/sessions").WithTags("Sessions");
sessions.MapPost("/", RegisterSession)
.Accepts<RegisterSessionRequest>("application/json")
.Produces<RegisterSessionResponse>(StatusCodes.Status201Created)
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
.Produces<ApiError>(StatusCodes.Status403Forbidden)
.Produces<ApiError>(StatusCodes.Status409Conflict)
.Produces<ApiError>(StatusCodes.Status410Gone)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("RegisterSession");
sessions.MapPost("/{listingId}/renew", RenewLease)
.Accepts<RenewLeaseRequest>("application/json")
.Produces<RenewLeaseResponse>()
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
.Produces<ApiError>(StatusCodes.Status403Forbidden)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status409Conflict)
.Produces<ApiError>(StatusCodes.Status410Gone)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("RenewSessionLease");
sessions.MapPut("/{listingId}", UpdateSession)
.Accepts<UpdateSessionRequest>("application/json")
.Produces(StatusCodes.Status204NoContent)
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
.Produces<ApiError>(StatusCodes.Status403Forbidden)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("UpdateSession");
sessions.MapDelete("/{listingId}", DeleteSession)
.Accepts<DeleteSessionRequest>("application/json")
.Produces(StatusCodes.Status204NoContent)
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status401Unauthorized)
.Produces<ApiError>(StatusCodes.Status403Forbidden)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("DeleteSession");
sessions.MapGet("/", BrowseSessions)
.Produces<BrowseSessionsResponse>()
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("BrowseSessions");
sessions.MapGet("/{listingId}", GetSession)
.Produces<GetSessionResponse>()
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("GetSession");
sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts)
.Produces<BrowseHostJoinAttemptsResponse>()
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("BrowseHostJoinAttempts");
RouteGroupBuilder attempts = endpoints
.MapGroup("/v1/join-attempts")
.WithTags("Join attempts");
attempts.MapPost("/", CreateJoinAttempt)
.Accepts<CreateJoinAttemptRequest>("application/json")
.Produces<CreateJoinAttemptResponse>(StatusCodes.Status201Created)
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status409Conflict)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("CreateJoinAttempt");
attempts.MapDelete("/{attemptId}", CancelJoinAttempt)
.Produces(StatusCodes.Status204NoContent)
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("CancelJoinAttempt");
attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome)
.Accepts<ReportConnectionOutcomeRequest>("application/json")
.Produces<ReportConnectionOutcomeResponse>()
.Produces<ApiError>(StatusCodes.Status501NotImplemented)
.WithName("ReportConnectionOutcome");
return endpoints;
}
private static IResult RegisterSession(
[FromBody] RegisterSessionRequest request,
[FromHeader(Name = "Authorization")] string? authorizationHeader,
[FromServices] PrincipalCredentialService credentials,
[FromServices] SessionLeaseService sessions,
[FromServices] IWallClock clock,
HttpContext httpContext,
CancellationToken cancellationToken)
{
if (!TryAuthenticatePublisher(
authorizationHeader,
credentials,
clock,
out AuthenticatedPrincipal? principal))
{
return AuthenticationRequired(httpContext);
}
SessionServiceResult<RegisterSessionResponse> result = sessions.Register(
principal!,
request,
cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Created($"/v1/sessions/{result.Value.ListingId}", result.Value)
: Error(result.Error);
}
private static IResult RenewLease(
SessionListingId listingId,
[FromBody] RenewLeaseRequest request,
[FromHeader(Name = "Authorization")] string? authorizationHeader,
[FromServices] PrincipalCredentialService credentials,
[FromServices] SessionLeaseService sessions,
[FromServices] IWallClock clock,
HttpContext httpContext,
CancellationToken cancellationToken)
{
if (!TryAuthenticatePublisher(
authorizationHeader,
credentials,
clock,
out AuthenticatedPrincipal? principal))
{
return AuthenticationRequired(httpContext);
}
SessionServiceResult<RenewLeaseResponse> result = sessions.Renew(
principal!,
listingId,
request,
cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Ok(result.Value)
: Error(result.Error);
}
private static IResult UpdateSession(
SessionListingId listingId,
[FromBody] UpdateSessionRequest request,
[FromHeader(Name = "Authorization")] string? authorizationHeader,
[FromServices] PrincipalCredentialService credentials,
[FromServices] SessionLeaseService sessions,
[FromServices] IWallClock clock,
HttpContext httpContext,
CancellationToken cancellationToken)
{
if (!TryAuthenticatePublisher(
authorizationHeader,
credentials,
clock,
out AuthenticatedPrincipal? principal))
{
return AuthenticationRequired(httpContext);
}
SessionServiceResult<bool> result = sessions.Update(
principal!,
listingId,
request,
cancellationToken);
return result.Succeeded ? Results.NoContent() : Error(result.Error);
}
private static IResult DeleteSession(
SessionListingId listingId,
[FromBody] DeleteSessionRequest request,
[FromHeader(Name = "Authorization")] string? authorizationHeader,
[FromServices] PrincipalCredentialService credentials,
[FromServices] SessionLeaseService sessions,
[FromServices] IWallClock clock,
HttpContext httpContext,
CancellationToken cancellationToken)
{
if (!TryAuthenticatePublisher(
authorizationHeader,
credentials,
clock,
out AuthenticatedPrincipal? principal))
{
return AuthenticationRequired(httpContext);
}
SessionServiceResult<bool> result = sessions.Delete(
principal!,
listingId,
request,
cancellationToken);
return result.Succeeded ? Results.NoContent() : Error(result.Error);
}
private static IResult BrowseSessions(
[FromQuery] int contractVersion,
[FromQuery] string gameId,
[FromQuery] string environmentId,
[FromQuery] uint protocolVersion,
[FromQuery] string? regionId,
[FromQuery] int? pageSize,
[FromQuery] bool? excludeFull,
[FromQuery] string? cursor,
[FromServices] SessionBrowserService browser,
CancellationToken cancellationToken)
{
if (!GameId.TryParse(gameId, out GameId parsedGameId)
|| !EnvironmentId.TryParse(environmentId, out EnvironmentId parsedEnvironmentId)
|| (regionId is not null && !RegionId.TryParse(regionId, out _)))
{
return Error(RendezvousErrorCode.InvalidRequest);
}
BrowserServiceResult<BrowseSessionsResponse> result = browser.Browse(new()
{
ContractVersion = contractVersion,
GameId = parsedGameId,
EnvironmentId = parsedEnvironmentId,
ProtocolVersion = protocolVersion,
RegionId = regionId is null ? null : new RegionId(regionId),
PageSize = pageSize ?? ContractLimits.BrowserPageMaxItems,
ExcludeFull = excludeFull ?? false,
Cursor = cursor,
}, cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Ok(result.Value)
: Error(result.Error);
}
private static IResult GetSession(
SessionListingId listingId,
[FromQuery] int contractVersion,
[FromQuery] string gameId,
[FromQuery] string environmentId,
[FromQuery] uint protocolVersion,
[FromServices] SessionBrowserService browser,
CancellationToken cancellationToken)
{
if (ContractValidation.ValidateContractVersion(contractVersion) != RendezvousErrorCode.None)
{
return Error(RendezvousErrorCode.UnsupportedContractVersion);
}
if (!GameId.TryParse(gameId, out GameId parsedGameId)
|| !EnvironmentId.TryParse(environmentId, out EnvironmentId parsedEnvironmentId))
{
return Error(RendezvousErrorCode.InvalidRequest);
}
BrowserServiceResult<GetSessionResponse> result = browser.Get(
listingId,
parsedGameId,
parsedEnvironmentId,
protocolVersion,
cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Ok(result.Value)
: Error(result.Error);
}
private static IResult BrowseHostJoinAttempts(
SessionListingId listingId,
[FromQuery] int contractVersion,
[FromHeader(Name = "X-Rendezvous-Lease-Token")] string leaseToken,
[FromQuery] int? pageSize,
[FromQuery] string? cursor,
[FromServices] JoinAttemptService attempts,
CancellationToken cancellationToken)
{
JoinAttemptServiceResult<BrowseHostJoinAttemptsResponse> result = attempts.BrowseForHost(
listingId,
contractVersion,
leaseToken,
pageSize ?? ContractLimits.BrowserPageMaxItems,
cursor,
cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Ok(result.Value)
: Error(result.Error);
}
private static IResult CreateJoinAttempt(
[FromBody] CreateJoinAttemptRequest request,
[FromServices] JoinAttemptService attempts,
HttpContext httpContext,
CancellationToken cancellationToken)
{
if (httpContext.Connection.RemoteIpAddress is not IPAddress remoteAddress)
{
return Error(RendezvousErrorCode.InvalidRequest);
}
string clientSubject = attempts.CreateAnonymousClientSubject(remoteAddress);
JoinAttemptServiceResult<CreateJoinAttemptResponse> result = attempts.Create(
clientSubject,
request,
cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Created($"/v1/join-attempts/{result.Value.AttemptId}", result.Value)
: Error(result.Error);
}
private static IResult CancelJoinAttempt(
JoinAttemptId attemptId,
[FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability,
[FromServices] JoinAttemptService attempts,
CancellationToken cancellationToken)
{
JoinAttemptServiceResult<bool> result = attempts.Cancel(
attemptId,
clientPunchCapability,
cancellationToken);
return result.Succeeded ? Results.NoContent() : Error(result.Error);
}
private static IResult ReportConnectionOutcome(
JoinAttemptId attemptId,
[FromBody] ReportConnectionOutcomeRequest request) => NotImplemented();
private static IResult NotImplemented() => Results.Json(
new ApiError
{
Code = RendezvousErrorCode.ServiceUnavailable,
Message = "The v1 contract is reserved; implementation is tracked by subsequent issues.",
},
ContractJson.Options,
statusCode: NotImplementedStatus);
private static bool TryAuthenticatePublisher(
string? authorizationHeader,
PrincipalCredentialService credentials,
IWallClock clock,
out AuthenticatedPrincipal? principal)
{
principal = null;
const string bearerPrefix = "Bearer ";
if (authorizationHeader is null
|| !authorizationHeader.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
{
return false;
}
string token = authorizationHeader[bearerPrefix.Length..];
CredentialValidationResult validation = credentials.Validate(token, clock.UtcNow);
if (!validation.IsValid || validation.Principal is not IPublisherPrincipal)
{
return false;
}
principal = validation.Principal;
return true;
}
private static IResult Error(RendezvousErrorCode code) => Results.Json(
new ApiError
{
Code = code,
Message = ErrorMessage(code),
},
ContractJson.Options,
statusCode: ErrorStatus(code));
private static IResult AuthenticationRequired(HttpContext context)
{
context.Response.Headers.WWWAuthenticate = "Bearer";
return Error(RendezvousErrorCode.AuthenticationRequired);
}
private static int ErrorStatus(RendezvousErrorCode code) => code switch
{
RendezvousErrorCode.AuthenticationRequired => StatusCodes.Status401Unauthorized,
RendezvousErrorCode.Forbidden => StatusCodes.Status403Forbidden,
RendezvousErrorCode.NotFound or RendezvousErrorCode.StaleHost => StatusCodes.Status404NotFound,
RendezvousErrorCode.Conflict or RendezvousErrorCode.ReplayRejected => StatusCodes.Status409Conflict,
RendezvousErrorCode.Expired => StatusCodes.Status410Gone,
RendezvousErrorCode.RateLimited or RendezvousErrorCode.CapacityExceeded =>
StatusCodes.Status429TooManyRequests,
RendezvousErrorCode.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable,
RendezvousErrorCode.InternalError => StatusCodes.Status500InternalServerError,
_ => StatusCodes.Status400BadRequest,
};
private static string ErrorMessage(RendezvousErrorCode code) => code switch
{
RendezvousErrorCode.AuthenticationRequired => "A valid publisher bearer credential is required.",
RendezvousErrorCode.Forbidden => "The publisher is not authorized for this operation.",
RendezvousErrorCode.NotFound => "The session was not found or is not owned by this publisher.",
RendezvousErrorCode.Conflict => "The session changed concurrently; retry with current state.",
RendezvousErrorCode.Expired => "The session lease has expired.",
RendezvousErrorCode.IncompatibleProtocol => "The gameplay protocol is not enabled for this game.",
RendezvousErrorCode.CapacityExceeded => "The configured session capacity is currently exhausted.",
RendezvousErrorCode.ServiceUnavailable => "Session state is temporarily unavailable.",
RendezvousErrorCode.UnsupportedContractVersion => "The requested contract version is not supported.",
_ => "The session request is invalid.",
};
}