diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index f47098f..f75b738 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -30,5 +30,8 @@ jobs:
- name: Build
run: dotnet build Rendezvous.slnx --configuration Release --no-restore
+ - name: Verify generated API contract
+ run: git diff --exit-code -- docs/api
+
- name: Test
run: dotnet test Rendezvous.slnx --configuration Release --no-build
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 6885e54..209a809 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -5,7 +5,11 @@
+
+
+
+
diff --git a/README.md b/README.md
index 07a013d..1b81b64 100644
--- a/README.md
+++ b/README.md
@@ -79,6 +79,8 @@ Rendezvous is currently in its initial design and bootstrap stage. The first imp
The ratified v1 boundaries, trust decisions, privacy rules, safety budgets, and
threat model are indexed in [the architecture documentation](docs/architecture/README.md).
+The frozen v1 wire surface is documented in the
+[HTTP, UDP, and generated OpenAPI contracts](docs/contracts/README.md).
## Development
diff --git a/docs/api/rendezvous-v1.json b/docs/api/rendezvous-v1.json
new file mode 100644
index 0000000..93b870a
--- /dev/null
+++ b/docs/api/rendezvous-v1.json
@@ -0,0 +1,1131 @@
+{
+ "openapi": "3.1.1",
+ "info": {
+ "title": "FinalFactory.Rendezvous.Server | v1",
+ "version": "1.0.0"
+ },
+ "paths": {
+ "/health/live": {
+ "get": {
+ "tags": [
+ "Health"
+ ],
+ "operationId": "GetLiveness",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HealthResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/health/ready": {
+ "get": {
+ "tags": [
+ "Health"
+ ],
+ "operationId": "GetReadiness",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HealthResponse"
+ }
+ }
+ }
+ },
+ "503": {
+ "description": "Service Unavailable"
+ }
+ }
+ }
+ },
+ "/v1/sessions": {
+ "post": {
+ "tags": [
+ "Sessions"
+ ],
+ "operationId": "RegisterSession",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RegisterSessionRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "description": "Created",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RegisterSessionResponse"
+ }
+ }
+ }
+ },
+ "501": {
+ "description": "Not Implemented",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "get": {
+ "tags": [
+ "Sessions"
+ ],
+ "operationId": "BrowseSessions",
+ "parameters": [
+ {
+ "name": "contractVersion",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int32"
+ }
+ },
+ {
+ "name": "gameId",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "environmentId",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "protocolVersion",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "uint32"
+ }
+ },
+ {
+ "name": "regionId",
+ "in": "query",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "pageSize",
+ "in": "query",
+ "schema": {
+ "type": "integer",
+ "format": "int32"
+ }
+ },
+ {
+ "name": "cursor",
+ "in": "query",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BrowseSessionsResponse"
+ }
+ }
+ }
+ },
+ "501": {
+ "description": "Not Implemented",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/sessions/{listingId}/renew": {
+ "post": {
+ "tags": [
+ "Sessions"
+ ],
+ "operationId": "RenewSessionLease",
+ "parameters": [
+ {
+ "name": "listingId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RenewLeaseRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RenewLeaseResponse"
+ }
+ }
+ }
+ },
+ "501": {
+ "description": "Not Implemented",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/sessions/{listingId}": {
+ "put": {
+ "tags": [
+ "Sessions"
+ ],
+ "operationId": "UpdateSession",
+ "parameters": [
+ {
+ "name": "listingId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateSessionRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "204": {
+ "description": "No Content"
+ },
+ "501": {
+ "description": "Not Implemented",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "Sessions"
+ ],
+ "operationId": "DeleteSession",
+ "parameters": [
+ {
+ "name": "listingId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DeleteSessionRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "204": {
+ "description": "No Content"
+ },
+ "501": {
+ "description": "Not Implemented",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "get": {
+ "tags": [
+ "Sessions"
+ ],
+ "operationId": "GetSession",
+ "parameters": [
+ {
+ "name": "listingId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/GetSessionResponse"
+ }
+ }
+ }
+ },
+ "501": {
+ "description": "Not Implemented",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/sessions/{listingId}/join-attempts": {
+ "get": {
+ "tags": [
+ "Sessions"
+ ],
+ "operationId": "BrowseHostJoinAttempts",
+ "parameters": [
+ {
+ "name": "listingId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "contractVersion",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int32"
+ }
+ },
+ {
+ "name": "X-Rendezvous-Lease-Token",
+ "in": "header",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "pageSize",
+ "in": "query",
+ "schema": {
+ "type": "integer",
+ "format": "int32"
+ }
+ },
+ {
+ "name": "cursor",
+ "in": "query",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BrowseHostJoinAttemptsResponse"
+ }
+ }
+ }
+ },
+ "501": {
+ "description": "Not Implemented",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/join-attempts": {
+ "post": {
+ "tags": [
+ "Join attempts"
+ ],
+ "operationId": "CreateJoinAttempt",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateJoinAttemptRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "description": "Created",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateJoinAttemptResponse"
+ }
+ }
+ }
+ },
+ "501": {
+ "description": "Not Implemented",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/join-attempts/{attemptId}/outcome": {
+ "post": {
+ "tags": [
+ "Join attempts"
+ ],
+ "operationId": "ReportConnectionOutcome",
+ "parameters": [
+ {
+ "name": "attemptId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ReportConnectionOutcomeRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ReportConnectionOutcomeResponse"
+ }
+ }
+ }
+ },
+ "501": {
+ "description": "Not Implemented",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiError"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "schemas": {
+ "AddressFamilyKind": {
+ "enum": [
+ "ipv4",
+ "ipv6"
+ ]
+ },
+ "ApiError": {
+ "required": [
+ "contractVersion",
+ "code",
+ "message"
+ ],
+ "type": "object",
+ "properties": {
+ "contractVersion": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "code": {
+ "$ref": "#/components/schemas/RendezvousErrorCode"
+ },
+ "message": {
+ "type": "string"
+ },
+ "correlationId": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "retryAfterSeconds": {
+ "type": [
+ "null",
+ "integer"
+ ],
+ "format": "int32"
+ }
+ }
+ },
+ "BrowseHostJoinAttemptsResponse": {
+ "required": [
+ "contractVersion",
+ "items"
+ ],
+ "type": "object",
+ "properties": {
+ "contractVersion": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "items": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/HostJoinAttempt"
+ }
+ },
+ "nextCursor": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
+ "BrowseSessionsResponse": {
+ "required": [
+ "contractVersion",
+ "items"
+ ],
+ "type": "object",
+ "properties": {
+ "contractVersion": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "items": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/SessionListing"
+ }
+ },
+ "nextCursor": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
+ "ConnectionOutcomeKind": {
+ "enum": [
+ "connected",
+ "cancelled",
+ "timedOut",
+ "incompatibleProtocol",
+ "staleHost",
+ "serviceRejected",
+ "hostRejected",
+ "transportFailed",
+ "fallbackOffered"
+ ]
+ },
+ "CreateJoinAttemptRequest": {
+ "required": [
+ "contractVersion",
+ "idempotencyKey",
+ "gameId",
+ "environmentId",
+ "listingId",
+ "protocolVersion"
+ ],
+ "type": "object",
+ "properties": {
+ "contractVersion": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "idempotencyKey": {
+ "type": "string"
+ },
+ "gameId": {
+ "$ref": "#/components/schemas/GameId"
+ },
+ "environmentId": {
+ "$ref": "#/components/schemas/EnvironmentId"
+ },
+ "listingId": {
+ "$ref": "#/components/schemas/SessionListingId"
+ },
+ "protocolVersion": {
+ "type": "integer",
+ "format": "uint32"
+ }
+ }
+ },
+ "CreateJoinAttemptResponse": {
+ "required": [
+ "contractVersion",
+ "attemptId",
+ "mediationHandle",
+ "clientPunchCapability",
+ "expiresAt"
+ ],
+ "type": "object",
+ "properties": {
+ "contractVersion": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "attemptId": {
+ "$ref": "#/components/schemas/JoinAttemptId"
+ },
+ "mediationHandle": {
+ "$ref": "#/components/schemas/MediationHandle"
+ },
+ "clientPunchCapability": {
+ "type": "string"
+ },
+ "expiresAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "dedicatedFallback": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/NetworkEndpoint"
+ }
+ ]
+ }
+ }
+ },
+ "DeleteSessionRequest": {
+ "required": [
+ "contractVersion",
+ "leaseToken"
+ ],
+ "type": "object",
+ "properties": {
+ "contractVersion": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "leaseToken": {
+ "type": "string"
+ }
+ }
+ },
+ "EnvironmentId": {
+ "type": "string"
+ },
+ "GameId": {
+ "type": "string"
+ },
+ "GetSessionResponse": {
+ "required": [
+ "contractVersion",
+ "session"
+ ],
+ "type": "object",
+ "properties": {
+ "contractVersion": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "session": {
+ "$ref": "#/components/schemas/SessionListing"
+ }
+ }
+ },
+ "HealthResponse": {
+ "required": [
+ "contractVersion",
+ "status"
+ ],
+ "type": "object",
+ "properties": {
+ "contractVersion": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "status": {
+ "type": "string"
+ }
+ }
+ },
+ "HostJoinAttempt": {
+ "required": [
+ "attemptId",
+ "mediationHandle",
+ "hostPunchCapability",
+ "expiresAt"
+ ],
+ "type": "object",
+ "properties": {
+ "attemptId": {
+ "$ref": "#/components/schemas/JoinAttemptId"
+ },
+ "mediationHandle": {
+ "$ref": "#/components/schemas/MediationHandle"
+ },
+ "hostPunchCapability": {
+ "type": "string"
+ },
+ "expiresAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
+ "JoinAttemptId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "LeaseId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "ListingVisibility": {
+ "enum": [
+ "public",
+ "unlisted"
+ ]
+ },
+ "MediationHandle": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "NetworkEndpoint": {
+ "required": [
+ "addressFamily",
+ "address",
+ "port"
+ ],
+ "type": "object",
+ "properties": {
+ "addressFamily": {
+ "$ref": "#/components/schemas/AddressFamilyKind"
+ },
+ "address": {
+ "type": "string"
+ },
+ "port": {
+ "type": "integer",
+ "format": "int32"
+ }
+ }
+ },
+ "PublisherTrustMode": {
+ "enum": [
+ "managedDedicated",
+ "playerGrant",
+ "anonymousUnlisted"
+ ]
+ },
+ "RegionId": {
+ "type": "string"
+ },
+ "RegisterSessionRequest": {
+ "required": [
+ "contractVersion",
+ "idempotencyKey",
+ "gameId",
+ "environmentId",
+ "regionId",
+ "protocolVersion",
+ "buildVersion",
+ "displayName",
+ "visibility",
+ "capacity",
+ "metadata"
+ ],
+ "type": "object",
+ "properties": {
+ "contractVersion": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "idempotencyKey": {
+ "type": "string"
+ },
+ "gameId": {
+ "$ref": "#/components/schemas/GameId"
+ },
+ "environmentId": {
+ "$ref": "#/components/schemas/EnvironmentId"
+ },
+ "regionId": {
+ "$ref": "#/components/schemas/RegionId"
+ },
+ "protocolVersion": {
+ "type": "integer",
+ "format": "uint32"
+ },
+ "buildVersion": {
+ "type": "string"
+ },
+ "displayName": {
+ "type": "string"
+ },
+ "visibility": {
+ "$ref": "#/components/schemas/ListingVisibility"
+ },
+ "capacity": {
+ "$ref": "#/components/schemas/SessionCapacity"
+ },
+ "metadata": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "RegisterSessionResponse": {
+ "required": [
+ "contractVersion",
+ "listingId",
+ "leaseId",
+ "leaseToken",
+ "hostPresenceHandle",
+ "hostPresenceCapability",
+ "expiresAt"
+ ],
+ "type": "object",
+ "properties": {
+ "contractVersion": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "listingId": {
+ "$ref": "#/components/schemas/SessionListingId"
+ },
+ "leaseId": {
+ "$ref": "#/components/schemas/LeaseId"
+ },
+ "leaseToken": {
+ "type": "string"
+ },
+ "hostPresenceHandle": {
+ "$ref": "#/components/schemas/MediationHandle"
+ },
+ "hostPresenceCapability": {
+ "type": "string"
+ },
+ "expiresAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
+ "RendezvousErrorCode": {
+ "enum": [
+ "none",
+ "invalidRequest",
+ "unsupportedContractVersion",
+ "incompatibleProtocol",
+ "authenticationRequired",
+ "forbidden",
+ "notFound",
+ "conflict",
+ "rateLimited",
+ "staleHost",
+ "expired",
+ "replayRejected",
+ "capacityExceeded",
+ "serviceUnavailable",
+ "internalError"
+ ]
+ },
+ "RenewLeaseRequest": {
+ "required": [
+ "contractVersion",
+ "leaseToken"
+ ],
+ "type": "object",
+ "properties": {
+ "contractVersion": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "leaseToken": {
+ "type": "string"
+ }
+ }
+ },
+ "RenewLeaseResponse": {
+ "required": [
+ "contractVersion",
+ "expiresAt"
+ ],
+ "type": "object",
+ "properties": {
+ "contractVersion": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "expiresAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
+ "ReportConnectionOutcomeRequest": {
+ "required": [
+ "contractVersion",
+ "outcome",
+ "elapsedMilliseconds"
+ ],
+ "type": "object",
+ "properties": {
+ "contractVersion": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "outcome": {
+ "$ref": "#/components/schemas/ConnectionOutcomeKind"
+ },
+ "elapsedMilliseconds": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "diagnosticCode": {
+ "type": [
+ "null",
+ "string"
+ ]
+ }
+ }
+ },
+ "ReportConnectionOutcomeResponse": {
+ "required": [
+ "contractVersion",
+ "accepted"
+ ],
+ "type": "object",
+ "properties": {
+ "contractVersion": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "accepted": {
+ "type": "boolean"
+ }
+ }
+ },
+ "SessionCapacity": {
+ "required": [
+ "currentPlayers",
+ "maximumPlayers"
+ ],
+ "type": "object",
+ "properties": {
+ "currentPlayers": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "maximumPlayers": {
+ "type": "integer",
+ "format": "int32"
+ }
+ }
+ },
+ "SessionListing": {
+ "required": [
+ "contractVersion",
+ "listingId",
+ "gameId",
+ "environmentId",
+ "regionId",
+ "protocolVersion",
+ "buildVersion",
+ "displayName",
+ "visibility",
+ "publisherTrustMode",
+ "capacity",
+ "metadata"
+ ],
+ "type": "object",
+ "properties": {
+ "contractVersion": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "listingId": {
+ "$ref": "#/components/schemas/SessionListingId"
+ },
+ "gameId": {
+ "$ref": "#/components/schemas/GameId"
+ },
+ "environmentId": {
+ "$ref": "#/components/schemas/EnvironmentId"
+ },
+ "regionId": {
+ "$ref": "#/components/schemas/RegionId"
+ },
+ "protocolVersion": {
+ "type": "integer",
+ "format": "uint32"
+ },
+ "buildVersion": {
+ "type": "string"
+ },
+ "displayName": {
+ "type": "string"
+ },
+ "visibility": {
+ "$ref": "#/components/schemas/ListingVisibility"
+ },
+ "publisherTrustMode": {
+ "$ref": "#/components/schemas/PublisherTrustMode"
+ },
+ "capacity": {
+ "$ref": "#/components/schemas/SessionCapacity"
+ },
+ "metadata": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "SessionListingId": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "UpdateSessionRequest": {
+ "required": [
+ "contractVersion",
+ "leaseToken",
+ "buildVersion",
+ "displayName",
+ "capacity",
+ "metadata"
+ ],
+ "type": "object",
+ "properties": {
+ "contractVersion": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "leaseToken": {
+ "type": "string"
+ },
+ "buildVersion": {
+ "type": "string"
+ },
+ "displayName": {
+ "type": "string"
+ },
+ "capacity": {
+ "$ref": "#/components/schemas/SessionCapacity"
+ },
+ "metadata": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ {
+ "name": "Health"
+ },
+ {
+ "name": "Sessions"
+ },
+ {
+ "name": "Join attempts"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/docs/architecture/README.md b/docs/architecture/README.md
index 38c5825..0b4ab0f 100644
--- a/docs/architecture/README.md
+++ b/docs/architecture/README.md
@@ -8,6 +8,7 @@ decision requires a superseding ADR and corresponding contract/test updates.
- [ADR 0003: state, privacy, availability, and safety budgets](0003-state-privacy-availability-and-budgets.md)
- [Threat model](../security/threat-model.md)
- [Security promise and test matrix](../security/control-matrix.md)
+- [Versioned HTTP and UDP contracts](../contracts/README.md)
These decisions intentionally leave gameplay authority, player identity,
simulation, persistence, social features, skill matchmaking, and gameplay
diff --git a/docs/architecture/project-boundaries.md b/docs/architecture/project-boundaries.md
index a518eef..ae5f8f3 100644
--- a/docs/architecture/project-boundaries.md
+++ b/docs/architecture/project-boundaries.md
@@ -11,11 +11,13 @@ FinalFactory.Rendezvous.Server FinalFactory.Rendezvous.TestClient
- `Contracts` targets `netstandard2.1` and contains only versioned,
transport-neutral IDs and wire contracts. It cannot reference Server,
- LiteNetLib, or Godot.
+ LiteNetLib, or Godot. Its only package is `System.Text.Json`, used for the
+ canonical cross-runtime JSON contract.
- `Client` targets `netstandard2.1`, references Contracts and the pinned
LiteNetLib package, and contains no Godot or Server dependency.
-- `Server` targets .NET 10 LTS, references Contracts and LiteNetLib, and owns
- HTTP hosting, UDP mediation, application policy, and ephemeral state.
+- `Server` targets .NET 10 LTS, references Contracts, LiteNetLib, and the
+ first-party ASP.NET Core OpenAPI generator, and owns HTTP hosting, UDP
+ mediation, application policy, and ephemeral state.
- `TestClient` targets .NET 8, references only the public Client/Contracts seams
and LiteNetLib, and must never reach into Server internals.
- `Tests` target .NET 10 and may reference every project solely to verify public
@@ -32,6 +34,8 @@ engine, transport, or server dependency therefore fails the normal test gate.
.NET 8-or-later runtime used by current Godot 4 C# projects.
- TestClient runtime: .NET 8.
- LiteNetLib: 2.1.4, pinned centrally and restored from the lock files.
+- Microsoft.OpenApi: patched 2.7.5 line, centrally pinned because the version
+ originally pulled by the .NET 10 generator is affected by CVE-2026-49451.
The repository uses central package versions, per-project lock files,
deterministic compilation, nullable reference types, warnings as errors, current
@@ -43,3 +47,5 @@ Primary compatibility references:
- [.NET support policy](https://dotnet.microsoft.com/en-us/platform/support/policy)
- [Godot stable C# prerequisites](https://docs.godotengine.org/en/stable/tutorials/scripting/c_sharp/c_sharp_basics.html)
- [LiteNetLib 2.1.4 on NuGet](https://www.nuget.org/packages/LiteNetLib/2.1.4)
+- [ASP.NET Core OpenAPI generation](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/overview?view=aspnetcore-10.0)
+- [Microsoft.OpenApi security advisory](https://github.com/advisories/GHSA-v5pm-xwqc-g5wc)
diff --git a/docs/contracts/README.md b/docs/contracts/README.md
new file mode 100644
index 0000000..294c386
--- /dev/null
+++ b/docs/contracts/README.md
@@ -0,0 +1,18 @@
+# Versioned contracts
+
+Tracking: #4
+
+The v1 contract is defined by three artifacts that are reviewed and versioned
+together:
+
+- [HTTP v1 semantics](http-v1.md)
+- [UDP v1 wire format](udp-v1.md)
+- [Generated OpenAPI 3.1 document](../api/rendezvous-v1.json)
+
+The public .NET types live in `FinalFactory.Rendezvous.Contracts`, target
+`netstandard2.1`, and contain no Server, Godot, or LiteNetLib dependency. Golden
+vectors and a public-API snapshot make accidental wire or source compatibility
+changes fail the normal test gate.
+
+Any incompatible change requires a new contract version. Additive JSON fields
+may be introduced within v1 because v1 readers ignore unknown object members.
diff --git a/docs/contracts/http-v1.md b/docs/contracts/http-v1.md
new file mode 100644
index 0000000..658374e
--- /dev/null
+++ b/docs/contracts/http-v1.md
@@ -0,0 +1,105 @@
+# HTTP contract v1
+
+Tracking: #4
+
+All production endpoints require HTTPS. JSON uses UTF-8, camel-case property
+names, compact output, string-valued camel-case enums, and ISO 8601 timestamps.
+Every request that contains a body carries `contractVersion: 1`; browse carries
+the same value as a required query parameter.
+
+## Compatibility and parsing
+
+- Contract version matching is exact. Any value other than `1` fails with
+ `unsupportedContractVersion`; it is never guessed or downgraded.
+- Gameplay protocol matching is exact. `buildVersion` is display and diagnostic
+ text only and never decides compatibility.
+- Unknown JSON object properties are ignored so additive v1 responses remain
+ readable. Unknown enum names, numeric enum values, comments, trailing commas,
+ invalid identifier strings, and excessive nesting are rejected.
+- Game, environment, and region IDs are lowercase URL-safe slugs. Listing,
+ lease, join-attempt, and mediation IDs are non-empty UUIDs serialized as JSON
+ strings.
+- Clients must honor request cancellation. A disconnected or cancelled request
+ does not promise a response body; the server should stop work where safe.
+
+## Endpoints
+
+| Method | Path | Purpose |
+| --- | --- | --- |
+| `POST` | `/v1/sessions` | Register a session and create its renewable lease. |
+| `POST` | `/v1/sessions/{listingId}/renew` | Renew the listing lease. |
+| `PUT` | `/v1/sessions/{listingId}` | Replace mutable browser fields and capacity. |
+| `DELETE` | `/v1/sessions/{listingId}` | Withdraw a listing. |
+| `GET` | `/v1/sessions` | Browse compatible public sessions. |
+| `GET` | `/v1/sessions/{listingId}` | Resolve a public or explicitly shared unlisted listing. |
+| `POST` | `/v1/join-attempts` | Authorize and create a short-lived join attempt. |
+| `GET` | `/v1/sessions/{listingId}/join-attempts` | Let an authenticated host poll pending attempts. |
+| `POST` | `/v1/join-attempts/{attemptId}/outcome` | Report a bounded connection outcome. |
+| `GET` | `/health/live` | Report that the HTTP process is alive. |
+| `GET` | `/health/ready` | Report whether the UDP mediator is bound and ready. |
+
+The generated [OpenAPI document](../api/rendezvous-v1.json) is the normative
+shape reference for parameters, bodies, and responses. Contract-only endpoints
+return `501` until their behavior is implemented by the subsequent directory,
+lease, and join-orchestration issues.
+
+Host polling sends its reusable lease credential in
+`X-Rendezvous-Lease-Token`; it must never be placed in a URL. Lease credentials
+for mutation operations are carried in their request bodies. Public browser
+responses contain no IP endpoints, lease tokens, punch capabilities, connection
+tickets, player identifiers, or gameplay state.
+
+## Idempotency, cursors, and retries
+
+Registration and join creation require a caller-generated visible-ASCII
+`idempotencyKey`. A repeat in the same authorization scope returns the original
+result while the key is retained; reusing a key with a different payload fails
+with `conflict`. Keys are opaque and must not contain credentials.
+
+Cursors are opaque, endpoint-specific, short-lived values. A client may echo a
+cursor only to the endpoint and filters that produced it. Invalid or expired
+cursors fail with `invalidRequest`; clients restart browsing from the first page.
+Renew, update, delete, and outcome reporting are safe to retry with the same
+lease/attempt identity after a transport-level failure.
+
+## Limits
+
+Limits are measured after UTF-8 encoding where stated. Servers reject the
+entire request rather than truncate values.
+
+| Item | v1 limit |
+| --- | ---: |
+| HTTP request body | 16 KiB |
+| Browser response body | 256 KiB |
+| Browser page | 100 listings |
+| Metadata document | 4 KiB, 32 keys |
+| Metadata key / value | 64 / 256 UTF-8 bytes |
+| Game / environment / region ID | 64 / 32 / 32 characters |
+| Display name / build version | 128 / 64 UTF-8 bytes |
+| Idempotency key | 64 visible ASCII characters |
+| Cursor | 512 visible ASCII characters |
+| Diagnostic code | 64 visible ASCII characters |
+| Error message | 256 UTF-8 bytes |
+| Reusable HTTP credential | 1,024 characters |
+| Session capacity | 1–10,000 players |
+
+## Error mapping
+
+Errors use `ApiError` with a stable `code`, bounded safe `message`, optional
+`correlationId`, and optional `retryAfterSeconds`. Messages are diagnostic and
+must not be parsed. Secrets and raw credentials are never echoed.
+
+| HTTP | Codes |
+| ---: | --- |
+| 400 | `invalidRequest`, `unsupportedContractVersion` |
+| 401 | `authenticationRequired` |
+| 403 | `forbidden` |
+| 404 | `notFound` |
+| 409 | `conflict`, `incompatibleProtocol`, `replayRejected`, `capacityExceeded` |
+| 410 | `expired`, `staleHost` |
+| 429 | `rateLimited` (with retry guidance when known) |
+| 503 | `serviceUnavailable` (with retry guidance when known) |
+| 500 | `internalError` |
+
+Malformed input must receive the same bounded error family regardless of which
+parser or validation stage rejected it.
diff --git a/docs/contracts/udp-v1.md b/docs/contracts/udp-v1.md
new file mode 100644
index 0000000..65bede1
--- /dev/null
+++ b/docs/contracts/udp-v1.md
@@ -0,0 +1,53 @@
+# UDP presence contract v1
+
+Tracking: #4
+
+The UDP mediator accepts a single bounded presence envelope from a host or
+client. It associates the authenticated mediation handle with the packet's
+observed public source endpoint and the sender's reported local endpoint. It
+does not carry gameplay packets.
+
+All multi-byte integers use network byte order. UUID bytes use the canonical
+RFC 4122 textual order (the byte pairs from the 32 hexadecimal digits), not the
+mixed-endian layout returned by `Guid.ToByteArray()`.
+
+## Datagram layout
+
+| Offset | Size | Field |
+| ---: | ---: | --- |
+| 0 | 2 | Magic bytes `52 56` (`RV`). |
+| 2 | 1 | Contract version, exactly `01`. |
+| 3 | 1 | Message type: host presence `01`, client presence `02`. |
+| 4 | 1 | Flags, exactly `00` in v1. |
+| 5 | 16 | Non-empty mediation-handle UUID. |
+| 21 | 1 | Address family: IPv4 `04`, IPv6 `06`. |
+| 22 | 1 | Address length: `04` for IPv4, `10` for IPv6. |
+| 23 | 4 or 16 | Raw local IP address bytes. |
+| next | 2 | Local UDP port, 1–65535. |
+| next | 1 | Capability length, 1–192. |
+| next | variable | ASCII base64url capability, without padding. |
+
+No trailing bytes are permitted. The whole datagram is limited to 1,200 bytes,
+well below common Internet path MTUs. The v1 capability limit is 192 characters,
+which also keeps any value passed through LiteNetLib's 256-character NAT token
+surface safely below that library boundary.
+
+## Validation and failure behavior
+
+Decoders return one stable failure category: oversized, truncated, invalid
+magic, unsupported version, unknown message type, non-zero flags, invalid
+handle, invalid address family, invalid address, invalid port, invalid
+capability, or trailing data. Unknown versions and message types are rejected;
+they are never interpreted as v1.
+
+The address-family byte, encoded address length, and parsed address must agree.
+The service derives the public endpoint from the UDP packet source and never
+trusts a client-supplied public address. Reported local endpoints are candidates
+only and grant no authority.
+
+Capabilities are short-lived, single-purpose, scoped to one mediation handle,
+and compared without exposing them in logs. A valid-looking packet does not
+prove authorization until the capability is checked. Invalid packets receive
+no UDP response, preventing the mediator from becoming an amplification oracle.
+Replay, expiry, pairing, and rate-limit policy are defined by later mediator
+issues; the v1 envelope deliberately leaves no unbounded or reflected payload.
diff --git a/src/FinalFactory.Rendezvous.Client/packages.lock.json b/src/FinalFactory.Rendezvous.Client/packages.lock.json
index fc4b597..3a3a4c1 100644
--- a/src/FinalFactory.Rendezvous.Client/packages.lock.json
+++ b/src/FinalFactory.Rendezvous.Client/packages.lock.json
@@ -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"
+ }
}
}
}
diff --git a/src/FinalFactory.Rendezvous.Contracts/ContractEnums.cs b/src/FinalFactory.Rendezvous.Contracts/ContractEnums.cs
new file mode 100644
index 0000000..482009f
--- /dev/null
+++ b/src/FinalFactory.Rendezvous.Contracts/ContractEnums.cs
@@ -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,
+}
diff --git a/src/FinalFactory.Rendezvous.Contracts/ContractLimits.cs b/src/FinalFactory.Rendezvous.Contracts/ContractLimits.cs
new file mode 100644
index 0000000..ead6b77
--- /dev/null
+++ b/src/FinalFactory.Rendezvous.Contracts/ContractLimits.cs
@@ -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;
+}
diff --git a/src/FinalFactory.Rendezvous.Contracts/ContractModels.cs b/src/FinalFactory.Rendezvous.Contracts/ContractModels.cs
new file mode 100644
index 0000000..56968cf
--- /dev/null
+++ b/src/FinalFactory.Rendezvous.Contracts/ContractModels.cs
@@ -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;
+}
diff --git a/src/FinalFactory.Rendezvous.Contracts/ContractValidation.cs b/src/FinalFactory.Rendezvous.Contracts/ContractValidation.cs
new file mode 100644
index 0000000..3e8d345
--- /dev/null
+++ b/src/FinalFactory.Rendezvous.Contracts/ContractValidation.cs
@@ -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? metadata)
+ {
+ if (metadata is null || metadata.Count > ContractLimits.MetadataMaxKeys)
+ {
+ return false;
+ }
+
+ foreach (KeyValuePair 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 <= '~');
+}
diff --git a/src/FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj b/src/FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj
index 1384e57..8eefa3d 100644
--- a/src/FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj
+++ b/src/FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj
@@ -7,4 +7,7 @@
FinalFactory.Rendezvous.Contracts
Versioned transport-neutral contracts for Final Factory Rendezvous.
+
+
+
diff --git a/src/FinalFactory.Rendezvous.Contracts/Http/JoinContracts.cs b/src/FinalFactory.Rendezvous.Contracts/Http/JoinContracts.cs
new file mode 100644
index 0000000..a55a5b8
--- /dev/null
+++ b/src/FinalFactory.Rendezvous.Contracts/Http/JoinContracts.cs
@@ -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 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; }
+}
diff --git a/src/FinalFactory.Rendezvous.Contracts/Http/SessionContracts.cs b/src/FinalFactory.Rendezvous.Contracts/Http/SessionContracts.cs
new file mode 100644
index 0000000..3a5cf2a
--- /dev/null
+++ b/src/FinalFactory.Rendezvous.Contracts/Http/SessionContracts.cs
@@ -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 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 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 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 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();
+}
diff --git a/src/FinalFactory.Rendezvous.Contracts/Identifiers/OpaqueIdentifiers.cs b/src/FinalFactory.Rendezvous.Contracts/Identifiers/OpaqueIdentifiers.cs
new file mode 100644
index 0000000..02d5679
--- /dev/null
+++ b/src/FinalFactory.Rendezvous.Contracts/Identifiers/OpaqueIdentifiers.cs
@@ -0,0 +1,112 @@
+using System.Text.Json.Serialization;
+
+namespace FinalFactory.Rendezvous.Contracts;
+
+[JsonConverter(typeof(SessionListingIdJsonConverter))]
+public readonly struct SessionListingId : IEquatable
+{
+ 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
+{
+ 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
+{
+ 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
+{
+ 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(
+ string? value,
+ Func 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 :
+ StringIdentifierJsonConverter
+{
+ protected sealed override string Format(TIdentifier value) => value?.ToString() ?? string.Empty;
+}
+
+internal sealed class SessionListingIdJsonConverter : GuidIdentifierJsonConverter
+{
+ protected override SessionListingId Parse(string value) => new(Guid.ParseExact(value, "D"));
+}
+
+internal sealed class LeaseIdJsonConverter : GuidIdentifierJsonConverter
+{
+ protected override LeaseId Parse(string value) => new(Guid.ParseExact(value, "D"));
+}
+
+internal sealed class JoinAttemptIdJsonConverter : GuidIdentifierJsonConverter
+{
+ protected override JoinAttemptId Parse(string value) => new(Guid.ParseExact(value, "D"));
+}
+
+internal sealed class MediationHandleJsonConverter : GuidIdentifierJsonConverter
+{
+ protected override MediationHandle Parse(string value) => new(Guid.ParseExact(value, "D"));
+}
diff --git a/src/FinalFactory.Rendezvous.Contracts/Identifiers/ScopeIdentifiers.cs b/src/FinalFactory.Rendezvous.Contracts/Identifiers/ScopeIdentifiers.cs
new file mode 100644
index 0000000..cff0426
--- /dev/null
+++ b/src/FinalFactory.Rendezvous.Contracts/Identifiers/ScopeIdentifiers.cs
@@ -0,0 +1,126 @@
+using System.Text.Json.Serialization;
+
+namespace FinalFactory.Rendezvous.Contracts;
+
+[JsonConverter(typeof(GameIdJsonConverter))]
+public readonly struct GameId : IEquatable
+{
+ 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
+{
+ 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
+{
+ 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
+{
+ protected override GameId Parse(string value) => new(value);
+ protected override string Format(GameId value) => value.Value;
+}
+
+internal sealed class EnvironmentIdJsonConverter : StringIdentifierJsonConverter
+{
+ protected override EnvironmentId Parse(string value) => new(value);
+ protected override string Format(EnvironmentId value) => value.Value;
+}
+
+internal sealed class RegionIdJsonConverter : StringIdentifierJsonConverter
+{
+ protected override RegionId Parse(string value) => new(value);
+ protected override string Format(RegionId value) => value.Value;
+}
diff --git a/src/FinalFactory.Rendezvous.Contracts/Identifiers/StringIdentifierJsonConverter.cs b/src/FinalFactory.Rendezvous.Contracts/Identifiers/StringIdentifierJsonConverter.cs
new file mode 100644
index 0000000..c48f3d0
--- /dev/null
+++ b/src/FinalFactory.Rendezvous.Contracts/Identifiers/StringIdentifierJsonConverter.cs
@@ -0,0 +1,37 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace FinalFactory.Rendezvous.Contracts;
+
+internal abstract class StringIdentifierJsonConverter : JsonConverter
+{
+ 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);
+}
diff --git a/src/FinalFactory.Rendezvous.Contracts/Serialization/ContractJson.cs b/src/FinalFactory.Rendezvous.Contracts/Serialization/ContractJson.cs
new file mode 100644
index 0000000..ff719b8
--- /dev/null
+++ b/src/FinalFactory.Rendezvous.Contracts/Serialization/ContractJson.cs
@@ -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().Any())
+ {
+ options.Converters.Add(
+ new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false));
+ }
+ }
+
+ private static JsonSerializerOptions CreateReadOnlyOptions()
+ {
+ JsonSerializerOptions options = CreateOptions();
+ options.MakeReadOnly(populateMissingResolver: true);
+ return options;
+ }
+}
diff --git a/src/FinalFactory.Rendezvous.Contracts/Udp/PresenceDatagram.cs b/src/FinalFactory.Rendezvous.Contracts/Udp/PresenceDatagram.cs
new file mode 100644
index 0000000..4c06cfa
--- /dev/null
+++ b/src/FinalFactory.Rendezvous.Contracts/Udp/PresenceDatagram.cs
@@ -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;
+}
diff --git a/src/FinalFactory.Rendezvous.Contracts/Udp/RendezvousUdpCodec.cs b/src/FinalFactory.Rendezvous.Contracts/Udp/RendezvousUdpCodec.cs
new file mode 100644
index 0000000..0cc5f53
--- /dev/null
+++ b/src/FinalFactory.Rendezvous.Contracts/Udp/RendezvousUdpCodec.cs
@@ -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 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 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);
+}
diff --git a/src/FinalFactory.Rendezvous.Contracts/packages.lock.json b/src/FinalFactory.Rendezvous.Contracts/packages.lock.json
index a2617b3..aa828f7 100644
--- a/src/FinalFactory.Rendezvous.Contracts/packages.lock.json
+++ b/src/FinalFactory.Rendezvous.Contracts/packages.lock.json
@@ -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=="
+ }
+ }
}
}
\ No newline at end of file
diff --git a/src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj b/src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj
index eff1ba5..84b8560 100644
--- a/src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj
+++ b/src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj
@@ -4,9 +4,14 @@
FinalFactory.Rendezvous.Server
FinalFactory.Rendezvous.Server
false
+ true
+ $(MSBuildProjectDirectory)/../../docs/api
+ --document-name v1 --file-name rendezvous-v1 --openapi-version OpenApi3_1
+
+
diff --git a/src/FinalFactory.Rendezvous.Server/Http/ContractEndpoints.cs b/src/FinalFactory.Rendezvous.Server/Http/ContractEndpoints.cs
new file mode 100644
index 0000000..a7d6eb8
--- /dev/null
+++ b/src/FinalFactory.Rendezvous.Server/Http/ContractEndpoints.cs
@@ -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("application/json")
+ .Produces(StatusCodes.Status201Created)
+ .Produces(NotImplementedStatus)
+ .WithName("RegisterSession");
+ sessions.MapPost("/{listingId}/renew", RenewLease)
+ .Accepts("application/json")
+ .Produces()
+ .Produces(NotImplementedStatus)
+ .WithName("RenewSessionLease");
+ sessions.MapPut("/{listingId}", UpdateSession)
+ .Accepts("application/json")
+ .Produces(StatusCodes.Status204NoContent)
+ .Produces(NotImplementedStatus)
+ .WithName("UpdateSession");
+ sessions.MapDelete("/{listingId}", DeleteSession)
+ .Accepts("application/json")
+ .Produces(StatusCodes.Status204NoContent)
+ .Produces(NotImplementedStatus)
+ .WithName("DeleteSession");
+ sessions.MapGet("/", BrowseSessions)
+ .Produces()
+ .Produces(NotImplementedStatus)
+ .WithName("BrowseSessions");
+ sessions.MapGet("/{listingId}", GetSession)
+ .Produces()
+ .Produces(NotImplementedStatus)
+ .WithName("GetSession");
+ sessions.MapGet("/{listingId}/join-attempts", BrowseHostJoinAttempts)
+ .Produces()
+ .Produces(NotImplementedStatus)
+ .WithName("BrowseHostJoinAttempts");
+
+ RouteGroupBuilder attempts = endpoints
+ .MapGroup("/v1/join-attempts")
+ .WithTags("Join attempts");
+ attempts.MapPost("/", CreateJoinAttempt)
+ .Accepts("application/json")
+ .Produces(StatusCodes.Status201Created)
+ .Produces(NotImplementedStatus)
+ .WithName("CreateJoinAttempt");
+ attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome)
+ .Accepts("application/json")
+ .Produces()
+ .Produces(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);
+}
diff --git a/src/FinalFactory.Rendezvous.Server/Program.cs b/src/FinalFactory.Rendezvous.Server/Program.cs
index e91eec1..f4c31ca 100644
--- a/src/FinalFactory.Rendezvous.Server/Program.cs
+++ b/src/FinalFactory.Rendezvous.Server/Program.cs
@@ -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()
.BindConfiguration(UdpMediatorOptions.SectionName)
@@ -12,16 +43,31 @@ builder.Services
$"{UdpMediatorOptions.SectionName}:ListenAddress must be an IP address.")
.ValidateOnStart();
builder.Services.AddSingleton();
-builder.Services.AddHostedService(static services => services.GetRequiredService());
+if (!isOpenApiGeneration)
+{
+ builder.Services.AddHostedService(static services =>
+ services.GetRequiredService());
+}
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()
+ .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()
+ .Produces(StatusCodes.Status503ServiceUnavailable)
+ .WithName("GetReadiness")
+ .WithTags("Health");
await app.RunAsync();
diff --git a/src/FinalFactory.Rendezvous.Server/packages.lock.json b/src/FinalFactory.Rendezvous.Server/packages.lock.json
index 8ca1330..17d9780 100644
--- a/src/FinalFactory.Rendezvous.Server/packages.lock.json
+++ b/src/FinalFactory.Rendezvous.Server/packages.lock.json
@@ -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=="
}
}
}
diff --git a/src/FinalFactory.Rendezvous.TestClient/packages.lock.json b/src/FinalFactory.Rendezvous.TestClient/packages.lock.json
index fda156e..f3e75b5 100644
--- a/src/FinalFactory.Rendezvous.TestClient/packages.lock.json
+++ b/src/FinalFactory.Rendezvous.TestClient/packages.lock.json
@@ -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"
+ }
}
}
}
diff --git a/tests/FinalFactory.Rendezvous.Tests/Architecture/DependencyBoundaryTests.cs b/tests/FinalFactory.Rendezvous.Tests/Architecture/DependencyBoundaryTests.cs
index d9b627f..f31275e 100644
--- a/tests/FinalFactory.Rendezvous.Tests/Architecture/DependencyBoundaryTests.cs
+++ b/tests/FinalFactory.Rendezvous.Tests/Architecture/DependencyBoundaryTests.cs
@@ -39,7 +39,7 @@ public sealed class DependencyBoundaryTests
AssertDeclaredDependencies(
"src/FinalFactory.Rendezvous.Contracts/FinalFactory.Rendezvous.Contracts.csproj",
[],
- []);
+ ["System.Text.Json"]);
AssertDeclaredDependencies(
"src/FinalFactory.Rendezvous.Client/FinalFactory.Rendezvous.Client.csproj",
["FinalFactory.Rendezvous.Contracts"],
@@ -47,7 +47,11 @@ public sealed class DependencyBoundaryTests
AssertDeclaredDependencies(
"src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj",
["FinalFactory.Rendezvous.Contracts"],
- ["LiteNetLib"]);
+ [
+ "LiteNetLib",
+ "Microsoft.AspNetCore.OpenApi",
+ "Microsoft.Extensions.ApiDescription.Server",
+ ]);
AssertDeclaredDependencies(
"src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj",
["FinalFactory.Rendezvous.Client", "FinalFactory.Rendezvous.Contracts"],
diff --git a/tests/FinalFactory.Rendezvous.Tests/Contracts/ContractLimitTests.cs b/tests/FinalFactory.Rendezvous.Tests/Contracts/ContractLimitTests.cs
new file mode 100644
index 0000000..ce8fa70
--- /dev/null
+++ b/tests/FinalFactory.Rendezvous.Tests/Contracts/ContractLimitTests.cs
@@ -0,0 +1,140 @@
+using FinalFactory.Rendezvous.Contracts;
+
+namespace FinalFactory.Rendezvous.Tests.Contracts;
+
+public sealed class ContractLimitTests
+{
+ [Fact]
+ public void ByteAndCollectionLimitsAcceptTheBoundaryOnly()
+ {
+ Assert.True(ContractValidation.IsHttpRequestSizeValid(ContractLimits.HttpRequestMaxBytes));
+ Assert.False(ContractValidation.IsHttpRequestSizeValid(ContractLimits.HttpRequestMaxBytes + 1));
+ Assert.True(ContractValidation.IsBrowserResponseSizeValid(ContractLimits.BrowserResponseMaxBytes));
+ Assert.False(ContractValidation.IsBrowserResponseSizeValid(ContractLimits.BrowserResponseMaxBytes + 1));
+ Assert.True(ContractValidation.IsPageSizeValid(ContractLimits.BrowserPageMaxItems));
+ Assert.False(ContractValidation.IsPageSizeValid(ContractLimits.BrowserPageMaxItems + 1));
+ Assert.False(ContractValidation.IsPageSizeValid(0));
+ }
+
+ [Fact]
+ public void Utf8LimitsCountBytesInsteadOfCharacters()
+ {
+ string atLimit = new('é', ContractLimits.DisplayNameMaxBytes / 2);
+ string overLimit = atLimit + "é";
+
+ Assert.True(ContractValidation.IsDisplayNameValid(atLimit));
+ Assert.False(ContractValidation.IsDisplayNameValid(overLimit));
+ Assert.True(ContractValidation.IsBuildVersionValid(
+ new string('a', ContractLimits.BuildVersionMaxBytes)));
+ Assert.False(ContractValidation.IsBuildVersionValid(
+ new string('a', ContractLimits.BuildVersionMaxBytes + 1)));
+ }
+
+ [Fact]
+ public void CapabilityLimitStaysBelowLiteNetLibTokenLimit()
+ {
+ Assert.True(ContractValidation.IsCapabilityValid(
+ new string('a', ContractLimits.UdpCapabilityMaxCharacters - 1)));
+ Assert.True(ContractValidation.IsCapabilityValid(
+ new string('a', ContractLimits.UdpCapabilityMaxCharacters)));
+ Assert.False(ContractValidation.IsCapabilityValid(
+ new string('a', ContractLimits.UdpCapabilityMaxCharacters + 1)));
+ Assert.False(ContractValidation.IsCapabilityValid("not+base64url"));
+ Assert.True(
+ ContractLimits.UdpCapabilityMaxCharacters
+ < ContractLimits.LiteNetLibNatTokenMaxCharacters);
+ }
+
+ [Fact]
+ public void CapacityAndMetadataLimitsAreBounded()
+ {
+ Assert.True(ContractValidation.IsCapacityValid(new SessionCapacity
+ {
+ CurrentPlayers = ContractLimits.SessionCapacityMaxPlayers,
+ MaximumPlayers = ContractLimits.SessionCapacityMaxPlayers,
+ }));
+ Assert.False(ContractValidation.IsCapacityValid(new SessionCapacity
+ {
+ CurrentPlayers = 0,
+ MaximumPlayers = ContractLimits.SessionCapacityMaxPlayers + 1,
+ }));
+
+ Dictionary maximumKeys = Enumerable
+ .Range(0, ContractLimits.MetadataMaxKeys)
+ .ToDictionary(index => $"key-{index}", _ => "value", StringComparer.Ordinal);
+ Dictionary tooManyKeys = new(maximumKeys, StringComparer.Ordinal)
+ {
+ ["overflow"] = "value",
+ };
+
+ Assert.True(ContractValidation.IsMetadataValid(maximumKeys));
+ Assert.False(ContractValidation.IsMetadataValid(tooManyKeys));
+ Assert.False(ContractValidation.IsMetadataValid(new Dictionary
+ {
+ [new string('k', ContractLimits.MetadataKeyMaxBytes + 1)] = "value",
+ }));
+ Assert.False(ContractValidation.IsMetadataValid(new Dictionary
+ {
+ ["key"] = new string('v', ContractLimits.MetadataValueMaxBytes + 1),
+ }));
+ }
+
+ [Fact]
+ public void MetadataDocumentLimitAcceptsExactlyFourKibibytes()
+ {
+ Dictionary atLimit = Enumerable
+ .Range(0, ContractLimits.MetadataMaxKeys)
+ .ToDictionary(index => $"k{index:00}", _ => string.Empty, StringComparer.Ordinal);
+ for (int index = 0; index < 14; index++)
+ {
+ atLimit[$"k{index:00}"] = new string('v', ContractLimits.MetadataValueMaxBytes);
+ }
+
+ atLimit["k14"] = new string('v', 223);
+ Dictionary overLimit = new(atLimit, StringComparer.Ordinal)
+ {
+ ["k14"] = new string('v', 224),
+ };
+
+ Assert.True(ContractValidation.IsMetadataValid(atLimit));
+ Assert.False(ContractValidation.IsMetadataValid(overLimit));
+ }
+
+ [Fact]
+ public void EndpointValidationIsAddressFamilyAware()
+ {
+ Assert.True(ContractValidation.IsNetworkEndpointValid(new NetworkEndpoint
+ {
+ AddressFamily = AddressFamilyKind.Ipv4,
+ Address = "192.0.2.10",
+ Port = 9050,
+ }));
+ Assert.True(ContractValidation.IsNetworkEndpointValid(new NetworkEndpoint
+ {
+ AddressFamily = AddressFamilyKind.Ipv6,
+ Address = "2001:db8::10",
+ Port = 9050,
+ }));
+ Assert.False(ContractValidation.IsNetworkEndpointValid(new NetworkEndpoint
+ {
+ AddressFamily = AddressFamilyKind.Ipv4,
+ Address = "2001:db8::10",
+ Port = 9050,
+ }));
+ }
+
+ [Fact]
+ public void ScopeAndOpaqueTextBoundariesAreEnforced()
+ {
+ Assert.True(GameId.TryParse(new string('a', ContractLimits.GameIdMaxCharacters), out _));
+ Assert.False(GameId.TryParse(
+ new string('a', ContractLimits.GameIdMaxCharacters + 1),
+ out _));
+ Assert.True(ContractValidation.IsIdempotencyKeyValid(
+ new string('i', ContractLimits.IdempotencyKeyMaxCharacters)));
+ Assert.False(ContractValidation.IsIdempotencyKeyValid(
+ new string('i', ContractLimits.IdempotencyKeyMaxCharacters + 1)));
+ Assert.True(ContractValidation.IsCursorValid(null));
+ Assert.False(ContractValidation.IsCursorValid("contains whitespace"));
+ }
+}
diff --git a/tests/FinalFactory.Rendezvous.Tests/Contracts/ContractSerializationTests.cs b/tests/FinalFactory.Rendezvous.Tests/Contracts/ContractSerializationTests.cs
new file mode 100644
index 0000000..ef92215
--- /dev/null
+++ b/tests/FinalFactory.Rendezvous.Tests/Contracts/ContractSerializationTests.cs
@@ -0,0 +1,104 @@
+using System.Text.Json;
+using FinalFactory.Rendezvous.Contracts;
+
+namespace FinalFactory.Rendezvous.Tests.Contracts;
+
+public sealed class ContractSerializationTests
+{
+ public static TheoryData GoldenJsonVectors => new()
+ {
+ { "register-session.json", typeof(RegisterSessionRequest) },
+ { "browse-sessions.json", typeof(BrowseSessionsResponse) },
+ { "create-join-response.json", typeof(CreateJoinAttemptResponse) },
+ { "api-error.json", typeof(ApiError) },
+ };
+
+ [Theory]
+ [MemberData(nameof(GoldenJsonVectors))]
+ public void CanonicalJsonRoundtripsGoldenVectors(string fileName, Type contractType)
+ {
+ string expected = ContractTestFiles.Read(fileName);
+ object? value = JsonSerializer.Deserialize(expected, contractType, ContractJson.Options);
+
+ Assert.NotNull(value);
+ Assert.Equal(expected, JsonSerializer.Serialize(value, contractType, ContractJson.Options));
+ }
+
+ [Fact]
+ public void IdentifiersAreSerializedAsStrings()
+ {
+ SessionListingId id = new(new Guid("00112233-4455-6677-8899-aabbccddeeff"));
+
+ Assert.Equal(
+ "\"00112233-4455-6677-8899-aabbccddeeff\"",
+ JsonSerializer.Serialize(id, ContractJson.Options));
+ Assert.Equal(id, JsonSerializer.Deserialize(
+ "\"00112233-4455-6677-8899-aabbccddeeff\"",
+ ContractJson.Options));
+ }
+
+ [Fact]
+ public void UnknownObjectFieldsAreIgnoredForAdditiveV1Changes()
+ {
+ const string json = """
+ {"contractVersion":1,"idempotencyKey":"join-001","gameId":"space-game","environmentId":"production","listingId":"00112233-4455-6677-8899-aabbccddeeff","protocolVersion":7,"futureField":{"nested":true}}
+ """;
+
+ CreateJoinAttemptRequest? request = JsonSerializer.Deserialize(
+ json,
+ ContractJson.Options);
+
+ Assert.NotNull(request);
+ Assert.Equal(new GameId("space-game"), request.GameId);
+ Assert.Equal(7u, request.ProtocolVersion);
+ }
+
+ [Fact]
+ public void MissingNormativeFieldsAreRejectedInsteadOfDefaulted()
+ {
+ const string missingVersion = """
+ {"idempotencyKey":"join-001","gameId":"space-game","environmentId":"production","listingId":"00112233-4455-6677-8899-aabbccddeeff","protocolVersion":7}
+ """;
+
+ Assert.Throws(() => JsonSerializer.Deserialize(
+ missingVersion,
+ ContractJson.Options));
+ }
+
+ [Fact]
+ public void UnknownEnumNamesAndNumericValuesAreRejected()
+ {
+ Assert.Throws(() => JsonSerializer.Deserialize(
+ "{\"contractVersion\":1,\"outcome\":\"futureOutcome\",\"elapsedMilliseconds\":1}",
+ ContractJson.Options));
+ Assert.Throws(() => JsonSerializer.Deserialize(
+ "{\"contractVersion\":1,\"outcome\":99,\"elapsedMilliseconds\":1}",
+ ContractJson.Options));
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(2)]
+ [InlineData(int.MaxValue)]
+ public void UnknownContractVersionsFailPredictably(int version)
+ {
+ Assert.Equal(
+ RendezvousErrorCode.UnsupportedContractVersion,
+ ContractValidation.ValidateContractVersion(version));
+ }
+
+ [Fact]
+ public void GameplayProtocolCompatibilityIsExactAndBuildIndependent()
+ {
+ Assert.True(ContractValidation.AreProtocolsCompatible(7, 7));
+ Assert.False(ContractValidation.AreProtocolsCompatible(7, 8));
+ }
+
+ [Fact]
+ public void SharedCanonicalOptionsCannotBeMutatedByConsumers()
+ {
+ Assert.True(ContractJson.Options.IsReadOnly);
+ Assert.Throws(() =>
+ ContractJson.Options.WriteIndented = true);
+ }
+}
diff --git a/tests/FinalFactory.Rendezvous.Tests/Contracts/ContractTestFiles.cs b/tests/FinalFactory.Rendezvous.Tests/Contracts/ContractTestFiles.cs
new file mode 100644
index 0000000..198f6e7
--- /dev/null
+++ b/tests/FinalFactory.Rendezvous.Tests/Contracts/ContractTestFiles.cs
@@ -0,0 +1,30 @@
+namespace FinalFactory.Rendezvous.Tests.Contracts;
+
+internal static class ContractTestFiles
+{
+ public static string Read(string fileName) => File
+ .ReadAllText(Path.Combine(Directory, fileName))
+ .TrimEnd('\r', '\n');
+
+ public static string Directory
+ {
+ get
+ {
+ DirectoryInfo? directory = new(AppContext.BaseDirectory);
+ while (directory is not null)
+ {
+ string solution = Path.Combine(directory.FullName, "Rendezvous.slnx");
+ if (File.Exists(solution))
+ {
+ return Path.Combine(
+ directory.FullName,
+ "tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1");
+ }
+
+ directory = directory.Parent;
+ }
+
+ throw new DirectoryNotFoundException("Could not locate contract test data.");
+ }
+ }
+}
diff --git a/tests/FinalFactory.Rendezvous.Tests/Contracts/OpenApiCompatibilityTests.cs b/tests/FinalFactory.Rendezvous.Tests/Contracts/OpenApiCompatibilityTests.cs
new file mode 100644
index 0000000..55ac51b
--- /dev/null
+++ b/tests/FinalFactory.Rendezvous.Tests/Contracts/OpenApiCompatibilityTests.cs
@@ -0,0 +1,68 @@
+using System.Text.Json;
+
+namespace FinalFactory.Rendezvous.Tests.Contracts;
+
+public sealed class OpenApiCompatibilityTests
+{
+ private static readonly string[] ExpectedPaths =
+ [
+ "/health/live",
+ "/health/ready",
+ "/v1/join-attempts",
+ "/v1/join-attempts/{attemptId}/outcome",
+ "/v1/sessions",
+ "/v1/sessions/{listingId}",
+ "/v1/sessions/{listingId}/join-attempts",
+ "/v1/sessions/{listingId}/renew",
+ ];
+
+ private static readonly string[] ExpectedListingProperties =
+ [
+ "buildVersion",
+ "capacity",
+ "contractVersion",
+ "displayName",
+ "environmentId",
+ "gameId",
+ "listingId",
+ "metadata",
+ "protocolVersion",
+ "publisherTrustMode",
+ "regionId",
+ "visibility",
+ ];
+
+ [Fact]
+ public void GeneratedOpenApiContainsTheFrozenV1Surface()
+ {
+ string path = Path.Combine(
+ ContractTestFiles.Directory,
+ "../../../../../docs/api/rendezvous-v1.json");
+ using JsonDocument document = JsonDocument.Parse(File.ReadAllText(Path.GetFullPath(path)));
+ JsonElement root = document.RootElement;
+
+ Assert.Equal("3.1.1", root.GetProperty("openapi").GetString());
+ string[] paths = root.GetProperty("paths")
+ .EnumerateObject()
+ .Select(static item => item.Name)
+ .Order(StringComparer.Ordinal)
+ .ToArray();
+ Assert.Equal(ExpectedPaths, paths);
+
+ JsonElement schemas = root.GetProperty("components").GetProperty("schemas");
+ Assert.Equal("string", schemas.GetProperty("GameId").GetProperty("type").GetString());
+ Assert.Equal("uuid", schemas.GetProperty("SessionListingId").GetProperty("format").GetString());
+
+ string[] listingProperties = schemas.GetProperty("SessionListing")
+ .GetProperty("properties")
+ .EnumerateObject()
+ .Select(static item => item.Name)
+ .Order(StringComparer.Ordinal)
+ .ToArray();
+ Assert.Equal(ExpectedListingProperties, listingProperties);
+ Assert.DoesNotContain(listingProperties, static property =>
+ property.Contains("token", StringComparison.OrdinalIgnoreCase)
+ || property.Contains("endpoint", StringComparison.OrdinalIgnoreCase)
+ || property.Contains("playerId", StringComparison.OrdinalIgnoreCase));
+ }
+}
diff --git a/tests/FinalFactory.Rendezvous.Tests/Contracts/PublicApiCompatibilityTests.cs b/tests/FinalFactory.Rendezvous.Tests/Contracts/PublicApiCompatibilityTests.cs
new file mode 100644
index 0000000..76da70d
--- /dev/null
+++ b/tests/FinalFactory.Rendezvous.Tests/Contracts/PublicApiCompatibilityTests.cs
@@ -0,0 +1,103 @@
+using System.Reflection;
+using FinalFactory.Rendezvous.Contracts;
+
+namespace FinalFactory.Rendezvous.Tests.Contracts;
+
+public sealed class PublicApiCompatibilityTests
+{
+ [Fact]
+ public void ContractsPublicApiMatchesTheV1Snapshot()
+ {
+ string snapshot = CreateSnapshot(typeof(ContractLimits).Assembly);
+ string expected = ContractTestFiles.Read("contracts-public-api.txt");
+ if (expected == "SNAPSHOT_PENDING"
+ && Environment.GetEnvironmentVariable("RENDEZVOUS_UPDATE_CONTRACT_SNAPSHOT") == "1")
+ {
+ string snapshotPath = Path.Combine(
+ ContractTestFiles.Directory,
+ "contracts-public-api.txt");
+ File.WriteAllText(snapshotPath, snapshot + Environment.NewLine);
+ expected = snapshot;
+ }
+
+ Assert.Equal(expected, snapshot);
+ }
+
+ private static string CreateSnapshot(Assembly assembly)
+ {
+ List lines = [];
+ foreach (Type type in assembly.GetExportedTypes().OrderBy(static type => type.FullName, StringComparer.Ordinal))
+ {
+ lines.Add($"TYPE {FormatType(type)}");
+ if (type.IsEnum)
+ {
+ foreach (string name in Enum.GetNames(type))
+ {
+ object value = Enum.Parse(type, name);
+ lines.Add($" ENUM {name}={Convert.ToInt64(value, System.Globalization.CultureInfo.InvariantCulture)}");
+ }
+
+ continue;
+ }
+
+ foreach (FieldInfo field in type.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)
+ .OrderBy(static field => field.Name, StringComparer.Ordinal))
+ {
+ string value = field.IsLiteral
+ ? Convert.ToString(field.GetRawConstantValue(), System.Globalization.CultureInfo.InvariantCulture) ?? "null"
+ : "non-literal";
+ lines.Add($" FIELD {FormatType(field.FieldType)} {field.Name}={value}");
+ }
+
+ foreach (ConstructorInfo constructor in type.GetConstructors(BindingFlags.Public | BindingFlags.Instance)
+ .OrderBy(static constructor => FormatParameters(constructor.GetParameters()), StringComparer.Ordinal))
+ {
+ lines.Add($" CTOR ({FormatParameters(constructor.GetParameters())})");
+ }
+
+ 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)}";
+ lines.Add($" PROP {FormatType(property.PropertyType)} {property.Name} {{{accessors}}}");
+ }
+
+ 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)
+ .ThenBy(static method => FormatParameters(method.GetParameters()), StringComparer.Ordinal))
+ {
+ lines.Add($" METHOD {FormatType(method.ReturnType)} {method.Name}({FormatParameters(method.GetParameters())})");
+ }
+ }
+
+ return string.Join('\n', lines);
+ }
+
+ private static string FormatParameters(ParameterInfo[] parameters) => string.Join(
+ ", ",
+ parameters.Select(static parameter =>
+ $"{FormatType(parameter.ParameterType)} {parameter.Name}"));
+
+ private static string FormatType(Type type)
+ {
+ if (type.IsByRef)
+ {
+ return $"{FormatType(type.GetElementType()!)}&";
+ }
+
+ if (type.IsArray)
+ {
+ return $"{FormatType(type.GetElementType()!)}[]";
+ }
+
+ if (type.IsGenericType)
+ {
+ string name = type.GetGenericTypeDefinition().FullName!;
+ name = name[..name.IndexOf('`')];
+ return $"{name}<{string.Join(",", type.GetGenericArguments().Select(FormatType))}>";
+ }
+
+ return type.FullName ?? type.Name;
+ }
+}
diff --git a/tests/FinalFactory.Rendezvous.Tests/Contracts/UdpCodecTests.cs b/tests/FinalFactory.Rendezvous.Tests/Contracts/UdpCodecTests.cs
new file mode 100644
index 0000000..3ae04a4
--- /dev/null
+++ b/tests/FinalFactory.Rendezvous.Tests/Contracts/UdpCodecTests.cs
@@ -0,0 +1,101 @@
+using FinalFactory.Rendezvous.Contracts;
+
+namespace FinalFactory.Rendezvous.Tests.Contracts;
+
+public sealed class UdpCodecTests
+{
+ private static readonly Guid Handle = new("00112233-4455-6677-8899-aabbccddeeff");
+
+ [Fact]
+ public void HostPresenceMatchesTheV1GoldenVector()
+ {
+ byte[] encoded = RendezvousUdpCodec.Encode(CreateDatagram());
+
+ Assert.Equal(ContractTestFiles.Read("udp-host-ipv4.hex"), Convert.ToHexString(encoded).ToLowerInvariant());
+ Assert.True(RendezvousUdpCodec.TryDecode(encoded, out PresenceDatagram? decoded, out UdpDecodeError error));
+ Assert.Equal(UdpDecodeError.None, error);
+ Assert.NotNull(decoded);
+ Assert.Equal(Handle, decoded.MediationHandle.Value);
+ Assert.Equal("192.0.2.10", decoded.LocalAddress);
+ Assert.Equal(9050, decoded.LocalPort);
+ Assert.Equal("Abc_123-xYz", decoded.Capability);
+ }
+
+ [Fact]
+ public void Ipv6RoundtripsWithoutLosingItsAddressFamily()
+ {
+ PresenceDatagram original = CreateDatagram();
+ original.MessageType = UdpPresenceMessageType.ClientPresence;
+ original.AddressFamily = AddressFamilyKind.Ipv6;
+ original.LocalAddress = "2001:db8::10";
+
+ byte[] encoded = RendezvousUdpCodec.Encode(original);
+
+ Assert.True(RendezvousUdpCodec.TryDecode(encoded, out PresenceDatagram? decoded, out _));
+ Assert.NotNull(decoded);
+ Assert.Equal(AddressFamilyKind.Ipv6, decoded.AddressFamily);
+ Assert.Equal("2001:db8::10", decoded.LocalAddress);
+ }
+
+ [Fact]
+ public void EncoderRejectsAnAddressFamilyMismatch()
+ {
+ PresenceDatagram datagram = CreateDatagram();
+ datagram.AddressFamily = AddressFamilyKind.Ipv4;
+ datagram.LocalAddress = "2001:db8::10";
+
+ Assert.Throws(() => RendezvousUdpCodec.Encode(datagram));
+ }
+
+ [Theory]
+ [InlineData(2, 2, UdpDecodeError.UnsupportedVersion)]
+ [InlineData(3, 3, UdpDecodeError.UnknownMessageType)]
+ [InlineData(4, 1, UdpDecodeError.InvalidFlags)]
+ [InlineData(21, 5, UdpDecodeError.InvalidAddressFamily)]
+ public void DecoderReturnsStableErrorsForUnknownHeaderValues(
+ int offset,
+ byte replacement,
+ UdpDecodeError expected)
+ {
+ byte[] encoded = RendezvousUdpCodec.Encode(CreateDatagram());
+ encoded[offset] = replacement;
+
+ Assert.False(RendezvousUdpCodec.TryDecode(encoded, out _, out UdpDecodeError actual));
+ Assert.Equal(expected, actual);
+ }
+
+ [Fact]
+ public void DecoderRejectsTruncationTrailingDataAndOversizedPackets()
+ {
+ byte[] encoded = RendezvousUdpCodec.Encode(CreateDatagram());
+ byte[] trailing = [.. encoded, 0];
+ byte[] oversized = new byte[ContractLimits.UdpDatagramMaxBytes + 1];
+
+ Assert.False(RendezvousUdpCodec.TryDecode(encoded.AsSpan(0, encoded.Length - 1), out _, out UdpDecodeError truncated));
+ Assert.Equal(UdpDecodeError.Truncated, truncated);
+ Assert.False(RendezvousUdpCodec.TryDecode(trailing, out _, out UdpDecodeError trailingError));
+ Assert.Equal(UdpDecodeError.TrailingData, trailingError);
+ Assert.False(RendezvousUdpCodec.TryDecode(oversized, out _, out UdpDecodeError oversizedError));
+ Assert.Equal(UdpDecodeError.DatagramTooLarge, oversizedError);
+ }
+
+ [Fact]
+ public void DecoderRejectsNonBase64UrlCapabilities()
+ {
+ byte[] encoded = RendezvousUdpCodec.Encode(CreateDatagram());
+ encoded[^1] = (byte)'+';
+
+ Assert.False(RendezvousUdpCodec.TryDecode(encoded, out _, out UdpDecodeError error));
+ Assert.Equal(UdpDecodeError.InvalidCapability, error);
+ }
+
+ private static PresenceDatagram CreateDatagram() => new()
+ {
+ MessageType = UdpPresenceMessageType.HostPresence,
+ MediationHandle = new MediationHandle(Handle),
+ AddressFamily = AddressFamilyKind.Ipv4,
+ LocalAddress = "192.0.2.10",
+ LocalPort = 9050,
+ Capability = "Abc_123-xYz",
+ };
+}
diff --git a/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/api-error.json b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/api-error.json
new file mode 100644
index 0000000..e59f0ee
--- /dev/null
+++ b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/api-error.json
@@ -0,0 +1 @@
+{"contractVersion":1,"code":"rateLimited","message":"Try again later.","correlationId":"request-001","retryAfterSeconds":3}
diff --git a/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/browse-sessions.json b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/browse-sessions.json
new file mode 100644
index 0000000..2b6055b
--- /dev/null
+++ b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/browse-sessions.json
@@ -0,0 +1 @@
+{"contractVersion":1,"items":[{"contractVersion":1,"listingId":"00112233-4455-6677-8899-aabbccddeeff","gameId":"space-game","environmentId":"production","regionId":"eu-central","protocolVersion":7,"buildVersion":"1.4.2","displayName":"Europa Relay","visibility":"public","publisherTrustMode":"managedDedicated","capacity":{"currentPlayers":2,"maximumPlayers":8},"metadata":{"mode":"co-op","map":"europa"}}],"nextCursor":"cursor-002"}
diff --git a/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/contracts-public-api.txt b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/contracts-public-api.txt
new file mode 100644
index 0000000..cec8d9b
--- /dev/null
+++ b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/contracts-public-api.txt
@@ -0,0 +1,315 @@
+TYPE FinalFactory.Rendezvous.Contracts.AddressFamilyKind
+ ENUM Ipv4=4
+ ENUM Ipv6=6
+TYPE FinalFactory.Rendezvous.Contracts.ApiError
+ CTOR ()
+ PROP FinalFactory.Rendezvous.Contracts.RendezvousErrorCode Code {get;set;}
+ PROP System.Int32 ContractVersion {get;set;}
+ PROP System.String CorrelationId {get;set;}
+ PROP System.String Message {get;set;}
+ PROP System.Nullable RetryAfterSeconds {get;set;}
+TYPE FinalFactory.Rendezvous.Contracts.BrowseHostJoinAttemptsResponse
+ CTOR ()
+ PROP System.Int32 ContractVersion {get;set;}
+ PROP System.Collections.Generic.List Items {get;set;}
+ PROP System.String NextCursor {get;set;}
+TYPE FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest
+ CTOR ()
+ PROP System.Int32 ContractVersion {get;set;}
+ PROP System.String Cursor {get;set;}
+ PROP FinalFactory.Rendezvous.Contracts.EnvironmentId EnvironmentId {get;set;}
+ PROP FinalFactory.Rendezvous.Contracts.GameId GameId {get;set;}
+ PROP System.Int32 PageSize {get;set;}
+ PROP System.UInt32 ProtocolVersion {get;set;}
+ PROP System.Nullable RegionId {get;set;}
+TYPE FinalFactory.Rendezvous.Contracts.BrowseSessionsResponse
+ CTOR ()
+ PROP System.Int32 ContractVersion {get;set;}
+ PROP System.Collections.Generic.List Items {get;set;}
+ PROP System.String NextCursor {get;set;}
+TYPE FinalFactory.Rendezvous.Contracts.ConnectionOutcomeKind
+ ENUM Connected=1
+ ENUM Cancelled=2
+ ENUM TimedOut=3
+ ENUM IncompatibleProtocol=4
+ ENUM StaleHost=5
+ ENUM ServiceRejected=6
+ ENUM HostRejected=7
+ ENUM TransportFailed=8
+ ENUM FallbackOffered=9
+TYPE FinalFactory.Rendezvous.Contracts.ContractJson
+ PROP System.Text.Json.JsonSerializerOptions Options {get;}
+ METHOD System.Void Configure(System.Text.Json.JsonSerializerOptions options)
+ METHOD System.Text.Json.JsonSerializerOptions CreateOptions()
+TYPE FinalFactory.Rendezvous.Contracts.ContractLimits
+ FIELD System.Int32 BrowserPageMaxItems=100
+ FIELD System.Int32 BrowserResponseMaxBytes=262144
+ FIELD System.Int32 BuildVersionMaxBytes=64
+ FIELD System.Int32 ConnectionTicketMaxCharacters=192
+ FIELD System.Int32 ContractVersion=1
+ FIELD System.Int32 CursorMaxCharacters=512
+ FIELD System.Int32 DiagnosticCodeMaxCharacters=64
+ FIELD System.Int32 DisplayNameMaxBytes=128
+ FIELD System.Int32 EnvironmentIdMaxCharacters=32
+ FIELD System.Int32 ErrorMessageMaxBytes=256
+ FIELD System.Int32 GameIdMaxCharacters=64
+ FIELD System.Int32 HttpRequestMaxBytes=16384
+ FIELD System.Int32 IdempotencyKeyMaxCharacters=64
+ FIELD System.Int32 LiteNetLibNatTokenMaxCharacters=256
+ FIELD System.Int32 MetadataKeyMaxBytes=64
+ FIELD System.Int32 MetadataMaxBytes=4096
+ FIELD System.Int32 MetadataMaxKeys=32
+ FIELD System.Int32 MetadataValueMaxBytes=256
+ FIELD System.Int32 OpaqueHttpCredentialMaxCharacters=1024
+ FIELD System.Int32 RegionIdMaxCharacters=32
+ FIELD System.Int32 SessionCapacityMaxPlayers=10000
+ FIELD System.Int32 UdpCapabilityMaxCharacters=192
+ FIELD System.Int32 UdpDatagramMaxBytes=1200
+TYPE FinalFactory.Rendezvous.Contracts.ContractValidation
+ METHOD System.Boolean AreProtocolsCompatible(System.UInt32 requested, System.UInt32 offered)
+ METHOD System.Boolean IsBrowserResponseSizeValid(System.Int32 byteCount)
+ METHOD System.Boolean IsBuildVersionValid(System.String value)
+ METHOD System.Boolean IsCapabilityValid(System.String capability)
+ METHOD System.Boolean IsCapacityValid(FinalFactory.Rendezvous.Contracts.SessionCapacity capacity)
+ METHOD System.Boolean IsConnectionTicketValid(System.String ticket)
+ METHOD System.Boolean IsCursorValid(System.String value)
+ METHOD System.Boolean IsDiagnosticCodeValid(System.String value)
+ METHOD System.Boolean IsDisplayNameValid(System.String value)
+ METHOD System.Boolean IsHttpRequestSizeValid(System.Int32 byteCount)
+ METHOD System.Boolean IsIdempotencyKeyValid(System.String value)
+ METHOD System.Boolean IsMetadataValid(System.Collections.Generic.IReadOnlyDictionary metadata)
+ METHOD System.Boolean IsNetworkEndpointValid(FinalFactory.Rendezvous.Contracts.NetworkEndpoint endpoint)
+ METHOD System.Boolean IsOpaqueHttpCredentialValid(System.String value)
+ METHOD System.Boolean IsPageSizeValid(System.Int32 pageSize)
+ METHOD System.Boolean IsUtf8LengthWithin(System.String value, System.Int32 maximumBytes)
+ METHOD FinalFactory.Rendezvous.Contracts.RendezvousErrorCode ValidateContractVersion(System.Int32 contractVersion)
+TYPE FinalFactory.Rendezvous.Contracts.CreateJoinAttemptRequest
+ CTOR ()
+ PROP System.Int32 ContractVersion {get;set;}
+ PROP FinalFactory.Rendezvous.Contracts.EnvironmentId EnvironmentId {get;set;}
+ PROP FinalFactory.Rendezvous.Contracts.GameId GameId {get;set;}
+ PROP System.String IdempotencyKey {get;set;}
+ PROP FinalFactory.Rendezvous.Contracts.SessionListingId ListingId {get;set;}
+ PROP System.UInt32 ProtocolVersion {get;set;}
+TYPE FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse
+ CTOR ()
+ PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;set;}
+ PROP System.String ClientPunchCapability {get;set;}
+ PROP System.Int32 ContractVersion {get;set;}
+ PROP FinalFactory.Rendezvous.Contracts.NetworkEndpoint DedicatedFallback {get;set;}
+ PROP System.DateTimeOffset ExpiresAt {get;set;}
+ PROP FinalFactory.Rendezvous.Contracts.MediationHandle MediationHandle {get;set;}
+TYPE FinalFactory.Rendezvous.Contracts.DeleteSessionRequest
+ CTOR ()
+ PROP System.Int32 ContractVersion {get;set;}
+ PROP System.String LeaseToken {get;set;}
+TYPE FinalFactory.Rendezvous.Contracts.EnvironmentId
+ CTOR (System.String value)
+ PROP System.String Value {get;}
+ METHOD System.Boolean Equals(FinalFactory.Rendezvous.Contracts.EnvironmentId other)
+ METHOD System.Boolean Equals(System.Object obj)
+ METHOD System.Int32 GetHashCode()
+ METHOD System.String ToString()
+ METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.EnvironmentId& environmentId)
+ METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.EnvironmentId left, FinalFactory.Rendezvous.Contracts.EnvironmentId right)
+ METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.EnvironmentId left, FinalFactory.Rendezvous.Contracts.EnvironmentId right)
+TYPE FinalFactory.Rendezvous.Contracts.GameId
+ CTOR (System.String value)
+ PROP System.String Value {get;}
+ METHOD System.Boolean Equals(FinalFactory.Rendezvous.Contracts.GameId other)
+ METHOD System.Boolean Equals(System.Object obj)
+ METHOD System.Int32 GetHashCode()
+ METHOD System.String ToString()
+ METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.GameId& gameId)
+ METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.GameId left, FinalFactory.Rendezvous.Contracts.GameId right)
+ METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.GameId left, FinalFactory.Rendezvous.Contracts.GameId right)
+TYPE FinalFactory.Rendezvous.Contracts.GetSessionResponse
+ CTOR ()
+ PROP System.Int32 ContractVersion {get;set;}
+ PROP FinalFactory.Rendezvous.Contracts.SessionListing Session {get;set;}
+TYPE FinalFactory.Rendezvous.Contracts.HealthResponse
+ CTOR ()
+ PROP System.Int32 ContractVersion {get;set;}
+ PROP System.String Status {get;set;}
+TYPE FinalFactory.Rendezvous.Contracts.HostJoinAttempt
+ CTOR ()
+ PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;set;}
+ PROP System.DateTimeOffset ExpiresAt {get;set;}
+ PROP System.String HostPunchCapability {get;set;}
+ PROP FinalFactory.Rendezvous.Contracts.MediationHandle MediationHandle {get;set;}
+TYPE FinalFactory.Rendezvous.Contracts.JoinAttemptId
+ CTOR (System.Guid value)
+ PROP System.Guid Value {get;}
+ METHOD System.Boolean Equals(FinalFactory.Rendezvous.Contracts.JoinAttemptId other)
+ METHOD System.Boolean Equals(System.Object obj)
+ METHOD System.Int32 GetHashCode()
+ METHOD System.String ToString()
+ METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.JoinAttemptId& id)
+ METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.JoinAttemptId left, FinalFactory.Rendezvous.Contracts.JoinAttemptId right)
+ METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.JoinAttemptId left, FinalFactory.Rendezvous.Contracts.JoinAttemptId right)
+TYPE FinalFactory.Rendezvous.Contracts.LeaseId
+ CTOR (System.Guid value)
+ PROP System.Guid Value {get;}
+ METHOD System.Boolean Equals(FinalFactory.Rendezvous.Contracts.LeaseId other)
+ METHOD System.Boolean Equals(System.Object obj)
+ METHOD System.Int32 GetHashCode()
+ METHOD System.String ToString()
+ METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.LeaseId& id)
+ METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.LeaseId left, FinalFactory.Rendezvous.Contracts.LeaseId right)
+ METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.LeaseId left, FinalFactory.Rendezvous.Contracts.LeaseId right)
+TYPE FinalFactory.Rendezvous.Contracts.ListingVisibility
+ ENUM Public=1
+ ENUM Unlisted=2
+TYPE FinalFactory.Rendezvous.Contracts.MediationHandle
+ CTOR (System.Guid value)
+ PROP System.Guid Value {get;}
+ METHOD System.Boolean Equals(FinalFactory.Rendezvous.Contracts.MediationHandle other)
+ METHOD System.Boolean Equals(System.Object obj)
+ METHOD System.Int32 GetHashCode()
+ METHOD System.String ToString()
+ METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.MediationHandle& id)
+ METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.MediationHandle left, FinalFactory.Rendezvous.Contracts.MediationHandle right)
+ METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.MediationHandle left, FinalFactory.Rendezvous.Contracts.MediationHandle right)
+TYPE FinalFactory.Rendezvous.Contracts.NetworkEndpoint
+ CTOR ()
+ PROP System.String Address {get;set;}
+ PROP FinalFactory.Rendezvous.Contracts.AddressFamilyKind AddressFamily {get;set;}
+ PROP System.Int32 Port {get;set;}
+TYPE FinalFactory.Rendezvous.Contracts.PresenceDatagram
+ CTOR ()
+ PROP FinalFactory.Rendezvous.Contracts.AddressFamilyKind AddressFamily {get;set;}
+ PROP System.String Capability {get;set;}
+ PROP System.Int32 ContractVersion {get;set;}
+ PROP System.String LocalAddress {get;set;}
+ PROP System.Int32 LocalPort {get;set;}
+ PROP FinalFactory.Rendezvous.Contracts.MediationHandle MediationHandle {get;set;}
+ PROP FinalFactory.Rendezvous.Contracts.UdpPresenceMessageType MessageType {get;set;}
+TYPE FinalFactory.Rendezvous.Contracts.PublisherTrustMode
+ ENUM ManagedDedicated=1
+ ENUM PlayerGrant=2
+ ENUM AnonymousUnlisted=3
+TYPE FinalFactory.Rendezvous.Contracts.RegionId
+ CTOR (System.String value)
+ PROP System.String Value {get;}
+ METHOD System.Boolean Equals(FinalFactory.Rendezvous.Contracts.RegionId other)
+ METHOD System.Boolean Equals(System.Object obj)
+ METHOD System.Int32 GetHashCode()
+ METHOD System.String ToString()
+ METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.RegionId& regionId)
+ METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.RegionId left, FinalFactory.Rendezvous.Contracts.RegionId right)
+ METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.RegionId left, FinalFactory.Rendezvous.Contracts.RegionId right)
+TYPE FinalFactory.Rendezvous.Contracts.RegisterSessionRequest
+ CTOR ()
+ PROP System.String BuildVersion {get;set;}
+ PROP FinalFactory.Rendezvous.Contracts.SessionCapacity Capacity {get;set;}
+ PROP System.Int32 ContractVersion {get;set;}
+ PROP System.String DisplayName {get;set;}
+ PROP FinalFactory.Rendezvous.Contracts.EnvironmentId EnvironmentId {get;set;}
+ PROP FinalFactory.Rendezvous.Contracts.GameId GameId {get;set;}
+ PROP System.String IdempotencyKey {get;set;}
+ PROP System.Collections.Generic.Dictionary Metadata {get;set;}
+ PROP System.UInt32 ProtocolVersion {get;set;}
+ PROP FinalFactory.Rendezvous.Contracts.RegionId RegionId {get;set;}
+ PROP FinalFactory.Rendezvous.Contracts.ListingVisibility Visibility {get;set;}
+TYPE FinalFactory.Rendezvous.Contracts.RegisterSessionResponse
+ CTOR ()
+ PROP System.Int32 ContractVersion {get;set;}
+ PROP System.DateTimeOffset ExpiresAt {get;set;}
+ PROP System.String HostPresenceCapability {get;set;}
+ PROP FinalFactory.Rendezvous.Contracts.MediationHandle HostPresenceHandle {get;set;}
+ PROP FinalFactory.Rendezvous.Contracts.LeaseId LeaseId {get;set;}
+ PROP System.String LeaseToken {get;set;}
+ PROP FinalFactory.Rendezvous.Contracts.SessionListingId ListingId {get;set;}
+TYPE FinalFactory.Rendezvous.Contracts.RendezvousErrorCode
+ ENUM None=0
+ ENUM InvalidRequest=1
+ ENUM UnsupportedContractVersion=2
+ ENUM IncompatibleProtocol=3
+ ENUM AuthenticationRequired=4
+ ENUM Forbidden=5
+ ENUM NotFound=6
+ ENUM Conflict=7
+ ENUM RateLimited=8
+ ENUM StaleHost=9
+ ENUM Expired=10
+ ENUM ReplayRejected=11
+ ENUM CapacityExceeded=12
+ ENUM ServiceUnavailable=13
+ ENUM InternalError=14
+TYPE FinalFactory.Rendezvous.Contracts.RendezvousUdpCodec
+ FIELD System.Byte FlagsNone=0
+ FIELD System.Byte MagicFirst=82
+ FIELD System.Byte MagicSecond=86
+ METHOD System.Byte[] Encode(FinalFactory.Rendezvous.Contracts.PresenceDatagram datagram)
+ METHOD System.Boolean TryDecode(System.ReadOnlySpan encoded, FinalFactory.Rendezvous.Contracts.PresenceDatagram& datagram, FinalFactory.Rendezvous.Contracts.UdpDecodeError& error)
+TYPE FinalFactory.Rendezvous.Contracts.RenewLeaseRequest
+ CTOR ()
+ PROP System.Int32 ContractVersion {get;set;}
+ PROP System.String LeaseToken {get;set;}
+TYPE FinalFactory.Rendezvous.Contracts.RenewLeaseResponse
+ CTOR ()
+ PROP System.Int32 ContractVersion {get;set;}
+ PROP System.DateTimeOffset ExpiresAt {get;set;}
+TYPE FinalFactory.Rendezvous.Contracts.ReportConnectionOutcomeRequest
+ CTOR ()
+ PROP System.Int32 ContractVersion {get;set;}
+ PROP System.String DiagnosticCode {get;set;}
+ PROP System.Int32 ElapsedMilliseconds {get;set;}
+ PROP FinalFactory.Rendezvous.Contracts.ConnectionOutcomeKind Outcome {get;set;}
+TYPE FinalFactory.Rendezvous.Contracts.ReportConnectionOutcomeResponse
+ CTOR ()
+ PROP System.Boolean Accepted {get;set;}
+ PROP System.Int32 ContractVersion {get;set;}
+TYPE FinalFactory.Rendezvous.Contracts.SessionCapacity
+ CTOR ()
+ PROP System.Int32 CurrentPlayers {get;set;}
+ PROP System.Int32 MaximumPlayers {get;set;}
+TYPE FinalFactory.Rendezvous.Contracts.SessionListing
+ CTOR ()
+ PROP System.String BuildVersion {get;set;}
+ PROP FinalFactory.Rendezvous.Contracts.SessionCapacity Capacity {get;set;}
+ PROP System.Int32 ContractVersion {get;set;}
+ PROP System.String DisplayName {get;set;}
+ PROP FinalFactory.Rendezvous.Contracts.EnvironmentId EnvironmentId {get;set;}
+ PROP FinalFactory.Rendezvous.Contracts.GameId GameId {get;set;}
+ PROP FinalFactory.Rendezvous.Contracts.SessionListingId ListingId {get;set;}
+ PROP System.Collections.Generic.Dictionary Metadata {get;set;}
+ PROP System.UInt32 ProtocolVersion {get;set;}
+ PROP FinalFactory.Rendezvous.Contracts.PublisherTrustMode PublisherTrustMode {get;set;}
+ PROP FinalFactory.Rendezvous.Contracts.RegionId RegionId {get;set;}
+ PROP FinalFactory.Rendezvous.Contracts.ListingVisibility Visibility {get;set;}
+TYPE FinalFactory.Rendezvous.Contracts.SessionListingId
+ CTOR (System.Guid value)
+ PROP System.Guid Value {get;}
+ METHOD System.Boolean Equals(FinalFactory.Rendezvous.Contracts.SessionListingId other)
+ METHOD System.Boolean Equals(System.Object obj)
+ METHOD System.Int32 GetHashCode()
+ METHOD System.String ToString()
+ METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.SessionListingId& id)
+ METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.SessionListingId left, FinalFactory.Rendezvous.Contracts.SessionListingId right)
+ METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.SessionListingId left, FinalFactory.Rendezvous.Contracts.SessionListingId right)
+TYPE FinalFactory.Rendezvous.Contracts.UdpDecodeError
+ ENUM None=0
+ ENUM DatagramTooLarge=1
+ ENUM Truncated=2
+ ENUM InvalidMagic=3
+ ENUM UnsupportedVersion=4
+ ENUM UnknownMessageType=5
+ ENUM InvalidFlags=6
+ ENUM InvalidHandle=7
+ ENUM InvalidAddressFamily=8
+ ENUM InvalidAddress=9
+ ENUM InvalidPort=10
+ ENUM InvalidCapability=11
+ ENUM TrailingData=12
+TYPE FinalFactory.Rendezvous.Contracts.UdpPresenceMessageType
+ ENUM HostPresence=1
+ ENUM ClientPresence=2
+TYPE FinalFactory.Rendezvous.Contracts.UpdateSessionRequest
+ CTOR ()
+ PROP System.String BuildVersion {get;set;}
+ PROP FinalFactory.Rendezvous.Contracts.SessionCapacity Capacity {get;set;}
+ PROP System.Int32 ContractVersion {get;set;}
+ PROP System.String DisplayName {get;set;}
+ PROP System.String LeaseToken {get;set;}
+ PROP System.Collections.Generic.Dictionary Metadata {get;set;}
diff --git a/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/create-join-response.json b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/create-join-response.json
new file mode 100644
index 0000000..41280de
--- /dev/null
+++ b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/create-join-response.json
@@ -0,0 +1 @@
+{"contractVersion":1,"attemptId":"11112233-4455-6677-8899-aabbccddeeff","mediationHandle":"22222233-4455-6677-8899-aabbccddeeff","clientPunchCapability":"Abc_123-xYz","expiresAt":"2026-07-16T12:00:00+00:00","dedicatedFallback":{"addressFamily":"ipv6","address":"2001:db8::10","port":9050}}
diff --git a/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/register-session.json b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/register-session.json
new file mode 100644
index 0000000..b9ea758
--- /dev/null
+++ b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/register-session.json
@@ -0,0 +1 @@
+{"contractVersion":1,"idempotencyKey":"register-001","gameId":"space-game","environmentId":"production","regionId":"eu-central","protocolVersion":7,"buildVersion":"1.4.2","displayName":"Europa Relay","visibility":"public","capacity":{"currentPlayers":2,"maximumPlayers":8},"metadata":{"mode":"co-op","map":"europa"}}
diff --git a/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/udp-host-ipv4.hex b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/udp-host-ipv4.hex
new file mode 100644
index 0000000..cd3c248
--- /dev/null
+++ b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/udp-host-ipv4.hex
@@ -0,0 +1 @@
+525601010000112233445566778899aabbccddeeff0404c000020a235a0b4162635f3132332d78597a
diff --git a/tests/FinalFactory.Rendezvous.Tests/packages.lock.json b/tests/FinalFactory.Rendezvous.Tests/packages.lock.json
index bbf07c8..be9432e 100644
--- a/tests/FinalFactory.Rendezvous.Tests/packages.lock.json
+++ b/tests/FinalFactory.Rendezvous.Tests/packages.lock.json
@@ -107,7 +107,8 @@
"type": "Project",
"dependencies": {
"FinalFactory.Rendezvous.Contracts": "[1.0.0, )",
- "LiteNetLib": "[2.1.4, )"
+ "LiteNetLib": "[2.1.4, )",
+ "Microsoft.AspNetCore.OpenApi": "[10.0.9, )"
}
},
"finalfactory.rendezvous.testclient": {
@@ -123,6 +124,21 @@
"requested": "[2.1.4, )",
"resolved": "2.1.4",
"contentHash": "KWlxvMw3Urpqj9joD96LRiK+LC62pQNs/zkXRJc+rHnxgkGp+vV703xzDrxRmv+V1YhCFfIGzs5nrVWtREIlyA=="
+ },
+ "Microsoft.AspNetCore.OpenApi": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.9, )",
+ "resolved": "10.0.9",
+ "contentHash": "1ihb8FO9cGgEK1/m3CTtT/SfnynwmiZib0W2pcDVj3KSWk/Sca4VOXEtaptKQc582zpFrzTFiwkGRCglt6H+WQ==",
+ "dependencies": {
+ "Microsoft.OpenApi": "2.0.0"
+ }
+ },
+ "Microsoft.OpenApi": {
+ "type": "CentralTransitive",
+ "requested": "[2.7.5, )",
+ "resolved": "2.7.5",
+ "contentHash": "0FA67RSnRM4tcBKqiqVu/HPdZ9+QOKbmeRjxRUGTCjPU4C0bmUhd97Dso7Yild5P7nOV6GxJ2xrK0Kv/O9xp0w=="
}
}
}