Settings Schema
Make your options real controls
The Speculum admin console knows nothing about any module by name. It renders whatever settingsSchema() returns — declare your keys and they become labelled, typed, validated inputs.
What changes
Without a schema, every option in the console is an untyped pair of text boxes: the author types the key, then the value, with no label, no bounds and no hint about what is allowed. With one, the console draws a dropdown for an enum, a checkbox for a boolean, a bounded number field for an int, and a sentence of help underneath.
The schema travels from your JAR to the browser: the config-server loads your factory through the same ServiceLoader the mirror uses and serves the result on GET /api/modules.
mirror-api 1.3.0, which ships with Speculum v1.3.0. The method has a default empty implementation, so older plugins keep compiling and loading unchanged.The example module's schema
This module reads two keys, greeting and tickStep, and declares both:
override fun settingsSchema(): List<SettingSpec> =
listOf(
SettingSpec(
key = "greeting",
label = "Greeting",
default = "Hello, Speculum!",
help = "Headline text shown at the top of the module.",
),
SettingSpec(
key = "tickStep",
label = "Tick step",
type = SettingType.INT,
default = "1",
min = 1,
max = 100,
help = "How much the refresh counter advances on each refresh.",
),
)
Types
| Type | Control | Notes |
|---|---|---|
STRING | Single-line text field | The default when type is omitted. |
INT | Number field | Bounded by min/max, stepped by step. |
BOOL | Checkbox | Stores the strings "true"/"false". |
ENUM | Dropdown over options | A saved value no longer offered stays selectable rather than being silently rewritten. |
TEXT | Textarea | For multi-line values. |
URL | Full-width text field | With URL-shaped validation. |
IP | Dropdown of detected host addresses | Plus an auto-detect entry. |
CUSTOM | A purpose-built editor | Looked up by editor name; falls back to a textarea when the console has none. |
SettingSpec(
key = "units", label = "Units", type = SettingType.ENUM,
default = "metric", options = listOf("metric", "imperial"),
)
default vs. defaultConfig()
These two are easy to conflate and mean different things:
SettingSpec.default | defaultConfig() | |
|---|---|---|
| What it is | The fallback your module code reads when the key is absent. | The starting config a freshly added module is given. |
| Where it shows | As the hint in an empty field. | As an actual saved value. |
| Stored? | No. | Yes, written into config.json. |
They may legitimately differ. This module's tickStep has default = "1" because config.int("tickStep", 1) is what the code falls back to, while defaultConfig() seeds "2" to make the counter visibly move on first run.
default in sync with your code. It is the number the console shows as "what happens if I leave this blank". If the two drift, the hint lies.Advanced, help and placeholders
help renders as a sentence under the control; placeholder fills an empty text field; advanced = true folds an option behind a "show advanced" toggle, which is where rarely-touched tuning belongs:
SettingSpec(
key = "fadeSpeed", label = "Fade (ms)", type = SettingType.INT,
default = "4000", min = 0, max = 60000, step = 500,
advanced = true,
)
Preview templates
When the effective value is assembled from several keys, preview shows it live. Templates are tried in order; {key} interpolates and {key|fallback} fills in a blank. The first template whose plain references all resolve wins:
SettingSpec(
key = "url", label = "URL override", type = SettingType.URL,
help = "Leave blank to build the address from the IP and port.",
preview = listOf("{url}", "http://{ip|<auto LAN IP>}:{port|8080}"),
)
What happens without a schema
Nothing breaks. A module that declares no schema keeps the raw key/value rows, exactly as before the feature existed — which is why plugins built against an older mirror-api still work. The same applies per key: anything saved in a config but absent from the schema falls back to a raw row, so you can describe part of a module and grow the schema later.
Testing your schema
The cheapest guard is asserting that the schema covers everything defaultConfig() ships, which catches a key added to one and forgotten in the other:
@Test
fun `settingsSchema declares every key the default config ships`() {
val undeclared = factory.settingsSchema().undeclaredKeys(factory.defaultConfig().config)
assertEquals(emptyList(), undeclared, "defaultConfig keys missing from settingsSchema")
}
undeclaredKeys is an extension on List<SettingSpec> in org.speculum.config; defaults() alongside it returns the declared defaults as a map. Full reference in the mirror-api docs.