Compare commits
2 Commits
7a4a0bd4bd
...
be35d92b52
| Author | SHA1 | Date | |
|---|---|---|---|
| be35d92b52 | |||
| 59330bdf84 |
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
@@ -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()`.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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();
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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))
|
||||
)
|
||||
;
|
||||
|
||||
|
||||
+1
-1
@@ -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))
|
||||
)
|
||||
;
|
||||
|
||||
|
||||
+1
-1
@@ -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))
|
||||
)
|
||||
;
|
||||
|
||||
|
||||
+1
-1
@@ -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))
|
||||
)
|
||||
;
|
||||
|
||||
|
||||
+1
-1
@@ -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))
|
||||
)
|
||||
;
|
||||
|
||||
|
||||
+1
-1
@@ -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))
|
||||
)
|
||||
;
|
||||
|
||||
|
||||
+1
-1
@@ -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))
|
||||
)
|
||||
;
|
||||
|
||||
|
||||
+1
-1
@@ -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))
|
||||
)
|
||||
;
|
||||
|
||||
|
||||
+1
-1
@@ -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))
|
||||
)
|
||||
;
|
||||
|
||||
|
||||
+1
-1
@@ -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))
|
||||
)
|
||||
;
|
||||
|
||||
|
||||
+1
-1
@@ -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))
|
||||
)
|
||||
;
|
||||
|
||||
|
||||
+1
-1
@@ -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))
|
||||
)
|
||||
;
|
||||
|
||||
|
||||
+1
-1
@@ -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))
|
||||
)
|
||||
;
|
||||
|
||||
|
||||
+1
-1
@@ -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))
|
||||
)
|
||||
;
|
||||
|
||||
|
||||
+1
-1
@@ -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))
|
||||
)
|
||||
;
|
||||
|
||||
|
||||
+1
-1
@@ -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))
|
||||
)
|
||||
;
|
||||
|
||||
|
||||
+1
-1
@@ -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))
|
||||
)
|
||||
;
|
||||
|
||||
|
||||
+1
-1
@@ -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))
|
||||
)
|
||||
;
|
||||
|
||||
|
||||
+1
-1
@@ -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))
|
||||
)
|
||||
;
|
||||
|
||||
|
||||
+1
-1
@@ -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))
|
||||
)
|
||||
;
|
||||
|
||||
|
||||
+1
-1
@@ -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))
|
||||
)
|
||||
;
|
||||
|
||||
|
||||
+1
-1
@@ -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))
|
||||
)
|
||||
;
|
||||
|
||||
|
||||
+1
-1
@@ -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))
|
||||
)
|
||||
;
|
||||
|
||||
|
||||
+1
-1
@@ -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))
|
||||
)
|
||||
;
|
||||
|
||||
|
||||
+1
-1
@@ -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))
|
||||
)
|
||||
;
|
||||
|
||||
|
||||
+1
-1
@@ -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))
|
||||
)
|
||||
;
|
||||
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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))
|
||||
)
|
||||
;
|
||||
|
||||
|
||||
+1
-1
@@ -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))
|
||||
)
|
||||
;
|
||||
|
||||
|
||||
+1
-1
@@ -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))
|
||||
)
|
||||
;
|
||||
|
||||
|
||||
+1
-1
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user