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.

On this page

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.

Added in 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

TypeControlNotes
STRINGSingle-line text fieldThe default when type is omitted.
INTNumber fieldBounded by min/max, stepped by step.
BOOLCheckboxStores the strings "true"/"false".
ENUMDropdown over optionsA saved value no longer offered stays selectable rather than being silently rewritten.
TEXTTextareaFor multi-line values.
URLFull-width text fieldWith URL-shaped validation.
IPDropdown of detected host addressesPlus an auto-detect entry.
CUSTOMA purpose-built editorLooked 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.defaultdefaultConfig()
What it isThe fallback your module code reads when the key is absent.The starting config a freshly added module is given.
Where it showsAs 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.

Keep 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.