Compare commits

..

2 Commits

Author SHA1 Message Date
HeruEdhel be35d92b52 Refactor API response descriptors to use resource classes
Build Plugin / build (push) Has been cancelled
Build Plugin / release (push) Has been cancelled
- Updated response descriptors in various endpoints to utilize specific resource classes instead of generic JsonResponse.
- Changed response types for friendship, guild, party, and support issue endpoints to their respective resource classes.
- Incremented version number in manifest.json to 0.5.3 for the new changes.
2026-06-05 09:23:01 -07:00
HeruEdhel 59330bdf84 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.
2026-06-05 07:00:14 -07:00
97 changed files with 3959 additions and 542 deletions
+32 -5
View File
@@ -21,15 +21,22 @@ 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/
ControlPlaneApiV1.java
shared/
announcements/
assets/
audit/
batch/
configs/
entitlements/
gamepermissions/
gameplay/
health/
hub/
infrastructure/
integrations/
moderation/
@@ -39,6 +46,7 @@ src/main/java/net/kewwbec/network/generated/api/v1/
serviceaccounts/
social/
support/
worlds/
```
What that means:
@@ -48,7 +56,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 +70,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,15 +101,34 @@ 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)
- [Control Plane Client](docs/wiki/Control-Plane-Client.md)
- [Generated SDK](docs/wiki/Generated-SDK.md)
## Project structure
```text
core-network/
docs/wiki/
src/main/java/
net/kewwbec/network/
network.java
batch/
cache/
generated/api/v1/...
src/main/resources/
manifest.json
+5 -9
View File
@@ -47,18 +47,14 @@ javadoc {
// Adds the Hytale server as a build dependency, allowing you to reference and
// compile against their code without bundling it. When a local install is
// present we compile and run against that jar directly so there is no dependency
// on the Hytale Maven repositories. In CI (no local install) the Maven artifact
// is used together with an explicit Gson dep (bundled in the server at runtime).
// present, we still use its jar for launching the server in IDE run configs.
dependencies {
compileOnly("com.hypixel.hytale:Server:$hytale_build")
if (hasHytaleHome) {
def serverJar = files("$hytaleHome/install/$patchline/package/game/latest/Server/HytaleServer.jar")
compileOnly(serverJar)
runtimeOnly(serverJar)
} else {
compileOnly("com.hypixel.hytale:Server:$hytale_build")
compileOnly("com.google.gson:gson:2.11.0")
runtimeOnly(files("$hytaleHome/install/$patchline/package/game/latest/Server/HytaleServer.jar"))
}
// Your dependencies here
}
repositories {
+269
View File
@@ -0,0 +1,269 @@
# 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<ServerProfile> 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<ServerProfile> profile =
cache.getOrPull(serverProfileCall, CacheContext.shared());
```
Force refresh:
```java
CompletableFuture<ServerProfile> profile =
cache.forcePull(serverProfileCall, CacheContext.shared());
```
Invalidate a cached value:
```java
cache.invalidate(serverProfileCall, CacheContext.shared());
```
Prewarm after an event-driven invalidation:
```java
CompletableFuture<ServerProfile> profile =
cache.prewarm(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<PlayerSessionProfile> 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<PlayerSessionProfile> profile =
cache.getOrPull(sessionProfile, context);
```
`NetworkPlugin.setup()` registers `NetworkApiDataComponent` with the entity store registry.
## Reactive Player Hooks
`ReactivePlayerCacheHooks` gives ECS/event systems a small public surface for cache invalidation and prewarming when control-plane state changes.
Entry point:
```java
ReactivePlayerCacheHooks hooks =
NetworkPlugin.client().reactivePlayerCache();
```
Use it from player lifecycle or control-plane event handlers:
```java
PlayerCacheContext context = new PlayerCacheContext(playerId, playerRef, entityStore);
hooks.onPlayerJoin(context);
hooks.onPermissionChanged(context);
hooks.onSanctionChanged(context);
hooks.onNotificationCreated(context);
```
The hook currently covers:
- `players/{player}/moderation/sanction-snapshot`
- `players/{player}/authorization/permission-snapshot`
- `players/{player}/notifications`
`onPlayerJoin(...)` force-refreshes sanction and permission snapshots and prewarms notifications. The change-specific hooks invalidate the stale player entry and immediately pull the new value.
## 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<MyModel> 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<ApiCacheMetadata> 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<ApiCacheEntry> 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.
}
@Override
public void remove(ApiCacheKey key, CacheContext context) {
// Remove or mark the mapped asset entry stale when invalidated.
}
}
```
Use it like:
```java
CachedApiCall<JsonObject> 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();
```
The assets domain now exposes typed generated methods that can feed asset-store-backed cache calls, including:
```java
NetworkPlugin.client().assets().getActivePack(packKey);
NetworkPlugin.client().assets().getPackVersionFiles(versionId);
```
## Batching Behavior
When `ControlPlaneClient.batchingEnabled()` is true, cache pulls and pushes use `BatchClient`.
When batching is disabled, cache operations call the HTTP transport directly.
+102
View File
@@ -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<ServerProfile> future = NetworkPlugin.client()
.batch()
.get(ProfileEndpoint.VALUE, ProfileEndpoint.VALUE.uri(), ServerProfile.class);
```
```java
CompletableFuture<JsonObject> 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()`.
+105
View File
@@ -0,0 +1,105 @@
# 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
client.reactivePlayerCache(); // ReactivePlayerCacheHooks
```
## 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().sessionProfile(playerId);
List<PlayerEntitlement> entitlements = client.players().entitlementsIndex(playerId);
```
`AssetsClient`
```java
AssetPackVersion active = client.assets().getActivePack(packKey);
List<AssetPackVersionFile> files = client.assets().getPackVersionFiles(active.id());
```
## Reactive Player Cache
`reactivePlayerCache()` is intended for Hytale ECS/event handlers that know player state changed and need cache freshness now, not after a TTL expires.
```java
PlayerCacheContext context = new PlayerCacheContext(playerId, playerRef, entityStore);
client.reactivePlayerCache().onPlayerJoin(context);
client.reactivePlayerCache().onPermissionChanged(context);
client.reactivePlayerCache().onSanctionChanged(context);
client.reactivePlayerCache().onNotificationCreated(context);
```
These hooks invalidate and prewarm the player-scoped cache entries for permission snapshots, moderation sanction snapshots, and notifications.
## 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.
+78
View File
@@ -0,0 +1,78 @@
# 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
- typed domain clients and DTOs for generated API resources
## 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
);
```
Use generated domain clients for direct typed calls:
```java
PlayerPermissionSnapshot permissions =
NetworkPlugin.client().players().authorizationPermissionSnapshot(playerId);
AssetPackVersion activePack =
NetworkPlugin.client().assets().getActivePack(packKey);
```
Some generated clients include hand-friendly aliases for route-shaped names. For example, the assets domain exposes `getActivePack(...)` and `getPackVersionFiles(...)` over the generated active-pack and version-files endpoints.
## 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`.
+134
View File
@@ -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();
```
+44
View File
@@ -0,0 +1,44 @@
# 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
- reactive player cache hooks for ECS/event-driven invalidation and prewarming
## 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.
- Use `reactivePlayerCache()` from ECS/event handlers when player cache state must react immediately to permission, sanction, notification, or join events.
+116
View File
@@ -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.
+2 -2
View File
@@ -1,5 +1,5 @@
# The current version of your project. Please use semantic versioning!
version=0.0.2
version=0.5.3
# The group ID used for maven publishing. Usually the same as your package name
# but not the same as your plugin group!
@@ -22,7 +22,7 @@ patchline=release
# The exact Hytale build to compile against. Use the build string from the
# launcher (format YYYY.MM.DD-<hash>) so Gradle pulls the matching server jar
# for your selected patchline.
hytale_build=2026.01.27-734d39026
hytale_build=0.5.3
# Determines if the development server should also load mods from the user's
# standard mods folder. This lets you test mods by installing them where a
+1 -1
View File
@@ -1 +1 @@
rootProject.name = 'ExamplePlugin'
rootProject.name = 'core-network'
@@ -1,38 +1,138 @@
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.cache.ReactivePlayerCacheHooks;
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 ReactivePlayerCacheHooks reactivePlayerCache;
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);
this.reactivePlayerCache = new ReactivePlayerCacheHooks(cache);
}
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 ReactivePlayerCacheHooks reactivePlayerCache() { return reactivePlayerCache; }
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 +156,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 +183,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);
}
}
@@ -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<ControlPlaneRuntimeConfig> 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<ControlPlaneConfigurationSource> 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<ResolvedControlPlaneStartupConfig> 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());
}
}
}
@@ -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<PendingCall<?>> pendingCalls = new ConcurrentLinkedQueue<>();
private final Map<String, LoopRegistration<?>> 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 <T> CompletableFuture<T> enqueue(
ApiEndpoint endpoint,
String resolvedUri,
String method,
Object body,
Class<T> responseType
) {
String normalizedMethod = normalizeMethod(method);
if (endpoint != null && !endpoint.methods().contains(normalizedMethod)) {
throw new IllegalArgumentException("Endpoint " + endpoint.name() + " does not allow " + normalizedMethod);
}
PendingCall<T> 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 <T> CompletableFuture<T> get(ApiEndpoint endpoint, String resolvedUri, Class<T> responseType) {
return enqueue(endpoint, resolvedUri, "GET", null, responseType);
}
public <T> CompletableFuture<T> post(ApiEndpoint endpoint, String resolvedUri, Object body, Class<T> responseType) {
return enqueue(endpoint, resolvedUri, "POST", body, responseType);
}
public CompletableFuture<JsonObject> enqueueJson(String method, String resolvedUri, Object body) {
return enqueue(null, resolvedUri, method, body, JsonObject.class);
}
public <T> LoopHandle registerLoopedCall(LoopedApiCall<T> call) {
LoopRegistration<T> 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<PendingCall<?>> 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<PendingCall<?>> drainBatch() {
List<PendingCall<?>> batch = new ArrayList<>(maxBatchSize);
PendingCall<?> call;
while (batch.size() < maxBatchSize && (call = pendingCalls.poll()) != null) {
batch.add(call);
}
return batch;
}
private void sendBatch(List<PendingCall<?>> 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<T> {
private final String id;
private final String method;
private final String uri;
private final Object body;
private final Class<T> responseType;
private final CompletableFuture<T> future = new CompletableFuture<>();
private PendingCall(String id, String method, String uri, Object body, Class<T> 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<T> responseType() { return responseType; }
public void complete(T value) {
future.complete(value);
}
public void completeExceptionally(Throwable cause) {
future.completeExceptionally(cause);
}
}
private final class LoopRegistration<T> implements LoopHandle {
private final LoopedApiCall<T> call;
private final AtomicBoolean cancelled = new AtomicBoolean(false);
private volatile Instant nextRunAt = Instant.EPOCH;
private LoopRegistration(LoopedApiCall<T> 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<T> 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();
}
}
}
@@ -0,0 +1,7 @@
package net.kewwbec.network.batch;
public interface LoopHandle {
void cancel();
boolean isCancelled();
}
@@ -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<T> {
private final String id;
private final Duration interval;
private final ApiEndpoint endpoint;
private final String method;
private final Supplier<String> uriSupplier;
private final Supplier<?> bodySupplier;
private final Class<T> responseType;
private final Consumer<T> onSuccess;
private final Consumer<Throwable> onFailure;
private LoopedApiCall(Builder<T> 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 <T> Builder<T> builder(Class<T> 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<T> responseType() { return responseType; }
public Consumer<T> onSuccess() { return onSuccess; }
public Consumer<Throwable> 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<T> {
private final Class<T> responseType;
private String id;
private Duration interval;
private ApiEndpoint endpoint;
private String method;
private Supplier<String> uriSupplier;
private Supplier<?> bodySupplier;
private Consumer<T> onSuccess;
private Consumer<Throwable> onFailure;
private Builder(Class<T> responseType) {
this.responseType = responseType;
}
public Builder<T> id(String id) {
this.id = id;
return this;
}
public Builder<T> interval(Duration interval) {
this.interval = interval;
return this;
}
public Builder<T> endpoint(ApiEndpoint endpoint) {
this.endpoint = endpoint;
return this;
}
public Builder<T> method(String method) {
this.method = method;
return this;
}
public Builder<T> uri(String uri) {
this.uriSupplier = () -> uri;
return this;
}
public Builder<T> uri(Supplier<String> uriSupplier) {
this.uriSupplier = uriSupplier;
return this;
}
public Builder<T> body(Object body) {
this.bodySupplier = () -> body;
return this;
}
public Builder<T> body(Supplier<?> bodySupplier) {
this.bodySupplier = bodySupplier;
return this;
}
public Builder<T> onSuccess(Consumer<T> onSuccess) {
this.onSuccess = onSuccess;
return this;
}
public Builder<T> onFailure(Consumer<Throwable> onFailure) {
this.onFailure = onFailure;
return this;
}
public LoopedApiCall<T> build() {
return new LoopedApiCall<>(this);
}
}
}
@@ -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;
}
}
@@ -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"));
}
}
@@ -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;
}
}
@@ -0,0 +1,14 @@
package net.kewwbec.network.cache;
import java.util.Collection;
import java.util.Optional;
interface ApiCacheStore {
Optional<ApiCacheEntry> get(ApiCacheKey key, CacheContext context);
void put(ApiCacheKey key, ApiCacheEntry entry, CacheContext context);
void remove(ApiCacheKey key, CacheContext context);
Collection<StoredCacheEntry> entries();
}
@@ -0,0 +1,252 @@
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<Boolean> batchingEnabled;
private final RuntimeApiCacheStore runtimeStore = new RuntimeApiCacheStore();
private final PlayerApiCacheStore playerStore = new PlayerApiCacheStore();
private final Map<String, CachedApiCall<?>> registeredCalls = new ConcurrentHashMap<>();
private final Map<ApiCacheKey, CacheContext> touchedContexts = new ConcurrentHashMap<>();
public ApiDataCacheClient(ControlPlaneHttpClient http, BatchClient batch, Supplier<Boolean> batchingEnabled) {
this.http = http;
this.batch = batch;
this.batchingEnabled = batchingEnabled;
}
public <T> CachedApiCall<T> register(CachedApiCall<T> call) {
CachedApiCall<?> previous = registeredCalls.putIfAbsent(call.id(), call);
if (previous != null) {
throw new IllegalArgumentException("Cached API call already registered: " + call.id());
}
return call;
}
public <T> CompletableFuture<T> getOrPull(CachedApiCall<T> call, CacheContext context) {
registerIfNeeded(call);
ApiCacheKey key = key(call, context);
ApiCacheStore store = storeFor(call);
Optional<ApiCacheEntry> 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 <T> CompletableFuture<T> forcePull(CachedApiCall<T> 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 <T> CompletableFuture<T> prewarm(CachedApiCall<T> call, CacheContext context) {
return forcePull(call, context);
}
public void invalidate(CachedApiCall<?> call, CacheContext context) {
registerIfNeeded(call);
ApiCacheKey key = key(call, context);
storeFor(call).remove(key, context);
touchedContexts.remove(key);
}
public boolean invalidate(String callId, CacheContext context) {
CachedApiCall<?> call = registeredCalls.get(callId);
if (call == null) {
return false;
}
invalidate(call, context);
return true;
}
public <T> void updateLocal(CachedApiCall<T> 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 <T> CompletableFuture<T> forcePush(CachedApiCall<T> 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<Void> flushDirty() {
Collection<CompletableFuture<?>> 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<ApiCacheKey, CacheContext> 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<ApiCacheMetadata> metadata(CachedApiCall<?> call, CacheContext context) {
ApiCacheKey key = key(call, context);
return storeFor(call).get(key, context).map(ApiCacheEntry::metadata);
}
private <T> CompletableFuture<T> pullAndStore(
CachedApiCall<T> call,
CacheContext context,
ApiCacheKey key,
ApiCacheStore store,
Optional<ApiCacheEntry> 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 <T> CompletableFuture<T> push(
CachedApiCall<T> 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 <T> CompletableFuture<T> request(String method, String uri, Object body, Class<T> 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<StoredCacheEntry> entries, Instant now, Collection<CompletableFuture<?>> 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 <T> CompletableFuture<T> pushUnchecked(
CachedApiCall<?> rawCall,
CacheContext context,
ApiCacheKey key,
ApiCacheStore store,
ApiCacheEntry entry
) {
@SuppressWarnings("unchecked")
CachedApiCall<T> call = (CachedApiCall<T>) 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> T value(ApiCacheEntry entry, Class<T> 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;
}
}
@@ -0,0 +1,35 @@
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<ApiCacheEntry> 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 void remove(ApiCacheKey key, CacheContext context) {
adapter.remove(key, context);
}
@Override
public Collection<StoredCacheEntry> entries() {
return adapter.entries().stream()
.map(entry -> new StoredCacheEntry(entry.key(), entry.entry(), entry.context()))
.toList();
}
}
@@ -0,0 +1,25 @@
package net.kewwbec.network.cache;
import com.google.gson.JsonElement;
import java.util.Collection;
import java.util.Optional;
public interface AssetStoreCacheAdapter {
Optional<ApiCacheEntry> read(ApiCacheKey key, CacheContext context);
void write(ApiCacheKey key, ApiCacheEntry entry, CacheContext context);
default void remove(ApiCacheKey key, CacheContext context) {
}
default Collection<StoredAssetCacheEntry> 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);
}
}
@@ -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;
}
}
@@ -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<T> {
private final String id;
private final ApiEndpoint endpoint;
private final String method;
private final Supplier<String> uriSupplier;
private final Supplier<?> bodySupplier;
private final Class<T> responseType;
private final StorageTarget storageTarget;
private final PullPolicy pullPolicy;
private final PushPolicy pushPolicy;
private final AssetStoreCacheAdapter assetStoreAdapter;
private final String pushMethod;
private final BiFunction<CacheContext, T, String> pushUriMapper;
private final BiFunction<CacheContext, T, Object> pushBodyMapper;
private final Consumer<T> onSuccess;
private final Consumer<Throwable> onFailure;
private CachedApiCall(Builder<T> 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 <T> Builder<T> builder(Class<T> 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<T> 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<T> onSuccess() { return onSuccess; }
public Consumer<Throwable> 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<T> {
private final Class<T> responseType;
private String id;
private ApiEndpoint endpoint;
private String method;
private Supplier<String> 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<CacheContext, T, String> pushUriMapper;
private BiFunction<CacheContext, T, Object> pushBodyMapper;
private Consumer<T> onSuccess;
private Consumer<Throwable> onFailure;
private Builder(Class<T> responseType) {
this.responseType = responseType;
}
public Builder<T> id(String id) {
this.id = id;
return this;
}
public Builder<T> endpoint(ApiEndpoint endpoint) {
this.endpoint = endpoint;
return this;
}
public Builder<T> method(String method) {
this.method = method;
return this;
}
public Builder<T> uri(String uri) {
this.uriSupplier = () -> uri;
return this;
}
public Builder<T> uri(Supplier<String> uriSupplier) {
this.uriSupplier = uriSupplier;
return this;
}
public Builder<T> body(Object body) {
this.bodySupplier = () -> body;
return this;
}
public Builder<T> body(Supplier<?> bodySupplier) {
this.bodySupplier = bodySupplier;
return this;
}
public Builder<T> storageTarget(StorageTarget storageTarget) {
this.storageTarget = storageTarget;
return this;
}
public Builder<T> pullPolicy(PullPolicy pullPolicy) {
this.pullPolicy = pullPolicy;
return this;
}
public Builder<T> maxAge(Duration maxAge) {
this.pullPolicy = PullPolicy.maxAge(maxAge);
return this;
}
public Builder<T> neverRefresh() {
this.pullPolicy = PullPolicy.neverRefresh();
return this;
}
public Builder<T> pushPolicy(PushPolicy pushPolicy) {
this.pushPolicy = pushPolicy;
return this;
}
public Builder<T> dirtyMaxAge(Duration dirtyMaxAge) {
this.pushPolicy = PushPolicy.dirtyMaxAge(dirtyMaxAge);
return this;
}
public Builder<T> manualPushOnly() {
this.pushPolicy = PushPolicy.manualPushOnly();
return this;
}
public Builder<T> assetStoreAdapter(AssetStoreCacheAdapter assetStoreAdapter) {
this.assetStoreAdapter = assetStoreAdapter;
return this;
}
public Builder<T> push(String method, BiFunction<CacheContext, T, String> uriMapper, BiFunction<CacheContext, T, Object> bodyMapper) {
this.pushMethod = method;
this.pushUriMapper = uriMapper;
this.pushBodyMapper = bodyMapper;
return this;
}
public Builder<T> onSuccess(Consumer<T> onSuccess) {
this.onSuccess = onSuccess;
return this;
}
public Builder<T> onFailure(Consumer<Throwable> onFailure) {
this.onFailure = onFailure;
return this;
}
public CachedApiCall<T> build() {
return new CachedApiCall<>(this);
}
}
}
@@ -0,0 +1,62 @@
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<EntityStore> {
public static final BuilderCodec<NetworkApiDataComponent> 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<EntityStore, NetworkApiDataComponent> componentType;
private Map<String, String> entries = new HashMap<>();
public static void setComponentType(ComponentType<EntityStore, NetworkApiDataComponent> componentType) {
NetworkApiDataComponent.componentType = componentType;
}
public static ComponentType<EntityStore, NetworkApiDataComponent> componentType() {
if (componentType == null) {
throw new IllegalStateException("NetworkApiDataComponent has not been registered");
}
return componentType;
}
public Map<String, String> entries() {
return entries;
}
public String get(String key) {
return entries.get(key);
}
public void put(String key, String value) {
entries.put(key, value);
}
public void remove(String key) {
entries.remove(key);
}
@Nonnull
@Override
public Component<EntityStore> clone() {
NetworkApiDataComponent copy = new NetworkApiDataComponent();
copy.entries = new HashMap<>(entries);
return copy;
}
}
@@ -0,0 +1,50 @@
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<ApiCacheEntry> 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 void remove(ApiCacheKey key, CacheContext context) {
PlayerCacheContext playerContext = playerContext(context);
NetworkApiDataComponent component = playerContext.store()
.ensureAndGetComponent(playerContext.ref(), NetworkApiDataComponent.componentType());
component.remove(key.encoded());
playerContext.store().putComponent(playerContext.ref(), NetworkApiDataComponent.componentType(), component);
}
@Override
public Collection<StoredCacheEntry> 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;
}
}
@@ -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<EntityStore> ref;
private final Store<EntityStore> store;
public PlayerCacheContext(String playerId, Ref<EntityStore> ref, Store<EntityStore> 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<EntityStore> ref() { return ref; }
public Store<EntityStore> store() { return store; }
}
+29
View File
@@ -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<Duration> maxAge() {
return Optional.ofNullable(maxAge);
}
}
+29
View File
@@ -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<Duration> dirtyMaxAge() {
return Optional.ofNullable(dirtyMaxAge);
}
}
@@ -0,0 +1,134 @@
package net.kewwbec.network.cache;
import net.kewwbec.network.ControlPlaneClient;
import net.kewwbec.network.generated.api.v1.players.AuthorizationPermissionSnapshotEndpoint;
import net.kewwbec.network.generated.api.v1.players.ModerationSanctionSnapshotEndpoint;
import net.kewwbec.network.generated.api.v1.players.NotificationsIndexEndpoint;
import net.kewwbec.network.generated.api.v1.players.PlayerNotification;
import net.kewwbec.network.generated.api.v1.players.PlayerPermissionSnapshot;
import net.kewwbec.network.generated.api.v1.players.PlayerSanctionSnapshot;
import net.kewwbec.network.http.PathBuilder;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
public final class ReactivePlayerCacheHooks {
public static final String SANCTION_SNAPSHOT_PREFIX = "players.moderation.sanction-snapshot";
public static final String PERMISSION_SNAPSHOT_PREFIX = "players.authorization.permission-snapshot";
public static final String NOTIFICATIONS_INDEX_PREFIX = "players.notifications.index";
private final ApiDataCacheClient cache;
public ReactivePlayerCacheHooks(ControlPlaneClient client) {
this(Objects.requireNonNull(client, "client must not be null").cache());
}
public ReactivePlayerCacheHooks(ApiDataCacheClient cache) {
this.cache = Objects.requireNonNull(cache, "cache must not be null");
}
public CompletableFuture<Void> onPlayerJoin(PlayerCacheContext context) {
return CompletableFuture.allOf(
refreshSanctionSnapshot(context),
refreshPermissionSnapshot(context),
prewarmNotifications(context)
);
}
public CompletableFuture<PlayerSanctionSnapshot> onSanctionChanged(PlayerCacheContext context) {
return refreshSanctionSnapshot(context);
}
public CompletableFuture<PlayerPermissionSnapshot> onPermissionChanged(PlayerCacheContext context) {
return refreshPermissionSnapshot(context);
}
public CompletableFuture<PlayerNotification[]> onNotificationCreated(PlayerCacheContext context) {
return refreshNotifications(context);
}
public void invalidateSanctionSnapshot(PlayerCacheContext context) {
cache.invalidate(sanctionSnapshotCall(context.contextKey()), context);
}
public void invalidatePermissionSnapshot(PlayerCacheContext context) {
cache.invalidate(permissionSnapshotCall(context.contextKey()), context);
}
public void invalidateNotifications(PlayerCacheContext context) {
cache.invalidate(notificationsIndexCall(context.contextKey()), context);
}
public CompletableFuture<PlayerSanctionSnapshot> refreshSanctionSnapshot(PlayerCacheContext context) {
CachedApiCall<PlayerSanctionSnapshot> call = sanctionSnapshotCall(context.contextKey());
cache.invalidate(call, context);
return cache.prewarm(call, context);
}
public CompletableFuture<PlayerPermissionSnapshot> refreshPermissionSnapshot(PlayerCacheContext context) {
CachedApiCall<PlayerPermissionSnapshot> call = permissionSnapshotCall(context.contextKey());
cache.invalidate(call, context);
return cache.prewarm(call, context);
}
public CompletableFuture<PlayerNotification[]> refreshNotifications(PlayerCacheContext context) {
CachedApiCall<PlayerNotification[]> call = notificationsIndexCall(context.contextKey());
cache.invalidate(call, context);
return cache.prewarm(call, context);
}
public CompletableFuture<PlayerNotification[]> prewarmNotifications(PlayerCacheContext context) {
return cache.getOrPull(notificationsIndexCall(context.contextKey()), context);
}
public static CachedApiCall<PlayerSanctionSnapshot> sanctionSnapshotCall(String playerId) {
String player = requirePlayerId(playerId);
return CachedApiCall.builder(PlayerSanctionSnapshot.class)
.id(callId(SANCTION_SNAPSHOT_PREFIX, player))
.endpoint(ModerationSanctionSnapshotEndpoint.VALUE)
.method("GET")
.uri(() -> PathBuilder.resolve(ModerationSanctionSnapshotEndpoint.VALUE.uri(), "player", player))
.storageTarget(StorageTarget.PLAYER)
.pullPolicy(PullPolicy.maxAge(Duration.ofSeconds(30)))
.manualPushOnly()
.build();
}
public static CachedApiCall<PlayerPermissionSnapshot> permissionSnapshotCall(String playerId) {
String player = requirePlayerId(playerId);
return CachedApiCall.builder(PlayerPermissionSnapshot.class)
.id(callId(PERMISSION_SNAPSHOT_PREFIX, player))
.endpoint(AuthorizationPermissionSnapshotEndpoint.VALUE)
.method("GET")
.uri(() -> PathBuilder.resolve(AuthorizationPermissionSnapshotEndpoint.VALUE.uri(), "player", player))
.storageTarget(StorageTarget.PLAYER)
.pullPolicy(PullPolicy.maxAge(Duration.ofSeconds(30)))
.manualPushOnly()
.build();
}
public static CachedApiCall<PlayerNotification[]> notificationsIndexCall(String playerId) {
String player = requirePlayerId(playerId);
return CachedApiCall.builder(PlayerNotification[].class)
.id(callId(NOTIFICATIONS_INDEX_PREFIX, player))
.endpoint(NotificationsIndexEndpoint.VALUE)
.method("GET")
.uri(() -> PathBuilder.resolve(NotificationsIndexEndpoint.VALUE.uri(), "player", player))
.storageTarget(StorageTarget.PLAYER)
.pullPolicy(PullPolicy.maxAge(Duration.ofSeconds(15)))
.manualPushOnly()
.build();
}
public static String callId(String prefix, String playerId) {
return prefix + ":" + requirePlayerId(playerId);
}
private static String requirePlayerId(String playerId) {
if (playerId == null || playerId.isBlank()) {
throw new IllegalArgumentException("playerId must not be blank");
}
return playerId;
}
}
@@ -0,0 +1,31 @@
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<ApiCacheKey, ApiCacheEntry> entries = new ConcurrentHashMap<>();
@Override
public Optional<ApiCacheEntry> 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 void remove(ApiCacheKey key, CacheContext context) {
entries.remove(key);
}
@Override
public Collection<StoredCacheEntry> entries() {
return entries.entrySet().stream()
.map(entry -> new StoredCacheEntry(entry.getKey(), entry.getValue(), CacheContext.of(entry.getKey().contextKey())))
.toList();
}
}
@@ -0,0 +1,7 @@
package net.kewwbec.network.cache;
public enum StorageTarget {
PLAYER,
RUNTIME_SHARED,
ASSET_STORE
}
@@ -0,0 +1,4 @@
package net.kewwbec.network.cache;
record StoredCacheEntry(ApiCacheKey key, ApiCacheEntry entry, CacheContext context) {
}
@@ -0,0 +1,7 @@
package net.kewwbec.network.cache;
public enum SyncState {
CLEAN,
DIRTY,
FAILED
}
@@ -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<PlayerEntitlement> getEntitlements(String playerId) throws ControlPlaneException {
String uri = PathBuilder.resolve(EntitlementsIndexEndpoint.VALUE.uri(), "player", playerId);
return http.getList(uri, PlayerEntitlement.class);
}
}
@@ -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) {}
}
@@ -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);
}
}
@@ -0,0 +1,8 @@
package net.kewwbec.network.config;
public enum ControlPlaneConfigurationSource {
MANUAL,
CONFIG_FILE,
SYSTEM_PROPERTY,
ENVIRONMENT
}
@@ -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<ControlPlaneRuntimeConfig> 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
);
}
}
@@ -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<ResolvedControlPlaneStartupConfig> resolve(ControlPlaneStartupSettings fileSettings) {
return resolve(System.getenv(), System.getProperties(), fileSettings);
}
public Optional<ResolvedControlPlaneStartupConfig> resolve(
Map<String, String> 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<String, String> 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<String> stringProperty(Properties properties, String key) {
return nonBlank(properties.getProperty(key));
}
private static Optional<String> stringEnv(Map<String, String> environment, String key) {
return nonBlank(environment.get(key));
}
private static Optional<String> 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
);
}
}
}
@@ -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;
}
}
@@ -0,0 +1,7 @@
package net.kewwbec.network.config;
public record ResolvedControlPlaneStartupConfig(
ControlPlaneStartupSettings settings,
ControlPlaneConfigurationSource source
) {
}
@@ -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 = "e918a5e4ab91e8ed17150e80ee593fd4400d2d54a994b28cf7281bef22eacd73";
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;
}
@@ -19,7 +19,7 @@ public final class ServerTemplatesStoreEndpoint {
List.of("api", "auth:sanctum", "service.abilities:server-templates.write"),
List.of(),
new RequestDescriptor("App\\Domains\\Infrastructure\\Http\\Requests\\StoreServerTemplateRequest", "body", List.of(new RequestField("region_key", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:regions,key"))), new RequestField("key", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:150"), new RuleDescriptor("string", null, "regex:/^[a-z0-9._-]+$/"), new RuleDescriptor("string", null, "unique:server_templates,key"))), new RequestField("name", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:120"))), new RequestField("category", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:100"))), new RequestField("lifecycle_strategy", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("object", "Illuminate\\Validation\\Rules\\In", "in:\"manual\",\"static\",\"elastic\""))), new RequestField("version", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:80"))), new RequestField("max_players", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "integer"), new RuleDescriptor("string", null, "min:0"))), new RequestField("is_active", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "boolean"))), new RequestField("startup_world_key", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:150"), new RuleDescriptor("string", null, "exists:worlds,key"))), new RequestField("asset_pack_keys", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "array"))), new RequestField("asset_pack_keys.*", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "distinct"), new RuleDescriptor("string", null, "exists:asset_packs,key"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Infrastructure\\Resources\\ServerTemplateResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class ServerTemplatesUpdateEndpoint {
List.of("api", "auth:sanctum", "service.abilities:server-templates.write"),
List.of(new RouteParameter("serverTemplate", new TypeDescriptor("App\\Domains\\Infrastructure\\Models\\ServerTemplate", false, false))),
new RequestDescriptor("App\\Domains\\Infrastructure\\Http\\Requests\\UpdateServerTemplateRequest", "body", List.of(new RequestField("region_key", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:regions,key"))), new RequestField("key", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:150"), new RuleDescriptor("string", null, "regex:/^[a-z0-9._-]+$/"), new RuleDescriptor("object", "Illuminate\\Validation\\Rules\\Unique", "unique:server_templates,key,NULL,id"))), new RequestField("name", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:120"))), new RequestField("category", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:100"))), new RequestField("lifecycle_strategy", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("object", "Illuminate\\Validation\\Rules\\In", "in:\"manual\",\"static\",\"elastic\""))), new RequestField("version", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:80"))), new RequestField("max_players", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "integer"), new RuleDescriptor("string", null, "min:0"))), new RequestField("is_active", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "boolean"))), new RequestField("startup_world_key", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:150"), new RuleDescriptor("string", null, "exists:worlds,key"))), new RequestField("asset_pack_keys", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "array"))), new RequestField("asset_pack_keys.*", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "distinct"), new RuleDescriptor("string", null, "exists:asset_packs,key"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Infrastructure\\Resources\\ServerTemplateResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class StoreEndpoint {
List.of("api", "auth:sanctum", "service.abilities:integrations.write"),
List.of(),
new RequestDescriptor("App\\Domains\\Integrations\\Http\\Requests\\StoreIntegrationRequest", "body", List.of(new RequestField("key", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:100"), new RuleDescriptor("string", null, "unique:integrations,key"))), new RequestField("name", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:255"))), new RequestField("driver", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("object", "Illuminate\\Validation\\Rules\\In", "in:\"discord\",\"website\",\"webhook\",\"commerce\",\"analytics\",\"internal_tool\""))), new RequestField("direction", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("object", "Illuminate\\Validation\\Rules\\In", "in:\"inbound\",\"outbound\",\"bidirectional\""))), new RequestField("status", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("object", "Illuminate\\Validation\\Rules\\In", "in:\"active\",\"paused\",\"error\",\"retired\""))), new RequestField("endpoint", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:255"))), new RequestField("secret_reference", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:255"))), new RequestField("last_sync_at", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Integrations\\Resources\\IntegrationResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class UpdateEndpoint {
List.of("api", "auth:sanctum", "service.abilities:integrations.write"),
List.of(new RouteParameter("integration", new TypeDescriptor("App\\Domains\\Integrations\\Models\\Integration", false, false))),
new RequestDescriptor("App\\Domains\\Integrations\\Http\\Requests\\UpdateIntegrationRequest", "body", List.of(new RequestField("name", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:255"))), new RequestField("driver", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("object", "Illuminate\\Validation\\Rules\\In", "in:\"discord\",\"website\",\"webhook\",\"commerce\",\"analytics\",\"internal_tool\""))), new RequestField("direction", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("object", "Illuminate\\Validation\\Rules\\In", "in:\"inbound\",\"outbound\",\"bidirectional\""))), new RequestField("status", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("object", "Illuminate\\Validation\\Rules\\In", "in:\"active\",\"paused\",\"error\",\"retired\""))), new RequestField("endpoint", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:255"))), new RequestField("secret_reference", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:255"))), new RequestField("last_sync_at", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("last_error_at", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("last_error_summary", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Integrations\\Resources\\IntegrationResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class CasesReopenEndpoint {
List.of("api", "auth:sanctum", "service.abilities:moderation.cases.write"),
List.of(new RouteParameter("moderationCase", new TypeDescriptor("App\\Domains\\Moderation\\Models\\ModerationCase", false, false))),
new RequestDescriptor("App\\Domains\\Moderation\\Http\\Requests\\ReopenModerationCaseRequest", "body", List.of(new RequestField("reason", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"))), new RequestField("status", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("object", "Illuminate\\Validation\\Rules\\In", "in:\"open\",\"under_review\""))), new RequestField("summary", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"))), new RequestField("assigned_to_player_id", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("reopened_at", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Moderation\\Resources\\ModerationCaseResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class CasesStoreEndpoint {
List.of("api", "auth:sanctum", "service.abilities:moderation.cases.write"),
List.of(),
new RequestDescriptor("App\\Domains\\Moderation\\Http\\Requests\\StoreModerationCaseRequest", "body", List.of(new RequestField("case_type", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("object", "Illuminate\\Validation\\Rules\\In", "in:\"report\",\"investigation\",\"appeal\""))), new RequestField("priority", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("object", "Illuminate\\Validation\\Rules\\In", "in:\"low\",\"normal\",\"high\",\"urgent\""))), new RequestField("subject_player_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("reported_by_player_id", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("assigned_to_player_id", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("title", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:255"))), new RequestField("summary", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"))), new RequestField("incident_occurred_at", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Moderation\\Resources\\ModerationCaseResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class CasesUpdateEndpoint {
List.of("api", "auth:sanctum", "service.abilities:moderation.cases.write"),
List.of(new RouteParameter("moderationCase", new TypeDescriptor("App\\Domains\\Moderation\\Models\\ModerationCase", false, false))),
new RequestDescriptor("App\\Domains\\Moderation\\Http\\Requests\\UpdateModerationCaseRequest", "body", List.of(new RequestField("status", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("object", "Illuminate\\Validation\\Rules\\In", "in:\"open\",\"under_review\",\"resolved\",\"closed\""))), new RequestField("priority", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("object", "Illuminate\\Validation\\Rules\\In", "in:\"low\",\"normal\",\"high\",\"urgent\""))), new RequestField("assigned_to_player_id", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("summary", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"))), new RequestField("resolution_summary", List.of(new RuleDescriptor("object", "Illuminate\\Validation\\Rules\\RequiredIf", ""), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"))), new RequestField("last_reviewed_at", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("resolved_at", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Moderation\\Resources\\ModerationCaseResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class NotificationsReadEndpoint {
List.of("api", "auth:sanctum", "service.abilities:notifications.write"),
List.of(new RouteParameter("player", new TypeDescriptor("App\\Domains\\Identity\\Models\\Player", false, false)), new RouteParameter("notification", new TypeDescriptor("App\\Domains\\Notifications\\Models\\PlayerNotification", false, false))),
null,
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Notifications\\Resources\\PlayerNotificationResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class NotificationsStoreEndpoint {
List.of("api", "auth:sanctum", "service.abilities:notifications.write"),
List.of(new RouteParameter("player", new TypeDescriptor("App\\Domains\\Identity\\Models\\Player", false, false))),
new RequestDescriptor("App\\Domains\\Notifications\\Http\\Requests\\StorePlayerNotificationRequest", "body", List.of(new RequestField("type", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:100"))), new RequestField("title", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:255"))), new RequestField("body", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"))), new RequestField("priority", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("object", "Illuminate\\Validation\\Rules\\In", "in:\"low\",\"normal\",\"high\""))), new RequestField("sent_at", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "date"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Notifications\\Resources\\PlayerNotificationResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class ConfigVersionsStoreEndpoint {
List.of("api", "auth:sanctum", "service.abilities:plugins.deploy.write"),
List.of(new RouteParameter("plugin", new TypeDescriptor("App\\Domains\\Plugins\\Models\\Plugin", false, false))),
new RequestDescriptor("App\\Domains\\Plugins\\Http\\Requests\\StorePluginConfigVersionRequest", "body", List.of(new RequestField("version", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:255"))), new RequestField("format", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:100"))), new RequestField("content_driver", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:100"))), new RequestField("content_reference", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:2048"))), new RequestField("checksum", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:255"))), new RequestField("released_at", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Plugins\\Resources\\PluginConfigVersionResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class DeploymentsRollbackEndpoint {
List.of("api", "auth:sanctum", "service.abilities:plugins.deploy.write"),
List.of(new RouteParameter("pluginDeployment", new TypeDescriptor("App\\Domains\\Plugins\\Models\\PluginDeployment", false, false))),
new RequestDescriptor("App\\Domains\\Plugins\\Http\\Requests\\RollbackPluginDeploymentRequest", "body", List.of(new RequestField("reason", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"))), new RequestField("rolled_back_at", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Plugins\\Resources\\PluginDeploymentResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class DeploymentsStoreEndpoint {
List.of("api", "auth:sanctum", "service.abilities:plugins.deploy.write"),
List.of(new RouteParameter("plugin", new TypeDescriptor("App\\Domains\\Plugins\\Models\\Plugin", false, false))),
new RequestDescriptor("App\\Domains\\Plugins\\Http\\Requests\\StorePluginDeploymentRequest", "body", List.of(new RequestField("plugin_version_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:plugin_versions,id"))), new RequestField("plugin_config_version_id", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:plugin_config_versions,id"))), new RequestField("server_template_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:server_templates,id"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Plugins\\Resources\\PluginDeploymentResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class InstancesMatchesResultsStoreEndpoint {
List.of("api", "auth:sanctum", "service.abilities:matches.result.write", "service.instance:instance"),
List.of(new RouteParameter("instance", new TypeDescriptor("App\\Domains\\Infrastructure\\Models\\ServerInstance", false, false))),
new RequestDescriptor("App\\Domains\\Gameplay\\Http\\Requests\\StoreMatchResultRequest", "body", List.of(new RequestField("match_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:255"))), new RequestField("game_mode", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:100"))), new RequestField("status", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("object", "Illuminate\\Validation\\Rules\\In", "in:\"completed\",\"abandoned\",\"cancelled\""))), new RequestField("started_at", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "date"))), new RequestField("ended_at", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "date"), new RuleDescriptor("string", null, "after_or_equal:started_at"))), new RequestField("replay_driver", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:100"))), new RequestField("replay_reference", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:2048"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))), new RequestField("participants", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "array"), new RuleDescriptor("string", null, "min:1"), new RuleDescriptor("string", null, "max:100"))), new RequestField("participants.*.player_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("participants.*.team_key", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:100"))), new RequestField("participants.*.placement", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "integer"), new RuleDescriptor("string", null, "min:1"))), new RequestField("participants.*.outcome", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("object", "Illuminate\\Validation\\Rules\\In", "in:\"win\",\"loss\",\"draw\",\"abandoned\""))), new RequestField("participants.*.score", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "integer"))), new RequestField("participants.*.stats", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Gameplay\\Resources\\MatchSessionResource", false, false))
)
;
@@ -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))
)
;
@@ -19,7 +19,7 @@ public final class FriendshipsAcceptEndpoint {
List.of("api", "auth:sanctum", "service.abilities:social.write"),
List.of(new RouteParameter("friendship", new TypeDescriptor("App\\Domains\\Social\\Models\\PlayerFriendship", false, false))),
new RequestDescriptor("App\\Domains\\Social\\Http\\Requests\\AcceptFriendshipRequest", "body", List.of(new RequestField("accepting_player_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("accepted_at", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Social\\Resources\\FriendshipResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class FriendshipsStoreEndpoint {
List.of("api", "auth:sanctum", "service.abilities:social.write"),
List.of(),
new RequestDescriptor("App\\Domains\\Social\\Http\\Requests\\StoreFriendshipRequest", "body", List.of(new RequestField("requester_player_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("addressee_player_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "different:requester_player_id"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("requested_at", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Social\\Resources\\FriendshipResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class GuildInvitesAcceptEndpoint {
List.of("api", "auth:sanctum", "service.abilities:social.write"),
List.of(new RouteParameter("guildInvite", new TypeDescriptor("App\\Domains\\Social\\Models\\GuildInvite", false, false))),
new RequestDescriptor("App\\Domains\\Social\\Http\\Requests\\AcceptGuildInviteRequest", "body", List.of(new RequestField("accepting_player_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("accepted_at", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Social\\Resources\\GuildResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class GuildInvitesCancelEndpoint {
List.of("api", "auth:sanctum", "service.abilities:social.write"),
List.of(new RouteParameter("guildInvite", new TypeDescriptor("App\\Domains\\Social\\Models\\GuildInvite", false, false))),
new RequestDescriptor("App\\Domains\\Social\\Http\\Requests\\CancelGuildInviteRequest", "body", List.of(new RequestField("cancelled_by_player_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("cancelled_at", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("reason", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:500"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Social\\Resources\\GuildInviteResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class GuildInvitesDeclineEndpoint {
List.of("api", "auth:sanctum", "service.abilities:social.write"),
List.of(new RouteParameter("guildInvite", new TypeDescriptor("App\\Domains\\Social\\Models\\GuildInvite", false, false))),
new RequestDescriptor("App\\Domains\\Social\\Http\\Requests\\DeclineGuildInviteRequest", "body", List.of(new RequestField("declining_player_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("declined_at", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("reason", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:500"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Social\\Resources\\GuildInviteResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class GuildsInvitesStoreEndpoint {
List.of("api", "auth:sanctum", "service.abilities:social.write"),
List.of(new RouteParameter("guild", new TypeDescriptor("App\\Domains\\Social\\Models\\Guild", false, false))),
new RequestDescriptor("App\\Domains\\Social\\Http\\Requests\\StoreGuildInviteRequest", "body", List.of(new RequestField("invited_player_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("invited_by_player_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("message", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:1000"))), new RequestField("invited_at", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("expires_at", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Social\\Resources\\GuildInviteResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class GuildsKicksStoreEndpoint {
List.of("api", "auth:sanctum", "service.abilities:social.write"),
List.of(new RouteParameter("guild", new TypeDescriptor("App\\Domains\\Social\\Models\\Guild", false, false))),
new RequestDescriptor("App\\Domains\\Social\\Http\\Requests\\KickGuildMemberRequest", "body", List.of(new RequestField("acting_player_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("target_player_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("kicked_at", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("reason", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:500"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Social\\Resources\\GuildResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class GuildsLeaveEndpoint {
List.of("api", "auth:sanctum", "service.abilities:social.write"),
List.of(new RouteParameter("guild", new TypeDescriptor("App\\Domains\\Social\\Models\\Guild", false, false))),
new RequestDescriptor("App\\Domains\\Social\\Http\\Requests\\LeaveGuildRequest", "body", List.of(new RequestField("player_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("left_at", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("reason", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:500"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Social\\Resources\\GuildResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class GuildsMembersStoreEndpoint {
List.of("api", "auth:sanctum", "service.abilities:social.write"),
List.of(new RouteParameter("guild", new TypeDescriptor("App\\Domains\\Social\\Models\\Guild", false, false))),
new RequestDescriptor("App\\Domains\\Social\\Http\\Requests\\StoreGuildMemberRequest", "body", List.of(new RequestField("player_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("invited_by_player_id", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("role", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("object", "Illuminate\\Validation\\Rules\\In", "in:\"leader\",\"officer\",\"member\""))), new RequestField("joined_at", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Social\\Resources\\GuildResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class GuildsOwnershipTransferEndpoint {
List.of("api", "auth:sanctum", "service.abilities:social.write"),
List.of(new RouteParameter("guild", new TypeDescriptor("App\\Domains\\Social\\Models\\Guild", false, false))),
new RequestDescriptor("App\\Domains\\Social\\Http\\Requests\\TransferGuildOwnershipRequest", "body", List.of(new RequestField("acting_player_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("new_owner_player_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("transferred_at", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("reason", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:500"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Social\\Resources\\GuildResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class GuildsStoreEndpoint {
List.of("api", "auth:sanctum", "service.abilities:social.write"),
List.of(),
new RequestDescriptor("App\\Domains\\Social\\Http\\Requests\\StoreGuildRequest", "body", List.of(new RequestField("owner_player_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("slug", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:80"), new RuleDescriptor("string", null, "regex:/^[a-z0-9-]+$/"), new RuleDescriptor("string", null, "unique:guilds,slug"))), new RequestField("name", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:120"))), new RequestField("tag", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:12"), new RuleDescriptor("object", "Illuminate\\Validation\\Rules\\Unique", "unique:guilds,tag,NULL,id"))), new RequestField("max_members", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "integer"), new RuleDescriptor("string", null, "between:2,200"))), new RequestField("motd", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:255"))), new RequestField("founded_at", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Social\\Resources\\GuildResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class PartiesInvitesStoreEndpoint {
List.of("api", "auth:sanctum", "service.abilities:social.write"),
List.of(new RouteParameter("party", new TypeDescriptor("App\\Domains\\Social\\Models\\Party", false, false))),
new RequestDescriptor("App\\Domains\\Social\\Http\\Requests\\StorePartyInviteRequest", "body", List.of(new RequestField("invited_player_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("invited_by_player_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("message", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:1000"))), new RequestField("invited_at", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("expires_at", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Social\\Resources\\PartyInviteResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class PartiesKicksStoreEndpoint {
List.of("api", "auth:sanctum", "service.abilities:social.write"),
List.of(new RouteParameter("party", new TypeDescriptor("App\\Domains\\Social\\Models\\Party", false, false))),
new RequestDescriptor("App\\Domains\\Social\\Http\\Requests\\KickPartyMemberRequest", "body", List.of(new RequestField("acting_player_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("target_player_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("kicked_at", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("reason", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:500"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Social\\Resources\\PartyResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class PartiesLeadershipTransferEndpoint {
List.of("api", "auth:sanctum", "service.abilities:social.write"),
List.of(new RouteParameter("party", new TypeDescriptor("App\\Domains\\Social\\Models\\Party", false, false))),
new RequestDescriptor("App\\Domains\\Social\\Http\\Requests\\TransferPartyLeadershipRequest", "body", List.of(new RequestField("acting_player_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("new_leader_player_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("transferred_at", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("reason", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:500"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Social\\Resources\\PartyResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class PartiesLeaveEndpoint {
List.of("api", "auth:sanctum", "service.abilities:social.write"),
List.of(new RouteParameter("party", new TypeDescriptor("App\\Domains\\Social\\Models\\Party", false, false))),
new RequestDescriptor("App\\Domains\\Social\\Http\\Requests\\LeavePartyRequest", "body", List.of(new RequestField("player_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("left_at", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("reason", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:500"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Social\\Resources\\PartyResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class PartiesMembersStoreEndpoint {
List.of("api", "auth:sanctum", "service.abilities:social.write"),
List.of(new RouteParameter("party", new TypeDescriptor("App\\Domains\\Social\\Models\\Party", false, false))),
new RequestDescriptor("App\\Domains\\Social\\Http\\Requests\\StorePartyMemberRequest", "body", List.of(new RequestField("player_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("invited_by_player_id", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("joined_at", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Social\\Resources\\PartyResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class PartiesStoreEndpoint {
List.of("api", "auth:sanctum", "service.abilities:social.write"),
List.of(),
new RequestDescriptor("App\\Domains\\Social\\Http\\Requests\\StorePartyRequest", "body", List.of(new RequestField("leader_player_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("max_members", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "integer"), new RuleDescriptor("string", null, "between:2,12"))), new RequestField("formed_at", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Social\\Resources\\PartyResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class PartyInvitesAcceptEndpoint {
List.of("api", "auth:sanctum", "service.abilities:social.write"),
List.of(new RouteParameter("partyInvite", new TypeDescriptor("App\\Domains\\Social\\Models\\PartyInvite", false, false))),
new RequestDescriptor("App\\Domains\\Social\\Http\\Requests\\AcceptPartyInviteRequest", "body", List.of(new RequestField("accepting_player_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("accepted_at", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Social\\Resources\\PartyResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class PartyInvitesCancelEndpoint {
List.of("api", "auth:sanctum", "service.abilities:social.write"),
List.of(new RouteParameter("partyInvite", new TypeDescriptor("App\\Domains\\Social\\Models\\PartyInvite", false, false))),
new RequestDescriptor("App\\Domains\\Social\\Http\\Requests\\CancelPartyInviteRequest", "body", List.of(new RequestField("cancelled_by_player_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("cancelled_at", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("reason", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:500"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Social\\Resources\\PartyInviteResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class PartyInvitesDeclineEndpoint {
List.of("api", "auth:sanctum", "service.abilities:social.write"),
List.of(new RouteParameter("partyInvite", new TypeDescriptor("App\\Domains\\Social\\Models\\PartyInvite", false, false))),
new RequestDescriptor("App\\Domains\\Social\\Http\\Requests\\DeclinePartyInviteRequest", "body", List.of(new RequestField("declining_player_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("declined_at", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("reason", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:500"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Social\\Resources\\PartyInviteResource", false, false))
)
;
@@ -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;
}
@@ -19,7 +19,7 @@ public final class IssuesAssignmentsStoreEndpoint {
List.of("api", "auth:sanctum", "service.abilities:support.issues.write"),
List.of(new RouteParameter("supportIssue", new TypeDescriptor("App\\Domains\\Support\\Models\\SupportIssue", false, false))),
new RequestDescriptor("App\\Domains\\Support\\Http\\Requests\\ChangeSupportIssueAssignmentRequest", "body", List.of(new RequestField("assigned_to_player_id", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("reason", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"))), new RequestField("assigned_at", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Support\\Resources\\SupportIssueResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class IssuesMessagesStoreEndpoint {
List.of("api", "auth:sanctum", "service.abilities:support.issues.write"),
List.of(new RouteParameter("supportIssue", new TypeDescriptor("App\\Domains\\Support\\Models\\SupportIssue", false, false))),
new RequestDescriptor("App\\Domains\\Support\\Http\\Requests\\StoreSupportIssueMessageRequest", "body", List.of(new RequestField("visibility", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("object", "Illuminate\\Validation\\Rules\\In", "in:\"requester\",\"internal\""))), new RequestField("body", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"))), new RequestField("sent_at", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Support\\Resources\\SupportIssueResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class IssuesReopenEndpoint {
List.of("api", "auth:sanctum", "service.abilities:support.issues.write"),
List.of(new RouteParameter("supportIssue", new TypeDescriptor("App\\Domains\\Support\\Models\\SupportIssue", false, false))),
new RequestDescriptor("App\\Domains\\Support\\Http\\Requests\\ReopenSupportIssueRequest", "body", List.of(new RequestField("reason", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"))), new RequestField("status", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("object", "Illuminate\\Validation\\Rules\\In", "in:\"open\",\"in_progress\",\"waiting_on_player\""))), new RequestField("summary", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"))), new RequestField("assigned_to_player_id", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("reopened_at", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Support\\Resources\\SupportIssueResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class IssuesStoreEndpoint {
List.of("api", "auth:sanctum", "service.abilities:support.issues.write"),
List.of(),
new RequestDescriptor("App\\Domains\\Support\\Http\\Requests\\StoreSupportIssueRequest", "body", List.of(new RequestField("subject_player_id", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("assigned_to_player_id", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("category", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("object", "Illuminate\\Validation\\Rules\\In", "in:\"account\",\"purchase\",\"bug_report\",\"appeal\",\"other\""))), new RequestField("channel", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("object", "Illuminate\\Validation\\Rules\\In", "in:\"player_portal\",\"email\",\"discord\",\"in_game\",\"internal\""))), new RequestField("priority", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("object", "Illuminate\\Validation\\Rules\\In", "in:\"low\",\"normal\",\"high\",\"urgent\""))), new RequestField("title", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "max:255"))), new RequestField("summary", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"))), new RequestField("initial_message", List.of(new RuleDescriptor("string", null, "required"), new RuleDescriptor("string", null, "string"))), new RequestField("message_visibility", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("object", "Illuminate\\Validation\\Rules\\In", "in:\"requester\",\"internal\""))), new RequestField("opened_at", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "date"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "array"))), new RequestField("initial_message_metadata", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Support\\Resources\\SupportIssueResource", false, false))
)
;
@@ -19,7 +19,7 @@ public final class IssuesUpdateEndpoint {
List.of("api", "auth:sanctum", "service.abilities:support.issues.write"),
List.of(new RouteParameter("supportIssue", new TypeDescriptor("App\\Domains\\Support\\Models\\SupportIssue", false, false))),
new RequestDescriptor("App\\Domains\\Support\\Http\\Requests\\UpdateSupportIssueRequest", "body", List.of(new RequestField("status", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("object", "Illuminate\\Validation\\Rules\\In", "in:\"open\",\"in_progress\",\"waiting_on_player\",\"resolved\",\"closed\""))), new RequestField("priority", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("object", "Illuminate\\Validation\\Rules\\In", "in:\"low\",\"normal\",\"high\",\"urgent\""))), new RequestField("assigned_to_player_id", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"), new RuleDescriptor("string", null, "exists:players,id"))), new RequestField("summary", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"))), new RequestField("resolution_summary", List.of(new RuleDescriptor("object", "Illuminate\\Validation\\Rules\\RequiredIf", ""), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "string"))), new RequestField("resolved_at", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "date"))), new RequestField("metadata", List.of(new RuleDescriptor("string", null, "sometimes"), new RuleDescriptor("string", null, "nullable"), new RuleDescriptor("string", null, "array"))))),
new ResponseDescriptor("json_response", new TypeDescriptor("Illuminate\\Http\\JsonResponse", false, false))
new ResponseDescriptor("json_resource", new TypeDescriptor("App\\Domains\\Support\\Resources\\SupportIssueResource", false, false))
)
;
@@ -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> T get(String uri, Class<T> 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> T request(String method, String uri, Object requestBody, Class<T> responseType) throws ControlPlaneException {
JsonObject body = request(method, uri, requestBody);
return GSON.fromJson(extractData(body), responseType);
}
public void postBatch(String uri, List<BatchClient.PendingCall<?>> 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<String, BatchClient.PendingCall<?>> 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 <T> void completeBatchCall(BatchClient.PendingCall<T> 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<String> 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<String, String[]> parseErrors(String body) {
Map<String, String[]> 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<String, String[]> parseErrors(JsonObject obj) {
Map<String, String[]> 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);
}
}
@@ -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; }
}
}
@@ -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<PlayerEntitlement> 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<PlayerEntitlement> 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; }
}
}
@@ -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; }
}
@@ -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;
};
}
}
@@ -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;
};
}
}
@@ -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; }
}
@@ -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;
}
}
@@ -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; }
}
}
+10 -6
View File
@@ -1,7 +1,7 @@
{
"Group": "net.kewwbec",
"Name": "core-network",
"Version": "0.0.2",
"Version": "0.5.3",
"Description": "KweebecNetwork control-plane SDK for Hytale server plugins",
"Authors": [
{
@@ -9,10 +9,14 @@
}
],
"Website": "https://kewwbec.net",
"ServerVersion": "2026.02.17-255364b8e",
"Dependencies": {},
"OptionalDependencies": {},
"ServerVersion": "0.5.3",
"Dependencies": {
},
"OptionalDependencies": {
},
"DisabledByDefault": false,
"Main": "net.kewwbec.network.NetworkPlugin",
"IncludesAssetPack": false
}
"IncludesAssetPack": true
}
@@ -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<JsonObject> 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(); }
}
}
@@ -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<SampleResponse> call = profileCall(Duration.ofMinutes(1));
CompletableFuture<SampleResponse> 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<SampleResponse> call = profileCall(Duration.ofMillis(1));
CompletableFuture<SampleResponse> first = client.cache().forcePull(call, CacheContext.shared());
client.batch().flush();
assertEquals("v1", first.get(1, TimeUnit.SECONDS).value);
Thread.sleep(5);
CompletableFuture<SampleResponse> 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<SampleResponse> call = profileCall(Duration.ofMillis(1));
CompletableFuture<SampleResponse> first = client.cache().forcePull(call, CacheContext.shared());
client.batch().flush();
assertEquals("ok", first.get(1, TimeUnit.SECONDS).value);
Thread.sleep(5);
CompletableFuture<SampleResponse> 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<SampleResponse> call = profileCall(Duration.ofMillis(1));
CompletableFuture<SampleResponse> 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<SampleResponse> 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<Void> 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<SampleResponse> call = profileCall(Duration.ofMinutes(1));
CompletableFuture<SampleResponse> first = client.cache().forcePull(call, CacheContext.of("one"));
CompletableFuture<SampleResponse> 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<SampleResponse> 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<SampleResponse> 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<SampleResponse> 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<ApiCacheKey, ApiCacheEntry> entries = new ConcurrentHashMap<>();
private final AtomicInteger readCount = new AtomicInteger();
private final AtomicInteger writeCount = new AtomicInteger();
@Override
public Optional<ApiCacheEntry> 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<StoredAssetCacheEntry> entries() {
return entries.entrySet().stream()
.map(entry -> new StoredAssetCacheEntry(entry.getKey(), entry.getValue(), CacheContext.shared()))
.toList();
}
}
}
@@ -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<ResolvedControlPlaneStartupConfig> 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<ResolvedControlPlaneStartupConfig> 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<ResolvedControlPlaneStartupConfig> 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"
);
}
}