Add typed settings areas to the REST API

/api/v1/settings/router/firewall, .../router/lans/<name>, .../system, down to
a single option: GET reads, PUT replaces, PATCH merges, DELETE removes, with
the settings' ETag/If-Match and the same limits as the whole-document edit
(editSettings is now shared). Values are checked against a JSON Schema made
from the option documentation (schema.go reads the types, which nixosOptionsDoc
describes in words); a wrong type, a value out of range or an unknown key is
refused with its place. The OpenAPI document has the area paths with their
schemas; the API reference page lists every area's options.

Tests: unit tests for the type reader, the validator and the paths; the real
option file and a router's own settings validate (ROUTER_OPTIONS_JSON,
LIVE_SETTINGS_JSON); a subtest in apply.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
2026-11-01 20:09:20 +01:00
co-authored by Claude Sonnet 5.5
parent 42ceb78d2b
commit 64e08fb485
11 changed files with 1064 additions and 10 deletions
+10 -3
View File
@@ -54,6 +54,7 @@ func (a *app) registerAPI(mux *http.ServeMux) {
for _, rt := range apiRoutes {
mux.HandleFunc(rt.method+" "+rt.path, a.apiHandler(rt))
}
a.registerAreas(mux)
mux.HandleFunc("GET /api/v1/options", a.apiOptions)
mux.HandleFunc("GET /api/openapi.json", a.apiSpec)
}
@@ -172,10 +173,10 @@ func (a *app) apiSpec(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(openAPISpec(a.sys.installedRelease().Version))
_ = enc.Encode(openAPISpec(a.sys.installedRelease().Version, a.settingsSchema()))
}
func openAPISpec(version string) map[string]any {
func openAPISpec(version string, schema map[string]any) map[string]any {
errResp := func(d string) map[string]any {
return map[string]any{"description": d, "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"$ref": "#/components/schemas/Error"}}}}
}
@@ -235,13 +236,19 @@ func openAPISpec(version string) map[string]any {
"security": []any{map[string]any{"bearer": []string{}}},
"responses": map[string]any{"200": map[string]any{"description": "The options (JSON)", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{}}}}, "404": errResp("Not available.")},
}}
if schema != nil {
for p, ops := range areaPaths(schema) {
paths[p] = ops
}
}
return map[string]any{
"openapi": "3.1.0",
"info": map[string]any{
"title": "nixos-router",
"version": version,
"description": "The router's management API, equal to its MCP server: read its state and logs, edit the saved settings. " +
"Changes are saved, never applied: a person applies them in the web UI. Tokens are made on the API access page (scope read or change).",
"Changes are saved, never applied: a person applies them in the web UI. Tokens are made on the API access page (scope read or change). " +
"The settings are also a tree of areas: /api/v1/settings/router/firewall, /router/lans/{name}, /system, ... (any depth) can be read, replaced (PUT), merged into (PATCH) or removed (DELETE); the documented ones go three levels deep.",
},
"servers": []any{map[string]any{"url": "/"}},
"paths": paths,