From 69c8b2d2bc48bbaa531a7a65b216759c14ffe73b Mon Sep 17 00:00:00 2001 From: KyuubiYoru Date: Thu, 16 Jul 2026 04:52:38 +0200 Subject: [PATCH] feat: freeze v1 transport contracts (#4) Closes #4 --- .gitea/workflows/ci.yml | 3 + Directory.Packages.props | 4 + README.md | 2 + docs/api/rendezvous-v1.json | 1131 +++++++++++++++++ docs/architecture/README.md | 1 + docs/architecture/project-boundaries.md | 12 +- docs/contracts/README.md | 18 + docs/contracts/http-v1.md | 105 ++ docs/contracts/udp-v1.md | 53 + .../packages.lock.json | 65 +- .../ContractEnums.cs | 75 ++ .../ContractLimits.cs | 28 + .../ContractModels.cs | 47 + .../ContractValidation.cs | 134 ++ .../FinalFactory.Rendezvous.Contracts.csproj | 3 + .../Http/JoinContracts.cs | 92 ++ .../Http/SessionContracts.cs | 189 +++ .../Identifiers/OpaqueIdentifiers.cs | 112 ++ .../Identifiers/ScopeIdentifiers.cs | 126 ++ .../StringIdentifierJsonConverter.cs | 37 + .../Serialization/ContractJson.cs | 49 + .../Udp/PresenceDatagram.cs | 12 + .../Udp/RendezvousUdpCodec.cs | 281 ++++ .../packages.lock.json | 63 +- .../FinalFactory.Rendezvous.Server.csproj | 5 + .../Http/ContractEndpoints.cs | 112 ++ src/FinalFactory.Rendezvous.Server/Program.cs | 52 +- .../packages.lock.json | 21 + .../packages.lock.json | 25 +- .../Architecture/DependencyBoundaryTests.cs | 8 +- .../Contracts/ContractLimitTests.cs | 140 ++ .../Contracts/ContractSerializationTests.cs | 104 ++ .../Contracts/ContractTestFiles.cs | 30 + .../Contracts/OpenApiCompatibilityTests.cs | 68 + .../Contracts/PublicApiCompatibilityTests.cs | 103 ++ .../Contracts/UdpCodecTests.cs | 101 ++ .../TestData/Contracts/v1/api-error.json | 1 + .../Contracts/v1/browse-sessions.json | 1 + .../Contracts/v1/contracts-public-api.txt | 315 +++++ .../Contracts/v1/create-join-response.json | 1 + .../Contracts/v1/register-session.json | 1 + .../TestData/Contracts/v1/udp-host-ipv4.hex | 1 + .../packages.lock.json | 18 +- 43 files changed, 3737 insertions(+), 12 deletions(-) create mode 100644 docs/api/rendezvous-v1.json create mode 100644 docs/contracts/README.md create mode 100644 docs/contracts/http-v1.md create mode 100644 docs/contracts/udp-v1.md create mode 100644 src/FinalFactory.Rendezvous.Contracts/ContractEnums.cs create mode 100644 src/FinalFactory.Rendezvous.Contracts/ContractLimits.cs create mode 100644 src/FinalFactory.Rendezvous.Contracts/ContractModels.cs create mode 100644 src/FinalFactory.Rendezvous.Contracts/ContractValidation.cs create mode 100644 src/FinalFactory.Rendezvous.Contracts/Http/JoinContracts.cs create mode 100644 src/FinalFactory.Rendezvous.Contracts/Http/SessionContracts.cs create mode 100644 src/FinalFactory.Rendezvous.Contracts/Identifiers/OpaqueIdentifiers.cs create mode 100644 src/FinalFactory.Rendezvous.Contracts/Identifiers/ScopeIdentifiers.cs create mode 100644 src/FinalFactory.Rendezvous.Contracts/Identifiers/StringIdentifierJsonConverter.cs create mode 100644 src/FinalFactory.Rendezvous.Contracts/Serialization/ContractJson.cs create mode 100644 src/FinalFactory.Rendezvous.Contracts/Udp/PresenceDatagram.cs create mode 100644 src/FinalFactory.Rendezvous.Contracts/Udp/RendezvousUdpCodec.cs create mode 100644 src/FinalFactory.Rendezvous.Server/Http/ContractEndpoints.cs create mode 100644 tests/FinalFactory.Rendezvous.Tests/Contracts/ContractLimitTests.cs create mode 100644 tests/FinalFactory.Rendezvous.Tests/Contracts/ContractSerializationTests.cs create mode 100644 tests/FinalFactory.Rendezvous.Tests/Contracts/ContractTestFiles.cs create mode 100644 tests/FinalFactory.Rendezvous.Tests/Contracts/OpenApiCompatibilityTests.cs create mode 100644 tests/FinalFactory.Rendezvous.Tests/Contracts/PublicApiCompatibilityTests.cs create mode 100644 tests/FinalFactory.Rendezvous.Tests/Contracts/UdpCodecTests.cs create mode 100644 tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/api-error.json create mode 100644 tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/browse-sessions.json create mode 100644 tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/contracts-public-api.txt create mode 100644 tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/create-join-response.json create mode 100644 tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/register-session.json create mode 100644 tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/udp-host-ipv4.hex 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==" } } }