A client for reading configuration from a self-hosted KeyKosh (K2) platform. The core has one runtime
dependency (Jackson) and uses the JDK's java.net.http. Spring support is optional and lives in
the same jar — plain-JVM apps never pull Spring transitively. Java 17+.
The SDK talks to exactly one host — your own k2-app. There is no vendor default URL and no
callback home; baseUrl is required.
| Minimum | |
|---|---|
Core K2Client (Mode 3, plain JVM) |
Java 17 |
| Spring modes (Modes 1 & 2) | Java 17 — Spring Boot 3 is itself 17+ |
The jar is compiled with --release 17, so its class files are major version 61 and load on any
Java 17 or newer JVM. The floor is enforced at build time: maven-enforcer-plugin fails the build
if the target is raised, since raising it is a breaking change for every consumer on 17.
The two halves are worth stating separately, because only one is really a "floor" question:
- The core client uses no language feature newer than Java 16 (records, pattern-matching
instanceof), so 17 is simply the nearest supported LTS — see CHANGELOG.md → Java baseline. - The optional Spring integration compiles against Spring Boot 3, which is itself Java 17+. Spring mode therefore requires 17 regardless of what the core could support, and lowering the core baseline would not move this number.
On Java 17–20, use 1.1.2 or later. Both 1.1.0 and 1.1.1 were published with Java 21 bytecode
(1.1.1 only partially — 20 of 35 classes, including K2Client) and fail at class load with
UnsupportedClassVersionError. 1.1.2 is verified major 61 throughout.
<dependency>
<groupId>com.k2platform</groupId>
<artifactId>k2-sdk-java</artifactId>
<version>1.1.2</version>
</dependency>| Mode | For | What you write |
|---|---|---|
| 1. PropertySource (Spring) | Spring Boot apps — replace application.yml |
nothing in code; 2 bootstrap props |
2. @K2Config proxy (Spring) |
typed, live-refreshing config beans | one interface |
3. Core K2Client |
plain JVM / batch / non-Spring | direct calls |
K2 replaces application.yml as the source of truth. Just add the dependency and set the bootstrap
trio; every existing @Value("${...}") / @ConfigurationProperties resolves from K2 first, with
application.yml as fallback. No code change.
# application.yml — now just bootstrap + fallback defaults
k2:
base-url: http://localhost:8080 # https://k2.acme.com in prod
env: prod
property-source:
precedence: highest # highest = beats application.yml AND -D/OS env
# above-application-yaml = operators keep -D/env override
db:
url: jdbc:postgresql://localhost/app # FALLBACK only — K2's "db.url" wins on a name matchexport K2_TOKEN=k2_live_... # the one secret — never commit it@Service
class MyService {
@Value("${db.url}") String dbUrl; // ← resolved FROM K2 at startup; falls back to yaml
}Hot-reload note: the K2PropertySource is refreshed live (see Hot reload), but
@Value / @ConfigurationProperties bind once at startup and keep their initial value — that's
Spring's injection model. For values that must follow a live change, read through Environment or use
Mode 2. If k2-app is unreachable at startup, Mode 1 boots from k2config-<env>.json (then
application.yml defaults) — it never blocks boot.
For the handful of properties you need live without a refresh:
@K2Config(prefix = "feature")
public interface FeatureFlags {
@K2ConfigProperty(key = "x", defaultValue = "false")
boolean x(); // reads the K2 cache on every call
}
@SpringBootApplication
@EnableK2Config(basePackages = "com.acme.config")
public class MyApp { }@Service
class Gate {
private final FeatureFlags flags;
Gate(FeatureFlags flags) { this.flags = flags; } // injected like any bean
}Method→key: explicit key(), else getXxx/isXxx → xxx, else camelCase → dotted; prefixed with
@K2Config.prefix. Return types String/int/long/boolean/double are coerced; defaultValue
applies when the key is absent.
In both Spring modes the bootstrap props bind from your existing application.yml (and K2_BASE_URL/
K2_TOKEN/K2_ENV env vars via Spring relaxed binding) — no separate k2.yml, and you never
declare an app name (the token carries app+env scope).
K2Client k2 = K2Client.builder()
.baseUrl("http://localhost:8080") // your K2 URL — https://k2.acme.com in prod
.token("k2_live_...") // SDK token from the admin UI (Tokens tab)
.build();
// Full config for an environment
K2Configuration cfg = k2.getConfiguration("prod");
String dbUrl = cfg.getString("db.url", "jdbc:postgresql://localhost/app");
int pool = cfg.getInt("db.pool", 10);
boolean flag = cfg.getBoolean("feature.x", false);
// Or a single property
Object value = k2.getProperty("prod", "db.url");The token is sent as both Authorization: Bearer <token> and X-API-Token: <token>,
so it works regardless of which header your platform build expects. The token's
environment scope is enforced server-side.
| SDK call | Endpoint |
|---|---|
getConfiguration(env) |
GET /api/config/token/{env}/current |
getProperty(env, key) |
GET /api/config/token/{env}/properties/{key} |
K2Exception carries a stable getCode() (a K2ErrorCode — branch on this, not on the
message) plus getStatusCode(): 401/403 token rejected, 404 config not found, 421 baseUrl
host not licensed (must match the platform's K2_PUBLIC_HOST), -1 transport/config/file
failure. isAvailabilityError() is true only for K2_UNREACHABLE, K2_TIMEOUT and
K2_SERVER_ERROR — the codes eligible for the local file. K2ErrorCode.kind() groups the codes
as CONFIG / FILE / AUTH / AVAILABILITY / OTHER; K2_REQUEST_FAILED moved AUTH →
OTHER in 1.1.1 (classification only — it has never served the local file).
Precedence: .token(...) → K2_TOKEN → .tokenFile(...) / k2.token-file / K2_TOKEN_FILE →
.tokenEnc(...) / K2_TOKEN_ENC. A token file is the Docker/Kubernetes secret-mount shape: its
contents are stripped of surrounding whitespace (mounted secrets end in a newline) and read once,
at token resolution — not per request. A missing, unreadable or blank file throws
K2_TOKEN_FILE_UNREADABLE naming the path, never a silent fallthrough to "no token". Supported
by the core client since 1.1.1; before that only the Spring integration read it. The Node and
Python SDKs honour the same variable from 1.2.0.
K2Client k2 = K2Client.fromEnv(); // everything from K2_*
K2Client k2b = K2Client.builderFromEnv() // ... or override before building
.requestTimeout(Duration.ofSeconds(30))
.build();New in 1.1.1. fromEnv() is the Mode-3 counterpart of Node's createClient() and Python's
K2Client.from_env(): it reads K2_BASE_URL, K2_TOKEN, K2_TOKEN_FILE, K2_TOKEN_ENC,
K2_ENV, K2_ORG, K2_APP, K2_OFFLINE, K2_OFFLINE_CACHE, K2_HOT_RELOAD, K2_CONFIG_DIR,
K2_CONFIG_FILE, K2_OFFLINE_MAX_AGE, K2_CACHE_TTL and K2_STS_ENABLED — the same set as the
other two SDKs, blank counting as unset. builder() remains how you configure a client in code;
fromEnv() is how you configure one from a container's environment, and a builder() client
reads no environment variable.
fromEnv() does not honour the deprecated K2_SOURCE / K2_CACHE_DIR aliases (Node and
Python still do, for one more minor release). The core never read either, so nothing regresses.
Use K2_OFFLINE and K2_CONFIG_DIR.
Cross-language note: Node and Python raise K2Error (not K2Exception), have no
K2ErrorCode.Kind, have no snapshot(env) or getOfflineCacheAllowed(), honour the deprecated
env aliases this SDK's fromEnv() drops, and are zero-dependency where this SDK needs Jackson.
Codes and variable names are otherwise identical across all three.
See USER_MANUAL.md → Cross-language differences.
| Concern | Builder | Spring property | Default |
|---|---|---|---|
| Token from a mounted secret file | .tokenFile(path) |
k2.token-file / K2_TOKEN_FILE |
— |
| TTL cache (collapses repeat reads) | .cacheTtl(Duration) |
k2.cache.ttl-seconds |
300s (Spring) / off (core) |
| Never contact the server | .offline(true) |
k2.offline |
false |
| Keep a local config file at all | .offlineCache(false) |
k2.offline-cache |
true |
| Hot reload over SSE | .hotReload(false) |
k2.hot-reload |
on when online |
| Local file location | .configDir(...) / .configFile(...) |
k2.config-dir, k2.config-file |
~/.k2/config |
| Hard staleness limit | .offlineMaxAge(Duration) |
k2.offline-max-age |
none |
| PropertySource on/off + precedence | — | k2.property-source.enabled, k2.property-source.precedence |
enabled, highest |
k2config-<env>.json is written on every successful fetch and read only on an availability
failure (unreachable, timeout, or 5xx) — 401/403/404/421 always surface, because an auth
failure is not an outage. k2.offline=true inverts this: the file becomes the source of truth
and no server is ever contacted.
The file is plaintext and holds secret values in clear. Gitignore
k2config-*.json.
AutoCloseable sub = client.watch("prod", cfg -> pool.resize(cfg.getInt("pool.size", 10)));Subscribes over Server-Sent Events and fires when an admin edits config — no polling. The
stream carries a signal, not values, so the SDK re-fetches on each event (secrets never sit on a
long-lived connection). Reconnects with backoff, and falls back to polling on cacheTtl if the
platform predates the stream or a proxy strips SSE.
Under Spring, K2ConfigWatcher does this for you and swaps fresh values into the
K2PropertySource. Note: a field injected with @Value is resolved once at bean creation
and keeps its original value — that is Spring's injection model, not an SDK limitation.
@K2Config proxies and Environment reads do track live changes.
K2_BASE_URL=http://localhost:8080 \
K2_TOKEN=k2_live_xxx \
K2_ENV=prod \
mvn -q exec:java -Dexec.mainClass=com.k2platform.sdk.K2SdkDemoAdd K2_KEY=some.property to also fetch a single property. Mint the token first in
the admin UI: log in → workspace → Tokens → generate, scoped to the app + env.
mvn clean test # 50 tests — model, HTTP (in-JVM fake server), config file, SSE, both Spring modes
mvn package # builds the jarAll tests run offline against an in-process fake k2-app (FakeK2Server) — no network, no external platform.