feat: freeze v1 transport contracts (#4)
quality-gate / quality (push) Successful in 51s

Closes #4
This commit is contained in:
KyuubiYoru
2026-07-16 04:52:38 +02:00
parent e626b89909
commit 69c8b2d2bc
43 changed files with 3737 additions and 12 deletions
@@ -8,8 +8,71 @@
"resolved": "2.1.4",
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "TFI6OKYE1XZz4SGuTSH70c6SBdPpFktXsoa1gCxTr3mKrhmXirnvaS0tKz+J3ZWICEAmMpEGn59nO4ICtUpQXA=="
},
"System.Buffers": {
"type": "Transitive",
"resolved": "4.6.1",
"contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw=="
},
"System.IO.Pipelines": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "7WX0W96y3dpQdYG4sEGdh38g3/0lOD4/dKbn2rRVOVzKhzoZUn2gKNIKaFeKWs8RCbpFfmmEWsRhSy95hMpvqA==",
"dependencies": {
"System.Buffers": "4.6.1",
"System.Memory": "4.6.3",
"System.Threading.Tasks.Extensions": "4.6.3"
}
},
"System.Memory": {
"type": "Transitive",
"resolved": "4.6.3",
"contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A=="
},
"System.Runtime.CompilerServices.Unsafe": {
"type": "Transitive",
"resolved": "6.1.2",
"contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw=="
},
"System.Text.Encodings.Web": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "o16m2YpDN/pjHsnxf9pTGwkpcuvjW8v1/wGUwJtM1c3QZUKm7ZEO/eYRJg7iIx6GxS2Zv9lAMHpiQwHDdgqauA==",
"dependencies": {
"System.Buffers": "4.6.1",
"System.Memory": "4.6.3",
"System.Runtime.CompilerServices.Unsafe": "6.1.2"
}
},
"System.Threading.Tasks.Extensions": {
"type": "Transitive",
"resolved": "4.6.3",
"contentHash": "7sCiwilJLYbTZELaKnc7RecBBXWXA+xMLQWZKWawBxYjp6DBlSE3v9/UcvKBvr1vv2tTOhipiogM8rRmxlhrVA=="
},
"finalfactory.rendezvous.contracts": {
"type": "Project"
"type": "Project",
"dependencies": {
"System.Text.Json": "[10.0.10, )"
}
},
"System.Text.Json": {
"type": "CentralTransitive",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "bmsO6UdYtBdtn32zYXfsh7KlyTIzV/3V9hdT9RIb4pXKgYOsNxXR+VbWigNwBtNFVGYGm6Hwmqw5a+/IWFd36Q==",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "10.0.10",
"System.Buffers": "4.6.1",
"System.IO.Pipelines": "10.0.10",
"System.Memory": "4.6.3",
"System.Runtime.CompilerServices.Unsafe": "6.1.2",
"System.Text.Encodings.Web": "10.0.10",
"System.Threading.Tasks.Extensions": "4.6.3"
}
}
}
}
@@ -0,0 +1,75 @@
namespace FinalFactory.Rendezvous.Contracts;
public enum RendezvousErrorCode
{
None = 0,
InvalidRequest = 1,
UnsupportedContractVersion = 2,
IncompatibleProtocol = 3,
AuthenticationRequired = 4,
Forbidden = 5,
NotFound = 6,
Conflict = 7,
RateLimited = 8,
StaleHost = 9,
Expired = 10,
ReplayRejected = 11,
CapacityExceeded = 12,
ServiceUnavailable = 13,
InternalError = 14,
}
public enum ListingVisibility
{
Public = 1,
Unlisted = 2,
}
public enum PublisherTrustMode
{
ManagedDedicated = 1,
PlayerGrant = 2,
AnonymousUnlisted = 3,
}
public enum AddressFamilyKind
{
Ipv4 = 4,
Ipv6 = 6,
}
public enum ConnectionOutcomeKind
{
Connected = 1,
Cancelled = 2,
TimedOut = 3,
IncompatibleProtocol = 4,
StaleHost = 5,
ServiceRejected = 6,
HostRejected = 7,
TransportFailed = 8,
FallbackOffered = 9,
}
public enum UdpPresenceMessageType : byte
{
HostPresence = 1,
ClientPresence = 2,
}
public enum UdpDecodeError
{
None = 0,
DatagramTooLarge = 1,
Truncated = 2,
InvalidMagic = 3,
UnsupportedVersion = 4,
UnknownMessageType = 5,
InvalidFlags = 6,
InvalidHandle = 7,
InvalidAddressFamily = 8,
InvalidAddress = 9,
InvalidPort = 10,
InvalidCapability = 11,
TrailingData = 12,
}
@@ -0,0 +1,28 @@
namespace FinalFactory.Rendezvous.Contracts;
public static class ContractLimits
{
public const int ContractVersion = 1;
public const int HttpRequestMaxBytes = 16 * 1024;
public const int BrowserResponseMaxBytes = 256 * 1024;
public const int UdpDatagramMaxBytes = 1_200;
public const int MetadataMaxBytes = 4 * 1024;
public const int MetadataMaxKeys = 32;
public const int MetadataKeyMaxBytes = 64;
public const int MetadataValueMaxBytes = 256;
public const int BrowserPageMaxItems = 100;
public const int GameIdMaxCharacters = 64;
public const int EnvironmentIdMaxCharacters = 32;
public const int RegionIdMaxCharacters = 32;
public const int DisplayNameMaxBytes = 128;
public const int BuildVersionMaxBytes = 64;
public const int IdempotencyKeyMaxCharacters = 64;
public const int CursorMaxCharacters = 512;
public const int DiagnosticCodeMaxCharacters = 64;
public const int ErrorMessageMaxBytes = 256;
public const int OpaqueHttpCredentialMaxCharacters = 1_024;
public const int UdpCapabilityMaxCharacters = 192;
public const int ConnectionTicketMaxCharacters = 192;
public const int LiteNetLibNatTokenMaxCharacters = 256;
public const int SessionCapacityMaxPlayers = 10_000;
}
@@ -0,0 +1,47 @@
using System.Text.Json.Serialization;
namespace FinalFactory.Rendezvous.Contracts;
public sealed class SessionCapacity
{
[JsonRequired]
public int CurrentPlayers { get; set; }
[JsonRequired]
public int MaximumPlayers { get; set; }
}
public sealed class NetworkEndpoint
{
[JsonRequired]
public AddressFamilyKind AddressFamily { get; set; }
[JsonRequired]
public string Address { get; set; } = string.Empty;
[JsonRequired]
public int Port { get; set; }
}
public sealed class ApiError
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public RendezvousErrorCode Code { get; set; }
[JsonRequired]
public string Message { get; set; } = string.Empty;
public string? CorrelationId { get; set; }
public int? RetryAfterSeconds { get; set; }
}
public sealed class HealthResponse
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public string Status { get; set; } = string.Empty;
}
@@ -0,0 +1,134 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
namespace FinalFactory.Rendezvous.Contracts;
public static class ContractValidation
{
private const string CapabilityAlphabet =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
public static RendezvousErrorCode ValidateContractVersion(int contractVersion) =>
contractVersion == ContractLimits.ContractVersion
? RendezvousErrorCode.None
: RendezvousErrorCode.UnsupportedContractVersion;
public static bool AreProtocolsCompatible(uint requested, uint offered) => requested == offered;
public static bool IsHttpRequestSizeValid(int byteCount) =>
byteCount is >= 0 and <= ContractLimits.HttpRequestMaxBytes;
public static bool IsBrowserResponseSizeValid(int byteCount) =>
byteCount is >= 0 and <= ContractLimits.BrowserResponseMaxBytes;
public static bool IsUtf8LengthWithin(string? value, int maximumBytes)
{
if (maximumBytes < 0)
{
throw new ArgumentOutOfRangeException(nameof(maximumBytes));
}
return value is not null && Encoding.UTF8.GetByteCount(value) <= maximumBytes;
}
public static bool IsPageSizeValid(int pageSize) =>
pageSize is >= 1 and <= ContractLimits.BrowserPageMaxItems;
public static bool IsIdempotencyKeyValid(string? value) =>
IsVisibleAsciiWithin(value, ContractLimits.IdempotencyKeyMaxCharacters);
public static bool IsCursorValid(string? value) =>
value is null || IsVisibleAsciiWithin(value, ContractLimits.CursorMaxCharacters);
public static bool IsDiagnosticCodeValid(string? value) =>
value is null || IsVisibleAsciiWithin(value, ContractLimits.DiagnosticCodeMaxCharacters);
public static bool IsBuildVersionValid(string? value) =>
IsUtf8LengthWithin(value, ContractLimits.BuildVersionMaxBytes);
public static bool IsDisplayNameValid(string? value) =>
IsUtf8LengthWithin(value, ContractLimits.DisplayNameMaxBytes);
public static bool IsOpaqueHttpCredentialValid(string? value) =>
value is not null
&& value.Length is > 0 and <= ContractLimits.OpaqueHttpCredentialMaxCharacters;
public static bool IsCapacityValid(SessionCapacity? capacity) =>
capacity is not null
&& capacity.MaximumPlayers is >= 1 and <= ContractLimits.SessionCapacityMaxPlayers
&& capacity.CurrentPlayers >= 0
&& capacity.CurrentPlayers <= capacity.MaximumPlayers;
public static bool IsCapabilityValid(string? capability) =>
IsBase64UrlValueValid(capability, ContractLimits.UdpCapabilityMaxCharacters);
public static bool IsConnectionTicketValid(string? ticket) =>
IsBase64UrlValueValid(ticket, ContractLimits.ConnectionTicketMaxCharacters);
public static bool IsNetworkEndpointValid(NetworkEndpoint? endpoint)
{
if (endpoint is null
|| endpoint.Port is < 1 or > ushort.MaxValue
|| !IPAddress.TryParse(endpoint.Address, out IPAddress? address))
{
return false;
}
return endpoint.AddressFamily switch
{
AddressFamilyKind.Ipv4 => address.AddressFamily == AddressFamily.InterNetwork,
AddressFamilyKind.Ipv6 => address.AddressFamily == AddressFamily.InterNetworkV6,
_ => false,
};
}
public static bool IsMetadataValid(IReadOnlyDictionary<string, string>? metadata)
{
if (metadata is null || metadata.Count > ContractLimits.MetadataMaxKeys)
{
return false;
}
foreach (KeyValuePair<string, string> item in metadata)
{
if (string.IsNullOrWhiteSpace(item.Key)
|| !IsUtf8LengthWithin(item.Key, ContractLimits.MetadataKeyMaxBytes)
|| !IsUtf8LengthWithin(item.Value, ContractLimits.MetadataValueMaxBytes))
{
return false;
}
}
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(metadata, ContractJson.Options);
return encoded.Length <= ContractLimits.MetadataMaxBytes;
}
internal static bool IsSlug(string? value, int maximumCharacters)
{
if (string.IsNullOrEmpty(value)
|| value.Length > maximumCharacters
|| value[0] is < 'a' or > 'z')
{
return false;
}
return value.All(static character =>
character is >= 'a' and <= 'z'
or >= '0' and <= '9'
or '-');
}
private static bool IsBase64UrlValueValid(string? value, int maximumCharacters) =>
value is not null
&& value.Length is > 0
&& value.Length <= maximumCharacters
&& value.All(static character => CapabilityAlphabet.Contains(character));
private static bool IsVisibleAsciiWithin(string? value, int maximumCharacters) =>
value is not null
&& value.Length is > 0
&& value.Length <= maximumCharacters
&& value.All(static character => character is >= '!' and <= '~');
}
@@ -7,4 +7,7 @@
<PackageId>FinalFactory.Rendezvous.Contracts</PackageId>
<Description>Versioned transport-neutral contracts for Final Factory Rendezvous.</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Text.Json" />
</ItemGroup>
</Project>
@@ -0,0 +1,92 @@
using System.Text.Json.Serialization;
namespace FinalFactory.Rendezvous.Contracts;
public sealed class CreateJoinAttemptRequest
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public string IdempotencyKey { get; set; } = string.Empty;
[JsonRequired]
public GameId GameId { get; set; }
[JsonRequired]
public EnvironmentId EnvironmentId { get; set; }
[JsonRequired]
public SessionListingId ListingId { get; set; }
[JsonRequired]
public uint ProtocolVersion { get; set; }
}
public sealed class CreateJoinAttemptResponse
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public JoinAttemptId AttemptId { get; set; }
[JsonRequired]
public MediationHandle MediationHandle { get; set; }
[JsonRequired]
public string ClientPunchCapability { get; set; } = string.Empty;
[JsonRequired]
public DateTimeOffset ExpiresAt { get; set; }
public NetworkEndpoint? DedicatedFallback { get; set; }
}
public sealed class HostJoinAttempt
{
[JsonRequired]
public JoinAttemptId AttemptId { get; set; }
[JsonRequired]
public MediationHandle MediationHandle { get; set; }
[JsonRequired]
public string HostPunchCapability { get; set; } = string.Empty;
[JsonRequired]
public DateTimeOffset ExpiresAt { get; set; }
}
public sealed class BrowseHostJoinAttemptsResponse
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public List<HostJoinAttempt> Items { get; set; } = [];
public string? NextCursor { get; set; }
}
public sealed class ReportConnectionOutcomeRequest
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public ConnectionOutcomeKind Outcome { get; set; }
[JsonRequired]
public int ElapsedMilliseconds { get; set; }
public string? DiagnosticCode { get; set; }
}
public sealed class ReportConnectionOutcomeResponse
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public bool Accepted { get; set; }
}
@@ -0,0 +1,189 @@
using System.Text.Json.Serialization;
namespace FinalFactory.Rendezvous.Contracts;
public sealed class SessionListing
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public SessionListingId ListingId { get; set; }
[JsonRequired]
public GameId GameId { get; set; }
[JsonRequired]
public EnvironmentId EnvironmentId { get; set; }
[JsonRequired]
public RegionId RegionId { get; set; }
[JsonRequired]
public uint ProtocolVersion { get; set; }
[JsonRequired]
public string BuildVersion { get; set; } = string.Empty;
[JsonRequired]
public string DisplayName { get; set; } = string.Empty;
[JsonRequired]
public ListingVisibility Visibility { get; set; }
[JsonRequired]
public PublisherTrustMode PublisherTrustMode { get; set; }
[JsonRequired]
public SessionCapacity Capacity { get; set; } = new();
[JsonRequired]
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
}
public sealed class RegisterSessionRequest
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public string IdempotencyKey { get; set; } = string.Empty;
[JsonRequired]
public GameId GameId { get; set; }
[JsonRequired]
public EnvironmentId EnvironmentId { get; set; }
[JsonRequired]
public RegionId RegionId { get; set; }
[JsonRequired]
public uint ProtocolVersion { get; set; }
[JsonRequired]
public string BuildVersion { get; set; } = string.Empty;
[JsonRequired]
public string DisplayName { get; set; } = string.Empty;
[JsonRequired]
public ListingVisibility Visibility { get; set; }
[JsonRequired]
public SessionCapacity Capacity { get; set; } = new();
[JsonRequired]
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
}
public sealed class RegisterSessionResponse
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public SessionListingId ListingId { get; set; }
[JsonRequired]
public LeaseId LeaseId { get; set; }
[JsonRequired]
public string LeaseToken { get; set; } = string.Empty;
[JsonRequired]
public MediationHandle HostPresenceHandle { get; set; }
[JsonRequired]
public string HostPresenceCapability { get; set; } = string.Empty;
[JsonRequired]
public DateTimeOffset ExpiresAt { get; set; }
}
public sealed class RenewLeaseRequest
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public string LeaseToken { get; set; } = string.Empty;
}
public sealed class RenewLeaseResponse
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public DateTimeOffset ExpiresAt { get; set; }
}
public sealed class UpdateSessionRequest
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public string LeaseToken { get; set; } = string.Empty;
[JsonRequired]
public string BuildVersion { get; set; } = string.Empty;
[JsonRequired]
public string DisplayName { get; set; } = string.Empty;
[JsonRequired]
public SessionCapacity Capacity { get; set; } = new();
[JsonRequired]
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
}
public sealed class DeleteSessionRequest
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public string LeaseToken { get; set; } = string.Empty;
}
public sealed class BrowseSessionsRequest
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public GameId GameId { get; set; }
[JsonRequired]
public EnvironmentId EnvironmentId { get; set; }
[JsonRequired]
public uint ProtocolVersion { get; set; }
public RegionId? RegionId { get; set; }
public int PageSize { get; set; } = ContractLimits.BrowserPageMaxItems;
public string? Cursor { get; set; }
}
public sealed class BrowseSessionsResponse
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public List<SessionListing> Items { get; set; } = [];
public string? NextCursor { get; set; }
}
public sealed class GetSessionResponse
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public SessionListing Session { get; set; } = new();
}
@@ -0,0 +1,112 @@
using System.Text.Json.Serialization;
namespace FinalFactory.Rendezvous.Contracts;
[JsonConverter(typeof(SessionListingIdJsonConverter))]
public readonly struct SessionListingId : IEquatable<SessionListingId>
{
public SessionListingId(Guid value) => Value = GuidIdentifier.RequireNonEmpty(value, nameof(value));
public Guid Value { get; }
public static bool TryParse(string? value, out SessionListingId id) =>
GuidIdentifier.TryParse(value, static guid => new SessionListingId(guid), out id);
public bool Equals(SessionListingId other) => Value.Equals(other.Value);
public override bool Equals(object? obj) => obj is SessionListingId other && Equals(other);
public override int GetHashCode() => Value.GetHashCode();
public override string ToString() => Value.ToString("D");
public static bool operator ==(SessionListingId left, SessionListingId right) => left.Equals(right);
public static bool operator !=(SessionListingId left, SessionListingId right) => !left.Equals(right);
}
[JsonConverter(typeof(LeaseIdJsonConverter))]
public readonly struct LeaseId : IEquatable<LeaseId>
{
public LeaseId(Guid value) => Value = GuidIdentifier.RequireNonEmpty(value, nameof(value));
public Guid Value { get; }
public static bool TryParse(string? value, out LeaseId id) =>
GuidIdentifier.TryParse(value, static guid => new LeaseId(guid), out id);
public bool Equals(LeaseId other) => Value.Equals(other.Value);
public override bool Equals(object? obj) => obj is LeaseId other && Equals(other);
public override int GetHashCode() => Value.GetHashCode();
public override string ToString() => Value.ToString("D");
public static bool operator ==(LeaseId left, LeaseId right) => left.Equals(right);
public static bool operator !=(LeaseId left, LeaseId right) => !left.Equals(right);
}
[JsonConverter(typeof(JoinAttemptIdJsonConverter))]
public readonly struct JoinAttemptId : IEquatable<JoinAttemptId>
{
public JoinAttemptId(Guid value) => Value = GuidIdentifier.RequireNonEmpty(value, nameof(value));
public Guid Value { get; }
public static bool TryParse(string? value, out JoinAttemptId id) =>
GuidIdentifier.TryParse(value, static guid => new JoinAttemptId(guid), out id);
public bool Equals(JoinAttemptId other) => Value.Equals(other.Value);
public override bool Equals(object? obj) => obj is JoinAttemptId other && Equals(other);
public override int GetHashCode() => Value.GetHashCode();
public override string ToString() => Value.ToString("D");
public static bool operator ==(JoinAttemptId left, JoinAttemptId right) => left.Equals(right);
public static bool operator !=(JoinAttemptId left, JoinAttemptId right) => !left.Equals(right);
}
[JsonConverter(typeof(MediationHandleJsonConverter))]
public readonly struct MediationHandle : IEquatable<MediationHandle>
{
public MediationHandle(Guid value) => Value = GuidIdentifier.RequireNonEmpty(value, nameof(value));
public Guid Value { get; }
public static bool TryParse(string? value, out MediationHandle id) =>
GuidIdentifier.TryParse(value, static guid => new MediationHandle(guid), out id);
public bool Equals(MediationHandle other) => Value.Equals(other.Value);
public override bool Equals(object? obj) => obj is MediationHandle other && Equals(other);
public override int GetHashCode() => Value.GetHashCode();
public override string ToString() => Value.ToString("D");
public static bool operator ==(MediationHandle left, MediationHandle right) => left.Equals(right);
public static bool operator !=(MediationHandle left, MediationHandle right) => !left.Equals(right);
}
internal static class GuidIdentifier
{
public static Guid RequireNonEmpty(Guid value, string parameterName) =>
value != Guid.Empty
? value
: throw new ArgumentException("Opaque identifiers cannot be empty.", parameterName);
public static bool TryParse<TIdentifier>(
string? value,
Func<Guid, TIdentifier> factory,
out TIdentifier identifier)
{
if (Guid.TryParseExact(value, "D", out Guid guid) && guid != Guid.Empty)
{
identifier = factory(guid);
return true;
}
identifier = default!;
return false;
}
}
internal abstract class GuidIdentifierJsonConverter<TIdentifier> :
StringIdentifierJsonConverter<TIdentifier>
{
protected sealed override string Format(TIdentifier value) => value?.ToString() ?? string.Empty;
}
internal sealed class SessionListingIdJsonConverter : GuidIdentifierJsonConverter<SessionListingId>
{
protected override SessionListingId Parse(string value) => new(Guid.ParseExact(value, "D"));
}
internal sealed class LeaseIdJsonConverter : GuidIdentifierJsonConverter<LeaseId>
{
protected override LeaseId Parse(string value) => new(Guid.ParseExact(value, "D"));
}
internal sealed class JoinAttemptIdJsonConverter : GuidIdentifierJsonConverter<JoinAttemptId>
{
protected override JoinAttemptId Parse(string value) => new(Guid.ParseExact(value, "D"));
}
internal sealed class MediationHandleJsonConverter : GuidIdentifierJsonConverter<MediationHandle>
{
protected override MediationHandle Parse(string value) => new(Guid.ParseExact(value, "D"));
}
@@ -0,0 +1,126 @@
using System.Text.Json.Serialization;
namespace FinalFactory.Rendezvous.Contracts;
[JsonConverter(typeof(GameIdJsonConverter))]
public readonly struct GameId : IEquatable<GameId>
{
public GameId(string value)
{
if (!ContractValidation.IsSlug(value, ContractLimits.GameIdMaxCharacters))
{
throw new ArgumentException("Game IDs must be lowercase URL-safe slugs.", nameof(value));
}
Value = value;
}
public string Value { get; }
public static bool TryParse(string? value, out GameId gameId)
{
if (ContractValidation.IsSlug(value, ContractLimits.GameIdMaxCharacters))
{
gameId = new GameId(value!);
return true;
}
gameId = default;
return false;
}
public bool Equals(GameId other) => string.Equals(Value, other.Value, StringComparison.Ordinal);
public override bool Equals(object? obj) => obj is GameId other && Equals(other);
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value ?? string.Empty);
public override string ToString() => Value ?? string.Empty;
public static bool operator ==(GameId left, GameId right) => left.Equals(right);
public static bool operator !=(GameId left, GameId right) => !left.Equals(right);
}
[JsonConverter(typeof(EnvironmentIdJsonConverter))]
public readonly struct EnvironmentId : IEquatable<EnvironmentId>
{
public EnvironmentId(string value)
{
if (!ContractValidation.IsSlug(value, ContractLimits.EnvironmentIdMaxCharacters))
{
throw new ArgumentException("Environment IDs must be lowercase URL-safe slugs.", nameof(value));
}
Value = value;
}
public string Value { get; }
public static bool TryParse(string? value, out EnvironmentId environmentId)
{
if (ContractValidation.IsSlug(value, ContractLimits.EnvironmentIdMaxCharacters))
{
environmentId = new EnvironmentId(value!);
return true;
}
environmentId = default;
return false;
}
public bool Equals(EnvironmentId other) => string.Equals(Value, other.Value, StringComparison.Ordinal);
public override bool Equals(object? obj) => obj is EnvironmentId other && Equals(other);
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value ?? string.Empty);
public override string ToString() => Value ?? string.Empty;
public static bool operator ==(EnvironmentId left, EnvironmentId right) => left.Equals(right);
public static bool operator !=(EnvironmentId left, EnvironmentId right) => !left.Equals(right);
}
[JsonConverter(typeof(RegionIdJsonConverter))]
public readonly struct RegionId : IEquatable<RegionId>
{
public RegionId(string value)
{
if (!ContractValidation.IsSlug(value, ContractLimits.RegionIdMaxCharacters))
{
throw new ArgumentException("Region IDs must be lowercase URL-safe slugs.", nameof(value));
}
Value = value;
}
public string Value { get; }
public static bool TryParse(string? value, out RegionId regionId)
{
if (ContractValidation.IsSlug(value, ContractLimits.RegionIdMaxCharacters))
{
regionId = new RegionId(value!);
return true;
}
regionId = default;
return false;
}
public bool Equals(RegionId other) => string.Equals(Value, other.Value, StringComparison.Ordinal);
public override bool Equals(object? obj) => obj is RegionId other && Equals(other);
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value ?? string.Empty);
public override string ToString() => Value ?? string.Empty;
public static bool operator ==(RegionId left, RegionId right) => left.Equals(right);
public static bool operator !=(RegionId left, RegionId right) => !left.Equals(right);
}
internal sealed class GameIdJsonConverter : StringIdentifierJsonConverter<GameId>
{
protected override GameId Parse(string value) => new(value);
protected override string Format(GameId value) => value.Value;
}
internal sealed class EnvironmentIdJsonConverter : StringIdentifierJsonConverter<EnvironmentId>
{
protected override EnvironmentId Parse(string value) => new(value);
protected override string Format(EnvironmentId value) => value.Value;
}
internal sealed class RegionIdJsonConverter : StringIdentifierJsonConverter<RegionId>
{
protected override RegionId Parse(string value) => new(value);
protected override string Format(RegionId value) => value.Value;
}
@@ -0,0 +1,37 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace FinalFactory.Rendezvous.Contracts;
internal abstract class StringIdentifierJsonConverter<TIdentifier> : JsonConverter<TIdentifier>
{
public sealed override TIdentifier Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.String)
{
throw new JsonException($"{typeof(TIdentifier).Name} must be a JSON string.");
}
string value = reader.GetString() ?? string.Empty;
try
{
return Parse(value);
}
catch (Exception exception) when (exception is ArgumentException or FormatException)
{
throw new JsonException($"Invalid {typeof(TIdentifier).Name}.", exception);
}
}
public sealed override void Write(
Utf8JsonWriter writer,
TIdentifier value,
JsonSerializerOptions options) => writer.WriteStringValue(Format(value));
protected abstract TIdentifier Parse(string value);
protected abstract string Format(TIdentifier value);
}
@@ -0,0 +1,49 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace FinalFactory.Rendezvous.Contracts;
public static class ContractJson
{
private static readonly JsonSerializerOptions SharedOptions = CreateReadOnlyOptions();
public static JsonSerializerOptions Options => SharedOptions;
public static JsonSerializerOptions CreateOptions()
{
JsonSerializerOptions options = new(JsonSerializerDefaults.Web);
Configure(options);
return options;
}
public static void Configure(JsonSerializerOptions options)
{
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
options.AllowTrailingCommas = false;
options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
options.MaxDepth = 8;
options.NumberHandling = JsonNumberHandling.Strict;
options.PropertyNameCaseInsensitive = false;
options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.ReadCommentHandling = JsonCommentHandling.Disallow;
options.UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip;
options.WriteIndented = false;
if (!options.Converters.OfType<JsonStringEnumConverter>().Any())
{
options.Converters.Add(
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false));
}
}
private static JsonSerializerOptions CreateReadOnlyOptions()
{
JsonSerializerOptions options = CreateOptions();
options.MakeReadOnly(populateMissingResolver: true);
return options;
}
}
@@ -0,0 +1,12 @@
namespace FinalFactory.Rendezvous.Contracts;
public sealed class PresenceDatagram
{
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
public UdpPresenceMessageType MessageType { get; set; }
public MediationHandle MediationHandle { get; set; }
public AddressFamilyKind AddressFamily { get; set; }
public string LocalAddress { get; set; } = string.Empty;
public int LocalPort { get; set; }
public string Capability { get; set; } = string.Empty;
}
@@ -0,0 +1,281 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace FinalFactory.Rendezvous.Contracts;
public static class RendezvousUdpCodec
{
public const byte MagicFirst = 0x52;
public const byte MagicSecond = 0x56;
public const byte FlagsNone = 0;
private const int FixedPrefixLength = 23;
private const int FixedSuffixLength = 3;
public static byte[] Encode(PresenceDatagram datagram)
{
if (datagram is null)
{
throw new ArgumentNullException(nameof(datagram));
}
if (ContractValidation.ValidateContractVersion(datagram.ContractVersion)
!= RendezvousErrorCode.None)
{
throw new ArgumentException("The UDP contract version is unsupported.", nameof(datagram));
}
if (datagram.MessageType is not UdpPresenceMessageType.HostPresence
and not UdpPresenceMessageType.ClientPresence)
{
throw new ArgumentException("The UDP presence message type is unknown.", nameof(datagram));
}
if (datagram.MediationHandle.Value == Guid.Empty)
{
throw new ArgumentException("The mediation handle cannot be empty.", nameof(datagram));
}
if (!TryGetAddressBytes(datagram.LocalAddress, datagram.AddressFamily, out byte[] addressBytes))
{
throw new ArgumentException("The local address does not match its address family.", nameof(datagram));
}
if (datagram.LocalPort is < 1 or > ushort.MaxValue)
{
throw new ArgumentOutOfRangeException(nameof(datagram), "The local port must be between 1 and 65535.");
}
if (!ContractValidation.IsCapabilityValid(datagram.Capability))
{
throw new ArgumentException("The UDP capability is invalid.", nameof(datagram));
}
byte[] capabilityBytes = Encoding.ASCII.GetBytes(datagram.Capability);
int encodedLength = FixedPrefixLength + addressBytes.Length + FixedSuffixLength
+ capabilityBytes.Length;
if (encodedLength > ContractLimits.UdpDatagramMaxBytes)
{
throw new ArgumentException("The encoded UDP datagram exceeds its size limit.", nameof(datagram));
}
byte[] encoded = new byte[encodedLength];
int offset = 0;
encoded[offset++] = MagicFirst;
encoded[offset++] = MagicSecond;
encoded[offset++] = checked((byte)datagram.ContractVersion);
encoded[offset++] = (byte)datagram.MessageType;
encoded[offset++] = FlagsNone;
WriteGuid(datagram.MediationHandle.Value, encoded, offset);
offset += 16;
encoded[offset++] = (byte)datagram.AddressFamily;
encoded[offset++] = checked((byte)addressBytes.Length);
addressBytes.CopyTo(encoded, offset);
offset += addressBytes.Length;
encoded[offset++] = checked((byte)(datagram.LocalPort >> 8));
encoded[offset++] = checked((byte)(datagram.LocalPort & 0xff));
encoded[offset++] = checked((byte)capabilityBytes.Length);
capabilityBytes.CopyTo(encoded, offset);
return encoded;
}
public static bool TryDecode(
ReadOnlySpan<byte> encoded,
out PresenceDatagram? datagram,
out UdpDecodeError error)
{
datagram = null;
error = UdpDecodeError.None;
if (encoded.Length > ContractLimits.UdpDatagramMaxBytes)
{
error = UdpDecodeError.DatagramTooLarge;
return false;
}
if (encoded.Length < FixedPrefixLength)
{
error = UdpDecodeError.Truncated;
return false;
}
int offset = 0;
if (encoded[offset++] != MagicFirst || encoded[offset++] != MagicSecond)
{
error = UdpDecodeError.InvalidMagic;
return false;
}
int version = encoded[offset++];
if (ContractValidation.ValidateContractVersion(version) != RendezvousErrorCode.None)
{
error = UdpDecodeError.UnsupportedVersion;
return false;
}
UdpPresenceMessageType messageType = (UdpPresenceMessageType)encoded[offset++];
if (messageType is not UdpPresenceMessageType.HostPresence
and not UdpPresenceMessageType.ClientPresence)
{
error = UdpDecodeError.UnknownMessageType;
return false;
}
if (encoded[offset++] != FlagsNone)
{
error = UdpDecodeError.InvalidFlags;
return false;
}
if (!TryReadGuid(encoded.Slice(offset, 16), out Guid handle)
|| handle == Guid.Empty)
{
error = UdpDecodeError.InvalidHandle;
return false;
}
offset += 16;
AddressFamilyKind addressFamily = (AddressFamilyKind)encoded[offset++];
int expectedAddressLength = addressFamily switch
{
AddressFamilyKind.Ipv4 => 4,
AddressFamilyKind.Ipv6 => 16,
_ => 0,
};
if (expectedAddressLength == 0)
{
error = UdpDecodeError.InvalidAddressFamily;
return false;
}
int addressLength = encoded[offset++];
if (addressLength != expectedAddressLength)
{
error = UdpDecodeError.InvalidAddress;
return false;
}
if (encoded.Length < offset + addressLength + FixedSuffixLength)
{
error = UdpDecodeError.Truncated;
return false;
}
string address;
try
{
address = new IPAddress(encoded.Slice(offset, addressLength).ToArray()).ToString();
}
catch (ArgumentException)
{
error = UdpDecodeError.InvalidAddress;
return false;
}
offset += addressLength;
int port = (encoded[offset++] << 8) | encoded[offset++];
if (port == 0)
{
error = UdpDecodeError.InvalidPort;
return false;
}
int capabilityLength = encoded[offset++];
if (capabilityLength == 0 || capabilityLength > ContractLimits.UdpCapabilityMaxCharacters)
{
error = UdpDecodeError.InvalidCapability;
return false;
}
if (encoded.Length < offset + capabilityLength)
{
error = UdpDecodeError.Truncated;
return false;
}
if (encoded.Length > offset + capabilityLength)
{
error = UdpDecodeError.TrailingData;
return false;
}
string capability = Encoding.ASCII.GetString(encoded.Slice(offset, capabilityLength).ToArray());
if (!ContractValidation.IsCapabilityValid(capability))
{
error = UdpDecodeError.InvalidCapability;
return false;
}
datagram = new PresenceDatagram
{
ContractVersion = version,
MessageType = messageType,
MediationHandle = new MediationHandle(handle),
AddressFamily = addressFamily,
LocalAddress = address,
LocalPort = port,
Capability = capability,
};
return true;
}
private static bool TryGetAddressBytes(
string value,
AddressFamilyKind addressFamily,
out byte[] addressBytes)
{
addressBytes = [];
if (!IPAddress.TryParse(value, out IPAddress? address))
{
return false;
}
bool familyMatches = addressFamily switch
{
AddressFamilyKind.Ipv4 => address.AddressFamily == AddressFamily.InterNetwork,
AddressFamilyKind.Ipv6 => address.AddressFamily == AddressFamily.InterNetworkV6,
_ => false,
};
if (!familyMatches)
{
return false;
}
addressBytes = address.GetAddressBytes();
return true;
}
private static void WriteGuid(Guid value, byte[] destination, int offset)
{
string hexadecimal = value.ToString("N");
for (int index = 0; index < 16; index++)
{
int high = ParseHexadecimal(hexadecimal[index * 2]);
int low = ParseHexadecimal(hexadecimal[(index * 2) + 1]);
destination[offset + index] = checked((byte)((high << 4) | low));
}
}
private static bool TryReadGuid(ReadOnlySpan<byte> encoded, out Guid value)
{
char[] hexadecimal = new char[32];
for (int index = 0; index < encoded.Length; index++)
{
hexadecimal[index * 2] = FormatHexadecimal(encoded[index] >> 4);
hexadecimal[(index * 2) + 1] = FormatHexadecimal(encoded[index] & 0x0f);
}
return Guid.TryParseExact(new string(hexadecimal), "N", out value);
}
private static int ParseHexadecimal(char value) => value switch
{
>= '0' and <= '9' => value - '0',
>= 'a' and <= 'f' => value - 'a' + 10,
_ => throw new FormatException("A GUID contained a non-hexadecimal character."),
};
private static char FormatHexadecimal(int value) =>
(char)(value < 10 ? '0' + value : 'a' + value - 10);
}
@@ -1,6 +1,67 @@
{
"version": 2,
"dependencies": {
".NETStandard,Version=v2.1": {}
".NETStandard,Version=v2.1": {
"System.Text.Json": {
"type": "Direct",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "bmsO6UdYtBdtn32zYXfsh7KlyTIzV/3V9hdT9RIb4pXKgYOsNxXR+VbWigNwBtNFVGYGm6Hwmqw5a+/IWFd36Q==",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "10.0.10",
"System.Buffers": "4.6.1",
"System.IO.Pipelines": "10.0.10",
"System.Memory": "4.6.3",
"System.Runtime.CompilerServices.Unsafe": "6.1.2",
"System.Text.Encodings.Web": "10.0.10",
"System.Threading.Tasks.Extensions": "4.6.3"
}
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "TFI6OKYE1XZz4SGuTSH70c6SBdPpFktXsoa1gCxTr3mKrhmXirnvaS0tKz+J3ZWICEAmMpEGn59nO4ICtUpQXA=="
},
"System.Buffers": {
"type": "Transitive",
"resolved": "4.6.1",
"contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw=="
},
"System.IO.Pipelines": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "7WX0W96y3dpQdYG4sEGdh38g3/0lOD4/dKbn2rRVOVzKhzoZUn2gKNIKaFeKWs8RCbpFfmmEWsRhSy95hMpvqA==",
"dependencies": {
"System.Buffers": "4.6.1",
"System.Memory": "4.6.3",
"System.Threading.Tasks.Extensions": "4.6.3"
}
},
"System.Memory": {
"type": "Transitive",
"resolved": "4.6.3",
"contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A=="
},
"System.Runtime.CompilerServices.Unsafe": {
"type": "Transitive",
"resolved": "6.1.2",
"contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw=="
},
"System.Text.Encodings.Web": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "o16m2YpDN/pjHsnxf9pTGwkpcuvjW8v1/wGUwJtM1c3QZUKm7ZEO/eYRJg7iIx6GxS2Zv9lAMHpiQwHDdgqauA==",
"dependencies": {
"System.Buffers": "4.6.1",
"System.Memory": "4.6.3",
"System.Runtime.CompilerServices.Unsafe": "6.1.2"
}
},
"System.Threading.Tasks.Extensions": {
"type": "Transitive",
"resolved": "4.6.3",
"contentHash": "7sCiwilJLYbTZELaKnc7RecBBXWXA+xMLQWZKWawBxYjp6DBlSE3v9/UcvKBvr1vv2tTOhipiogM8rRmxlhrVA=="
}
}
}
}
@@ -4,9 +4,14 @@
<AssemblyName>FinalFactory.Rendezvous.Server</AssemblyName>
<RootNamespace>FinalFactory.Rendezvous.Server</RootNamespace>
<IsPackable>false</IsPackable>
<OpenApiGenerateDocuments>true</OpenApiGenerateDocuments>
<OpenApiDocumentsDirectory>$(MSBuildProjectDirectory)/../../docs/api</OpenApiDocumentsDirectory>
<OpenApiGenerateDocumentsOptions>--document-name v1 --file-name rendezvous-v1 --openapi-version OpenApi3_1</OpenApiGenerateDocumentsOptions>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj" />
<PackageReference Include="LiteNetLib" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" PrivateAssets="all" />
</ItemGroup>
</Project>
@@ -0,0 +1,112 @@
using FinalFactory.Rendezvous.Contracts;
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>(NotImplementedStatus)
.WithName("RegisterSession");
sessions.MapPost("/{listingId}/renew", RenewLease)
.Accepts<RenewLeaseRequest>("application/json")
.Produces<RenewLeaseResponse>()
.Produces<ApiError>(NotImplementedStatus)
.WithName("RenewSessionLease");
sessions.MapPut("/{listingId}", UpdateSession)
.Accepts<UpdateSessionRequest>("application/json")
.Produces(StatusCodes.Status204NoContent)
.Produces<ApiError>(NotImplementedStatus)
.WithName("UpdateSession");
sessions.MapDelete("/{listingId}", DeleteSession)
.Accepts<DeleteSessionRequest>("application/json")
.Produces(StatusCodes.Status204NoContent)
.Produces<ApiError>(NotImplementedStatus)
.WithName("DeleteSession");
sessions.MapGet("/", BrowseSessions)
.Produces<BrowseSessionsResponse>()
.Produces<ApiError>(NotImplementedStatus)
.WithName("BrowseSessions");
sessions.MapGet("/{listingId}", GetSession)
.Produces<GetSessionResponse>()
.Produces<ApiError>(NotImplementedStatus)
.WithName("GetSession");
sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts)
.Produces<BrowseHostJoinAttemptsResponse>()
.Produces<ApiError>(NotImplementedStatus)
.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>(NotImplementedStatus)
.WithName("CreateJoinAttempt");
attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome)
.Accepts<ReportConnectionOutcomeRequest>("application/json")
.Produces<ReportConnectionOutcomeResponse>()
.Produces<ApiError>(NotImplementedStatus)
.WithName("ReportConnectionOutcome");
return endpoints;
}
private static IResult RegisterSession([FromBody] RegisterSessionRequest request) =>
NotImplemented();
private static IResult RenewLease(
SessionListingId listingId,
[FromBody] RenewLeaseRequest request) => NotImplemented();
private static IResult UpdateSession(
SessionListingId listingId,
[FromBody] UpdateSessionRequest request) => NotImplemented();
private static IResult DeleteSession(
SessionListingId listingId,
[FromBody] DeleteSessionRequest request) => NotImplemented();
private static IResult BrowseSessions(
[FromQuery] int contractVersion,
[FromQuery] string gameId,
[FromQuery] string environmentId,
[FromQuery] uint protocolVersion,
[FromQuery] string? regionId,
[FromQuery] int? pageSize,
[FromQuery] string? cursor) => NotImplemented();
private static IResult GetSession(SessionListingId listingId) => NotImplemented();
private static IResult BrowseHostJoinAttempts(
SessionListingId listingId,
[FromQuery] int contractVersion,
[FromHeader(Name = "X-Rendezvous-Lease-Token")] string leaseToken,
[FromQuery] int? pageSize,
[FromQuery] string? cursor) => NotImplemented();
private static IResult CreateJoinAttempt([FromBody] CreateJoinAttemptRequest request) =>
NotImplemented();
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);
}
+49 -3
View File
@@ -1,8 +1,39 @@
using System.Net;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Http;
using FinalFactory.Rendezvous.Server.Transport;
using Microsoft.OpenApi;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
bool isOpenApiGeneration = Environment.GetCommandLineArgs().Any(static argument =>
string.Equals(
Path.GetFileName(argument),
"dotnet-getdocument.dll",
StringComparison.OrdinalIgnoreCase));
builder.Services.AddOpenApi("v1", static options =>
options.AddSchemaTransformer(static (schema, context, cancellationToken) =>
{
Type type = context.JsonTypeInfo.Type;
if (type == typeof(GameId)
|| type == typeof(EnvironmentId)
|| type == typeof(RegionId))
{
schema.Type = JsonSchemaType.String;
}
else if (type == typeof(SessionListingId)
|| type == typeof(LeaseId)
|| type == typeof(JoinAttemptId)
|| type == typeof(MediationHandle))
{
schema.Type = JsonSchemaType.String;
schema.Format = "uuid";
}
return Task.CompletedTask;
}));
builder.Services.ConfigureHttpJsonOptions(static options =>
ContractJson.Configure(options.SerializerOptions));
builder.Services
.AddOptions<UdpMediatorOptions>()
.BindConfiguration(UdpMediatorOptions.SectionName)
@@ -12,16 +43,31 @@ builder.Services
$"{UdpMediatorOptions.SectionName}:ListenAddress must be an IP address.")
.ValidateOnStart();
builder.Services.AddSingleton<UdpMediatorService>();
builder.Services.AddHostedService(static services => services.GetRequiredService<UdpMediatorService>());
if (!isOpenApiGeneration)
{
builder.Services.AddHostedService(static services =>
services.GetRequiredService<UdpMediatorService>());
}
WebApplication app = builder.Build();
app.MapGet("/health/live", static () => Results.Ok(new { status = "live" }));
app.MapOpenApi();
app.MapRendezvousContractEndpoints();
app.MapGet(
"/health/live",
static () => Results.Ok(new HealthResponse { Status = "live" }))
.Produces<HealthResponse>()
.WithName("GetLiveness")
.WithTags("Health");
app.MapGet(
"/health/ready",
static (UdpMediatorService mediator) => mediator.LocalEndpoint is null
? Results.StatusCode(StatusCodes.Status503ServiceUnavailable)
: Results.Ok(new { status = "ready" }));
: Results.Ok(new HealthResponse { Status = "ready" }))
.Produces<HealthResponse>()
.Produces(StatusCodes.Status503ServiceUnavailable)
.WithName("GetReadiness")
.WithTags("Health");
await app.RunAsync();
@@ -8,8 +8,29 @@
"resolved": "2.1.4",
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
},
"Microsoft.AspNetCore.OpenApi": {
"type": "Direct",
"requested": "[10.0.9, )",
"resolved": "10.0.9",
"contentHash": "1ihb8FO9cGgEK1/m3CTtT/SfnynwmiZib0W2pcDVj3KSWk/Sca4VOXEtaptKQc582zpFrzTFiwkGRCglt6H+WQ==",
"dependencies": {
"Microsoft.OpenApi": "2.0.0"
}
},
"Microsoft.Extensions.ApiDescription.Server": {
"type": "Direct",
"requested": "[10.0.9, )",
"resolved": "10.0.9",
"contentHash": "n1m7EAbCbHMGiTy++F+mLSan4MrZe0t00XEpJrkai6BFpB6lwEcirflI9FiMm4G4u55h/2RnWToYBDwS+MnN6g=="
},
"finalfactory.rendezvous.contracts": {
"type": "Project"
},
"Microsoft.OpenApi": {
"type": "CentralTransitive",
"requested": "[2.7.5, )",
"resolved": "2.7.5",
"contentHash": "0FA67RSnRM4tcBKqiqVu/HPdZ9+QOKbmeRjxRUGTCjPU4C0bmUhd97Dso7Yild5P7nOV6GxJ2xrK0Kv/O9xp0w=="
}
}
}
@@ -8,6 +8,16 @@
"resolved": "2.1.4",
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
},
"System.IO.Pipelines": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "7WX0W96y3dpQdYG4sEGdh38g3/0lOD4/dKbn2rRVOVzKhzoZUn2gKNIKaFeKWs8RCbpFfmmEWsRhSy95hMpvqA=="
},
"System.Text.Encodings.Web": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "o16m2YpDN/pjHsnxf9pTGwkpcuvjW8v1/wGUwJtM1c3QZUKm7ZEO/eYRJg7iIx6GxS2Zv9lAMHpiQwHDdgqauA=="
},
"finalfactory.rendezvous.client": {
"type": "Project",
"dependencies": {
@@ -16,7 +26,20 @@
}
},
"finalfactory.rendezvous.contracts": {
"type": "Project"
"type": "Project",
"dependencies": {
"System.Text.Json": "[10.0.10, )"
}
},
"System.Text.Json": {
"type": "CentralTransitive",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "bmsO6UdYtBdtn32zYXfsh7KlyTIzV/3V9hdT9RIb4pXKgYOsNxXR+VbWigNwBtNFVGYGm6Hwmqw5a+/IWFd36Q==",
"dependencies": {
"System.IO.Pipelines": "10.0.10",
"System.Text.Encodings.Web": "10.0.10"
}
}
}
}