Compose schemas from several components¶
Validate configuration covers one schema for one application. This page covers the other shape: several components (plugins, subsystems, libraries) that each know the configuration they need, none of which knows the whole.
Two surfaces do it, and they answer different questions:
| Surface | Question | Reported as |
|---|---|---|
WithSchemaAt |
Is the merged configuration valid? | An aggregate result, one entry per violation |
Constrained |
Is this source allowed to supply this? | The same result, attributed to the source |
Mount a component's schema where it lives¶
A component declares its configuration in its own terms, such as enabled and ttl, and
the code assembling the store decides where that lives:
store, err := config.NewStore(ctx,
config.WithFiles(config.OS(), "/etc/mytool/config.yaml"),
config.WithEnv("MYTOOL"),
config.WithSchemaAt("plugins.cache", cacheSchema),
config.WithSchemaAt("server", serverSchema, config.Required),
)
The component never hard-codes its own location, so the same schema can be mounted twice at different points:
config.WithSchemaAt("plugins.primary", cacheSchema),
config.WithSchemaAt("plugins.secondary", cacheSchema),
Failures come back at the full path, plugins.secondary.ttl rather than ttl, because a
relative key is not one anybody could go and edit.
An empty prefix mounts against the whole configuration, which is what you want for a schema whose paths are already absolute.
Mark a section required, or it says nothing when it is missing¶
A schema constrains a key only when that key is present. So a component mounted at
plugins.cache with no plugins.cache section at all raises nothing. Its required keys
never fire, because there is no object for them to be required in.
That is right for a plugin nobody enabled and wrong for one that is mandatory.
config.Required says which:
It is the contribution's choice rather than the composer's, because the component is what knows whether it can run without configuration.
Ask what is wrong, whenever you like¶
Validation runs on every load. A reload that violates a mounted schema is rejected and the
last known good configuration stays live; the first load returns the store alongside
ErrInvalidConfig so it can still be repaired (see
an invalid configuration is still repairable).
Store.Validate asks the same question again on demand:
res := store.Validate()
for _, e := range res.Errors {
fmt.Printf("%s: %s [%s]\n", e.Key, e.Message, e.Contributor)
}
server.port: maximum: got 99999, want 65535 [server-component]
plugins.cache.ttl: minimum: got 0, want 1 [cache-plugin]
telemetry.level: value must be one of 'off', 'basic', 'full' [telemetry-component]
Pass paths to scope it, so a component can ask about its own branch without hearing about anybody else's:
Every failure names who raised it¶
Contributor is what makes an aggregate report actionable. Three components objecting at
once is noise until you can see whose expectation each one violated.
A schema implementing Name() string is used for it. One that does not is attributed to
its mount point, which is still something a reader can act on. Either way you never get an
anonymous complaint. An implementation that sets Contributor itself keeps what it set: a
schema composing several documents knows which one objected, and the mount does not.
Bound what a source may contribute¶
Constrained is about provenance rather than shape: the value may be fine, but not from
this source.
config.WithBackend(config.Constrained(
config.NewFileBackend(fsys, "app.yaml"),
config.Forbid("credentials.*"),
config.ConstraintName("project-config-file"),
))
Patterns are the same glob shape Deny
uses, so there is no second syntax to learn.
This is not Filtered, and the wrong choice costs you the case that matters most
Filtered bounds visibility: a denied key is simply
absent, reads fall through to a lower layer, and nothing is said.
Constrained bounds policy: a forbidden key stays visible and is reported.
A literal credential checked into a file, sitting beneath a working environment reference, is a leak. Filtering it out removes it from the store, so nothing ever reports the leak: the configuration looks fine because the evidence was discarded. Filtering hides; constraining tells.
Writes to a forbidden key are refused¶
_, err := store.Apply(ctx, config.Set("credentials.token", secret))
// config: forbidden key: "credentials.token" may not be written to source "app.yaml"
Match it with errors.Is(err, config.ErrForbiddenKey). This is deliberately the opposite
of Filtered, where a write to a denied key routes past the backend and lands
somewhere else. A forbidden credential that quietly rerouted would be the same leak in a
different file, with nothing said about it.
Check the shape of what a source hands back¶
MustMatch validates only the keys the source actually carries:
A layer is never required to be complete, since a base file legitimately omits what an
overlay supplies. A schema's required is therefore not enforced here, and a key this
source does not carry is somebody else's problem. What it catches is a source supplying a
value of the wrong shape, reported against the layer that produced it before the merge has
hidden which one that was.
Where the schemas come from¶
config defines what validating means and takes no position on how a schema is written.
config.NewSchema builds one from struct tags; for JSON Schema, add
config-schema:
It stays a separate module on purpose. Every adapter depends on config and each
pins its dependency footprint, so a JSON Schema library linked into the core would widen
every one of them for a capability most do not use.
Both satisfy the same config.Schema interface, so they mount identically and mix freely
in one store.
Related¶
- Validate configuration: one schema, struct tags, and write-time validation
- Bound what a backend contributes:
Filtered, and when to reach for it instead - Errors:
ErrForbiddenKeyandErrInvalidConfigin full - Hot-reload safety: why a rejected reload changes nothing