From 59330bdf8412dffdbe9be461596e07949f67e855 Mon Sep 17 00:00:00 2001 From: Dakroach Date: Fri, 5 Jun 2026 07:00:14 -0700 Subject: [PATCH] feat: Implement runtime API cache store and related configurations - Added RuntimeApiCacheStore for managing API cache entries in memory. - Introduced StorageTarget enum to define various storage targets. - Created StoredCacheEntry record to encapsulate cache entries. - Defined SyncState enum to represent the synchronization state of cache entries. - Added ControlPlaneConfigurationSource enum for different configuration sources. - Developed ControlPlaneRuntimeConfig class for runtime configuration management. - Implemented ControlPlaneStartupConfigResolver for resolving configuration from various sources. - Created ControlPlaneStartupSettings to hold startup configuration values. - Added ResolvedControlPlaneStartupConfig record to encapsulate resolved configuration. - Implemented tests for batch client and cache client functionalities. - Added tests for ControlPlaneStartupConfigResolver to validate configuration resolution logic. --- README.md | 25 +- build.gradle | 7 + docs/wiki/API-Response-Storage.md | 214 ++++++++++++ docs/wiki/Batched-API-Calls.md | 102 ++++++ docs/wiki/Control-Plane-Client.md | 82 +++++ docs/wiki/Generated-SDK.md | 65 ++++ docs/wiki/Getting-Started.md | 134 ++++++++ docs/wiki/Home.md | 42 +++ docs/wiki/Troubleshooting.md | 116 +++++++ .../kewwbec/network/ControlPlaneClient.java | 146 +++++++- .../net/kewwbec/network/NetworkPlugin.java | 199 ++++++++++- .../kewwbec/network/batch/BatchClient.java | 233 +++++++++++++ .../net/kewwbec/network/batch/LoopHandle.java | 7 + .../kewwbec/network/batch/LoopedApiCall.java | 127 +++++++ .../kewwbec/network/cache/ApiCacheEntry.java | 107 ++++++ .../kewwbec/network/cache/ApiCacheKey.java | 20 ++ .../network/cache/ApiCacheMetadata.java | 15 + .../kewwbec/network/cache/ApiCacheStore.java | 12 + .../network/cache/ApiDataCacheClient.java | 232 +++++++++++++ .../cache/AssetStoreApiCacheStore.java | 30 ++ .../network/cache/AssetStoreCacheAdapter.java | 22 ++ .../kewwbec/network/cache/CacheContext.java | 13 + .../kewwbec/network/cache/CachedApiCall.java | 209 ++++++++++++ .../cache/NetworkApiDataComponent.java | 58 ++++ .../network/cache/PlayerApiCacheStore.java | 41 +++ .../network/cache/PlayerCacheContext.java | 30 ++ .../net/kewwbec/network/cache/PullPolicy.java | 29 ++ .../net/kewwbec/network/cache/PushPolicy.java | 29 ++ .../network/cache/RuntimeApiCacheStore.java | 26 ++ .../kewwbec/network/cache/StorageTarget.java | 7 + .../network/cache/StoredCacheEntry.java | 4 + .../net/kewwbec/network/cache/SyncState.java | 7 + .../kewwbec/network/client/PlayersClient.java | 29 -- .../network/client/PresenceClient.java | 40 --- .../kewwbec/network/client/ServerClient.java | 18 - .../ControlPlaneConfigurationSource.java | 8 + .../config/ControlPlaneRuntimeConfig.java | 109 ++++++ .../ControlPlaneStartupConfigResolver.java | 196 +++++++++++ .../config/ControlPlaneStartupSettings.java | 75 +++++ .../ResolvedControlPlaneStartupConfig.java | 7 + .../generated/api/v1/ControlPlaneApiV1.java | 16 +- .../api/v1/server/ProfileEndpoint.java | 2 +- .../generated/api/v1/social/SocialApi.java | 5 + .../network/http/ControlPlaneHttpClient.java | 174 ++++++++-- .../models/players/PlayerEntitlement.java | 49 --- .../models/players/PlayerSessionProfile.java | 97 ------ .../models/server/HeartbeatPayload.java | 31 -- .../network/models/server/PresenceState.java | 16 - .../network/models/server/ReadinessState.java | 18 - .../models/server/RegistrationPayload.java | 37 -- .../network/models/server/ServerInstance.java | 67 ---- .../network/models/server/ServerProfile.java | 32 -- src/main/resources/manifest.json | 12 +- .../network/batch/BatchClientTest.java | 235 +++++++++++++ .../network/cache/ApiDataCacheClientTest.java | 318 ++++++++++++++++++ ...ControlPlaneStartupConfigResolverTest.java | 136 ++++++++ 56 files changed, 3627 insertions(+), 490 deletions(-) create mode 100644 docs/wiki/API-Response-Storage.md create mode 100644 docs/wiki/Batched-API-Calls.md create mode 100644 docs/wiki/Control-Plane-Client.md create mode 100644 docs/wiki/Generated-SDK.md create mode 100644 docs/wiki/Getting-Started.md create mode 100644 docs/wiki/Home.md create mode 100644 docs/wiki/Troubleshooting.md create mode 100644 src/main/java/net/kewwbec/network/batch/BatchClient.java create mode 100644 src/main/java/net/kewwbec/network/batch/LoopHandle.java create mode 100644 src/main/java/net/kewwbec/network/batch/LoopedApiCall.java create mode 100644 src/main/java/net/kewwbec/network/cache/ApiCacheEntry.java create mode 100644 src/main/java/net/kewwbec/network/cache/ApiCacheKey.java create mode 100644 src/main/java/net/kewwbec/network/cache/ApiCacheMetadata.java create mode 100644 src/main/java/net/kewwbec/network/cache/ApiCacheStore.java create mode 100644 src/main/java/net/kewwbec/network/cache/ApiDataCacheClient.java create mode 100644 src/main/java/net/kewwbec/network/cache/AssetStoreApiCacheStore.java create mode 100644 src/main/java/net/kewwbec/network/cache/AssetStoreCacheAdapter.java create mode 100644 src/main/java/net/kewwbec/network/cache/CacheContext.java create mode 100644 src/main/java/net/kewwbec/network/cache/CachedApiCall.java create mode 100644 src/main/java/net/kewwbec/network/cache/NetworkApiDataComponent.java create mode 100644 src/main/java/net/kewwbec/network/cache/PlayerApiCacheStore.java create mode 100644 src/main/java/net/kewwbec/network/cache/PlayerCacheContext.java create mode 100644 src/main/java/net/kewwbec/network/cache/PullPolicy.java create mode 100644 src/main/java/net/kewwbec/network/cache/PushPolicy.java create mode 100644 src/main/java/net/kewwbec/network/cache/RuntimeApiCacheStore.java create mode 100644 src/main/java/net/kewwbec/network/cache/StorageTarget.java create mode 100644 src/main/java/net/kewwbec/network/cache/StoredCacheEntry.java create mode 100644 src/main/java/net/kewwbec/network/cache/SyncState.java delete mode 100644 src/main/java/net/kewwbec/network/client/PlayersClient.java delete mode 100644 src/main/java/net/kewwbec/network/client/PresenceClient.java delete mode 100644 src/main/java/net/kewwbec/network/client/ServerClient.java create mode 100644 src/main/java/net/kewwbec/network/config/ControlPlaneConfigurationSource.java create mode 100644 src/main/java/net/kewwbec/network/config/ControlPlaneRuntimeConfig.java create mode 100644 src/main/java/net/kewwbec/network/config/ControlPlaneStartupConfigResolver.java create mode 100644 src/main/java/net/kewwbec/network/config/ControlPlaneStartupSettings.java create mode 100644 src/main/java/net/kewwbec/network/config/ResolvedControlPlaneStartupConfig.java delete mode 100644 src/main/java/net/kewwbec/network/models/players/PlayerEntitlement.java delete mode 100644 src/main/java/net/kewwbec/network/models/players/PlayerSessionProfile.java delete mode 100644 src/main/java/net/kewwbec/network/models/server/HeartbeatPayload.java delete mode 100644 src/main/java/net/kewwbec/network/models/server/PresenceState.java delete mode 100644 src/main/java/net/kewwbec/network/models/server/ReadinessState.java delete mode 100644 src/main/java/net/kewwbec/network/models/server/RegistrationPayload.java delete mode 100644 src/main/java/net/kewwbec/network/models/server/ServerInstance.java delete mode 100644 src/main/java/net/kewwbec/network/models/server/ServerProfile.java create mode 100644 src/test/java/net/kewwbec/network/batch/BatchClientTest.java create mode 100644 src/test/java/net/kewwbec/network/cache/ApiDataCacheClientTest.java create mode 100644 src/test/java/net/kewwbec/network/config/ControlPlaneStartupConfigResolverTest.java diff --git a/README.md b/README.md index 4ef3bd7..1b90d4d 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ The generated files are meant to make the control plane feel like a stable Java ## Generated control-plane SDK tree -The generated Java API output now lives under: +The generated Java API output lives under: ```text src/main/java/net/kewwbec/network/generated/api/v1/ @@ -48,7 +48,7 @@ What that means: - each route has its own generated `*Endpoint` file - shared descriptor records live in `shared/` -Example imports now look like: +Example imports look like: ```java import net.kewwbec.network.generated.api.v1.ControlPlaneApiV1; @@ -62,11 +62,11 @@ The handwritten bridge is still: src/main/java/net/kewwbec/network/network.java ``` -Everything under `src/main/java/net/kewwbec/network/generated` is generator-owned and should not be edited manually. +Everything under `src/main/java/net/kewwbec/network/generated` is generator-owned and should **not** be edited manually. If they need edits check the generator in the control panel repo ## How generation works -The source of truth for the generated API tree is the sibling `core-control-plane` repository. +The source of truth for the generated API tree is the `core-control-plane` repository. Run the sync from that repository: @@ -93,12 +93,27 @@ gradlew.bat build gradlew.bat runServer ``` -If the upstream Hytale server artifact is unavailable, full Gradle compilation may fail before it reaches your plugin code. In that case, validate generated Java-only slices separately and re-run once the dependency is resolvable. + +## Wiki docs + +Developer-facing wiki pages live under: + +```text +docs/wiki/ +``` + +Start with: + +- [Core Network Wiki](docs/wiki/Home.md) +- [Getting Started](docs/wiki/Getting-Started.md) +- [Batched API Calls](docs/wiki/Batched-API-Calls.md) +- [API Response Storage](docs/wiki/API-Response-Storage.md) ## Project structure ```text core-network/ + docs/wiki/ src/main/java/ net/kewwbec/network/ network.java diff --git a/build.gradle b/build.gradle index e99ef87..ec759ad 100644 --- a/build.gradle +++ b/build.gradle @@ -59,6 +59,9 @@ dependencies { compileOnly("com.hypixel.hytale:Server:$hytale_build") compileOnly("com.google.gson:gson:2.11.0") } + testImplementation("org.junit.jupiter:junit-jupiter:5.11.4") + testImplementation("com.google.code.gson:gson:2.11.0") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") } repositories { @@ -104,6 +107,10 @@ tasks.named('build') { dependsOn 'shadowJar' } +tasks.named('test') { + useJUnitPlatform() +} + if (hasHytaleHome) { // Create the working directory to run the server if it does not already exist. def serverRunDir = file("$projectDir/run") diff --git a/docs/wiki/API-Response-Storage.md b/docs/wiki/API-Response-Storage.md new file mode 100644 index 0000000..f1323f3 --- /dev/null +++ b/docs/wiki/API-Response-Storage.md @@ -0,0 +1,214 @@ +# API Response Storage + +The cache layer stores API results and controls when data should be pulled from or pushed to the control plane. + +Use it when multiple plugins need shared ownership of: + +- API response freshness +- stale fallback behavior +- player-persistent API data +- dirty local data that should be pushed later + +Entry point: + +```java +ApiDataCacheClient cache = NetworkPlugin.client().cache(); +``` + +## Storage Targets + +Every cached call explicitly chooses a target. + +```java +StorageTarget.RUNTIME_SHARED +StorageTarget.PLAYER +StorageTarget.ASSET_STORE +``` + +`RUNTIME_SHARED` + +- process-local in-memory cache +- best for server profile, config, catalog reads, active announcements +- does not survive restart + +`PLAYER` + +- stored on `NetworkApiDataComponent` +- requires a `PlayerCacheContext` +- persists with Hytale player component data + +`ASSET_STORE` + +- requires an `AssetStoreCacheAdapter` +- intended for plugins that can map API data into concrete Hytale asset types +- core-network owns freshness metadata, the adapter owns asset conversion + +## Register A Cached Call + +```java +CachedApiCall serverProfileCall = CachedApiCall + .builder(ServerProfile.class) + .id("server-profile") + .method("GET") + .uri("/api/v1/server/profile") + .storageTarget(StorageTarget.RUNTIME_SHARED) + .maxAge(Duration.ofSeconds(30)) + .build(); + +cache.register(serverProfileCall); +``` + +Read cached or pull if stale: + +```java +CompletableFuture profile = + cache.getOrPull(serverProfileCall, CacheContext.shared()); +``` + +Force refresh: + +```java +CompletableFuture profile = + cache.forcePull(serverProfileCall, CacheContext.shared()); +``` + +## Player Storage + +Player storage requires explicit context. Core-network does not infer player identity from the API URI. + +```java +PlayerCacheContext context = new PlayerCacheContext(playerId, playerRef, entityStore); + +CachedApiCall sessionProfile = CachedApiCall + .builder(PlayerSessionProfile.class) + .id("player-session-profile") + .method("GET") + .uri(() -> "/api/v1/players/" + playerId + "/session-profile") + .storageTarget(StorageTarget.PLAYER) + .maxAge(Duration.ofMinutes(5)) + .build(); + +CompletableFuture profile = + cache.getOrPull(sessionProfile, context); +``` + +`NetworkPlugin.setup()` registers `NetworkApiDataComponent` with the entity store registry. + +## Freshness Policies + +Auto-refresh when older than max age: + +```java +.maxAge(Duration.ofMinutes(5)) +``` + +Never auto-refresh: + +```java +.neverRefresh() +``` + +`forcePull(...)` always bypasses freshness checks. + +## Dirty Push Policies + +Update local data and mark it dirty: + +```java +cache.updateLocal(call, context, value); +``` + +Push immediately: + +```java +cache.forcePush(call, context); +``` + +Push after dirty TTL: + +```java +CachedApiCall call = CachedApiCall + .builder(MyModel.class) + .id("my-writeback") + .method("GET") + .uri("/api/v1/some/read/path") + .dirtyMaxAge(Duration.ofSeconds(10)) + .push( + "POST", + (context, value) -> "/api/v1/some/write/path", + (context, value) -> value + ) + .build(); +``` + +The plugin scheduler calls: + +```java +cache.flushDirty(); +``` + +Manual-only push: + +```java +.manualPushOnly() +``` + +## Failed Sync Metadata + +If refresh fails and an old value exists: + +- stale value is returned +- `failedSyncCount` increments +- `lastError` is stored +- `syncState` becomes `FAILED` + +If refresh fails and no value exists: + +- the future completes exceptionally + +Read metadata: + +```java +Optional metadata = + cache.metadata(call, context); +``` + +Successful pull or push resets `failedSyncCount` to `0`. + +## Asset Store Adapter + +Asset-store-backed calls need a plugin adapter. + +```java +public final class MyAssetCacheAdapter implements AssetStoreCacheAdapter { + @Override + public Optional read(ApiCacheKey key, CacheContext context) { + // Read concrete Hytale asset and convert to ApiCacheEntry. + } + + @Override + public void write(ApiCacheKey key, ApiCacheEntry entry, CacheContext context) { + // Convert entry payload into concrete asset and load/write through AssetStore. + } +} +``` + +Use it like: + +```java +CachedApiCall call = CachedApiCall + .builder(JsonObject.class) + .id("asset-backed-config") + .method("GET") + .uri("/api/v1/configs/active") + .storageTarget(StorageTarget.ASSET_STORE) + .assetStoreAdapter(new MyAssetCacheAdapter()) + .maxAge(Duration.ofMinutes(10)) + .build(); +``` + +## Batching Behavior + +When `ControlPlaneClient.batchingEnabled()` is true, cache pulls and pushes use `BatchClient`. + +When batching is disabled, cache operations call the HTTP transport directly. diff --git a/docs/wiki/Batched-API-Calls.md b/docs/wiki/Batched-API-Calls.md new file mode 100644 index 0000000..ea07478 --- /dev/null +++ b/docs/wiki/Batched-API-Calls.md @@ -0,0 +1,102 @@ +# Batched API Calls + +Batching lets multiple plugins enqueue control-plane calls that are sent as one HTTP request to `/api/v1/batch`. + +Use batching when: + +- many plugins read data at the same time +- looped tasks report state periodically +- call latency can be handled asynchronously + +Do not use batching when: + +- the caller needs a blocking result immediately +- the endpoint should bypass queueing for operational safety + +## One-Off Calls + +```java +CompletableFuture future = NetworkPlugin.client() + .batch() + .get(ProfileEndpoint.VALUE, ProfileEndpoint.VALUE.uri(), ServerProfile.class); +``` + +```java +CompletableFuture future = NetworkPlugin.client() + .batch() + .enqueueJson("GET", "/api/v1/server/profile", null); +``` + +Queued calls complete when the batch loop flushes, or when code calls: + +```java +NetworkPlugin.client().batch().flush(); +``` + +## Looped Calls + +```java +LoopHandle handle = NetworkPlugin.client().batch().registerLoopedCall( + LoopedApiCall.builder(ServerProfile.class) + .id("server-profile-loop") + .interval(Duration.ofSeconds(5)) + .method("GET") + .uri("/api/v1/server/profile") + .onSuccess(profile -> { + // update local state + }) + .onFailure(error -> { + // log or mark degraded + }) + .build() +); +``` + +Cancel when no longer needed: + +```java +handle.cancel(); +``` + +## Batch Endpoint Contract + +Request: + +```json +{ + "requests": [ + { + "id": "generated-call-id", + "method": "GET", + "uri": "/api/v1/server/profile", + "body": null + } + ] +} +``` + +Response: + +```json +{ + "responses": [ + { + "id": "generated-call-id", + "status": 200, + "body": { "data": {} }, + "error": null + } + ] +} +``` + +Each item completes independently. A failed item does not fail unrelated futures. + +## Defaults + +- endpoint: `/api/v1/batch` +- flush interval: `250ms` +- max batch size: `100` +- batching enabled: `true` + +Configure with `ControlPlaneClient.builder()`. diff --git a/docs/wiki/Control-Plane-Client.md b/docs/wiki/Control-Plane-Client.md new file mode 100644 index 0000000..0378be0 --- /dev/null +++ b/docs/wiki/Control-Plane-Client.md @@ -0,0 +1,82 @@ +# Control Plane Client + +`ControlPlaneClient` is the main public entry point for plugins. + +```java +ControlPlaneClient client = NetworkPlugin.client(); +``` + +## Public Clients + +```java +client.server(); // ServerClient +client.presence(); // PresenceClient +client.players(); // PlayersClient +client.batch(); // BatchClient +client.cache(); // ApiDataCacheClient +``` + +## Handwritten Domain Clients + +`ServerClient` + +```java +ServerProfile profile = client.server().currentProfile(); +``` + +`PresenceClient` + +```java +client.presence().register(instanceId, registrationPayload); +client.presence().heartbeat(instanceId, HeartbeatPayload.running(12, 150)); +client.presence().shutdown(instanceId, "restart"); +``` + +`PlayersClient` + +```java +PlayerSessionProfile profile = client.players().getSessionProfile(playerId); +List entitlements = client.players().getEntitlements(playerId); +``` + +## Error Handling + +Transport errors use checked `ControlPlaneException` subclasses: + +- `AuthException` for `401` and `403` +- `NotFoundException` for `404` +- `ValidationException` for `422` +- `RemoteException` for `5xx` +- `ControlPlaneException` for other transport or parsing failures + +Example: + +```java +try { + ServerProfile profile = client.server().currentProfile(); +} catch (AuthException e) { + // token is missing, invalid, inactive, or lacks ability +} catch (ControlPlaneException e) { + // generic control-plane failure +} +``` + +## Endpoint Descriptors + +Generated endpoint descriptors are available under: + +```text +net.kewwbec.network.generated.api.v1 +``` + +Use them for stable route metadata and URI templates. Resolve path variables with `PathBuilder`. + +```java +String uri = PathBuilder.resolve( + SessionProfileEndpoint.VALUE.uri(), + "player", + playerId +); +``` + +Do not edit generated descriptors manually. Update routes in `core-control-plane`, then run the SDK sync command from that repo. diff --git a/docs/wiki/Generated-SDK.md b/docs/wiki/Generated-SDK.md new file mode 100644 index 0000000..88034f8 --- /dev/null +++ b/docs/wiki/Generated-SDK.md @@ -0,0 +1,65 @@ +# Generated SDK + +The generated SDK is a Java view of the control-plane route contract. + +Generated files live under: + +```text +src/main/java/net/kewwbec/network/generated/api/v1 +``` + +These files include: + +- route names +- allowed HTTP methods +- URI templates +- required abilities +- middleware metadata +- request field descriptors +- response type descriptors + +## Usage + +Import the generated root: + +```java +import net.kewwbec.network.generated.api.v1.ControlPlaneApiV1; +``` + +Use domain APIs to inspect route descriptors: + +```java +ControlPlaneApiV1 api = ControlPlaneApiV1.INSTANCE; +String version = api.apiVersion(); +int endpointCount = api.endpointCount(); +``` + +Use route endpoint constants with handwritten clients or batching: + +```java +String uri = PathBuilder.resolve( + SessionProfileEndpoint.VALUE.uri(), + "player", + playerId +); +``` + +## Regeneration + +The source of truth is `../core-control-plane`. + +Run from `core-control-plane`: + +```bash +php artisan control-plane:sync-network-sdk --dry-run +php artisan control-plane:sync-network-sdk +``` + +Review generated churn carefully before committing. Broad generated diffs can happen if `core-control-plane` has unrelated route changes. + +## Rules + +- Do not manually patch generated Java. +- Fix Laravel routes, requests, resources, or the generator upstream. +- Re-run the sync after route changes. +- Keep handwritten Java under `net.kewwbec.network`, outside `generated`. diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md new file mode 100644 index 0000000..881055f --- /dev/null +++ b/docs/wiki/Getting-Started.md @@ -0,0 +1,134 @@ +# Getting Started + +## Build + +From `core-network`: + +```bat +gradlew.bat compileJava +gradlew.bat test +gradlew.bat shadowJar +``` + +`compileJava` and `test` are the fastest checks for SDK/runtime changes. `shadowJar` builds the plugin jar. + +## Configure The Shared Client + +`core-network` auto-configures `NetworkPlugin.client()` during plugin setup when startup configuration is present. + +Recommended production setup uses environment variables: + +```powershell +$env:KWB_CONTROL_PLANE_BASE_URL = "https://control-plane.internal" +$env:KWB_CONTROL_PLANE_TOKEN = "server-issued-token" +``` + +Supported optional environment variables: + +```text +KWB_CONTROL_PLANE_BATCHING_ENABLED=true +KWB_CONTROL_PLANE_BATCH_FLUSH_INTERVAL_MS=250 +KWB_CONTROL_PLANE_BATCH_MAX_SIZE=100 +KWB_CONTROL_PLANE_BATCH_ENDPOINT=/api/v1/batch +``` + +Launcher-controlled startup can use JVM properties: + +```text +-Dkweebec.controlPlane.baseUrl=https://control-plane.internal +-Dkweebec.controlPlane.token=server-issued-token +-Dkweebec.controlPlane.batching.enabled=true +-Dkweebec.controlPlane.batch.flushIntervalMs=250 +-Dkweebec.controlPlane.batch.maxSize=100 +-Dkweebec.controlPlane.batch.endpoint=/api/v1/batch +``` + +For small local deployments, `core-network` creates `control-plane.json` in the plugin data directory. Fill in the placeholders: + +```json +{ + "BaseUrl": "https://control-plane.internal", + "Token": "local-dev-token", + "BatchingEnabled": true, + "BatchFlushIntervalMs": 250, + "MaxBatchSize": 100, + "BatchEndpoint": "/api/v1/batch" +} +``` + +Precedence is environment variables, then JVM properties, then `control-plane.json`. + +Manual setup still works and overrides auto-config: + +```java +NetworkPlugin.configure( + "https://control-plane.internal", + System.getenv("KWB_CONTROL_PLANE_TOKEN") +); +``` + +Dependent plugins then use: + +```java +ControlPlaneClient client = NetworkPlugin.client(); +``` + +You can inspect the source that configured the shared client: + +```java +NetworkPlugin.configurationSource(); +``` + +## Dependency Setup + +Dependent jars should declare `core-network` as a plugin dependency in their manifest: + +```json +{ + "Dependencies": { + "core-network": "*" + } +} +``` + +## Common Direct Calls + +```java +ServerProfile profile = NetworkPlugin.client() + .server() + .currentProfile(); +``` + +```java +PlayerSessionProfile profile = NetworkPlugin.client() + .players() + .getSessionProfile(playerId); +``` + +```java +NetworkPlugin.client() + .presence() + .heartbeat(instanceId, HeartbeatPayload.running(currentPlayers, maxPlayers)); +``` + +## Runtime Scheduling + +`NetworkPlugin` starts a scheduled loop when the Hytale plugin starts. The loop: + +- flushes due batched API calls +- flushes dirty cached API entries + +Default batch cadence is `250ms`. + +Builder overrides: + +```java +ControlPlaneClient client = ControlPlaneClient.builder() + .baseUrl("https://control-plane.internal") + .token(token) + .batchingEnabled(true) + .batchFlushInterval(Duration.ofMillis(250)) + .maxBatchSize(100) + .batchEndpoint("/api/v1/batch") + .build(); +``` diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md new file mode 100644 index 0000000..f4aa18a --- /dev/null +++ b/docs/wiki/Home.md @@ -0,0 +1,42 @@ +# Core Network Wiki + +`core-network` is the Hytale plugin-side runtime for Kweebec Network control-plane access. + +It provides: + +- a shared `ControlPlaneClient` +- handwritten convenience clients for common server/player operations +- generated API endpoint descriptors from `core-control-plane` +- batched HTTP calls through `/api/v1/batch` +- API response storage policies for runtime, player, and asset-store-backed data + +## Pages + +- [Getting Started](Getting-Started.md) +- [Control Plane Client](Control-Plane-Client.md) +- [Batched API Calls](Batched-API-Calls.md) +- [API Response Storage](API-Response-Storage.md) +- [Generated SDK](Generated-SDK.md) +- [Troubleshooting](Troubleshooting.md) + +## Package Map + +```text +net.kewwbec.network + ControlPlaneClient.java shared client entry point + NetworkPlugin.java Hytale plugin entry point and scheduler hook + client/ handwritten domain clients + http/ low-level JSON HTTP transport + batch/ one-off and looped batched calls + cache/ cached API data and storage policies + generated/api/v1/ generated endpoint descriptors + models/ handwritten response/request models +``` + +## Design Rules + +- Do not edit `net.kewwbec.network.generated` manually. +- Add handwritten runtime behavior outside `generated`. +- Treat `core-control-plane` as the API owner. +- Use batching for repeated or concurrent calls that can tolerate async completion. +- Use cache policies when plugin code needs shared ownership of API freshness or push timing. diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md new file mode 100644 index 0000000..0713343 --- /dev/null +++ b/docs/wiki/Troubleshooting.md @@ -0,0 +1,116 @@ +# Troubleshooting + +## `NetworkPlugin has not been configured` + +No startup source produced both a control-plane base URL and token before another plugin requested `NetworkPlugin.client()`. + +Fix with one of: + +- environment variables: `KWB_CONTROL_PLANE_BASE_URL` and `KWB_CONTROL_PLANE_TOKEN` +- JVM properties: `kweebec.controlPlane.baseUrl` and `kweebec.controlPlane.token` +- local plugin config: `control-plane.json` +- explicit startup call: `NetworkPlugin.configure(baseUrl, token)` + +Check which source configured the client: + +```java +NetworkPlugin.configurationSource(); +``` + +`Optional.empty()` means no source configured the shared client. + +## Startup Config Precedence + +Startup values are resolved in this order: + +1. environment variables +2. JVM system properties +3. `control-plane.json` + +Environment variables and JVM properties override only the fields they set. Missing fields can still come from a lower-priority source. + +## Config File Token Warning + +`control-plane.json` is a short-term local deployment fallback. Prefer environment variables for production or control-plane-provisioned servers. + +JVM `-D` properties are supported, but some host tooling can expose process arguments. Avoid logging launcher commands that contain tokens. + +## Auth Failures + +`AuthException` maps to HTTP `401` or `403`. + +Common causes: + +- missing token +- expired or invalid token +- inactive service account +- token missing required ability +- server-instance token trying to access a different instance route + +## Batch Futures Never Complete + +Likely causes: + +- batching scheduler has not started +- `NetworkPlugin` is not loaded as the plugin main class +- code enqueued calls but never called `batch().flush()` in tests +- control plane does not expose `/api/v1/batch` + +In tests, call: + +```java +client.batch().flush(); +``` + +## Cached Reads Keep Returning Stale Data + +This can be expected behavior. If a refresh fails and an old value exists, cache APIs return the stale value and increment failed sync metadata. + +Check: + +```java +cache.metadata(call, context) +``` + +Look at: + +- `failedSyncCount` +- `lastError` +- `syncState` + +## `PLAYER cache calls require PlayerCacheContext` + +Player storage is explicit. Use: + +```java +new PlayerCacheContext(playerId, playerRef, entityStore) +``` + +Core-network will not infer player identity from `/players/{player}` URIs. + +## `NetworkApiDataComponent has not been registered` + +The player cache component is registered in `NetworkPlugin.setup()`. + +This error usually means: + +- tests are using player storage without plugin setup +- `NetworkPlugin` is not the active Hytale plugin main class +- code is accessing player cache before component registration + +## Asset Store Cache Does Nothing + +`ASSET_STORE` storage requires a plugin-provided `AssetStoreCacheAdapter`. + +Core-network does not create a generic Hytale asset store because Hytale assets require concrete asset classes, codecs, keys, and load/write behavior. + +## Manifest Rewrites During Gradle Tasks + +`processResources` depends on `updatePluginManifest`, which rewrites `src/main/resources/manifest.json` from Gradle properties. + +If the manifest changes unexpectedly after tests/builds, check: + +- `includes_pack` in `gradle.properties` +- `version` in `gradle.properties` + +Avoid committing unrelated manifest churn. diff --git a/src/main/java/net/kewwbec/network/ControlPlaneClient.java b/src/main/java/net/kewwbec/network/ControlPlaneClient.java index 8ccc052..33113fc 100644 --- a/src/main/java/net/kewwbec/network/ControlPlaneClient.java +++ b/src/main/java/net/kewwbec/network/ControlPlaneClient.java @@ -1,38 +1,134 @@ package net.kewwbec.network; import net.kewwbec.network.auth.ApiToken; -import net.kewwbec.network.client.PlayersClient; -import net.kewwbec.network.client.PresenceClient; -import net.kewwbec.network.client.ServerClient; +import net.kewwbec.network.batch.BatchClient; +import net.kewwbec.network.cache.ApiDataCacheClient; +import net.kewwbec.network.generated.api.v1.announcements.AnnouncementsClient; +import net.kewwbec.network.generated.api.v1.assets.AssetsClient; +import net.kewwbec.network.generated.api.v1.audit.AuditClient; +import net.kewwbec.network.generated.api.v1.configs.ConfigsClient; +import net.kewwbec.network.generated.api.v1.entitlements.EntitlementsClient; +import net.kewwbec.network.generated.api.v1.gamepermissions.GamePermissionsClient; +import net.kewwbec.network.generated.api.v1.gameplay.GameplayClient; +import net.kewwbec.network.generated.api.v1.health.HealthClient; +import net.kewwbec.network.generated.api.v1.hub.HubClient; +import net.kewwbec.network.generated.api.v1.infrastructure.InfrastructureClient; +import net.kewwbec.network.generated.api.v1.integrations.IntegrationsClient; +import net.kewwbec.network.generated.api.v1.moderation.ModerationClient; +import net.kewwbec.network.generated.api.v1.players.PlayersClient; +import net.kewwbec.network.generated.api.v1.plugins.PluginsClient; +import net.kewwbec.network.generated.api.v1.progression.ProgressionClient; +import net.kewwbec.network.generated.api.v1.rewards.RewardsClient; +import net.kewwbec.network.generated.api.v1.server.ServerClient; +import net.kewwbec.network.generated.api.v1.serviceaccounts.ServiceAccountsClient; +import net.kewwbec.network.generated.api.v1.social.SocialClient; +import net.kewwbec.network.generated.api.v1.support.SupportClient; +import net.kewwbec.network.generated.api.v1.worlds.WorldsClient; import net.kewwbec.network.http.ControlPlaneHttpClient; import java.time.Duration; public final class ControlPlaneClient { private final ControlPlaneHttpClient http; - private final PresenceClient presence; - private final ServerClient server; + private final AnnouncementsClient announcements; + private final AssetsClient assets; + private final AuditClient audit; + private final ConfigsClient configs; + private final EntitlementsClient entitlements; + private final GamePermissionsClient gamePermissions; + private final GameplayClient gameplay; + private final HealthClient health; + private final HubClient hub; + private final InfrastructureClient infrastructure; + private final IntegrationsClient integrations; + private final ModerationClient moderation; private final PlayersClient players; + private final PluginsClient plugins; + private final ProgressionClient progression; + private final RewardsClient rewards; + private final ServerClient server; + private final ServiceAccountsClient serviceAccounts; + private final SocialClient social; + private final SupportClient support; + private final WorldsClient worlds; + private final BatchClient batch; + private final ApiDataCacheClient cache; + private final boolean batchingEnabled; + private final Duration batchFlushInterval; + private final int maxBatchSize; + private final String batchEndpoint; private ControlPlaneClient(Builder builder) { this.http = new ControlPlaneHttpClient(builder.baseUrl, builder.token, builder.connectTimeout); - this.presence = new PresenceClient(http); - this.server = new ServerClient(http); + this.announcements = new AnnouncementsClient(http); + this.assets = new AssetsClient(http); + this.audit = new AuditClient(http); + this.configs = new ConfigsClient(http); + this.entitlements = new EntitlementsClient(http); + this.gamePermissions = new GamePermissionsClient(http); + this.gameplay = new GameplayClient(http); + this.health = new HealthClient(http); + this.hub = new HubClient(http); + this.infrastructure = new InfrastructureClient(http); + this.integrations = new IntegrationsClient(http); + this.moderation = new ModerationClient(http); this.players = new PlayersClient(http); + this.plugins = new PluginsClient(http); + this.progression = new ProgressionClient(http); + this.rewards = new RewardsClient(http); + this.server = new ServerClient(http); + this.serviceAccounts = new ServiceAccountsClient(http); + this.social = new SocialClient(http); + this.support = new SupportClient(http); + this.worlds = new WorldsClient(http); + this.batch = new BatchClient(http, builder.batchEndpoint, builder.maxBatchSize); + this.batchingEnabled = builder.batchingEnabled; + this.batchFlushInterval = builder.batchFlushInterval; + this.maxBatchSize = builder.maxBatchSize; + this.batchEndpoint = builder.batchEndpoint; + this.cache = new ApiDataCacheClient(http, batch, this::batchingEnabled); } public static Builder builder() { return new Builder(); } - public PresenceClient presence() { return presence; } - public ServerClient server() { return server; } + public AnnouncementsClient announcements() { return announcements; } + public AssetsClient assets() { return assets; } + public AuditClient audit() { return audit; } + public ConfigsClient configs() { return configs; } + public EntitlementsClient entitlements() { return entitlements; } + public GamePermissionsClient gamePermissions() { return gamePermissions; } + public GameplayClient gameplay() { return gameplay; } + public HealthClient health() { return health; } + public HubClient hub() { return hub; } + public InfrastructureClient infrastructure() { return infrastructure; } + public IntegrationsClient integrations() { return integrations; } + public ModerationClient moderation() { return moderation; } public PlayersClient players() { return players; } + public PluginsClient plugins() { return plugins; } + public ProgressionClient progression() { return progression; } + public RewardsClient rewards() { return rewards; } + public ServerClient server() { return server; } + public ServiceAccountsClient serviceAccounts() { return serviceAccounts; } + public SocialClient social() { return social; } + public SupportClient support() { return support; } + public WorldsClient worlds() { return worlds; } + public BatchClient batch() { return batch; } + public ApiDataCacheClient cache() { return cache; } + public boolean batchingEnabled() { return batchingEnabled; } + public Duration batchFlushInterval() { return batchFlushInterval; } + public int maxBatchSize() { return maxBatchSize; } + public String batchEndpoint() { return batchEndpoint; } public static final class Builder { private String baseUrl; private ApiToken token; private Duration connectTimeout = Duration.ofSeconds(5); + private boolean batchingEnabled = true; + private Duration batchFlushInterval = Duration.ofMillis(250); + private int maxBatchSize = 100; + private String batchEndpoint = "/api/v1/batch"; private Builder() {} @@ -56,6 +152,26 @@ public final class ControlPlaneClient { return this; } + public Builder batchingEnabled(boolean batchingEnabled) { + this.batchingEnabled = batchingEnabled; + return this; + } + + public Builder batchFlushInterval(Duration batchFlushInterval) { + this.batchFlushInterval = batchFlushInterval; + return this; + } + + public Builder maxBatchSize(int maxBatchSize) { + this.maxBatchSize = maxBatchSize; + return this; + } + + public Builder batchEndpoint(String batchEndpoint) { + this.batchEndpoint = batchEndpoint; + return this; + } + public ControlPlaneClient build() { if (baseUrl == null || baseUrl.isBlank()) { throw new IllegalStateException("baseUrl must be set"); @@ -63,6 +179,18 @@ public final class ControlPlaneClient { if (token == null) { throw new IllegalStateException("token must be set"); } + if (connectTimeout == null || connectTimeout.isNegative() || connectTimeout.isZero()) { + throw new IllegalStateException("connectTimeout must be positive"); + } + if (batchFlushInterval == null || batchFlushInterval.isNegative() || batchFlushInterval.isZero()) { + throw new IllegalStateException("batchFlushInterval must be positive"); + } + if (maxBatchSize <= 0) { + throw new IllegalStateException("maxBatchSize must be positive"); + } + if (batchEndpoint == null || batchEndpoint.isBlank()) { + throw new IllegalStateException("batchEndpoint must be set"); + } return new ControlPlaneClient(this); } } diff --git a/src/main/java/net/kewwbec/network/NetworkPlugin.java b/src/main/java/net/kewwbec/network/NetworkPlugin.java index 56f1ec0..9314304 100644 --- a/src/main/java/net/kewwbec/network/NetworkPlugin.java +++ b/src/main/java/net/kewwbec/network/NetworkPlugin.java @@ -1,20 +1,63 @@ package net.kewwbec.network; +import com.hypixel.hytale.server.core.HytaleServer; +import com.hypixel.hytale.server.core.plugin.JavaPlugin; +import com.hypixel.hytale.server.core.plugin.JavaPluginInit; +import com.hypixel.hytale.server.core.util.Config; +import net.kewwbec.network.cache.NetworkApiDataComponent; +import net.kewwbec.network.config.ControlPlaneConfigurationSource; +import net.kewwbec.network.config.ControlPlaneRuntimeConfig; +import net.kewwbec.network.config.ControlPlaneStartupConfigResolver; +import net.kewwbec.network.config.ResolvedControlPlaneStartupConfig; +import net.kewwbec.network.errors.ControlPlaneException; + +import javax.annotation.Nonnull; +import java.util.Optional; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; + /** * Plugin entry point for core-network. * - * Call {@link #configure(String, String)} once during server startup (e.g. from - * your plugin's onLoad/onEnable equivalent) before any other plugin calls - * {@link #client()}. The base URL and token come from your server's environment - * or plugin config. + * The shared client is configured from startup environment variables, JVM + * properties, control-plane.json, or an explicit {@link #configure(String, String)} + * call before dependent plugins use {@link #client()}. * * Dependent plugins declare this plugin in their manifest Dependencies block: * "Dependencies": { "core-network": "*" } */ -public final class NetworkPlugin { +public final class NetworkPlugin extends JavaPlugin { private static volatile ControlPlaneClient instance; + private static volatile ControlPlaneConfigurationSource configurationSource; + private static volatile boolean runtimeStarted; + private static volatile ScheduledFuture batchTask; + private static final Object CONFIG_LOCK = new Object(); + private static final Object BATCH_LOCK = new Object(); - private NetworkPlugin() {} + private final Config controlPlaneConfig; + + public NetworkPlugin(@Nonnull JavaPluginInit init) { + super(init); + this.controlPlaneConfig = withConfig("control-plane", ControlPlaneRuntimeConfig.CODEC); + } + + @Override + protected void setup() { + NetworkApiDataComponent.setComponentType(getEntityStoreRegistry().registerComponent( + NetworkApiDataComponent.class, + "NetworkApiData", + NetworkApiDataComponent.CODEC + )); + loadAndApplyStartupConfig(); + } + + @Override + protected void start() { + runtimeStarted = true; + startBatching(); + } /** * Initialise the shared {@link ControlPlaneClient}. Must be called once @@ -24,18 +67,48 @@ public final class NetworkPlugin { * @param token Sanctum bearer token issued to this server instance */ public static void configure(String baseUrl, String token) { - instance = ControlPlaneClient.builder() + configure(ControlPlaneClient.builder() .baseUrl(baseUrl) .token(token) - .build(); + .build()); } /** * Initialise with a pre-built client (useful for testing or custom config). */ public static void configure(ControlPlaneClient client) { - if (client == null) throw new IllegalArgumentException("client must not be null"); - instance = client; + configure(client, ControlPlaneConfigurationSource.MANUAL); + } + + private static void configure(ControlPlaneClient client, ControlPlaneConfigurationSource source) { + if (client == null) { + throw new IllegalArgumentException("client must not be null"); + } + synchronized (CONFIG_LOCK) { + instance = client; + configurationSource = source; + } + if (runtimeStarted) { + restartBatching(); + } + } + + static boolean configureFromStartup(ResolvedControlPlaneStartupConfig resolved) { + if (resolved == null) { + throw new IllegalArgumentException("resolved must not be null"); + } + ControlPlaneClient client = resolved.settings().buildClient(); + synchronized (CONFIG_LOCK) { + if (instance != null) { + return false; + } + instance = client; + configurationSource = resolved.source(); + } + if (runtimeStarted) { + startBatching(); + } + return true; } /** @@ -48,7 +121,8 @@ public final class NetworkPlugin { if (c == null) { throw new IllegalStateException( "NetworkPlugin has not been configured. " + - "Call NetworkPlugin.configure(baseUrl, token) during plugin startup." + "Set KWB_CONTROL_PLANE_BASE_URL and KWB_CONTROL_PLANE_TOKEN, JVM properties, " + + "control-plane.json, or call NetworkPlugin.configure(baseUrl, token) during plugin startup." ); } return c; @@ -60,4 +134,107 @@ public final class NetworkPlugin { public static boolean isConfigured() { return instance != null; } + + public static Optional configurationSource() { + return Optional.ofNullable(configurationSource); + } + + public static void startBatching() { + startBatching(HytaleServer.SCHEDULED_EXECUTOR); + } + + public static void startBatching(ScheduledExecutorService scheduler) { + ControlPlaneClient c = instance; + if (c == null || !c.batchingEnabled()) { + return; + } + synchronized (BATCH_LOCK) { + if (batchTask != null && !batchTask.isCancelled() && !batchTask.isDone()) { + return; + } + long intervalMillis = Math.max(1L, c.batchFlushInterval().toMillis()); + batchTask = scheduler.scheduleAtFixedRate(() -> { + try { + ControlPlaneClient current = instance; + if (current != null && current.batchingEnabled()) { + current.batch().flushDue(); + current.cache().flushDirty(); + } + } catch (Exception ignored) { + } + }, intervalMillis, intervalMillis, TimeUnit.MILLISECONDS); + } + } + + private static void restartBatching() { + cancelBatchingTask(); + startBatching(); + } + + private static void cancelBatchingTask() { + synchronized (BATCH_LOCK) { + if (batchTask != null) { + batchTask.cancel(false); + batchTask = null; + } + } + } + + @Override + public void shutdown() { + runtimeStarted = false; + cancelBatchingTask(); + ControlPlaneClient c = instance; + if (c != null) { + try { + c.batch().flush(); + } catch (RuntimeException ignored) { + c.batch().cancelPending(new ControlPlaneException("Batch client shut down before pending calls were flushed")); + } + } + } + + private void loadAndApplyStartupConfig() { + ControlPlaneRuntimeConfig runtimeConfig; + try { + runtimeConfig = controlPlaneConfig.load().join(); + } catch (RuntimeException e) { + getLogger().at(Level.WARNING).withCause(e).log( + "Failed to load core-network control-plane config; trying environment and JVM properties only" + ); + runtimeConfig = new ControlPlaneRuntimeConfig(); + } + + applyStartupConfig(runtimeConfig); + + try { + controlPlaneConfig.save().join(); + } catch (RuntimeException e) { + getLogger().at(Level.WARNING).withCause(e).log("Failed to save core-network control-plane config placeholder"); + } + } + + private void applyStartupConfig(ControlPlaneRuntimeConfig runtimeConfig) { + if (isConfigured()) { + return; + } + Optional resolved; + try { + resolved = new ControlPlaneStartupConfigResolver().resolve(runtimeConfig.toStartupSettings()); + } catch (RuntimeException e) { + getLogger().at(Level.WARNING).withCause(e).log( + "Core-network control-plane startup configuration is invalid" + ); + return; + } + if (resolved.isEmpty()) { + getLogger().at(Level.WARNING).log( + "Core-network control plane is not configured; set KWB_CONTROL_PLANE_BASE_URL and KWB_CONTROL_PLANE_TOKEN, JVM properties, or control-plane.json" + ); + return; + } + if (configureFromStartup(resolved.get())) { + getLogger().at(Level.INFO).log("Configured core-network control plane from %s", resolved.get().source()); + } + } } diff --git a/src/main/java/net/kewwbec/network/batch/BatchClient.java b/src/main/java/net/kewwbec/network/batch/BatchClient.java new file mode 100644 index 0000000..bc7d58f --- /dev/null +++ b/src/main/java/net/kewwbec/network/batch/BatchClient.java @@ -0,0 +1,233 @@ +package net.kewwbec.network.batch; + +import com.google.gson.JsonObject; +import net.kewwbec.network.errors.ControlPlaneException; +import net.kewwbec.network.generated.api.v1.shared.ApiEndpoint; +import net.kewwbec.network.http.ControlPlaneHttpClient; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class BatchClient { + private final ControlPlaneHttpClient http; + private final String batchEndpoint; + private final int maxBatchSize; + private final ConcurrentLinkedQueue> pendingCalls = new ConcurrentLinkedQueue<>(); + private final Map> loopedCalls = new ConcurrentHashMap<>(); + private final AtomicBoolean flushing = new AtomicBoolean(false); + + public BatchClient(ControlPlaneHttpClient http, String batchEndpoint, int maxBatchSize) { + this.http = Objects.requireNonNull(http, "http must not be null"); + this.batchEndpoint = requireApiUri(batchEndpoint); + if (maxBatchSize <= 0) { + throw new IllegalArgumentException("maxBatchSize must be positive"); + } + this.maxBatchSize = maxBatchSize; + } + + public CompletableFuture enqueue( + ApiEndpoint endpoint, + String resolvedUri, + String method, + Object body, + Class responseType + ) { + String normalizedMethod = normalizeMethod(method); + if (endpoint != null && !endpoint.methods().contains(normalizedMethod)) { + throw new IllegalArgumentException("Endpoint " + endpoint.name() + " does not allow " + normalizedMethod); + } + PendingCall call = new PendingCall<>( + UUID.randomUUID().toString(), + normalizedMethod, + requireApiUri(resolvedUri), + body, + Objects.requireNonNull(responseType, "responseType must not be null") + ); + pendingCalls.add(call); + return call.future; + } + + public CompletableFuture get(ApiEndpoint endpoint, String resolvedUri, Class responseType) { + return enqueue(endpoint, resolvedUri, "GET", null, responseType); + } + + public CompletableFuture post(ApiEndpoint endpoint, String resolvedUri, Object body, Class responseType) { + return enqueue(endpoint, resolvedUri, "POST", body, responseType); + } + + public CompletableFuture enqueueJson(String method, String resolvedUri, Object body) { + return enqueue(null, resolvedUri, method, body, JsonObject.class); + } + + public LoopHandle registerLoopedCall(LoopedApiCall call) { + LoopRegistration registration = new LoopRegistration<>(Objects.requireNonNull(call, "call must not be null")); + LoopRegistration previous = loopedCalls.putIfAbsent(call.id(), registration); + if (previous != null) { + throw new IllegalArgumentException("Looped API call already registered: " + call.id()); + } + return registration; + } + + public void flushDue() { + Instant now = Instant.now(); + for (LoopRegistration registration : loopedCalls.values()) { + registration.enqueueIfDue(this, now); + } + flush(); + } + + public void flush() { + if (!flushing.compareAndSet(false, true)) { + return; + } + try { + List> batch = drainBatch(); + while (!batch.isEmpty()) { + sendBatch(batch); + batch = drainBatch(); + } + } finally { + flushing.set(false); + } + } + + public void cancelPending(Throwable cause) { + PendingCall call; + while ((call = pendingCalls.poll()) != null) { + call.completeExceptionally(cause); + } + } + + private List> drainBatch() { + List> batch = new ArrayList<>(maxBatchSize); + PendingCall call; + while (batch.size() < maxBatchSize && (call = pendingCalls.poll()) != null) { + batch.add(call); + } + return batch; + } + + private void sendBatch(List> batch) { + try { + http.postBatch(batchEndpoint, batch); + } catch (ControlPlaneException e) { + for (PendingCall call : batch) { + call.completeExceptionally(e); + } + } + } + + private static String normalizeMethod(String method) { + if (method == null || method.isBlank()) { + throw new IllegalArgumentException("method must not be blank"); + } + return method.toUpperCase(Locale.ROOT); + } + + private static String requireApiUri(String uri) { + if (uri == null || uri.isBlank()) { + throw new IllegalArgumentException("uri must not be blank"); + } + if (!uri.startsWith("/api/v1/") && !uri.equals("/api/v1")) { + throw new IllegalArgumentException("uri must be a relative /api/v1 path"); + } + if (uri.contains("://")) { + throw new IllegalArgumentException("uri must not be absolute"); + } + return uri; + } + + public static final class PendingCall { + private final String id; + private final String method; + private final String uri; + private final Object body; + private final Class responseType; + private final CompletableFuture future = new CompletableFuture<>(); + + private PendingCall(String id, String method, String uri, Object body, Class responseType) { + this.id = id; + this.method = method; + this.uri = uri; + this.body = body; + this.responseType = responseType; + } + + public String id() { return id; } + public String method() { return method; } + public String uri() { return uri; } + public Object body() { return body; } + public Class responseType() { return responseType; } + + public void complete(T value) { + future.complete(value); + } + + public void completeExceptionally(Throwable cause) { + future.completeExceptionally(cause); + } + } + + private final class LoopRegistration implements LoopHandle { + private final LoopedApiCall call; + private final AtomicBoolean cancelled = new AtomicBoolean(false); + private volatile Instant nextRunAt = Instant.EPOCH; + + private LoopRegistration(LoopedApiCall call) { + this.call = call; + } + + private void enqueueIfDue(BatchClient batchClient, Instant now) { + if (cancelled.get() || now.isBefore(nextRunAt)) { + return; + } + nextRunAt = now.plus(call.interval()); + try { + CompletableFuture future = batchClient.enqueue( + call.endpoint(), + call.uri(), + call.method(), + call.body(), + call.responseType() + ); + if (call.onSuccess() != null) { + future.thenAccept(call.onSuccess()); + } + if (call.onFailure() != null) { + future.exceptionally(error -> { + call.onFailure().accept(error); + return null; + }); + } + } catch (RuntimeException e) { + if (call.onFailure() != null) { + call.onFailure().accept(e); + } else { + throw e; + } + } + } + + @Override + public void cancel() { + if (cancelled.compareAndSet(false, true)) { + loopedCalls.remove(call.id(), this); + } + } + + @Override + public boolean isCancelled() { + return cancelled.get(); + } + } +} diff --git a/src/main/java/net/kewwbec/network/batch/LoopHandle.java b/src/main/java/net/kewwbec/network/batch/LoopHandle.java new file mode 100644 index 0000000..5e2869d --- /dev/null +++ b/src/main/java/net/kewwbec/network/batch/LoopHandle.java @@ -0,0 +1,7 @@ +package net.kewwbec.network.batch; + +public interface LoopHandle { + void cancel(); + + boolean isCancelled(); +} diff --git a/src/main/java/net/kewwbec/network/batch/LoopedApiCall.java b/src/main/java/net/kewwbec/network/batch/LoopedApiCall.java new file mode 100644 index 0000000..b2597f0 --- /dev/null +++ b/src/main/java/net/kewwbec/network/batch/LoopedApiCall.java @@ -0,0 +1,127 @@ +package net.kewwbec.network.batch; + +import net.kewwbec.network.generated.api.v1.shared.ApiEndpoint; + +import java.time.Duration; +import java.util.Locale; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Supplier; + +public final class LoopedApiCall { + private final String id; + private final Duration interval; + private final ApiEndpoint endpoint; + private final String method; + private final Supplier uriSupplier; + private final Supplier bodySupplier; + private final Class responseType; + private final Consumer onSuccess; + private final Consumer onFailure; + + private LoopedApiCall(Builder builder) { + this.id = requireNonBlank(builder.id, "id"); + this.interval = Objects.requireNonNull(builder.interval, "interval must not be null"); + if (this.interval.isZero() || this.interval.isNegative()) { + throw new IllegalArgumentException("interval must be positive"); + } + this.endpoint = builder.endpoint; + this.method = requireNonBlank(builder.method, "method").toUpperCase(Locale.ROOT); + this.uriSupplier = Objects.requireNonNull(builder.uriSupplier, "uriSupplier must not be null"); + this.bodySupplier = builder.bodySupplier; + this.responseType = Objects.requireNonNull(builder.responseType, "responseType must not be null"); + this.onSuccess = builder.onSuccess; + this.onFailure = builder.onFailure; + } + + public static Builder builder(Class responseType) { + return new Builder<>(responseType); + } + + public String id() { return id; } + public Duration interval() { return interval; } + public ApiEndpoint endpoint() { return endpoint; } + public String method() { return method; } + public String uri() { return uriSupplier.get(); } + public Object body() { return bodySupplier != null ? bodySupplier.get() : null; } + public Class responseType() { return responseType; } + public Consumer onSuccess() { return onSuccess; } + public Consumer onFailure() { return onFailure; } + + private static String requireNonBlank(String value, String name) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return value; + } + + public static final class Builder { + private final Class responseType; + private String id; + private Duration interval; + private ApiEndpoint endpoint; + private String method; + private Supplier uriSupplier; + private Supplier bodySupplier; + private Consumer onSuccess; + private Consumer onFailure; + + private Builder(Class responseType) { + this.responseType = responseType; + } + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder interval(Duration interval) { + this.interval = interval; + return this; + } + + public Builder endpoint(ApiEndpoint endpoint) { + this.endpoint = endpoint; + return this; + } + + public Builder method(String method) { + this.method = method; + return this; + } + + public Builder uri(String uri) { + this.uriSupplier = () -> uri; + return this; + } + + public Builder uri(Supplier uriSupplier) { + this.uriSupplier = uriSupplier; + return this; + } + + public Builder body(Object body) { + this.bodySupplier = () -> body; + return this; + } + + public Builder body(Supplier bodySupplier) { + this.bodySupplier = bodySupplier; + return this; + } + + public Builder onSuccess(Consumer onSuccess) { + this.onSuccess = onSuccess; + return this; + } + + public Builder onFailure(Consumer onFailure) { + this.onFailure = onFailure; + return this; + } + + public LoopedApiCall build() { + return new LoopedApiCall<>(this); + } + } +} diff --git a/src/main/java/net/kewwbec/network/cache/ApiCacheEntry.java b/src/main/java/net/kewwbec/network/cache/ApiCacheEntry.java new file mode 100644 index 0000000..6c92387 --- /dev/null +++ b/src/main/java/net/kewwbec/network/cache/ApiCacheEntry.java @@ -0,0 +1,107 @@ +package net.kewwbec.network.cache; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import java.time.Instant; + +public final class ApiCacheEntry { + private JsonElement payload; + private String valueClassName; + private Instant fetchedAt; + private Instant dirtyAt; + private int failedSyncCount; + private String lastError; + private SyncState syncState = SyncState.CLEAN; + + public ApiCacheEntry(JsonElement payload, String valueClassName, Instant fetchedAt) { + this.payload = payload; + this.valueClassName = valueClassName; + this.fetchedAt = fetchedAt; + } + + public JsonElement payload() { return payload; } + public String valueClassName() { return valueClassName; } + public Instant fetchedAt() { return fetchedAt; } + public Instant dirtyAt() { return dirtyAt; } + public int failedSyncCount() { return failedSyncCount; } + public String lastError() { return lastError; } + public SyncState syncState() { return syncState; } + + public boolean isFresh(PullPolicy policy, Instant now) { + if (payload == null || fetchedAt == null) { + return false; + } + return policy.maxAge() + .map(maxAge -> !fetchedAt.plus(maxAge).isBefore(now)) + .orElse(true); + } + + public boolean shouldPush(PushPolicy policy, Instant now) { + if (dirtyAt == null || syncState != SyncState.DIRTY) { + return false; + } + return policy.dirtyMaxAge() + .map(maxAge -> !dirtyAt.plus(maxAge).isAfter(now)) + .orElse(false); + } + + public void replacePulled(JsonElement payload, String valueClassName, Instant fetchedAt) { + this.payload = payload; + this.valueClassName = valueClassName; + this.fetchedAt = fetchedAt; + this.failedSyncCount = 0; + this.lastError = null; + this.syncState = SyncState.CLEAN; + } + + public void replaceLocal(JsonElement payload, String valueClassName, Instant dirtyAt) { + this.payload = payload; + this.valueClassName = valueClassName; + this.dirtyAt = dirtyAt; + this.syncState = SyncState.DIRTY; + } + + public void markPushed(Instant fetchedAt) { + this.fetchedAt = fetchedAt; + this.dirtyAt = null; + this.failedSyncCount = 0; + this.lastError = null; + this.syncState = SyncState.CLEAN; + } + + public void markFailed(Throwable error) { + this.failedSyncCount++; + this.lastError = error.getMessage(); + this.syncState = SyncState.FAILED; + } + + public ApiCacheMetadata metadata() { + return new ApiCacheMetadata(fetchedAt, dirtyAt, failedSyncCount, lastError, syncState); + } + + public JsonObject toJsonObject() { + JsonObject obj = new JsonObject(); + obj.add("payload", payload); + obj.addProperty("valueClassName", valueClassName); + if (fetchedAt != null) obj.addProperty("fetchedAt", fetchedAt.toString()); + if (dirtyAt != null) obj.addProperty("dirtyAt", dirtyAt.toString()); + obj.addProperty("failedSyncCount", failedSyncCount); + if (lastError != null) obj.addProperty("lastError", lastError); + obj.addProperty("syncState", syncState.name()); + return obj; + } + + public static ApiCacheEntry fromJsonObject(JsonObject obj) { + ApiCacheEntry entry = new ApiCacheEntry( + obj.get("payload"), + obj.has("valueClassName") ? obj.get("valueClassName").getAsString() : JsonObject.class.getName(), + obj.has("fetchedAt") ? Instant.parse(obj.get("fetchedAt").getAsString()) : null + ); + entry.dirtyAt = obj.has("dirtyAt") ? Instant.parse(obj.get("dirtyAt").getAsString()) : null; + entry.failedSyncCount = obj.has("failedSyncCount") ? obj.get("failedSyncCount").getAsInt() : 0; + entry.lastError = obj.has("lastError") ? obj.get("lastError").getAsString() : null; + entry.syncState = obj.has("syncState") ? SyncState.valueOf(obj.get("syncState").getAsString()) : SyncState.CLEAN; + return entry; + } +} diff --git a/src/main/java/net/kewwbec/network/cache/ApiCacheKey.java b/src/main/java/net/kewwbec/network/cache/ApiCacheKey.java new file mode 100644 index 0000000..ef9ad69 --- /dev/null +++ b/src/main/java/net/kewwbec/network/cache/ApiCacheKey.java @@ -0,0 +1,20 @@ +package net.kewwbec.network.cache; + +import java.util.Objects; + +public record ApiCacheKey(String callId, String contextKey) { + public ApiCacheKey { + if (callId == null || callId.isBlank()) { + throw new IllegalArgumentException("callId must not be blank"); + } + contextKey = contextKey == null || contextKey.isBlank() ? "shared" : contextKey; + } + + public String encoded() { + return callId + "|" + contextKey; + } + + public static ApiCacheKey of(String callId, String contextKey) { + return new ApiCacheKey(callId, Objects.requireNonNullElse(contextKey, "shared")); + } +} diff --git a/src/main/java/net/kewwbec/network/cache/ApiCacheMetadata.java b/src/main/java/net/kewwbec/network/cache/ApiCacheMetadata.java new file mode 100644 index 0000000..d7aba35 --- /dev/null +++ b/src/main/java/net/kewwbec/network/cache/ApiCacheMetadata.java @@ -0,0 +1,15 @@ +package net.kewwbec.network.cache; + +import java.time.Instant; + +public record ApiCacheMetadata( + Instant fetchedAt, + Instant dirtyAt, + int failedSyncCount, + String lastError, + SyncState syncState +) { + public boolean hasValue() { + return fetchedAt != null; + } +} diff --git a/src/main/java/net/kewwbec/network/cache/ApiCacheStore.java b/src/main/java/net/kewwbec/network/cache/ApiCacheStore.java new file mode 100644 index 0000000..a907e08 --- /dev/null +++ b/src/main/java/net/kewwbec/network/cache/ApiCacheStore.java @@ -0,0 +1,12 @@ +package net.kewwbec.network.cache; + +import java.util.Collection; +import java.util.Optional; + +interface ApiCacheStore { + Optional get(ApiCacheKey key, CacheContext context); + + void put(ApiCacheKey key, ApiCacheEntry entry, CacheContext context); + + Collection entries(); +} diff --git a/src/main/java/net/kewwbec/network/cache/ApiDataCacheClient.java b/src/main/java/net/kewwbec/network/cache/ApiDataCacheClient.java new file mode 100644 index 0000000..08f4875 --- /dev/null +++ b/src/main/java/net/kewwbec/network/cache/ApiDataCacheClient.java @@ -0,0 +1,232 @@ +package net.kewwbec.network.cache; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import net.kewwbec.network.batch.BatchClient; +import net.kewwbec.network.errors.ControlPlaneException; +import net.kewwbec.network.http.ControlPlaneHttpClient; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +public final class ApiDataCacheClient { + private static final Gson GSON = new GsonBuilder().create(); + + private final ControlPlaneHttpClient http; + private final BatchClient batch; + private final Supplier batchingEnabled; + private final RuntimeApiCacheStore runtimeStore = new RuntimeApiCacheStore(); + private final PlayerApiCacheStore playerStore = new PlayerApiCacheStore(); + private final Map> registeredCalls = new ConcurrentHashMap<>(); + private final Map touchedContexts = new ConcurrentHashMap<>(); + + public ApiDataCacheClient(ControlPlaneHttpClient http, BatchClient batch, Supplier batchingEnabled) { + this.http = http; + this.batch = batch; + this.batchingEnabled = batchingEnabled; + } + + public CachedApiCall register(CachedApiCall call) { + CachedApiCall previous = registeredCalls.putIfAbsent(call.id(), call); + if (previous != null) { + throw new IllegalArgumentException("Cached API call already registered: " + call.id()); + } + return call; + } + + public CompletableFuture getOrPull(CachedApiCall call, CacheContext context) { + registerIfNeeded(call); + ApiCacheKey key = key(call, context); + ApiCacheStore store = storeFor(call); + Optional existing = store.get(key, context); + if (existing.isPresent() && existing.get().isFresh(call.pullPolicy(), Instant.now())) { + return CompletableFuture.completedFuture(value(existing.get(), call.responseType())); + } + return pullAndStore(call, context, key, store, existing); + } + + public CompletableFuture forcePull(CachedApiCall call, CacheContext context) { + registerIfNeeded(call); + ApiCacheKey key = key(call, context); + ApiCacheStore store = storeFor(call); + return pullAndStore(call, context, key, store, store.get(key, context)); + } + + public void updateLocal(CachedApiCall call, CacheContext context, T value) { + registerIfNeeded(call); + ApiCacheKey key = key(call, context); + ApiCacheEntry entry = storeFor(call).get(key, context) + .orElseGet(() -> new ApiCacheEntry(null, call.responseType().getName(), null)); + entry.replaceLocal(GSON.toJsonTree(value), call.responseType().getName(), Instant.now()); + storeFor(call).put(key, entry, context); + touchedContexts.put(key, context); + } + + public CompletableFuture forcePush(CachedApiCall call, CacheContext context) { + registerIfNeeded(call); + ApiCacheKey key = key(call, context); + ApiCacheStore store = storeFor(call); + ApiCacheEntry entry = store.get(key, context) + .orElseThrow(() -> new IllegalStateException("No cached value exists for " + key.encoded())); + T value = value(entry, call.responseType()); + return push(call, context, key, store, entry, value); + } + + public CompletableFuture flushDirty() { + Collection> futures = new ArrayList<>(); + Instant now = Instant.now(); + for (CachedApiCall call : registeredCalls.values()) { + collectDirtyFlushes(call, runtimeStore.entries(), now, futures); + if (call.storageTarget() == StorageTarget.ASSET_STORE) { + collectDirtyFlushes(call, storeFor(call).entries(), now, futures); + } + } + for (Map.Entry touched : touchedContexts.entrySet()) { + CachedApiCall call = registeredCalls.get(touched.getKey().callId()); + if (call != null && call.storageTarget() == StorageTarget.PLAYER) { + ApiCacheStore store = storeFor(call); + store.get(touched.getKey(), touched.getValue()) + .filter(entry -> entry.shouldPush(call.pushPolicy(), now)) + .ifPresent(entry -> futures.add(pushUnchecked(call, touched.getValue(), touched.getKey(), store, entry))); + } + } + return CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)); + } + + public Optional metadata(CachedApiCall call, CacheContext context) { + ApiCacheKey key = key(call, context); + return storeFor(call).get(key, context).map(ApiCacheEntry::metadata); + } + + private CompletableFuture pullAndStore( + CachedApiCall call, + CacheContext context, + ApiCacheKey key, + ApiCacheStore store, + Optional existing + ) { + return request(call.method(), call.uri(), call.body(), call.responseType()) + .thenApply(value -> { + ApiCacheEntry entry = existing.orElseGet(() -> new ApiCacheEntry(null, call.responseType().getName(), null)); + entry.replacePulled(GSON.toJsonTree(value), call.responseType().getName(), Instant.now()); + store.put(key, entry, context); + touchedContexts.put(key, context); + if (call.onSuccess() != null) { + call.onSuccess().accept(value); + } + return value; + }) + .exceptionally(error -> { + Throwable cause = unwrap(error); + if (existing.isPresent() && existing.get().payload() != null) { + ApiCacheEntry entry = existing.get(); + entry.markFailed(cause); + store.put(key, entry, context); + touchedContexts.put(key, context); + if (call.onFailure() != null) { + call.onFailure().accept(cause); + } + return value(entry, call.responseType()); + } + if (call.onFailure() != null) { + call.onFailure().accept(cause); + } + throw new CompletionException(cause); + }); + } + + private CompletableFuture push( + CachedApiCall call, + CacheContext context, + ApiCacheKey key, + ApiCacheStore store, + ApiCacheEntry entry, + T value + ) { + return request(call.pushMethod(value), call.pushUri(context, value), call.pushBody(context, value), call.responseType()) + .thenApply(response -> { + entry.markPushed(Instant.now()); + store.put(key, entry, context); + touchedContexts.put(key, context); + return response; + }) + .exceptionally(error -> { + entry.markFailed(unwrap(error)); + store.put(key, entry, context); + touchedContexts.put(key, context); + throw new CompletionException(unwrap(error)); + }); + } + + private CompletableFuture request(String method, String uri, Object body, Class responseType) { + if (batchingEnabled.get()) { + return batch.enqueue(null, uri, method, body, responseType); + } + return CompletableFuture.supplyAsync(() -> { + try { + return http.request(method, uri, body, responseType); + } catch (ControlPlaneException e) { + throw new CompletionException(e); + } + }); + } + + private void collectDirtyFlushes(CachedApiCall call, Collection entries, Instant now, Collection> futures) { + for (StoredCacheEntry stored : entries) { + if (!stored.key().callId().equals(call.id()) || !stored.entry().shouldPush(call.pushPolicy(), now)) { + continue; + } + futures.add(pushUnchecked(call, stored.context(), stored.key(), storeFor(call), stored.entry())); + } + } + + private CompletableFuture pushUnchecked( + CachedApiCall rawCall, + CacheContext context, + ApiCacheKey key, + ApiCacheStore store, + ApiCacheEntry entry + ) { + @SuppressWarnings("unchecked") + CachedApiCall call = (CachedApiCall) rawCall; + return push(call, context, key, store, entry, value(entry, call.responseType())); + } + + private ApiCacheStore storeFor(CachedApiCall call) { + return switch (call.storageTarget()) { + case PLAYER -> playerStore; + case RUNTIME_SHARED -> runtimeStore; + case ASSET_STORE -> new AssetStoreApiCacheStore(call.assetStoreAdapter()); + }; + } + + private ApiCacheKey key(CachedApiCall call, CacheContext context) { + return ApiCacheKey.of(call.id(), context == null ? "shared" : context.contextKey()); + } + + private T value(ApiCacheEntry entry, Class responseType) { + if (responseType == JsonObject.class && entry.payload() != null && entry.payload().isJsonObject()) { + return responseType.cast(entry.payload().getAsJsonObject()); + } + return GSON.fromJson(entry.payload(), responseType); + } + + private void registerIfNeeded(CachedApiCall call) { + registeredCalls.putIfAbsent(call.id(), call); + } + + private Throwable unwrap(Throwable error) { + if (error instanceof CompletionException && error.getCause() != null) { + return error.getCause(); + } + return error; + } +} diff --git a/src/main/java/net/kewwbec/network/cache/AssetStoreApiCacheStore.java b/src/main/java/net/kewwbec/network/cache/AssetStoreApiCacheStore.java new file mode 100644 index 0000000..0e9680f --- /dev/null +++ b/src/main/java/net/kewwbec/network/cache/AssetStoreApiCacheStore.java @@ -0,0 +1,30 @@ +package net.kewwbec.network.cache; + +import java.util.Collection; +import java.util.Objects; +import java.util.Optional; + +final class AssetStoreApiCacheStore implements ApiCacheStore { + private final AssetStoreCacheAdapter adapter; + + AssetStoreApiCacheStore(AssetStoreCacheAdapter adapter) { + this.adapter = Objects.requireNonNull(adapter, "adapter must not be null"); + } + + @Override + public Optional get(ApiCacheKey key, CacheContext context) { + return adapter.read(key, context); + } + + @Override + public void put(ApiCacheKey key, ApiCacheEntry entry, CacheContext context) { + adapter.write(key, entry, context); + } + + @Override + public Collection entries() { + return adapter.entries().stream() + .map(entry -> new StoredCacheEntry(entry.key(), entry.entry(), entry.context())) + .toList(); + } +} diff --git a/src/main/java/net/kewwbec/network/cache/AssetStoreCacheAdapter.java b/src/main/java/net/kewwbec/network/cache/AssetStoreCacheAdapter.java new file mode 100644 index 0000000..2e03e00 --- /dev/null +++ b/src/main/java/net/kewwbec/network/cache/AssetStoreCacheAdapter.java @@ -0,0 +1,22 @@ +package net.kewwbec.network.cache; + +import com.google.gson.JsonElement; + +import java.util.Collection; +import java.util.Optional; + +public interface AssetStoreCacheAdapter { + Optional read(ApiCacheKey key, CacheContext context); + + void write(ApiCacheKey key, ApiCacheEntry entry, CacheContext context); + + default Collection entries() { + return java.util.List.of(); + } + + record StoredAssetCacheEntry(ApiCacheKey key, ApiCacheEntry entry, CacheContext context) {} + + static ApiCacheEntry entry(JsonElement payload, String valueClassName, java.time.Instant fetchedAt) { + return new ApiCacheEntry(payload, valueClassName, fetchedAt); + } +} diff --git a/src/main/java/net/kewwbec/network/cache/CacheContext.java b/src/main/java/net/kewwbec/network/cache/CacheContext.java new file mode 100644 index 0000000..f9d11be --- /dev/null +++ b/src/main/java/net/kewwbec/network/cache/CacheContext.java @@ -0,0 +1,13 @@ +package net.kewwbec.network.cache; + +public interface CacheContext { + String contextKey(); + + static CacheContext shared() { + return () -> "shared"; + } + + static CacheContext of(String contextKey) { + return () -> contextKey; + } +} diff --git a/src/main/java/net/kewwbec/network/cache/CachedApiCall.java b/src/main/java/net/kewwbec/network/cache/CachedApiCall.java new file mode 100644 index 0000000..c914d0e --- /dev/null +++ b/src/main/java/net/kewwbec/network/cache/CachedApiCall.java @@ -0,0 +1,209 @@ +package net.kewwbec.network.cache; + +import net.kewwbec.network.generated.api.v1.shared.ApiEndpoint; + +import java.time.Duration; +import java.util.Locale; +import java.util.Objects; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.function.Supplier; + +public final class CachedApiCall { + private final String id; + private final ApiEndpoint endpoint; + private final String method; + private final Supplier uriSupplier; + private final Supplier bodySupplier; + private final Class responseType; + private final StorageTarget storageTarget; + private final PullPolicy pullPolicy; + private final PushPolicy pushPolicy; + private final AssetStoreCacheAdapter assetStoreAdapter; + private final String pushMethod; + private final BiFunction pushUriMapper; + private final BiFunction pushBodyMapper; + private final Consumer onSuccess; + private final Consumer onFailure; + + private CachedApiCall(Builder builder) { + this.id = requireNonBlank(builder.id, "id"); + this.endpoint = builder.endpoint; + this.method = requireNonBlank(builder.method, "method").toUpperCase(Locale.ROOT); + this.uriSupplier = Objects.requireNonNull(builder.uriSupplier, "uriSupplier must not be null"); + this.bodySupplier = builder.bodySupplier; + this.responseType = Objects.requireNonNull(builder.responseType, "responseType must not be null"); + this.storageTarget = Objects.requireNonNull(builder.storageTarget, "storageTarget must not be null"); + this.pullPolicy = Objects.requireNonNull(builder.pullPolicy, "pullPolicy must not be null"); + this.pushPolicy = Objects.requireNonNull(builder.pushPolicy, "pushPolicy must not be null"); + this.assetStoreAdapter = builder.assetStoreAdapter; + if (storageTarget == StorageTarget.ASSET_STORE && assetStoreAdapter == null) { + throw new IllegalArgumentException("ASSET_STORE cache calls require an AssetStoreCacheAdapter"); + } + this.pushMethod = builder.pushMethod == null ? null : builder.pushMethod.toUpperCase(Locale.ROOT); + this.pushUriMapper = builder.pushUriMapper; + this.pushBodyMapper = builder.pushBodyMapper; + this.onSuccess = builder.onSuccess; + this.onFailure = builder.onFailure; + } + + public static Builder builder(Class responseType) { + return new Builder<>(responseType); + } + + public String id() { return id; } + public ApiEndpoint endpoint() { return endpoint; } + public String method() { return method; } + public String uri() { return uriSupplier.get(); } + public Object body() { return bodySupplier != null ? bodySupplier.get() : null; } + public Class responseType() { return responseType; } + public StorageTarget storageTarget() { return storageTarget; } + public PullPolicy pullPolicy() { return pullPolicy; } + public PushPolicy pushPolicy() { return pushPolicy; } + public AssetStoreCacheAdapter assetStoreAdapter() { return assetStoreAdapter; } + public Consumer onSuccess() { return onSuccess; } + public Consumer onFailure() { return onFailure; } + + public String pushMethod(T value) { + if (pushMethod != null) { + return pushMethod; + } + if ("GET".equals(method)) { + throw new IllegalStateException("GET cache calls require explicit push mapping"); + } + return method; + } + + public String pushUri(CacheContext context, T value) { + return pushUriMapper != null ? pushUriMapper.apply(context, value) : uri(); + } + + public Object pushBody(CacheContext context, T value) { + return pushBodyMapper != null ? pushBodyMapper.apply(context, value) : value; + } + + private static String requireNonBlank(String value, String name) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return value; + } + + public static final class Builder { + private final Class responseType; + private String id; + private ApiEndpoint endpoint; + private String method; + private Supplier uriSupplier; + private Supplier bodySupplier; + private StorageTarget storageTarget = StorageTarget.RUNTIME_SHARED; + private PullPolicy pullPolicy = PullPolicy.neverRefresh(); + private PushPolicy pushPolicy = PushPolicy.manualPushOnly(); + private AssetStoreCacheAdapter assetStoreAdapter; + private String pushMethod; + private BiFunction pushUriMapper; + private BiFunction pushBodyMapper; + private Consumer onSuccess; + private Consumer onFailure; + + private Builder(Class responseType) { + this.responseType = responseType; + } + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder endpoint(ApiEndpoint endpoint) { + this.endpoint = endpoint; + return this; + } + + public Builder method(String method) { + this.method = method; + return this; + } + + public Builder uri(String uri) { + this.uriSupplier = () -> uri; + return this; + } + + public Builder uri(Supplier uriSupplier) { + this.uriSupplier = uriSupplier; + return this; + } + + public Builder body(Object body) { + this.bodySupplier = () -> body; + return this; + } + + public Builder body(Supplier bodySupplier) { + this.bodySupplier = bodySupplier; + return this; + } + + public Builder storageTarget(StorageTarget storageTarget) { + this.storageTarget = storageTarget; + return this; + } + + public Builder pullPolicy(PullPolicy pullPolicy) { + this.pullPolicy = pullPolicy; + return this; + } + + public Builder maxAge(Duration maxAge) { + this.pullPolicy = PullPolicy.maxAge(maxAge); + return this; + } + + public Builder neverRefresh() { + this.pullPolicy = PullPolicy.neverRefresh(); + return this; + } + + public Builder pushPolicy(PushPolicy pushPolicy) { + this.pushPolicy = pushPolicy; + return this; + } + + public Builder dirtyMaxAge(Duration dirtyMaxAge) { + this.pushPolicy = PushPolicy.dirtyMaxAge(dirtyMaxAge); + return this; + } + + public Builder manualPushOnly() { + this.pushPolicy = PushPolicy.manualPushOnly(); + return this; + } + + public Builder assetStoreAdapter(AssetStoreCacheAdapter assetStoreAdapter) { + this.assetStoreAdapter = assetStoreAdapter; + return this; + } + + public Builder push(String method, BiFunction uriMapper, BiFunction bodyMapper) { + this.pushMethod = method; + this.pushUriMapper = uriMapper; + this.pushBodyMapper = bodyMapper; + return this; + } + + public Builder onSuccess(Consumer onSuccess) { + this.onSuccess = onSuccess; + return this; + } + + public Builder onFailure(Consumer onFailure) { + this.onFailure = onFailure; + return this; + } + + public CachedApiCall build() { + return new CachedApiCall<>(this); + } + } +} diff --git a/src/main/java/net/kewwbec/network/cache/NetworkApiDataComponent.java b/src/main/java/net/kewwbec/network/cache/NetworkApiDataComponent.java new file mode 100644 index 0000000..66559a2 --- /dev/null +++ b/src/main/java/net/kewwbec/network/cache/NetworkApiDataComponent.java @@ -0,0 +1,58 @@ +package net.kewwbec.network.cache; + +import com.hypixel.hytale.codec.Codec; +import com.hypixel.hytale.codec.KeyedCodec; +import com.hypixel.hytale.codec.builder.BuilderCodec; +import com.hypixel.hytale.codec.codecs.map.MapCodec; +import com.hypixel.hytale.component.Component; +import com.hypixel.hytale.component.ComponentType; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import java.util.HashMap; +import java.util.Map; + +public final class NetworkApiDataComponent implements Component { + public static final BuilderCodec CODEC = + BuilderCodec.builder(NetworkApiDataComponent.class, NetworkApiDataComponent::new) + .append(new KeyedCodec<>("Entries", new MapCodec<>(Codec.STRING, HashMap::new, false)), + (component, entries) -> component.entries = new HashMap<>(entries), + component -> component.entries) + .add() + .build(); + + private static ComponentType componentType; + + private Map entries = new HashMap<>(); + + public static void setComponentType(ComponentType componentType) { + NetworkApiDataComponent.componentType = componentType; + } + + public static ComponentType componentType() { + if (componentType == null) { + throw new IllegalStateException("NetworkApiDataComponent has not been registered"); + } + return componentType; + } + + public Map entries() { + return entries; + } + + public String get(String key) { + return entries.get(key); + } + + public void put(String key, String value) { + entries.put(key, value); + } + + @Nonnull + @Override + public Component clone() { + NetworkApiDataComponent copy = new NetworkApiDataComponent(); + copy.entries = new HashMap<>(entries); + return copy; + } +} diff --git a/src/main/java/net/kewwbec/network/cache/PlayerApiCacheStore.java b/src/main/java/net/kewwbec/network/cache/PlayerApiCacheStore.java new file mode 100644 index 0000000..d033e8d --- /dev/null +++ b/src/main/java/net/kewwbec/network/cache/PlayerApiCacheStore.java @@ -0,0 +1,41 @@ +package net.kewwbec.network.cache; + +import com.google.gson.JsonParser; + +import java.util.Collection; +import java.util.Optional; + +final class PlayerApiCacheStore implements ApiCacheStore { + @Override + public Optional get(ApiCacheKey key, CacheContext context) { + PlayerCacheContext playerContext = playerContext(context); + NetworkApiDataComponent component = playerContext.store() + .ensureAndGetComponent(playerContext.ref(), NetworkApiDataComponent.componentType()); + String raw = component.get(key.encoded()); + if (raw == null || raw.isBlank()) { + return Optional.empty(); + } + return Optional.of(ApiCacheEntry.fromJsonObject(JsonParser.parseString(raw).getAsJsonObject())); + } + + @Override + public void put(ApiCacheKey key, ApiCacheEntry entry, CacheContext context) { + PlayerCacheContext playerContext = playerContext(context); + NetworkApiDataComponent component = playerContext.store() + .ensureAndGetComponent(playerContext.ref(), NetworkApiDataComponent.componentType()); + component.put(key.encoded(), entry.toJsonObject().toString()); + playerContext.store().putComponent(playerContext.ref(), NetworkApiDataComponent.componentType(), component); + } + + @Override + public Collection entries() { + return java.util.List.of(); + } + + private PlayerCacheContext playerContext(CacheContext context) { + if (!(context instanceof PlayerCacheContext playerContext)) { + throw new IllegalArgumentException("PLAYER cache calls require PlayerCacheContext"); + } + return playerContext; + } +} diff --git a/src/main/java/net/kewwbec/network/cache/PlayerCacheContext.java b/src/main/java/net/kewwbec/network/cache/PlayerCacheContext.java new file mode 100644 index 0000000..ffec042 --- /dev/null +++ b/src/main/java/net/kewwbec/network/cache/PlayerCacheContext.java @@ -0,0 +1,30 @@ +package net.kewwbec.network.cache; + +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import java.util.Objects; + +public final class PlayerCacheContext implements CacheContext { + private final String playerId; + private final Ref ref; + private final Store store; + + public PlayerCacheContext(String playerId, Ref ref, Store store) { + if (playerId == null || playerId.isBlank()) { + throw new IllegalArgumentException("playerId must not be blank"); + } + this.playerId = playerId; + this.ref = Objects.requireNonNull(ref, "ref must not be null"); + this.store = Objects.requireNonNull(store, "store must not be null"); + } + + @Override + public String contextKey() { + return playerId; + } + + public Ref ref() { return ref; } + public Store store() { return store; } +} diff --git a/src/main/java/net/kewwbec/network/cache/PullPolicy.java b/src/main/java/net/kewwbec/network/cache/PullPolicy.java new file mode 100644 index 0000000..9f12172 --- /dev/null +++ b/src/main/java/net/kewwbec/network/cache/PullPolicy.java @@ -0,0 +1,29 @@ +package net.kewwbec.network.cache; + +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; + +public final class PullPolicy { + private final Duration maxAge; + + private PullPolicy(Duration maxAge) { + this.maxAge = maxAge; + } + + public static PullPolicy maxAge(Duration maxAge) { + Objects.requireNonNull(maxAge, "maxAge must not be null"); + if (maxAge.isNegative() || maxAge.isZero()) { + throw new IllegalArgumentException("maxAge must be positive"); + } + return new PullPolicy(maxAge); + } + + public static PullPolicy neverRefresh() { + return new PullPolicy(null); + } + + public Optional maxAge() { + return Optional.ofNullable(maxAge); + } +} diff --git a/src/main/java/net/kewwbec/network/cache/PushPolicy.java b/src/main/java/net/kewwbec/network/cache/PushPolicy.java new file mode 100644 index 0000000..26b33d7 --- /dev/null +++ b/src/main/java/net/kewwbec/network/cache/PushPolicy.java @@ -0,0 +1,29 @@ +package net.kewwbec.network.cache; + +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; + +public final class PushPolicy { + private final Duration dirtyMaxAge; + + private PushPolicy(Duration dirtyMaxAge) { + this.dirtyMaxAge = dirtyMaxAge; + } + + public static PushPolicy dirtyMaxAge(Duration dirtyMaxAge) { + Objects.requireNonNull(dirtyMaxAge, "dirtyMaxAge must not be null"); + if (dirtyMaxAge.isNegative() || dirtyMaxAge.isZero()) { + throw new IllegalArgumentException("dirtyMaxAge must be positive"); + } + return new PushPolicy(dirtyMaxAge); + } + + public static PushPolicy manualPushOnly() { + return new PushPolicy(null); + } + + public Optional dirtyMaxAge() { + return Optional.ofNullable(dirtyMaxAge); + } +} diff --git a/src/main/java/net/kewwbec/network/cache/RuntimeApiCacheStore.java b/src/main/java/net/kewwbec/network/cache/RuntimeApiCacheStore.java new file mode 100644 index 0000000..c3234cd --- /dev/null +++ b/src/main/java/net/kewwbec/network/cache/RuntimeApiCacheStore.java @@ -0,0 +1,26 @@ +package net.kewwbec.network.cache; + +import java.util.Collection; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +final class RuntimeApiCacheStore implements ApiCacheStore { + private final ConcurrentHashMap entries = new ConcurrentHashMap<>(); + + @Override + public Optional get(ApiCacheKey key, CacheContext context) { + return Optional.ofNullable(entries.get(key)); + } + + @Override + public void put(ApiCacheKey key, ApiCacheEntry entry, CacheContext context) { + entries.put(key, entry); + } + + @Override + public Collection entries() { + return entries.entrySet().stream() + .map(entry -> new StoredCacheEntry(entry.getKey(), entry.getValue(), CacheContext.of(entry.getKey().contextKey()))) + .toList(); + } +} diff --git a/src/main/java/net/kewwbec/network/cache/StorageTarget.java b/src/main/java/net/kewwbec/network/cache/StorageTarget.java new file mode 100644 index 0000000..e0cc599 --- /dev/null +++ b/src/main/java/net/kewwbec/network/cache/StorageTarget.java @@ -0,0 +1,7 @@ +package net.kewwbec.network.cache; + +public enum StorageTarget { + PLAYER, + RUNTIME_SHARED, + ASSET_STORE +} diff --git a/src/main/java/net/kewwbec/network/cache/StoredCacheEntry.java b/src/main/java/net/kewwbec/network/cache/StoredCacheEntry.java new file mode 100644 index 0000000..022eddd --- /dev/null +++ b/src/main/java/net/kewwbec/network/cache/StoredCacheEntry.java @@ -0,0 +1,4 @@ +package net.kewwbec.network.cache; + +record StoredCacheEntry(ApiCacheKey key, ApiCacheEntry entry, CacheContext context) { +} diff --git a/src/main/java/net/kewwbec/network/cache/SyncState.java b/src/main/java/net/kewwbec/network/cache/SyncState.java new file mode 100644 index 0000000..ef65f38 --- /dev/null +++ b/src/main/java/net/kewwbec/network/cache/SyncState.java @@ -0,0 +1,7 @@ +package net.kewwbec.network.cache; + +public enum SyncState { + CLEAN, + DIRTY, + FAILED +} diff --git a/src/main/java/net/kewwbec/network/client/PlayersClient.java b/src/main/java/net/kewwbec/network/client/PlayersClient.java deleted file mode 100644 index 6877a16..0000000 --- a/src/main/java/net/kewwbec/network/client/PlayersClient.java +++ /dev/null @@ -1,29 +0,0 @@ -package net.kewwbec.network.client; - -import net.kewwbec.network.errors.ControlPlaneException; -import net.kewwbec.network.generated.api.v1.players.EntitlementsIndexEndpoint; -import net.kewwbec.network.generated.api.v1.players.SessionProfileEndpoint; -import net.kewwbec.network.http.ControlPlaneHttpClient; -import net.kewwbec.network.http.PathBuilder; -import net.kewwbec.network.models.players.PlayerEntitlement; -import net.kewwbec.network.models.players.PlayerSessionProfile; - -import java.util.List; - -public final class PlayersClient { - private final ControlPlaneHttpClient http; - - public PlayersClient(ControlPlaneHttpClient http) { - this.http = http; - } - - public PlayerSessionProfile getSessionProfile(String playerId) throws ControlPlaneException { - String uri = PathBuilder.resolve(SessionProfileEndpoint.VALUE.uri(), "player", playerId); - return http.get(uri, PlayerSessionProfile.class); - } - - public List getEntitlements(String playerId) throws ControlPlaneException { - String uri = PathBuilder.resolve(EntitlementsIndexEndpoint.VALUE.uri(), "player", playerId); - return http.getList(uri, PlayerEntitlement.class); - } -} diff --git a/src/main/java/net/kewwbec/network/client/PresenceClient.java b/src/main/java/net/kewwbec/network/client/PresenceClient.java deleted file mode 100644 index 7d55994..0000000 --- a/src/main/java/net/kewwbec/network/client/PresenceClient.java +++ /dev/null @@ -1,40 +0,0 @@ -package net.kewwbec.network.client; - -import net.kewwbec.network.errors.ControlPlaneException; -import net.kewwbec.network.generated.api.v1.server.InstancesHeartbeatEndpoint; -import net.kewwbec.network.generated.api.v1.server.InstancesRegisterEndpoint; -import net.kewwbec.network.generated.api.v1.server.InstancesShutdownEndpoint; -import net.kewwbec.network.http.ControlPlaneHttpClient; -import net.kewwbec.network.http.PathBuilder; -import net.kewwbec.network.models.server.HeartbeatPayload; -import net.kewwbec.network.models.server.RegistrationPayload; - -public final class PresenceClient { - private final ControlPlaneHttpClient http; - - public PresenceClient(ControlPlaneHttpClient http) { - this.http = http; - } - - public void register(String instanceId, RegistrationPayload payload) throws ControlPlaneException { - String uri = PathBuilder.resolve(InstancesRegisterEndpoint.VALUE.uri(), "instance", instanceId); - http.post(uri, payload); - } - - public void heartbeat(String instanceId, HeartbeatPayload payload) throws ControlPlaneException { - String uri = PathBuilder.resolve(InstancesHeartbeatEndpoint.VALUE.uri(), "instance", instanceId); - http.post(uri, payload); - } - - public void shutdown(String instanceId) throws ControlPlaneException { - shutdown(instanceId, null); - } - - public void shutdown(String instanceId, String reason) throws ControlPlaneException { - String uri = PathBuilder.resolve(InstancesShutdownEndpoint.VALUE.uri(), "instance", instanceId); - ShutdownBody body = reason != null ? new ShutdownBody(reason) : null; - http.post(uri, body); - } - - private record ShutdownBody(String reason) {} -} diff --git a/src/main/java/net/kewwbec/network/client/ServerClient.java b/src/main/java/net/kewwbec/network/client/ServerClient.java deleted file mode 100644 index 34148d2..0000000 --- a/src/main/java/net/kewwbec/network/client/ServerClient.java +++ /dev/null @@ -1,18 +0,0 @@ -package net.kewwbec.network.client; - -import net.kewwbec.network.errors.ControlPlaneException; -import net.kewwbec.network.generated.api.v1.server.ProfileEndpoint; -import net.kewwbec.network.http.ControlPlaneHttpClient; -import net.kewwbec.network.models.server.ServerProfile; - -public final class ServerClient { - private final ControlPlaneHttpClient http; - - public ServerClient(ControlPlaneHttpClient http) { - this.http = http; - } - - public ServerProfile currentProfile() throws ControlPlaneException { - return http.get(ProfileEndpoint.VALUE.uri(), ServerProfile.class); - } -} diff --git a/src/main/java/net/kewwbec/network/config/ControlPlaneConfigurationSource.java b/src/main/java/net/kewwbec/network/config/ControlPlaneConfigurationSource.java new file mode 100644 index 0000000..9fe13c5 --- /dev/null +++ b/src/main/java/net/kewwbec/network/config/ControlPlaneConfigurationSource.java @@ -0,0 +1,8 @@ +package net.kewwbec.network.config; + +public enum ControlPlaneConfigurationSource { + MANUAL, + CONFIG_FILE, + SYSTEM_PROPERTY, + ENVIRONMENT +} diff --git a/src/main/java/net/kewwbec/network/config/ControlPlaneRuntimeConfig.java b/src/main/java/net/kewwbec/network/config/ControlPlaneRuntimeConfig.java new file mode 100644 index 0000000..3787b2b --- /dev/null +++ b/src/main/java/net/kewwbec/network/config/ControlPlaneRuntimeConfig.java @@ -0,0 +1,109 @@ +package net.kewwbec.network.config; + +import com.hypixel.hytale.codec.Codec; +import com.hypixel.hytale.codec.KeyedCodec; +import com.hypixel.hytale.codec.builder.BuilderCodec; + +import java.time.Duration; + +public final class ControlPlaneRuntimeConfig { + public static final BuilderCodec CODEC = + BuilderCodec.builder(ControlPlaneRuntimeConfig.class, ControlPlaneRuntimeConfig::new) + .append(new KeyedCodec<>("BaseUrl", Codec.STRING), + (config, value) -> config.baseUrl = value, + ControlPlaneRuntimeConfig::getBaseUrl) + .add() + .append(new KeyedCodec<>("Token", Codec.STRING), + (config, value) -> config.token = value, + ControlPlaneRuntimeConfig::getToken) + .add() + .append(new KeyedCodec<>("BatchingEnabled", Codec.BOOLEAN), + (config, value) -> { + if (value != null) config.batchingEnabled = value; + }, + ControlPlaneRuntimeConfig::isBatchingEnabled) + .add() + .append(new KeyedCodec<>("BatchFlushIntervalMs", Codec.INTEGER), + (config, value) -> { + if (value != null) config.batchFlushIntervalMs = value; + }, + ControlPlaneRuntimeConfig::getBatchFlushIntervalMs) + .add() + .append(new KeyedCodec<>("MaxBatchSize", Codec.INTEGER), + (config, value) -> { + if (value != null) config.maxBatchSize = value; + }, + ControlPlaneRuntimeConfig::getMaxBatchSize) + .add() + .append(new KeyedCodec<>("BatchEndpoint", Codec.STRING), + (config, value) -> config.batchEndpoint = value, + ControlPlaneRuntimeConfig::getBatchEndpoint) + .add() + .build(); + + private String baseUrl = ""; + private String token = ""; + private boolean batchingEnabled = ControlPlaneStartupSettings.DEFAULT_BATCHING_ENABLED; + private int batchFlushIntervalMs = Math.toIntExact(ControlPlaneStartupSettings.DEFAULT_BATCH_FLUSH_INTERVAL.toMillis()); + private int maxBatchSize = ControlPlaneStartupSettings.DEFAULT_MAX_BATCH_SIZE; + private String batchEndpoint = ControlPlaneStartupSettings.DEFAULT_BATCH_ENDPOINT; + + public String getBaseUrl() { + return baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public boolean isBatchingEnabled() { + return batchingEnabled; + } + + public void setBatchingEnabled(boolean batchingEnabled) { + this.batchingEnabled = batchingEnabled; + } + + public int getBatchFlushIntervalMs() { + return batchFlushIntervalMs; + } + + public void setBatchFlushIntervalMs(int batchFlushIntervalMs) { + this.batchFlushIntervalMs = batchFlushIntervalMs; + } + + public int getMaxBatchSize() { + return maxBatchSize; + } + + public void setMaxBatchSize(int maxBatchSize) { + this.maxBatchSize = maxBatchSize; + } + + public String getBatchEndpoint() { + return batchEndpoint; + } + + public void setBatchEndpoint(String batchEndpoint) { + this.batchEndpoint = batchEndpoint; + } + + public ControlPlaneStartupSettings toStartupSettings() { + return new ControlPlaneStartupSettings( + baseUrl, + token, + batchingEnabled, + Duration.ofMillis(batchFlushIntervalMs), + maxBatchSize, + batchEndpoint + ); + } +} diff --git a/src/main/java/net/kewwbec/network/config/ControlPlaneStartupConfigResolver.java b/src/main/java/net/kewwbec/network/config/ControlPlaneStartupConfigResolver.java new file mode 100644 index 0000000..53b7a06 --- /dev/null +++ b/src/main/java/net/kewwbec/network/config/ControlPlaneStartupConfigResolver.java @@ -0,0 +1,196 @@ +package net.kewwbec.network.config; + +import java.time.Duration; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; + +public final class ControlPlaneStartupConfigResolver { + public static final String ENV_BASE_URL = "KWB_CONTROL_PLANE_BASE_URL"; + public static final String ENV_TOKEN = "KWB_CONTROL_PLANE_TOKEN"; + public static final String ENV_BATCHING_ENABLED = "KWB_CONTROL_PLANE_BATCHING_ENABLED"; + public static final String ENV_BATCH_FLUSH_INTERVAL_MS = "KWB_CONTROL_PLANE_BATCH_FLUSH_INTERVAL_MS"; + public static final String ENV_MAX_BATCH_SIZE = "KWB_CONTROL_PLANE_BATCH_MAX_SIZE"; + public static final String ENV_BATCH_ENDPOINT = "KWB_CONTROL_PLANE_BATCH_ENDPOINT"; + + public static final String PROPERTY_BASE_URL = "kweebec.controlPlane.baseUrl"; + public static final String PROPERTY_TOKEN = "kweebec.controlPlane.token"; + public static final String PROPERTY_BATCHING_ENABLED = "kweebec.controlPlane.batching.enabled"; + public static final String PROPERTY_BATCH_FLUSH_INTERVAL_MS = "kweebec.controlPlane.batch.flushIntervalMs"; + public static final String PROPERTY_MAX_BATCH_SIZE = "kweebec.controlPlane.batch.maxSize"; + public static final String PROPERTY_BATCH_ENDPOINT = "kweebec.controlPlane.batch.endpoint"; + + public Optional resolve(ControlPlaneStartupSettings fileSettings) { + return resolve(System.getenv(), System.getProperties(), fileSettings); + } + + public Optional resolve( + Map environment, + Properties systemProperties, + ControlPlaneStartupSettings fileSettings + ) { + MutableSettings settings = MutableSettings.from(fileSettings != null + ? fileSettings + : ControlPlaneStartupSettings.defaults()); + ControlPlaneConfigurationSource source = settings.hasRequiredCredentials() + ? ControlPlaneConfigurationSource.CONFIG_FILE + : null; + + if (applySystemProperties(settings, systemProperties)) { + source = ControlPlaneConfigurationSource.SYSTEM_PROPERTY; + } + if (applyEnvironment(settings, environment)) { + source = ControlPlaneConfigurationSource.ENVIRONMENT; + } + + ControlPlaneStartupSettings resolved = settings.toImmutable(); + if (!resolved.hasRequiredCredentials()) { + return Optional.empty(); + } + return Optional.of(new ResolvedControlPlaneStartupConfig( + resolved, + source != null ? source : ControlPlaneConfigurationSource.CONFIG_FILE + )); + } + + private boolean applySystemProperties(MutableSettings settings, Properties properties) { + if (properties == null) { + return false; + } + boolean changed = false; + changed |= stringProperty(properties, PROPERTY_BASE_URL).map(value -> { + settings.baseUrl = value; + return true; + }).orElse(false); + changed |= stringProperty(properties, PROPERTY_TOKEN).map(value -> { + settings.token = value; + return true; + }).orElse(false); + changed |= stringProperty(properties, PROPERTY_BATCHING_ENABLED).map(value -> { + settings.batchingEnabled = parseBoolean(PROPERTY_BATCHING_ENABLED, value); + return true; + }).orElse(false); + changed |= stringProperty(properties, PROPERTY_BATCH_FLUSH_INTERVAL_MS).map(value -> { + settings.batchFlushInterval = parsePositiveMillis(PROPERTY_BATCH_FLUSH_INTERVAL_MS, value); + return true; + }).orElse(false); + changed |= stringProperty(properties, PROPERTY_MAX_BATCH_SIZE).map(value -> { + settings.maxBatchSize = parsePositiveInt(PROPERTY_MAX_BATCH_SIZE, value); + return true; + }).orElse(false); + changed |= stringProperty(properties, PROPERTY_BATCH_ENDPOINT).map(value -> { + settings.batchEndpoint = value; + return true; + }).orElse(false); + return changed; + } + + private boolean applyEnvironment(MutableSettings settings, Map environment) { + if (environment == null) { + return false; + } + boolean changed = false; + changed |= stringEnv(environment, ENV_BASE_URL).map(value -> { + settings.baseUrl = value; + return true; + }).orElse(false); + changed |= stringEnv(environment, ENV_TOKEN).map(value -> { + settings.token = value; + return true; + }).orElse(false); + changed |= stringEnv(environment, ENV_BATCHING_ENABLED).map(value -> { + settings.batchingEnabled = parseBoolean(ENV_BATCHING_ENABLED, value); + return true; + }).orElse(false); + changed |= stringEnv(environment, ENV_BATCH_FLUSH_INTERVAL_MS).map(value -> { + settings.batchFlushInterval = parsePositiveMillis(ENV_BATCH_FLUSH_INTERVAL_MS, value); + return true; + }).orElse(false); + changed |= stringEnv(environment, ENV_MAX_BATCH_SIZE).map(value -> { + settings.maxBatchSize = parsePositiveInt(ENV_MAX_BATCH_SIZE, value); + return true; + }).orElse(false); + changed |= stringEnv(environment, ENV_BATCH_ENDPOINT).map(value -> { + settings.batchEndpoint = value; + return true; + }).orElse(false); + return changed; + } + + private static Optional stringProperty(Properties properties, String key) { + return nonBlank(properties.getProperty(key)); + } + + private static Optional stringEnv(Map environment, String key) { + return nonBlank(environment.get(key)); + } + + private static Optional nonBlank(String value) { + if (value == null) { + return Optional.empty(); + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? Optional.empty() : Optional.of(trimmed); + } + + private static boolean parseBoolean(String key, String value) { + if ("true".equalsIgnoreCase(value)) { + return true; + } + if ("false".equalsIgnoreCase(value)) { + return false; + } + throw new IllegalArgumentException(key + " must be true or false"); + } + + private static Duration parsePositiveMillis(String key, String value) { + return Duration.ofMillis(parsePositiveInt(key, value)); + } + + private static int parsePositiveInt(String key, String value) { + try { + int parsed = Integer.parseInt(value); + if (parsed <= 0) { + throw new IllegalArgumentException(key + " must be positive"); + } + return parsed; + } catch (NumberFormatException e) { + throw new IllegalArgumentException(key + " must be a positive integer", e); + } + } + + private static final class MutableSettings { + private String baseUrl; + private String token; + private boolean batchingEnabled; + private Duration batchFlushInterval; + private int maxBatchSize; + private String batchEndpoint; + + private static MutableSettings from(ControlPlaneStartupSettings settings) { + MutableSettings mutable = new MutableSettings(); + mutable.baseUrl = settings.baseUrl(); + mutable.token = settings.token(); + mutable.batchingEnabled = settings.batchingEnabled(); + mutable.batchFlushInterval = settings.batchFlushInterval(); + mutable.maxBatchSize = settings.maxBatchSize(); + mutable.batchEndpoint = settings.batchEndpoint(); + return mutable; + } + + private boolean hasRequiredCredentials() { + return baseUrl != null && token != null; + } + + private ControlPlaneStartupSettings toImmutable() { + return new ControlPlaneStartupSettings( + baseUrl, + token, + batchingEnabled, + batchFlushInterval, + maxBatchSize, + batchEndpoint + ); + } + } +} diff --git a/src/main/java/net/kewwbec/network/config/ControlPlaneStartupSettings.java b/src/main/java/net/kewwbec/network/config/ControlPlaneStartupSettings.java new file mode 100644 index 0000000..e8dba9a --- /dev/null +++ b/src/main/java/net/kewwbec/network/config/ControlPlaneStartupSettings.java @@ -0,0 +1,75 @@ +package net.kewwbec.network.config; + +import net.kewwbec.network.ControlPlaneClient; + +import java.time.Duration; +import java.util.Objects; + +public record ControlPlaneStartupSettings( + String baseUrl, + String token, + boolean batchingEnabled, + Duration batchFlushInterval, + int maxBatchSize, + String batchEndpoint +) { + public static final boolean DEFAULT_BATCHING_ENABLED = true; + public static final Duration DEFAULT_BATCH_FLUSH_INTERVAL = Duration.ofMillis(250); + public static final int DEFAULT_MAX_BATCH_SIZE = 100; + public static final String DEFAULT_BATCH_ENDPOINT = "/api/v1/batch"; + + public ControlPlaneStartupSettings { + baseUrl = trimToNull(baseUrl); + token = trimToNull(token); + batchFlushInterval = Objects.requireNonNullElse(batchFlushInterval, DEFAULT_BATCH_FLUSH_INTERVAL); + batchEndpoint = defaultIfBlank(batchEndpoint, DEFAULT_BATCH_ENDPOINT); + if (batchFlushInterval.isZero() || batchFlushInterval.isNegative()) { + throw new IllegalArgumentException("batchFlushInterval must be positive"); + } + if (maxBatchSize <= 0) { + throw new IllegalArgumentException("maxBatchSize must be positive"); + } + } + + public static ControlPlaneStartupSettings defaults() { + return new ControlPlaneStartupSettings( + null, + null, + DEFAULT_BATCHING_ENABLED, + DEFAULT_BATCH_FLUSH_INTERVAL, + DEFAULT_MAX_BATCH_SIZE, + DEFAULT_BATCH_ENDPOINT + ); + } + + public boolean hasRequiredCredentials() { + return baseUrl != null && token != null; + } + + public ControlPlaneClient buildClient() { + if (!hasRequiredCredentials()) { + throw new IllegalStateException("baseUrl and token must be set"); + } + return ControlPlaneClient.builder() + .baseUrl(baseUrl) + .token(token) + .batchingEnabled(batchingEnabled) + .batchFlushInterval(batchFlushInterval) + .maxBatchSize(maxBatchSize) + .batchEndpoint(batchEndpoint) + .build(); + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static String defaultIfBlank(String value, String defaultValue) { + String trimmed = trimToNull(value); + return trimmed == null ? defaultValue : trimmed; + } +} diff --git a/src/main/java/net/kewwbec/network/config/ResolvedControlPlaneStartupConfig.java b/src/main/java/net/kewwbec/network/config/ResolvedControlPlaneStartupConfig.java new file mode 100644 index 0000000..c03f4d7 --- /dev/null +++ b/src/main/java/net/kewwbec/network/config/ResolvedControlPlaneStartupConfig.java @@ -0,0 +1,7 @@ +package net.kewwbec.network.config; + +public record ResolvedControlPlaneStartupConfig( + ControlPlaneStartupSettings settings, + ControlPlaneConfigurationSource source +) { +} diff --git a/src/main/java/net/kewwbec/network/generated/api/v1/ControlPlaneApiV1.java b/src/main/java/net/kewwbec/network/generated/api/v1/ControlPlaneApiV1.java index 8982974..2e73c0c 100644 --- a/src/main/java/net/kewwbec/network/generated/api/v1/ControlPlaneApiV1.java +++ b/src/main/java/net/kewwbec/network/generated/api/v1/ControlPlaneApiV1.java @@ -3,6 +3,8 @@ package net.kewwbec.network.generated.api.v1; import net.kewwbec.network.generated.api.v1.announcements.AnnouncementsApi; import net.kewwbec.network.generated.api.v1.assets.AssetsApi; import net.kewwbec.network.generated.api.v1.audit.AuditApi; +import net.kewwbec.network.generated.api.v1.batch.BatchApi; +import net.kewwbec.network.generated.api.v1.configs.ConfigsApi; import net.kewwbec.network.generated.api.v1.entitlements.EntitlementsApi; import net.kewwbec.network.generated.api.v1.gamepermissions.GamePermissionsApi; import net.kewwbec.network.generated.api.v1.gameplay.GameplayApi; @@ -26,9 +28,9 @@ public final class ControlPlaneApiV1 { private static final String API_VERSION = "v1"; private static final String BASE_PATH = "/api/v1"; - private static final String FINGERPRINT = "4627923de5907196aabfc8de361f6e168f139a0476a206a5367fb5550b9dedbc"; - private static final int DOMAIN_COUNT = 20; - private static final int ENDPOINT_COUNT = 136; + private static final String FINGERPRINT = "38972c4e6c8020ff35de5b63ad1dc0465d9f8a3c6d65736991e77759bd369db2"; + private static final int DOMAIN_COUNT = 22; + private static final int ENDPOINT_COUNT = 140; private ControlPlaneApiV1() { } @@ -65,6 +67,14 @@ public final class ControlPlaneApiV1 { return AuditApi.INSTANCE; } + public BatchApi batch() { + return BatchApi.INSTANCE; + } + + public ConfigsApi configs() { + return ConfigsApi.INSTANCE; + } + public EntitlementsApi entitlements() { return EntitlementsApi.INSTANCE; } diff --git a/src/main/java/net/kewwbec/network/generated/api/v1/server/ProfileEndpoint.java b/src/main/java/net/kewwbec/network/generated/api/v1/server/ProfileEndpoint.java index 7cc7fed..8f6e45c 100644 --- a/src/main/java/net/kewwbec/network/generated/api/v1/server/ProfileEndpoint.java +++ b/src/main/java/net/kewwbec/network/generated/api/v1/server/ProfileEndpoint.java @@ -19,7 +19,7 @@ public final class ProfileEndpoint { List.of("api", "auth:sanctum", "service.abilities:server.profile.read"), List.of(), null, - new ResponseDescriptor("unknown", new TypeDescriptor(null, null, false)) + new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Infrastructure\\Resources\\ServerProfileResource", false, false)) ) ; diff --git a/src/main/java/net/kewwbec/network/generated/api/v1/social/SocialApi.java b/src/main/java/net/kewwbec/network/generated/api/v1/social/SocialApi.java index edefa84..983da0d 100644 --- a/src/main/java/net/kewwbec/network/generated/api/v1/social/SocialApi.java +++ b/src/main/java/net/kewwbec/network/generated/api/v1/social/SocialApi.java @@ -19,6 +19,7 @@ public final class SocialApi { GuildsLeaveEndpoint.VALUE, GuildsMembersStoreEndpoint.VALUE, GuildsOwnershipTransferEndpoint.VALUE, + GuildsRenameEndpoint.VALUE, GuildsStoreEndpoint.VALUE, PartiesInvitesStoreEndpoint.VALUE, PartiesKicksStoreEndpoint.VALUE, @@ -104,6 +105,10 @@ public final class SocialApi { return GuildsOwnershipTransferEndpoint.VALUE; } + public ApiEndpoint guildsRename() { + return GuildsRenameEndpoint.VALUE; + } + public ApiEndpoint guildsStore() { return GuildsStoreEndpoint.VALUE; } diff --git a/src/main/java/net/kewwbec/network/http/ControlPlaneHttpClient.java b/src/main/java/net/kewwbec/network/http/ControlPlaneHttpClient.java index 0682cea..f156e34 100644 --- a/src/main/java/net/kewwbec/network/http/ControlPlaneHttpClient.java +++ b/src/main/java/net/kewwbec/network/http/ControlPlaneHttpClient.java @@ -2,12 +2,14 @@ package net.kewwbec.network.http; import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.google.gson.reflect.TypeToken; import java.lang.reflect.Type; +import net.kewwbec.network.batch.BatchClient; import net.kewwbec.network.auth.ApiToken; import net.kewwbec.network.errors.AuthException; import net.kewwbec.network.errors.ControlPlaneException; @@ -21,8 +23,11 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; +import java.util.List; import java.util.HashMap; import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; public final class ControlPlaneHttpClient { private static final Gson GSON = new GsonBuilder().create(); @@ -63,6 +68,23 @@ public final class ControlPlaneHttpClient { return send(request); } + public JsonObject request(String method, String uri, Object body) throws ControlPlaneException { + String normalizedMethod = method == null ? "" : method.toUpperCase(java.util.Locale.ROOT); + if ("GET".equals(normalizedMethod)) { + return get(uri); + } + String json = body != null ? GSON.toJson(body) : "{}"; + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + uri)) + .header("Authorization", "Bearer " + token.value()) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .method(normalizedMethod, HttpRequest.BodyPublishers.ofString(json)) + .timeout(Duration.ofSeconds(10)) + .build(); + return send(request); + } + public T get(String uri, Class responseType) throws ControlPlaneException { JsonObject body = get(uri); return GSON.fromJson(extractData(body), responseType); @@ -79,6 +101,73 @@ public final class ControlPlaneHttpClient { return GSON.fromJson(extractData(body), responseType); } + public T request(String method, String uri, Object requestBody, Class responseType) throws ControlPlaneException { + JsonObject body = request(method, uri, requestBody); + return GSON.fromJson(extractData(body), responseType); + } + + public void postBatch(String uri, List> calls) throws ControlPlaneException { + if (calls.isEmpty()) { + return; + } + + JsonObject payload = new JsonObject(); + JsonArray requests = new JsonArray(); + for (BatchClient.PendingCall call : calls) { + JsonObject request = new JsonObject(); + request.addProperty("id", call.id()); + request.addProperty("method", call.method()); + request.addProperty("uri", call.uri()); + request.add("body", call.body() != null ? GSON.toJsonTree(call.body()) : null); + requests.add(request); + } + payload.add("requests", requests); + + JsonObject envelope = post(uri, payload); + JsonArray responses = envelope.has("responses") && envelope.get("responses").isJsonArray() + ? envelope.getAsJsonArray("responses") + : new JsonArray(); + + Map> byId = calls.stream() + .collect(Collectors.toMap(BatchClient.PendingCall::id, Function.identity())); + + for (JsonElement responseElement : responses) { + if (!responseElement.isJsonObject()) { + continue; + } + JsonObject response = responseElement.getAsJsonObject(); + if (!response.has("id")) { + continue; + } + BatchClient.PendingCall call = byId.remove(response.get("id").getAsString()); + if (call != null) { + completeBatchCall(call, response); + } + } + + for (BatchClient.PendingCall missing : byId.values()) { + missing.completeExceptionally(new ControlPlaneException("Batch response missing item " + missing.id())); + } + } + + private void completeBatchCall(BatchClient.PendingCall call, JsonObject response) { + int status = response.has("status") ? response.get("status").getAsInt() : 0; + JsonObject body = response.has("body") && response.get("body").isJsonObject() + ? response.getAsJsonObject("body") + : new JsonObject(); + JsonObject error = response.has("error") && response.get("error").isJsonObject() + ? response.getAsJsonObject("error") + : body; + + if (status >= 200 && status < 300) { + T value = GSON.fromJson(extractData(body), call.responseType()); + call.complete(value); + return; + } + + call.completeExceptionally(exceptionForStatus(status, error)); + } + private JsonObject send(HttpRequest request) throws ControlPlaneException { HttpResponse response; try { @@ -101,17 +190,9 @@ public final class ControlPlaneHttpClient { return element.isJsonObject() ? element.getAsJsonObject() : new JsonObject(); } - if (status == 401 || status == 403) { - throw new AuthException(status, parseMessage(responseBody)); - } - if (status == 404) { - throw new NotFoundException(parseMessage(responseBody)); - } - if (status == 422) { - throw new ValidationException(parseMessage(responseBody), parseErrors(responseBody)); - } - if (status >= 500) { - throw new RemoteException(status, parseMessage(responseBody)); + ControlPlaneException mapped = exceptionForStatus(status, responseBody); + if (mapped != null) { + throw mapped; } throw new ControlPlaneException("Unexpected response status " + status + ": " + responseBody); @@ -127,29 +208,76 @@ public final class ControlPlaneHttpClient { private String parseMessage(String body) { try { JsonObject obj = JsonParser.parseString(body).getAsJsonObject(); - if (obj.has("message")) { - return obj.get("message").getAsString(); - } + return parseMessage(obj); } catch (Exception ignored) {} return body; } + private String parseMessage(JsonObject obj) { + if (obj.has("message")) { + return obj.get("message").getAsString(); + } + if (obj.has("error") && obj.get("error").isJsonPrimitive()) { + return obj.get("error").getAsString(); + } + return obj.toString(); + } + private Map parseErrors(String body) { Map errors = new HashMap<>(); try { JsonObject obj = JsonParser.parseString(body).getAsJsonObject(); - if (obj.has("errors")) { - JsonObject errObj = obj.getAsJsonObject("errors"); - for (String key : errObj.keySet()) { - com.google.gson.JsonArray arr = errObj.getAsJsonArray(key); - String[] messages = new String[arr.size()]; - for (int i = 0; i < arr.size(); i++) { - messages[i] = arr.get(i).getAsString(); - } - errors.put(key, messages); + return parseErrors(obj); + } catch (Exception ignored) {} + return errors; + } + + private Map parseErrors(JsonObject obj) { + Map errors = new HashMap<>(); + try { + if (!obj.has("errors") || !obj.get("errors").isJsonObject()) { + return errors; + } + JsonObject errObj = obj.getAsJsonObject("errors"); + for (String key : errObj.keySet()) { + JsonArray arr = errObj.getAsJsonArray(key); + String[] messages = new String[arr.size()]; + for (int i = 0; i < arr.size(); i++) { + messages[i] = arr.get(i).getAsString(); } + errors.put(key, messages); } } catch (Exception ignored) {} return errors; } + + private ControlPlaneException exceptionForStatus(int status, String responseBody) { + JsonObject body; + try { + body = JsonParser.parseString(responseBody).getAsJsonObject(); + } catch (Exception ignored) { + body = new JsonObject(); + body.addProperty("message", responseBody); + } + return exceptionForStatus(status, body); + } + + private ControlPlaneException exceptionForStatus(int status, JsonObject body) { + if (status == 401 || status == 403) { + return new AuthException(status, parseMessage(body)); + } + if (status == 404) { + return new NotFoundException(parseMessage(body)); + } + if (status == 422) { + return new ValidationException(parseMessage(body), parseErrors(body)); + } + if (status >= 500) { + return new RemoteException(status, parseMessage(body)); + } + if (status >= 300) { + return new ControlPlaneException("Unexpected response status " + status + ": " + body); + } + return new ControlPlaneException("Missing or invalid batch response status: " + status); + } } diff --git a/src/main/java/net/kewwbec/network/models/players/PlayerEntitlement.java b/src/main/java/net/kewwbec/network/models/players/PlayerEntitlement.java deleted file mode 100644 index 68af40c..0000000 --- a/src/main/java/net/kewwbec/network/models/players/PlayerEntitlement.java +++ /dev/null @@ -1,49 +0,0 @@ -package net.kewwbec.network.models.players; - -import com.google.gson.annotations.SerializedName; - -public final class PlayerEntitlement { - private String id; - private String status; - - @SerializedName("source_type") - private String sourceType; - - @SerializedName("granted_at") - private String grantedAt; - - @SerializedName("expires_at") - private String expiresAt; - - @SerializedName("revoked_at") - private String revokedAt; - - @SerializedName("revoked_reason") - private String revokedReason; - - private EntitlementDefinition definition; - - public String id() { return id; } - public String status() { return status; } - public String sourceType() { return sourceType; } - public String grantedAt() { return grantedAt; } - public String expiresAt() { return expiresAt; } - public String revokedAt() { return revokedAt; } - public String revokedReason() { return revokedReason; } - public EntitlementDefinition definition() { return definition; } - - public boolean isActive() { return "active".equals(status); } - public boolean isRevoked() { return "revoked".equals(status); } - - public static final class EntitlementDefinition { - private String id; - private String key; - private String type; - private String name; - - public String id() { return id; } - public String key() { return key; } - public String type() { return type; } - public String name() { return name; } - } -} diff --git a/src/main/java/net/kewwbec/network/models/players/PlayerSessionProfile.java b/src/main/java/net/kewwbec/network/models/players/PlayerSessionProfile.java deleted file mode 100644 index 5ac3924..0000000 --- a/src/main/java/net/kewwbec/network/models/players/PlayerSessionProfile.java +++ /dev/null @@ -1,97 +0,0 @@ -package net.kewwbec.network.models.players; - -import com.google.gson.annotations.SerializedName; - -import java.util.List; - -public final class PlayerSessionProfile { - private String id; - - @SerializedName("current_name") - private String currentName; - - @SerializedName("display_name") - private String displayName; - - private String status; - - @SerializedName("game_identity") - private GameIdentity gameIdentity; - - private List entitlements; - - @SerializedName("last_seen_at") - private String lastSeenAt; - - private SocialSummary social; - - public String id() { return id; } - public String currentName() { return currentName; } - public String displayName() { return displayName; } - public String status() { return status; } - public GameIdentity gameIdentity() { return gameIdentity; } - public List entitlements() { return entitlements != null ? entitlements : List.of(); } - public String lastSeenAt() { return lastSeenAt; } - public SocialSummary social() { return social; } - - public static final class GameIdentity { - private String provider; - - @SerializedName("player_id") - private String playerId; - - public String provider() { return provider; } - public String playerId() { return playerId; } - } - - public static final class SocialSummary { - @SerializedName("friends_count") - private int friendsCount; - - private GuildSummary guild; - private PartySummary party; - - public int friendsCount() { return friendsCount; } - public GuildSummary guild() { return guild; } - public PartySummary party() { return party; } - - public boolean isInGuild() { return guild != null; } - public boolean isInParty() { return party != null; } - } - - public static final class GuildSummary { - private String id; - private String slug; - private String name; - private String tag; - private String role; - - @SerializedName("member_count") - private int memberCount; - - public String id() { return id; } - public String slug() { return slug; } - public String name() { return name; } - public String tag() { return tag; } - public String role() { return role; } - public int memberCount() { return memberCount; } - } - - public static final class PartySummary { - private String id; - - @SerializedName("member_count") - private int memberCount; - - @SerializedName("max_members") - private int maxMembers; - - @SerializedName("is_leader") - private boolean isLeader; - - public String id() { return id; } - public int memberCount() { return memberCount; } - public int maxMembers() { return maxMembers; } - public boolean isLeader() { return isLeader; } - } -} diff --git a/src/main/java/net/kewwbec/network/models/server/HeartbeatPayload.java b/src/main/java/net/kewwbec/network/models/server/HeartbeatPayload.java deleted file mode 100644 index 082aa57..0000000 --- a/src/main/java/net/kewwbec/network/models/server/HeartbeatPayload.java +++ /dev/null @@ -1,31 +0,0 @@ -package net.kewwbec.network.models.server; - -import com.google.gson.annotations.SerializedName; - -public final class HeartbeatPayload { - private final String status; - - @SerializedName("current_players") - private final int currentPlayers; - - @SerializedName("max_players") - private final int maxPlayers; - - private HeartbeatPayload(String status, int currentPlayers, int maxPlayers) { - this.status = status; - this.currentPlayers = currentPlayers; - this.maxPlayers = maxPlayers; - } - - public static HeartbeatPayload running(int currentPlayers, int maxPlayers) { - return new HeartbeatPayload("running", currentPlayers, maxPlayers); - } - - public static HeartbeatPayload draining(int currentPlayers, int maxPlayers) { - return new HeartbeatPayload("draining", currentPlayers, maxPlayers); - } - - public String status() { return status; } - public int currentPlayers() { return currentPlayers; } - public int maxPlayers() { return maxPlayers; } -} diff --git a/src/main/java/net/kewwbec/network/models/server/PresenceState.java b/src/main/java/net/kewwbec/network/models/server/PresenceState.java deleted file mode 100644 index 0897b9b..0000000 --- a/src/main/java/net/kewwbec/network/models/server/PresenceState.java +++ /dev/null @@ -1,16 +0,0 @@ -package net.kewwbec.network.models.server; - -public enum PresenceState { - LIVE, - STALE, - UNKNOWN; - - public static PresenceState fromString(String value) { - if (value == null) return UNKNOWN; - return switch (value.toLowerCase()) { - case "live" -> LIVE; - case "stale" -> STALE; - default -> UNKNOWN; - }; - } -} diff --git a/src/main/java/net/kewwbec/network/models/server/ReadinessState.java b/src/main/java/net/kewwbec/network/models/server/ReadinessState.java deleted file mode 100644 index e5fabd3..0000000 --- a/src/main/java/net/kewwbec/network/models/server/ReadinessState.java +++ /dev/null @@ -1,18 +0,0 @@ -package net.kewwbec.network.models.server; - -public enum ReadinessState { - READY, - DRAINING, - STALE, - UNKNOWN; - - public static ReadinessState fromString(String value) { - if (value == null) return UNKNOWN; - return switch (value.toLowerCase()) { - case "ready" -> READY; - case "draining" -> DRAINING; - case "stale" -> STALE; - default -> UNKNOWN; - }; - } -} diff --git a/src/main/java/net/kewwbec/network/models/server/RegistrationPayload.java b/src/main/java/net/kewwbec/network/models/server/RegistrationPayload.java deleted file mode 100644 index 3c2ab4d..0000000 --- a/src/main/java/net/kewwbec/network/models/server/RegistrationPayload.java +++ /dev/null @@ -1,37 +0,0 @@ -package net.kewwbec.network.models.server; - -import com.google.gson.annotations.SerializedName; - -public final class RegistrationPayload { - private final String status; - private final String host; - private final int port; - - @SerializedName("current_players") - private final int currentPlayers; - - @SerializedName("max_players") - private final int maxPlayers; - - private RegistrationPayload(String status, String host, int port, int currentPlayers, int maxPlayers) { - this.status = status; - this.host = host; - this.port = port; - this.currentPlayers = currentPlayers; - this.maxPlayers = maxPlayers; - } - - public static RegistrationPayload starting(String host, int port, int maxPlayers) { - return new RegistrationPayload("starting", host, port, 0, maxPlayers); - } - - public static RegistrationPayload running(String host, int port, int currentPlayers, int maxPlayers) { - return new RegistrationPayload("running", host, port, currentPlayers, maxPlayers); - } - - public String status() { return status; } - public String host() { return host; } - public int port() { return port; } - public int currentPlayers() { return currentPlayers; } - public int maxPlayers() { return maxPlayers; } -} diff --git a/src/main/java/net/kewwbec/network/models/server/ServerInstance.java b/src/main/java/net/kewwbec/network/models/server/ServerInstance.java deleted file mode 100644 index 642d702..0000000 --- a/src/main/java/net/kewwbec/network/models/server/ServerInstance.java +++ /dev/null @@ -1,67 +0,0 @@ -package net.kewwbec.network.models.server; - -import com.google.gson.annotations.SerializedName; - -public final class ServerInstance { - private String id; - private String slug; - private String status; - private String host; - private int port; - - @SerializedName("current_players") - private int currentPlayers; - - @SerializedName("max_players") - private int maxPlayers; - - @SerializedName("available_slots") - private int availableSlots; - - @SerializedName("has_capacity") - private boolean hasCapacity; - - @SerializedName("presence_state") - private String presenceStateRaw; - - @SerializedName("readiness_state") - private String readinessStateRaw; - - @SerializedName("accepting_players") - private boolean acceptingPlayers; - - @SerializedName("last_heartbeat_at") - private String lastHeartbeatAt; - - public String id() { return id; } - public String slug() { return slug; } - public String status() { return status; } - public String host() { return host; } - public int port() { return port; } - public int currentPlayers() { return currentPlayers; } - public int maxPlayers() { return maxPlayers; } - public int availableSlots() { return availableSlots; } - public boolean hasCapacity() { return hasCapacity; } - public boolean acceptingPlayers() { return acceptingPlayers; } - public String lastHeartbeatAt() { return lastHeartbeatAt; } - - public PresenceState presenceState() { - return PresenceState.fromString(presenceStateRaw); - } - - public ReadinessState readinessState() { - return ReadinessState.fromString(readinessStateRaw); - } - - public boolean isReady() { - return readinessState() == ReadinessState.READY; - } - - public boolean isDraining() { - return readinessState() == ReadinessState.DRAINING; - } - - public boolean isStale() { - return readinessState() == ReadinessState.STALE || presenceState() == PresenceState.STALE; - } -} diff --git a/src/main/java/net/kewwbec/network/models/server/ServerProfile.java b/src/main/java/net/kewwbec/network/models/server/ServerProfile.java deleted file mode 100644 index 70df1ff..0000000 --- a/src/main/java/net/kewwbec/network/models/server/ServerProfile.java +++ /dev/null @@ -1,32 +0,0 @@ -package net.kewwbec.network.models.server; - -import com.google.gson.annotations.SerializedName; - -public final class ServerProfile { - @SerializedName("service_account") - private ServiceAccountSummary serviceAccount; - - @SerializedName("server_instance") - private ServerInstance serverInstance; - - public ServiceAccountSummary serviceAccount() { return serviceAccount; } - public ServerInstance serverInstance() { return serverInstance; } - - public boolean hasInstance() { return serverInstance != null; } - - public static final class ServiceAccountSummary { - private String id; - private String key; - private String name; - private String type; - - @SerializedName("last_used_at") - private String lastUsedAt; - - public String id() { return id; } - public String key() { return key; } - public String name() { return name; } - public String type() { return type; } - public String lastUsedAt() { return lastUsedAt; } - } -} diff --git a/src/main/resources/manifest.json b/src/main/resources/manifest.json index f512551..b0d5422 100644 --- a/src/main/resources/manifest.json +++ b/src/main/resources/manifest.json @@ -10,9 +10,13 @@ ], "Website": "https://kewwbec.net", "ServerVersion": "2026.02.17-255364b8e", - "Dependencies": {}, - "OptionalDependencies": {}, + "Dependencies": { + + }, + "OptionalDependencies": { + + }, "DisabledByDefault": false, "Main": "net.kewwbec.network.NetworkPlugin", - "IncludesAssetPack": false -} + "IncludesAssetPack": true +} \ No newline at end of file diff --git a/src/test/java/net/kewwbec/network/batch/BatchClientTest.java b/src/test/java/net/kewwbec/network/batch/BatchClientTest.java new file mode 100644 index 0000000..4a8a8fe --- /dev/null +++ b/src/test/java/net/kewwbec/network/batch/BatchClientTest.java @@ -0,0 +1,235 @@ +package net.kewwbec.network.batch; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import net.kewwbec.network.ControlPlaneClient; +import net.kewwbec.network.errors.NotFoundException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +final class BatchClientTest { + private TestBatchServer server; + + @AfterEach + void stopServer() { + if (server != null) { + server.stop(); + } + } + + @Test + void flushGroupsQueuedCallsIntoOneHttpRequest() throws Exception { + server = TestBatchServer.start(exchange -> responseFor(exchange, 200)); + ControlPlaneClient client = client(100); + + var first = client.batch().get(null, "/api/v1/server/profile", SampleResponse.class); + var second = client.batch().post(null, "/api/v1/server/instances/one/heartbeat", new SampleRequest("running"), SampleResponse.class); + + client.batch().flush(); + + assertEquals("ok-0", first.get(1, TimeUnit.SECONDS).value); + assertEquals("ok-1", second.get(1, TimeUnit.SECONDS).value); + assertEquals(1, server.requestCount.get()); + assertEquals(2, server.requests.getFirst().getAsJsonArray("requests").size()); + } + + @Test + void failedBatchItemCompletesOnlyItsFutureExceptionally() throws Exception { + server = TestBatchServer.start(exchange -> { + JsonObject request = readJson(exchange); + JsonArray responses = new JsonArray(); + JsonArray requests = request.getAsJsonArray("requests"); + responses.add(itemResponse(requests.get(0).getAsJsonObject().get("id").getAsString(), 200, "ok")); + JsonObject failed = new JsonObject(); + failed.addProperty("id", requests.get(1).getAsJsonObject().get("id").getAsString()); + failed.addProperty("status", 404); + JsonObject error = new JsonObject(); + error.addProperty("message", "missing"); + failed.add("error", error); + responses.add(failed); + JsonObject body = new JsonObject(); + body.add("responses", responses); + return body; + }); + ControlPlaneClient client = client(100); + + var success = client.batch().get(null, "/api/v1/server/profile", SampleResponse.class); + var failure = client.batch().get(null, "/api/v1/players/missing/session-profile", SampleResponse.class); + + client.batch().flush(); + + assertEquals("ok", success.get(1, TimeUnit.SECONDS).value); + CompletionException error = assertThrows(CompletionException.class, failure::join); + assertInstanceOf(NotFoundException.class, error.getCause()); + } + + @Test + void maxBatchSizeSplitsQueueIntoMultipleRequests() throws Exception { + server = TestBatchServer.start(exchange -> responseFor(exchange, 200)); + ControlPlaneClient client = client(1); + + var first = client.batch().get(null, "/api/v1/server/profile", SampleResponse.class); + var second = client.batch().get(null, "/api/v1/server/profile", SampleResponse.class); + + client.batch().flush(); + + assertEquals("ok-0", first.get(1, TimeUnit.SECONDS).value); + assertEquals("ok-0", second.get(1, TimeUnit.SECONDS).value); + assertEquals(2, server.requestCount.get()); + assertEquals(1, server.requests.get(0).getAsJsonArray("requests").size()); + assertEquals(1, server.requests.get(1).getAsJsonArray("requests").size()); + } + + @Test + void loopedCallsRunWhenDueAndStopAfterCancel() throws Exception { + server = TestBatchServer.start(exchange -> responseFor(exchange, 200)); + ControlPlaneClient client = client(100); + + LoopHandle handle = client.batch().registerLoopedCall(LoopedApiCall + .builder(SampleResponse.class) + .id("profile-loop") + .interval(Duration.ofMillis(1)) + .method("GET") + .uri("/api/v1/server/profile") + .build()); + + client.batch().flushDue(); + Thread.sleep(5); + client.batch().flushDue(); + handle.cancel(); + Thread.sleep(5); + client.batch().flushDue(); + + assertEquals(2, server.requestCount.get()); + } + + private ControlPlaneClient client(int maxBatchSize) { + return ControlPlaneClient.builder() + .baseUrl(server.baseUrl()) + .token("test-token") + .maxBatchSize(maxBatchSize) + .build(); + } + + private static JsonObject responseFor(HttpExchange exchange, int status) throws IOException { + JsonObject request = readJson(exchange); + JsonArray responses = new JsonArray(); + JsonArray requests = request.getAsJsonArray("requests"); + for (int i = 0; i < requests.size(); i++) { + responses.add(itemResponse(requests.get(i).getAsJsonObject().get("id").getAsString(), status, "ok-" + i)); + } + JsonObject body = new JsonObject(); + body.add("responses", responses); + return body; + } + + private static JsonObject itemResponse(String id, int status, String value) { + JsonObject response = new JsonObject(); + response.addProperty("id", id); + response.addProperty("status", status); + JsonObject data = new JsonObject(); + data.addProperty("value", value); + JsonObject body = new JsonObject(); + body.add("data", data); + response.add("body", body); + return response; + } + + private static JsonObject readJson(HttpExchange exchange) throws IOException { + return JsonParser.parseString(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)) + .getAsJsonObject(); + } + + private record SampleRequest(String status) {} + + private static final class SampleResponse { + private String value; + } + + private interface BatchHandler { + JsonObject handle(HttpExchange exchange) throws IOException; + } + + private static final class TestBatchServer { + private final HttpServer server; + private final AtomicInteger requestCount = new AtomicInteger(); + private final List requests = new ArrayList<>(); + + private TestBatchServer(HttpServer server) { + this.server = server; + } + + private static TestBatchServer start(BatchHandler handler) throws IOException { + HttpServer httpServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + TestBatchServer batchServer = new TestBatchServer(httpServer); + httpServer.createContext("/api/v1/batch", exchange -> { + batchServer.requestCount.incrementAndGet(); + JsonObject request = readJson(exchange); + batchServer.requests.add(request); + JsonObject response = handler.handle(new ReplayableExchange(exchange, request)); + byte[] bytes = response.toString().getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream body = exchange.getResponseBody()) { + body.write(bytes); + } + }); + httpServer.start(); + return batchServer; + } + + private String baseUrl() { + return "http://127.0.0.1:" + server.getAddress().getPort(); + } + + private void stop() { + server.stop(0); + } + } + + private static final class ReplayableExchange extends HttpExchange { + private final HttpExchange delegate; + private final byte[] requestBody; + + private ReplayableExchange(HttpExchange delegate, JsonObject request) { + this.delegate = delegate; + this.requestBody = request.toString().getBytes(StandardCharsets.UTF_8); + } + + @Override public java.net.URI getRequestURI() { return delegate.getRequestURI(); } + @Override public com.sun.net.httpserver.HttpContext getHttpContext() { return delegate.getHttpContext(); } + @Override public String getRequestMethod() { return delegate.getRequestMethod(); } + @Override public com.sun.net.httpserver.Headers getRequestHeaders() { return delegate.getRequestHeaders(); } + @Override public com.sun.net.httpserver.Headers getResponseHeaders() { return delegate.getResponseHeaders(); } + @Override public java.io.InputStream getRequestBody() { return new java.io.ByteArrayInputStream(requestBody); } + @Override public OutputStream getResponseBody() { return delegate.getResponseBody(); } + @Override public void sendResponseHeaders(int rCode, long responseLength) throws IOException { delegate.sendResponseHeaders(rCode, responseLength); } + @Override public java.net.InetSocketAddress getRemoteAddress() { return delegate.getRemoteAddress(); } + @Override public int getResponseCode() { return delegate.getResponseCode(); } + @Override public java.net.InetSocketAddress getLocalAddress() { return delegate.getLocalAddress(); } + @Override public String getProtocol() { return delegate.getProtocol(); } + @Override public Object getAttribute(String name) { return delegate.getAttribute(name); } + @Override public void setAttribute(String name, Object value) { delegate.setAttribute(name, value); } + @Override public void setStreams(java.io.InputStream i, OutputStream o) { delegate.setStreams(i, o); } + @Override public com.sun.net.httpserver.HttpPrincipal getPrincipal() { return delegate.getPrincipal(); } + @Override public void close() { delegate.close(); } + } +} diff --git a/src/test/java/net/kewwbec/network/cache/ApiDataCacheClientTest.java b/src/test/java/net/kewwbec/network/cache/ApiDataCacheClientTest.java new file mode 100644 index 0000000..e50f8fc --- /dev/null +++ b/src/test/java/net/kewwbec/network/cache/ApiDataCacheClientTest.java @@ -0,0 +1,318 @@ +package net.kewwbec.network.cache; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import net.kewwbec.network.ControlPlaneClient; +import net.kewwbec.network.errors.RemoteException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class ApiDataCacheClientTest { + private TestBatchServer server; + + @AfterEach + void stopServer() { + if (server != null) { + server.stop(); + } + } + + @Test + void freshCachedReadsDoNotCallHttpAgain() throws Exception { + server = TestBatchServer.start((item, index) -> itemResponse(item, 200, "first")); + ControlPlaneClient client = client(); + CachedApiCall call = profileCall(Duration.ofMinutes(1)); + + CompletableFuture pulled = client.cache().forcePull(call, CacheContext.shared()); + client.batch().flush(); + assertEquals("first", pulled.get(1, TimeUnit.SECONDS).value); + + SampleResponse cached = client.cache().getOrPull(call, CacheContext.shared()).get(1, TimeUnit.SECONDS); + + assertEquals("first", cached.value); + assertEquals(1, server.requestCount.get()); + } + + @Test + void staleCachedReadsPullAndReplaceValue() throws Exception { + AtomicInteger value = new AtomicInteger(1); + server = TestBatchServer.start((item, index) -> itemResponse(item, 200, "v" + value.getAndIncrement())); + ControlPlaneClient client = client(); + CachedApiCall call = profileCall(Duration.ofMillis(1)); + + CompletableFuture first = client.cache().forcePull(call, CacheContext.shared()); + client.batch().flush(); + assertEquals("v1", first.get(1, TimeUnit.SECONDS).value); + Thread.sleep(5); + + CompletableFuture second = client.cache().getOrPull(call, CacheContext.shared()); + client.batch().flush(); + + assertEquals("v2", second.get(1, TimeUnit.SECONDS).value); + assertEquals(2, server.requestCount.get()); + } + + @Test + void failedRefreshReturnsStaleValueAndTracksFailureCount() throws Exception { + AtomicInteger request = new AtomicInteger(); + server = TestBatchServer.start((item, index) -> { + if (request.getAndIncrement() == 0) { + return itemResponse(item, 200, "ok"); + } + return errorResponse(item, 500, "remote failed"); + }); + ControlPlaneClient client = client(); + CachedApiCall call = profileCall(Duration.ofMillis(1)); + + CompletableFuture first = client.cache().forcePull(call, CacheContext.shared()); + client.batch().flush(); + assertEquals("ok", first.get(1, TimeUnit.SECONDS).value); + Thread.sleep(5); + + CompletableFuture second = client.cache().getOrPull(call, CacheContext.shared()); + client.batch().flush(); + + assertEquals("ok", second.get(1, TimeUnit.SECONDS).value); + ApiCacheMetadata metadata = client.cache().metadata(call, CacheContext.shared()).orElseThrow(); + assertEquals(1, metadata.failedSyncCount()); + assertEquals(SyncState.FAILED, metadata.syncState()); + assertTrue(metadata.lastError().contains("remote failed")); + } + + @Test + void failedRefreshWithNoCachedValueFails() { + server = TestBatchServer.start((item, index) -> errorResponse(item, 500, "remote failed")); + ControlPlaneClient client = client(); + CachedApiCall call = profileCall(Duration.ofMillis(1)); + + CompletableFuture result = client.cache().getOrPull(call, CacheContext.shared()); + client.batch().flush(); + + CompletionException error = assertThrows(CompletionException.class, result::join); + assertInstanceOf(RemoteException.class, error.getCause()); + } + + @Test + void updateLocalMarksDirtyAndFlushDirtyPushesAfterDirtyTtl() throws Exception { + server = TestBatchServer.start((item, index) -> itemResponse(item, 200, "pushed")); + ControlPlaneClient client = client(); + CachedApiCall call = CachedApiCall.builder(SampleResponse.class) + .id("profile") + .method("GET") + .uri("/api/v1/server/profile") + .storageTarget(StorageTarget.RUNTIME_SHARED) + .dirtyMaxAge(Duration.ofMillis(1)) + .push("POST", (context, value) -> "/api/v1/server/profile", (context, value) -> value) + .build(); + + client.cache().updateLocal(call, CacheContext.shared(), new SampleResponse("local")); + Thread.sleep(5); + CompletableFuture flushed = client.cache().flushDirty(); + client.batch().flush(); + + flushed.get(1, TimeUnit.SECONDS); + assertEquals(1, server.requestCount.get()); + JsonObject pushed = server.lastBatch().getAsJsonArray("requests").get(0).getAsJsonObject(); + assertEquals("POST", pushed.get("method").getAsString()); + assertEquals("local", pushed.getAsJsonObject("body").get("value").getAsString()); + assertEquals(SyncState.CLEAN, client.cache().metadata(call, CacheContext.shared()).orElseThrow().syncState()); + } + + @Test + void runtimeSharedStorageIsolatesContextKeys() throws Exception { + server = TestBatchServer.start((item, index) -> itemResponse(item, 200, item.get("uri").getAsString())); + ControlPlaneClient client = client(); + CachedApiCall call = profileCall(Duration.ofMinutes(1)); + + CompletableFuture first = client.cache().forcePull(call, CacheContext.of("one")); + CompletableFuture second = client.cache().forcePull(call, CacheContext.of("two")); + client.batch().flush(); + + assertEquals("/api/v1/server/profile", first.get(1, TimeUnit.SECONDS).value); + assertEquals("/api/v1/server/profile", second.get(1, TimeUnit.SECONDS).value); + assertTrue(client.cache().metadata(call, CacheContext.of("one")).isPresent()); + assertTrue(client.cache().metadata(call, CacheContext.of("two")).isPresent()); + } + + @Test + void assetStoreAdapterIsUsedOnlyForAssetStoreCalls() throws Exception { + server = TestBatchServer.start((item, index) -> itemResponse(item, 200, "asset")); + ControlPlaneClient client = client(); + InMemoryAssetAdapter adapter = new InMemoryAssetAdapter(); + CachedApiCall call = CachedApiCall.builder(SampleResponse.class) + .id("asset-profile") + .method("GET") + .uri("/api/v1/server/profile") + .storageTarget(StorageTarget.ASSET_STORE) + .assetStoreAdapter(adapter) + .maxAge(Duration.ofMinutes(1)) + .build(); + + CompletableFuture pulled = client.cache().forcePull(call, CacheContext.shared()); + client.batch().flush(); + + assertEquals("asset", pulled.get(1, TimeUnit.SECONDS).value); + assertEquals(1, adapter.writeCount.get()); + assertEquals(1, adapter.readCount.get()); + } + + private ControlPlaneClient client() { + return ControlPlaneClient.builder() + .baseUrl(server.baseUrl()) + .token("test-token") + .build(); + } + + private CachedApiCall profileCall(Duration maxAge) { + return CachedApiCall.builder(SampleResponse.class) + .id("profile") + .method("GET") + .uri("/api/v1/server/profile") + .storageTarget(StorageTarget.RUNTIME_SHARED) + .maxAge(maxAge) + .build(); + } + + private static JsonObject itemResponse(JsonObject item, int status, String value) { + JsonObject response = new JsonObject(); + response.addProperty("id", item.get("id").getAsString()); + response.addProperty("status", status); + JsonObject data = new JsonObject(); + data.addProperty("value", value); + JsonObject body = new JsonObject(); + body.add("data", data); + response.add("body", body); + response.add("error", null); + return response; + } + + private static JsonObject errorResponse(JsonObject item, int status, String message) { + JsonObject response = new JsonObject(); + response.addProperty("id", item.get("id").getAsString()); + response.addProperty("status", status); + JsonObject error = new JsonObject(); + error.addProperty("message", message); + response.add("error", error); + return response; + } + + private static JsonObject readJson(HttpExchange exchange) throws IOException { + return JsonParser.parseString(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)) + .getAsJsonObject(); + } + + private record SampleRequest(String status) {} + + private static final class SampleResponse { + private String value; + + private SampleResponse() { + } + + private SampleResponse(String value) { + this.value = value; + } + } + + private interface BatchItemHandler { + JsonObject handle(JsonObject item, int index); + } + + private static final class TestBatchServer { + private final HttpServer server; + private final AtomicInteger requestCount = new AtomicInteger(); + private volatile JsonObject lastBatch; + + private TestBatchServer(HttpServer server) { + this.server = server; + } + + private static TestBatchServer start(BatchItemHandler handler) { + try { + HttpServer httpServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + TestBatchServer batchServer = new TestBatchServer(httpServer); + httpServer.createContext("/api/v1/batch", exchange -> { + batchServer.requestCount.incrementAndGet(); + JsonObject request = readJson(exchange); + batchServer.lastBatch = request; + JsonArray responses = new JsonArray(); + JsonArray requests = request.getAsJsonArray("requests"); + for (int i = 0; i < requests.size(); i++) { + responses.add(handler.handle(requests.get(i).getAsJsonObject(), i)); + } + JsonObject response = new JsonObject(); + response.add("responses", responses); + byte[] bytes = response.toString().getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream body = exchange.getResponseBody()) { + body.write(bytes); + } + }); + httpServer.start(); + return batchServer; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private String baseUrl() { + return "http://127.0.0.1:" + server.getAddress().getPort(); + } + + private JsonObject lastBatch() { + return lastBatch; + } + + private void stop() { + server.stop(0); + } + } + + private static final class InMemoryAssetAdapter implements AssetStoreCacheAdapter { + private final Map entries = new ConcurrentHashMap<>(); + private final AtomicInteger readCount = new AtomicInteger(); + private final AtomicInteger writeCount = new AtomicInteger(); + + @Override + public Optional read(ApiCacheKey key, CacheContext context) { + readCount.incrementAndGet(); + return Optional.ofNullable(entries.get(key)); + } + + @Override + public void write(ApiCacheKey key, ApiCacheEntry entry, CacheContext context) { + writeCount.incrementAndGet(); + entries.put(key, entry); + } + + @Override + public java.util.Collection entries() { + return entries.entrySet().stream() + .map(entry -> new StoredAssetCacheEntry(entry.getKey(), entry.getValue(), CacheContext.shared())) + .toList(); + } + } +} diff --git a/src/test/java/net/kewwbec/network/config/ControlPlaneStartupConfigResolverTest.java b/src/test/java/net/kewwbec/network/config/ControlPlaneStartupConfigResolverTest.java new file mode 100644 index 0000000..fcd3acd --- /dev/null +++ b/src/test/java/net/kewwbec/network/config/ControlPlaneStartupConfigResolverTest.java @@ -0,0 +1,136 @@ +package net.kewwbec.network.config; + +import net.kewwbec.network.ControlPlaneClient; +import net.kewwbec.network.auth.ApiToken; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class ControlPlaneStartupConfigResolverTest { + private final ControlPlaneStartupConfigResolver resolver = new ControlPlaneStartupConfigResolver(); + + @Test + void environmentValuesOverrideSystemPropertiesAndConfigFile() { + Properties properties = new Properties(); + properties.setProperty(ControlPlaneStartupConfigResolver.PROPERTY_BASE_URL, "https://system.example"); + properties.setProperty(ControlPlaneStartupConfigResolver.PROPERTY_TOKEN, "system-token"); + properties.setProperty(ControlPlaneStartupConfigResolver.PROPERTY_MAX_BATCH_SIZE, "50"); + + Optional resolved = resolver.resolve( + Map.of( + ControlPlaneStartupConfigResolver.ENV_BASE_URL, "https://env.example", + ControlPlaneStartupConfigResolver.ENV_TOKEN, "env-token", + ControlPlaneStartupConfigResolver.ENV_MAX_BATCH_SIZE, "25" + ), + properties, + fileSettings() + ); + + assertTrue(resolved.isPresent()); + assertEquals(ControlPlaneConfigurationSource.ENVIRONMENT, resolved.get().source()); + assertEquals("https://env.example", resolved.get().settings().baseUrl()); + assertEquals("env-token", resolved.get().settings().token()); + assertEquals(25, resolved.get().settings().maxBatchSize()); + } + + @Test + void systemPropertiesOverrideConfigFileWhenEnvironmentMissing() { + Properties properties = new Properties(); + properties.setProperty(ControlPlaneStartupConfigResolver.PROPERTY_BASE_URL, "https://system.example"); + properties.setProperty(ControlPlaneStartupConfigResolver.PROPERTY_TOKEN, "system-token"); + properties.setProperty(ControlPlaneStartupConfigResolver.PROPERTY_BATCH_FLUSH_INTERVAL_MS, "500"); + + Optional resolved = resolver.resolve( + Map.of(), + properties, + fileSettings() + ); + + assertTrue(resolved.isPresent()); + assertEquals(ControlPlaneConfigurationSource.SYSTEM_PROPERTY, resolved.get().source()); + assertEquals("https://system.example", resolved.get().settings().baseUrl()); + assertEquals("system-token", resolved.get().settings().token()); + assertEquals(Duration.ofMillis(500), resolved.get().settings().batchFlushInterval()); + } + + @Test + void missingBaseUrlOrTokenDoesNotResolve() { + Optional resolved = resolver.resolve( + Map.of(ControlPlaneStartupConfigResolver.ENV_TOKEN, "env-token"), + new Properties(), + ControlPlaneStartupSettings.defaults() + ); + + assertTrue(resolved.isEmpty()); + } + + @Test + void invalidOptionalValuesAreRejectedWithoutExposingToken() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> resolver.resolve( + Map.of( + ControlPlaneStartupConfigResolver.ENV_TOKEN, "secret-token", + ControlPlaneStartupConfigResolver.ENV_BATCHING_ENABLED, "sometimes" + ), + new Properties(), + fileSettings() + )); + + assertTrue(error.getMessage().contains(ControlPlaneStartupConfigResolver.ENV_BATCHING_ENABLED)); + assertFalse(error.getMessage().contains("secret-token")); + } + + @Test + void resolvedSettingsBuildClientWithBatchOptions() { + Properties properties = new Properties(); + properties.setProperty(ControlPlaneStartupConfigResolver.PROPERTY_BASE_URL, "https://system.example"); + properties.setProperty(ControlPlaneStartupConfigResolver.PROPERTY_TOKEN, "system-token"); + properties.setProperty(ControlPlaneStartupConfigResolver.PROPERTY_BATCHING_ENABLED, "false"); + properties.setProperty(ControlPlaneStartupConfigResolver.PROPERTY_BATCH_FLUSH_INTERVAL_MS, "500"); + properties.setProperty(ControlPlaneStartupConfigResolver.PROPERTY_MAX_BATCH_SIZE, "17"); + properties.setProperty(ControlPlaneStartupConfigResolver.PROPERTY_BATCH_ENDPOINT, "/api/v1/custom-batch"); + + ControlPlaneClient client = resolver.resolve(Map.of(), properties, ControlPlaneStartupSettings.defaults()) + .orElseThrow() + .settings() + .buildClient(); + + assertFalse(client.batchingEnabled()); + assertEquals(Duration.ofMillis(500), client.batchFlushInterval()); + assertEquals(17, client.maxBatchSize()); + assertEquals("/api/v1/custom-batch", client.batchEndpoint()); + } + + @Test + void defaultSettingsMatchCurrentBatchDefaults() { + ControlPlaneStartupSettings settings = ControlPlaneStartupSettings.defaults(); + + assertTrue(settings.batchingEnabled()); + assertEquals(Duration.ofMillis(250), settings.batchFlushInterval()); + assertEquals(100, settings.maxBatchSize()); + assertEquals("/api/v1/batch", settings.batchEndpoint()); + } + + @Test + void apiTokenDoesNotExposeRawValue() { + assertEquals("ApiToken[***]", ApiToken.of("secret-token").toString()); + } + + private ControlPlaneStartupSettings fileSettings() { + return new ControlPlaneStartupSettings( + "https://file.example", + "file-token", + true, + Duration.ofMillis(250), + 100, + "/api/v1/batch" + ); + } +}