Show the API reference with Redoc
Replace the hand-built reference page with Redoc rendering the generated OpenAPI document. The bundle is fetched by package.nix (pinned hash) and served from the UI's own address; only that page gets a style nonce and worker-src blob: in its CSP. The spec now explains authentication, saved-not-applied, ETags (If-Match, 409) and errors, documents the ETag header, gives each operation a curl sample, and no longer names MCP tools. Area GETs no longer emit "parameters": null, which Redoc could not render. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
+40
-20
@@ -19,20 +19,33 @@ import (
|
||||
type apiRoute struct {
|
||||
method, path, tool string
|
||||
summary string
|
||||
description string // instead of the tool's, where that names MCP tools
|
||||
}
|
||||
|
||||
var apiRoutes = []apiRoute{
|
||||
{"GET", "/api/v1/status", "get_status", "State now: uplink, pings, system, whether settings are unapplied"},
|
||||
{"GET", "/api/v1/settings", "get_settings", "The saved settings and their ETag"},
|
||||
{"PATCH", "/api/v1/settings", "update_settings", "Edit the saved settings with a JSON merge patch (saved, not applied)"},
|
||||
{"GET", "/api/v1/settings/check", "check_settings", "Evaluate the saved settings without applying them"},
|
||||
{"GET", "/api/v1/changes", "get_pending_changes", "The difference between the confirmed and the saved settings"},
|
||||
{"DELETE", "/api/v1/changes", "discard_changes", "Throw the unapplied changes away"},
|
||||
{"GET", "/api/v1/interfaces", "list_interfaces", "Network cards and VLAN devices"},
|
||||
{"GET", "/api/v1/dhcp-leases", "list_dhcp_leases", "DHCP clients holding a lease"},
|
||||
{"GET", "/api/v1/update", "get_update_status", "The installed release and the last update run"},
|
||||
{"GET", "/api/v1/apply-log", "get_apply_log", "The log and result of the last apply"},
|
||||
{"GET", "/api/v1/logs", "get_logs", "Lines of the system journal"},
|
||||
{"GET", "/api/v1/status", "get_status", "State now: uplink, pings, system, whether settings are unapplied", ""},
|
||||
{"GET", "/api/v1/settings", "get_settings", "The saved settings and their ETag",
|
||||
"The saved settings (`system` and `router`) and whether they differ from the confirmed (running) system. The response carries the settings' ETag, which an edit must send back in `If-Match`. Secrets are not in the settings, only paths of files that hold them."},
|
||||
{"PATCH", "/api/v1/settings", "update_settings", "Edit the saved settings with a JSON merge patch (saved, not applied)",
|
||||
"Edit the saved settings with a JSON merge patch (RFC 7386): objects merge, a value replaces, `null` removes a key, arrays are replaced whole. Send the ETag from the last read in `If-Match`; if the settings changed since, nothing is written and the answer is 409. The settings are saved but not applied: a person applies them in the web UI. Not allowed: ssh keys, the schema version, the state version, and file paths outside the router's state directory. Call `GET /api/v1/settings/check` afterwards."},
|
||||
{"GET", "/api/v1/settings/check", "check_settings", "Evaluate the saved settings without applying them", ""},
|
||||
{"GET", "/api/v1/changes", "get_pending_changes", "The difference between the confirmed and the saved settings", ""},
|
||||
{"DELETE", "/api/v1/changes", "discard_changes", "Throw the unapplied changes away", ""},
|
||||
{"GET", "/api/v1/interfaces", "list_interfaces", "Network cards and VLAN devices", ""},
|
||||
{"GET", "/api/v1/dhcp-leases", "list_dhcp_leases", "DHCP clients holding a lease", ""},
|
||||
{"GET", "/api/v1/update", "get_update_status", "The installed release and the last update run", ""},
|
||||
{"GET", "/api/v1/apply-log", "get_apply_log", "The log and result of the last apply", ""},
|
||||
{"GET", "/api/v1/logs", "get_logs", "Lines of the system journal", ""},
|
||||
}
|
||||
|
||||
// etagHeader documents the ETag header of the answers about the settings.
|
||||
var etagHeader = map[string]any{"ETag": map[string]any{"description": "The settings' ETag (in quotes): send it in `If-Match` to edit them.", "schema": map[string]any{"type": "string"}}}
|
||||
|
||||
func routeText(rt apiRoute, tool *mcpTool) string {
|
||||
if rt.description != "" {
|
||||
return rt.description
|
||||
}
|
||||
return tool.description
|
||||
}
|
||||
|
||||
func toolNamed(name string) *mcpTool {
|
||||
@@ -173,10 +186,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, a.settingsSchema()))
|
||||
_ = enc.Encode(openAPISpec(a.sys.installedRelease().Version, a.settingsSchema(), r.Host))
|
||||
}
|
||||
|
||||
func openAPISpec(version string, schema map[string]any) map[string]any {
|
||||
func openAPISpec(version string, schema map[string]any, host string) 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"}}}}
|
||||
}
|
||||
@@ -186,12 +199,12 @@ func openAPISpec(version string, schema map[string]any) map[string]any {
|
||||
op := map[string]any{
|
||||
"operationId": rt.tool,
|
||||
"summary": rt.summary,
|
||||
"description": tool.description,
|
||||
"description": routeText(rt, tool),
|
||||
"tags": []string{tool.scope},
|
||||
"security": []any{map[string]any{"bearer": []string{}}},
|
||||
"x-scope": tool.scope,
|
||||
"responses": map[string]any{
|
||||
"200": map[string]any{"description": "The result of " + rt.tool, "content": map[string]any{
|
||||
"200": map[string]any{"description": "The result.", "content": map[string]any{
|
||||
"application/json": map[string]any{"schema": map[string]any{}},
|
||||
"text/plain": map[string]any{"schema": map[string]any{"type": "string"}},
|
||||
}},
|
||||
@@ -224,6 +237,9 @@ func openAPISpec(version string, schema map[string]any) map[string]any {
|
||||
if params != nil {
|
||||
op["parameters"] = params
|
||||
}
|
||||
if rt.tool == "get_settings" || rt.tool == "update_settings" {
|
||||
op["responses"].(map[string]any)["200"].(map[string]any)["headers"] = etagHeader
|
||||
}
|
||||
entry, _ := paths[rt.path].(map[string]any)
|
||||
if entry == nil {
|
||||
entry = map[string]any{}
|
||||
@@ -241,14 +257,18 @@ func openAPISpec(version string, schema map[string]any) map[string]any {
|
||||
paths[p] = ops
|
||||
}
|
||||
}
|
||||
addCurlSamples(paths, "https://"+host)
|
||||
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). " +
|
||||
"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.",
|
||||
"title": "nixos-router",
|
||||
"version": version,
|
||||
"description": apiIntro,
|
||||
},
|
||||
"tags": []any{
|
||||
map[string]any{"name": scopeRead, "x-displayName": "Read", "description": "Allowed for every token."},
|
||||
map[string]any{"name": scopeChange, "x-displayName": "Change", "description": "Needs a token with the access *change*."},
|
||||
map[string]any{"name": "settings", "x-displayName": "Settings areas", "description": settingsAreasText},
|
||||
},
|
||||
"servers": []any{map[string]any{"url": "/"}},
|
||||
"paths": paths,
|
||||
|
||||
Reference in New Issue
Block a user