Skip to content

Dynamic API

Petrus Pradella edited this page Jul 1, 2026 · 5 revisions

The Dynamic API

The dynamic API operates directly on the canonical ObjectNode using dot-separated paths. Typed getters route through a single coercion seam (NodeCoercion), so "what does getInt do to a string node" is defined in one place.

Set & remove

cfg.setValue("server.port", 25565);        // auto-vivifies intermediate objects (only objects, never arrays)
cfg.setValue("server.motd", null);         // a Java null DELETES the entry
cfg.setValue("", someObjectNode);          // replaces the whole root (must be an object)
cfg.setValue("server.motd", "Welcome!", "Shown on the server list");  // value + comment in one call

boolean removed = cfg.removeValue("legacy.cache");  // true if it existed; drops the subtree's comments

Replacing a container subtree drops the comments of its descendants (they no longer apply).

Keys that contain a dot

The path separator is fixed at .. To address a key that legitimately contains a dot, escape it with a backslash — a\.b is the single key "a.b", and \\ is a literal backslash. The escape is a no-op for an ordinary key, so plain dotted paths are unaffected.

cfg.setValue("rates.usd\\.brl", 5.12);   // the key "usd.brl" under "rates" — ONE level deep
cfg.getDouble("rates.usd\\.brl");        // 5.12

cfg.contains("rates.usd");               // false — there is no "usd" child

The separator is not swappable (PathOptions(char) is private). Escaping is the only way to carry a dot inside a key.

Migrating a renamed key

migrateKey(old, new) is the explicit rename hook for a config migration (reconciliation never infers a rename). It moves the data and the key's full comment subtree — the key's own block/side comment and those of every descendant path — written as authoritative so they survive (not re-seeded).

It returns a MigrationResult so a re-run or a typo is observable, making it safe to call unconditionally on every startup:

switch (cfg.migrateKey("database.poolSize", "database.pool.size")) {
    case MOVED:            break;  // a real move happened (the only outcome that touched the tree)
    case ALREADY_MIGRATED: break;  // source gone, destination present — a benign re-run
    case SOURCE_ABSENT:    break;  // neither side exists — often a typo in the old path
    case SAME_PATH:        break;  // old == new
    case INVALID_ROOT:     break;  // either side is the root path ("")
}

When both paths already hold data, the source overwrites the destination and the result is MOVED. result.moved() is a shortcut for "the tree changed this call".

Typed getters

Each getter has a zero-default form and a (path, default) form:

cfg.getString("server.motd");        cfg.getString("server.motd", "A Minecraft Server");
cfg.getInt("server.port");           cfg.getInt("server.port", 25565);
cfg.getLong("stats.firstSeen");      cfg.getLong("stats.firstSeen", 0L);
cfg.getDouble("economy.startBal");   cfg.getDouble("economy.startBal", 100.0);
cfg.getBoolean("world.pvp");         cfg.getBoolean("world.pvp", true);
cfg.getStringList("motd.lines");     cfg.getStringList("motd.lines", defaultLines);
cfg.getList("ports", Integer.class); // typed; empty (never null) when absent / not a list
cfg.getValue("database", Db.class);  // a subtree bound to a type (POJO or scalar)
cfg.getUUID("owner.uuid");           cfg.getUUID("owner.uuid", fallbackUuid);
  • getString on a list joins elements with \n; on an object it returns the default (an object is not a string — like the numeric getters on a type mismatch, never the raw JSON).
  • getUUID(path) and getUUID(path, def) are both tolerant: an absent or malformed value yields null / the default and never throws.
  • getStringList(path) returns an empty list when absent; getStringList(path, def) returns def only when the path is absent.

Legacy long-as-string tolerance. The numeric getters parse a number stored as a quoted string: getLong("1700000000000") → the long, getLong("1.0")1, getInt("25565")25565. An empty string reads as the default.

The absent / null / value trichotomy

These three states are distinct:

cfg.setValue("maxPlayers", 20);
cfg.getRoot().putNull("whitelist");      // present, but null

cfg.contains("difficulty");     // false   — absent
cfg.getValue("difficulty");     // null
cfg.getInt("difficulty", 2);    // 2

cfg.contains("whitelist");      // true    — present but null
cfg.getValue("whitelist");      // null
cfg.getInt("whitelist", 9);     // 9       (a null flattens to the default)

cfg.contains("maxPlayers");     // true    — a real value
cfg.getInt("maxPlayers");       // 20

Keys, containment & sections

cfg.contains("database.url");        // does the path resolve?
cfg.getKeys();                       // direct child keys of the root
cfg.getKeys("database");             // direct child keys of "database"
cfg.getKeys("", true);               // DEEP — dotted descendant paths

cfg.getConfigSection("database");    // ALWAYS a (possibly empty) view — use contains() for existence

A ConfigSection is a scoped cursor: every method delegates to the owning Config with the section's path prefixed.

ConfigSection db = cfg.getConfigSection("database");
db.setValue("url", "jdbc:...");      // writes "database.url"
db.getInt("pool.size");              // reads "database.pool.size"
db.getSectionKey();                  // "database"

Path-scoped typed read

getValue(path, type) binds the subtree at a dotted path to a fresh POJO — the path-scoped form of loadAs. The 2-arg form uses the codec the config was opened with; pass a Codec to override it.

DbConfig db = cfg.getValue("database", DbConfig.class);              // bind the "database" subtree
DbConfig db = cfg.getValue("database", DbConfig.class, yamlCodec);   // override the codec

cfg.getValue("database", DbConfig.class);  // absent path → the type's defaults
cfg.getValue("", RootConfig.class);        // root path ("" or null) → the whole tree

The 2-arg form throws IllegalStateException if the config was built without a codec (new Config()). The full binding model (@Key, @Comment, @Section, lenient vs. strict, LoadIssues) lives in Entity Binding.

In-memory configs & cross-format saves

A Config does not need a file. Config.inMemory() gives the full typed/POJO API (setValue/mergeValue, getValue(path, type), @Key/@Section/@Comment, enum-by-name, java.time, Optional) backed only by the in-memory tree — there is no text format, so save() throws. This differs from a bare new Config(), which has no codec and accepts only native (scalar / Map / list / JsonNode) values.

Config cfg = Config.inMemory();
cfg.setValue("database", new DbConfig());   // POJO write works — no file needed
DbConfig db = cfg.getValue("database", DbConfig.class);

A file-backed config can persist in, or switch to, another format:

Config cfg = Config.open(Paths.get("config.yml"));   // codec inferred from the extension

cfg.save(new JsonCodec());      // one-shot dump as JSON to the SAME file; the live codec stays YAML
cfg.changeCodec(new TomlCodec());  // every subsequent save() now emits TOML

save(codec) and changeCodec(codec) do not rename the file. Emitting a format the extension does not imply is your call — a later extension-inferred Config.open would pick the wrong codec. Switching from a comment-bearing codec to one without fidelity (e.g. JSON) drops the comment overlay on the next save.

The escape hatch

getRoot() exposes the live ObjectNode — the single source of truth. Mutate it directly when you need raw Jackson; the dynamic API and binding both see the change, and unknown keys you add survive every save.

cfg.getRoot().put("internalFlag", "set outside the API");

→ See also Default Values & Comments · Entity Binding

Clone this wiki locally