# WDM Java/Kotlin SDK — Canonical v1 Facade

A Kotlin-first client for the canonical Warfighting Data Model (WDM)
ConnectRPC services. The SDK wires up authentication, TLS, timeout, and
logging, then exposes the buf-generated `ObjectService` and
`ActionService` stubs directly via `WdmV1Client`. Both Kotlin (suspend)
and Java (blocking) consumers are supported.

## Installation

Extract the SDK archive into your project (substitute your version):

```bash
tar -xzf wdm-sdk-java-<VERSION>.tar.gz
```

Add the extracted directory as a Gradle composite build by updating your
`settings.gradle.kts`:

```kotlin
includeBuild("wdm-sdk-java-<VERSION>")
```

Then depend on the SDK in your `build.gradle.kts`:

```kotlin
dependencies {
    implementation("com.raft.wdm:wdm-sdk-java:<VERSION>")
}
```

> Java callers access package-level functions (`loadConfig`, `toOptions`)
> via the `Wdm` facade class — e.g. `Wdm.loadConfig()`.

## Quick Start

The simplest way to get started is environment-based configuration:

```bash
export RDP_SERVER_URL=https://wdm.example.com
export RDP_CLIENT_ID=my-client-id
export RDP_CLIENT_SECRET=my-client-secret
```

```kotlin
val cfg = loadConfig()
val client = WdmV1Client.create(cfg.toOptions())

val resp = client.objectService.getObject(
    GetObjectRequest.newBuilder().setObjectId("550e8400-...").build()
)
```

Kotlin consumers use suspend functions on `objectService` and
`actionService`; Java consumers use the `*Blocking` variants exposed as
`objectServiceBlocking` / `actionServiceBlocking` (see [Java](#java)).

## Configuration

### Environment Variables

| Variable | Default | Description |
|----------|---------|-------------|
| `RDP_SERVER_URL` | `https://rdp.local` | Server base URL (scheme + host only — see validation rules below) |
| `RDP_SERVER_PORT` | — | Override port independently |
| `TLS_SKIP_VERIFY` | `false` | Skip TLS certificate verification (insecure mode) |
| `RDP_API_KEY` | — | API key authentication |
| `RDP_CLIENT_ID` | — | OAuth2 client credentials ID |
| `RDP_CLIENT_SECRET` | — | OAuth2 client credentials secret |

#### `RDP_SERVER_URL` validation rules

The OAuth2 auth endpoint is always at `{RDP_SERVER_URL}/api/v1/auth/token`,
so `loadConfig()` (and `WdmV1Client.create(options)`) rejects URLs that
would distort that path:

- Must be an absolute URL with a scheme and host.
- Must **not** include a path component (`https://host/base` is rejected).
- Must **not** include a query (`?foo=bar`) or fragment (`#frag`).
- Must **not** include userinfo (`https://user:pass@host`) — OkHttp would
  otherwise translate this into a Basic Auth header on every request,
  bypassing the SDK's auth model.

A trailing root slash (`https://host/`) is trimmed before validation and
accepted.

`loadConfig()` reads these from the environment (and optionally a `.env`
file) and returns a validated `WdmConfig`:

```kotlin
val cfg = loadConfig()              // reads environment
val cfg = loadConfig(".env.local")  // reads .env file + environment
```

### Config-driven initialization (recommended)

```kotlin
val cfg = loadConfig()
val client = WdmV1Client.create(cfg.toOptions())
```

`cfg.toOptions()` converts a validated `WdmConfig` into `WdmOptions`,
applying endpoint, auth credentials, and TLS settings.

### Direct options (manual)

For programmatic control without environment variables:

```kotlin
// OAuth2 client credentials
val client = WdmV1Client.create(WdmOptions(
    endpoint = "https://wdm.example.com",
    clientCredentials = ClientCredentials("cid", "csec"),
))

// API key
val client = WdmV1Client.create(WdmOptions(
    endpoint = "https://wdm.example.com",
    apiKey = "ak-00000000",
))

// Default endpoint (https://rdp.local)
val client = WdmV1Client.create(WdmOptions(apiKey = "ak-00000000"))
```

### Java

```java
// Config-driven
var cfg = Wdm.loadConfig(null, null);
var client = WdmV1Client.create(Wdm.toOptions(cfg));

// Manual via Builder
var options = new WdmOptions.Builder()
    .endpoint("https://wdm.example.com")
    .clientCredentials("cid", "csec")
    .build();
var client = WdmV1Client.create(options);

var resp = client.getObjectServiceBlocking().getObjectBlocking(
    GetObjectRequest.newBuilder().setObjectId("550e8400-...").build()
).execute();
```

#### Blocking streaming for Java

Streaming RPCs are exposed as Java-friendly facades off the client
(`getObjectServiceBlocking()`, `getActionServiceBlocking()`). Server
streams implement `Iterable<T>` plus `AutoCloseable` — use a plain `for`
loop inside try-with-resources. Client streams expose `send(Req)` and a
terminal `closeAndReceive()`.

```java
// Server stream: StreamObjects
try (var sub = client.getObjectServiceBlocking()
        .streamObjects(StreamObjectsRequest.getDefaultInstance())) {
    for (StreamObjectsResponse r : sub) {
        handle(r);
    }
}

// Client stream: PublishObjects
try (var pub = client.getObjectServiceBlocking().publishObjects()) {
    for (Object o : batch) {
        pub.send(PublishObjectsRequest.newBuilder().setObject(o).build());
    }
    PublishObjectsResponse ack = pub.closeAndReceive();
}
```

Once closed, further iteration or sends throw `StreamClosedException`.
On JDK 21+ each blocking stream pairs well with a virtual thread —
`Thread.startVirtualThread(() -> consumeStream())` scales to thousands
of concurrent consumers without platform-thread cost.

## Authentication

Two auth methods are supported. Providing both raises
`IllegalArgumentException` at client creation time.

### OAuth2 Client Credentials

```kotlin
val client = WdmV1Client.create(WdmOptions(
    endpoint = endpoint,
    clientCredentials = ClientCredentials("cid", "csec"),
))
```

The SDK automatically fetches a bearer token from
`{endpoint}/api/v1/auth/token` using the client credentials grant. Tokens
are cached per the server-returned `expires_in` field (no hardcoded
client-side TTL), with a 10-second early-refresh delta so a token that
is about to expire mid-flight is replaced before it is used. If the
server omits `expires_in`, the SDK falls back to 300 seconds. Concurrent
requests share a single in-flight token fetch via `ReentrantLock`.

Every token fetch is bounded by a **10-second OkHttp `callTimeout`**. A
hung auth server cannot deadlock RPC calls — it fails the token fetch
with an `IOException`, which propagates to the calling RPC.

### API Key

```kotlin
val client = WdmV1Client.create(WdmOptions(
    endpoint = endpoint,
    apiKey = "ak-00000000",
))
```

Sets the `X-API-Key` header on every request.

### Insecure Mode (TLS Skip Verify)

`tlsSkipVerify = true` (or `TLS_SKIP_VERIFY=true`) disables TLS
certificate verification so the client can talk to dev or staging servers
with self-signed certs. Auth credentials are **not** dropped — OAuth2 and
API-key auth flow through normally, and the OAuth2 token fetch also uses
the skip-verify OkHttp client so the token endpoint can be reached against
a self-signed cert. The SDK logs a warning at client construction:

```
wdm: TLS certificate verification disabled (insecure)
```

Empty or whitespace-only credentials are logged as warnings and ignored.

```kotlin
val client = WdmV1Client.create(WdmOptions(
    endpoint = "https://localhost:8443",
    clientCredentials = ClientCredentials("dev-id", "dev-secret"),
    tlsSkipVerify = true,
))
```

## Objects and Actions

```kotlin
val client = WdmV1Client.create(cfg.toOptions())

// Publish an object
client.objectService.publishObject(
    PublishObjectRequest.newBuilder()
        .setObject(Object.newBuilder()
            .setId("550e8400-...")
            .setName("example-object")
            .setDescription("Published by the WDM SDK.")
            .setProvenance(ProvenanceRecord.newBuilder()
                .setName("my-app")
                .setUpdatedAt(Timestamp.newBuilder().setSeconds(System.currentTimeMillis() / 1000))))
        .build()
)

// Get an object by ID
val resp = client.objectService.getObject(
    GetObjectRequest.newBuilder().setObjectId("550e8400-...").build()
)

// Search objects
val searchResp = client.objectService.searchObjects(
    SearchObjectsRequest.newBuilder().setPageSize(50).build()
)

// Create an action
val actionResp = client.actionService.createAction(
    CreateActionRequest.newBuilder()
        .setAction(Action.newBuilder()
            .setName("Fire Mission Alpha")
            .setPriority(ActionPriority.ACTION_PRIORITY_HIGH)
            .setProvenance(ProvenanceRecord.newBuilder()
                .setName("my-app")
                .setUpdatedAt(Timestamp.newBuilder().setSeconds(System.currentTimeMillis() / 1000))))
        .build()
)

// Update action state
client.actionService.updateActionState(
    UpdateActionStateRequest.newBuilder()
        .setActionId(actionId)
        .setState(ActionState.ACTION_STATE_IN_PROGRESS)
        .build()
)
```

## Batch Publishing

For bulk object ingestion, use the client-streaming `PublishObjects` RPC:

```kotlin
val stream = client.objectService.publishObjects()
for (obj in objects) {
    stream.send(PublishObjectsRequest.newBuilder().setObject(obj).build())
}
stream.receiveAndClose()
```

## Streaming

Server-streaming uses connect-kotlin's `ServerOnlyStreamInterface`:

```kotlin
val stream = client.objectService.streamObjects()
stream.sendAndClose(StreamObjectsRequest.getDefaultInstance())

for (response in stream.responseChannel()) {
    val obj = response.object
    println("Object: ${obj.name}")
}
```

`actionService.streamActions()` follows the same pattern.

## Logging

The SDK is silent by default. Logging can be enabled in two ways.

### Inject a Logger

```kotlin
import org.slf4j.LoggerFactory

val client = WdmV1Client.create(WdmOptions(
    endpoint = endpoint,
    logger = LoggerFactory.getLogger("my-app"),
))
```

### Environment Variable

When no logger is injected, set `SDK_LOG_LEVEL` to activate logging:

```bash
SDK_LOG_LEVEL=warn ./my-app
```

Valid levels: `debug`, `info`, `warn`, `error`.

**Precedence:** injected logger > `SDK_LOG_LEVEL` > silent (no-op).

**Note:** SLF4J is a logging facade. The actual log output format and
filtering depends on your SLF4J binding (logback, log4j2, slf4j-simple, etc.).

## Timeout Configuration

The SDK does not impose a default deadline. Callers own their deadlines
via Kotlin structured concurrency (`withTimeout`) or by setting the
`timeout` option explicitly.

When `options.timeout` is non-null, it acts as a hard deadline on
**unary** RPCs only. A unary RPC that is still active when the deadline
elapses is cancelled by connect-kotlin and the caller sees
`Code.DEADLINE_EXCEEDED`. When `options.timeout` is null (the default),
no SDK-imposed unary deadline is scheduled — the call runs until the
caller's context is cancelled or the server responds.

**Streaming RPCs (`PublishObjects`, `StreamObjects`, `StreamActions`)
are never bounded by `options.timeout`.** Streams are long-lived by
design. Use structured concurrency (coroutine cancellation,
`withTimeout`, or `CoroutineScope.cancel()`) to bound a stream's
duration from the caller side.

OkHttp's default per-operation `connectTimeout` / `readTimeout` /
`writeTimeout` (10 seconds each) still apply as a safety net for hung
individual operations — they bound *gaps between bytes*, not total stream
duration, so a stream that sends any message within 10 seconds stays
healthy indefinitely.

```kotlin
// No SDK deadline (default) — caller owns the deadline
val client = WdmV1Client.create(WdmOptions(endpoint = endpoint))
withTimeout(5000) {
    client.objectService.getObject(req)
}

// Opt-in SDK ceiling on unary calls
val client = WdmV1Client.create(WdmOptions(
    endpoint = endpoint,
    timeout = Duration.ofSeconds(10),
))
client.objectService.getObject(req)  // must complete in 10s

// Streaming: not bounded by options.timeout — use coroutine cancellation
coroutineScope {
    val job = launch {
        val stream = client.objectService.streamObjects()
        stream.sendAndClose(StreamObjectsRequest.getDefaultInstance())
        for (response in stream.responseChannel()) { /* ... */ }
    }
    delay(60_000)
    job.cancel()  // cleanly ends the stream after 60 seconds
}
```

## Error Handling

Errors pass through directly from the ConnectRPC stubs as `ResponseMessage`
results. Check for success or failure:

```kotlin
val resp = client.objectService.getObject(req)
resp.success { result ->
    println("Object: ${result.message.object.name}")
}
resp.failure { error ->
    if (error.cause.code == Code.NOT_FOUND) {
        // handle not found
    }
}
```
