Files
nixos-router/pkgs/router-webui/api.go
T
nilsandClaude Sonnet 5.5 98b0ebc130 Type the REST API's responses
Every tool now returns a Go struct (apitypes.go) instead of a map, and
typeschema.go derives the OpenAPI response schemas from those types by
reflection (json, doc and enum tags), so the document cannot drift from
what is sent. Text results are documented as text/plain, settings in
answers use the typed settings schema, and edits answer with EditResponse.

TestResponsesMatchTheirSchemas checks real responses against the
generated schemas.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-05 08:10:59 +01:00

293 lines
11 KiB
Go

package main
import (
"encoding/json"
"errors"
"io"
"net/http"
"os"
"reflect"
"slices"
"strconv"
"strings"
)
// A REST API (described by OpenAPI at /api/openapi.json) with the same
// features, tokens, scopes, limits and audit log as the MCP server: every
// operation runs one of the MCP tools (mcpTools), so the two cannot drift.
// The routes below say how a request becomes a tool's arguments.
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",
"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"}}}
// resultContent is the 200 response's content: the result's type as JSON, or text.
func resultContent(b *schemaBuilder, tool *mcpTool) map[string]any {
if tool.output == nil {
return map[string]any{"text/plain": map[string]any{"schema": map[string]any{"type": "string"}}}
}
return map[string]any{"application/json": map[string]any{"schema": b.of(reflect.TypeOf(tool.output))}}
}
func routeText(rt apiRoute, tool *mcpTool) string {
if rt.description != "" {
return rt.description
}
return tool.description
}
func toolNamed(name string) *mcpTool {
for i := range mcpTools {
if mcpTools[i].name == name {
return &mcpTools[i]
}
}
return nil
}
func apiError(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
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)
}
func (a *app) apiHandler(rt apiRoute) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
tok, ok := a.bearer(w, r)
if !ok {
return
}
tool := toolNamed(rt.tool)
if !tok.allows(tool.scope) {
apiError(w, http.StatusForbidden, "this token may not "+tool.scope+" (scope)")
return
}
args, err := apiArgs(w, r, tool)
if err != nil {
apiError(w, http.StatusBadRequest, err.Error())
return
}
res, err := a.runTool(tok, tool, args)
if err != nil {
var bad argError
switch {
case errors.Is(err, errStale):
apiError(w, http.StatusConflict, err.Error())
case errors.As(err, &bad) || tool.scope == scopeChange:
apiError(w, http.StatusBadRequest, err.Error())
default:
apiError(w, http.StatusInternalServerError, err.Error())
}
return
}
if text, ok := res.(string); ok {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = io.WriteString(w, text+"\n")
return
}
switch r := res.(type) {
case SettingsResponse:
w.Header().Set("ETag", `"`+r.ETag+`"`)
case EditResponse:
w.Header().Set("ETag", `"`+r.ETag+`"`)
}
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(res)
}
}
// apiArgs builds a tool's arguments from the request: the query for GET
// (typed by the tool's schema), If-Match and the body for the settings patch.
func apiArgs(w http.ResponseWriter, r *http.Request, tool *mcpTool) (json.RawMessage, error) {
switch r.Method {
case http.MethodPatch:
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
return nil, errors.New("request too large")
}
var patch any
if err := json.Unmarshal(body, &patch); err != nil {
return nil, errors.New("the body must be a JSON merge patch")
}
etag := strings.Trim(strings.TrimPrefix(r.Header.Get("If-Match"), "W/"), `"`)
if etag == "" {
return nil, errors.New("send the settings' ETag in If-Match (GET /api/v1/settings)")
}
return json.Marshal(map[string]any{"etag": etag, "patch": patch})
case http.MethodGet:
props, _ := tool.schema["properties"].(map[string]any)
args := map[string]any{}
for name, p := range props {
v := r.URL.Query().Get(name)
if v == "" {
continue
}
if typ, _ := p.(map[string]any)["type"].(string); typ == "integer" {
n, err := strconv.Atoi(v)
if err != nil {
return nil, errors.New(name + " must be a whole number")
}
args[name] = n
} else {
args[name] = v
}
}
return json.Marshal(args)
}
return nil, nil
}
func (a *app) apiOptions(w http.ResponseWriter, r *http.Request) {
tok, ok := a.bearer(w, r)
if !ok {
return
}
_ = tok
data, err := os.ReadFile(a.optionsFile)
if a.optionsFile == "" || err != nil {
apiError(w, http.StatusNotFound, "no option documentation on this router")
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(data)
}
// apiSpec serves the OpenAPI description. It describes the interface only (no
// router data), so it needs no token.
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(), r.Host))
}
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"}}}}
}
b := newSchemaBuilder()
paths := map[string]any{}
for _, rt := range apiRoutes {
tool := toolNamed(rt.tool)
op := map[string]any{
"operationId": rt.tool,
"summary": rt.summary,
"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.", "content": resultContent(b, tool)},
"401": errResp("No or an unknown token."),
"403": errResp("The token's scope does not allow this."),
},
}
var params []any
switch rt.method {
case "GET":
props, _ := tool.schema["properties"].(map[string]any)
required, _ := tool.schema["required"].([]string)
for name, p := range props {
params = append(params, map[string]any{"name": name, "in": "query", "schema": p, "description": p.(map[string]any)["description"], "required": slices.Contains(required, name)})
}
if rt.tool == "get_logs" {
op["responses"].(map[string]any)["400"] = errResp("An argument is not valid.")
}
case "PATCH":
props := tool.schema["properties"].(map[string]any)
params = append(params, map[string]any{"name": "If-Match", "in": "header", "required": true,
"description": props["etag"].(map[string]any)["description"], "schema": map[string]any{"type": "string"}})
op["requestBody"] = map[string]any{"required": true, "content": map[string]any{
"application/merge-patch+json": map[string]any{"schema": props["patch"]},
"application/json": map[string]any{"schema": props["patch"]},
}}
op["responses"].(map[string]any)["400"] = errResp("The patch is refused (see the message).")
op["responses"].(map[string]any)["409"] = errResp("The settings changed since the ETag was read.")
}
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{}
paths[rt.path] = entry
}
entry[strings.ToLower(rt.method)] = op
}
paths["/api/v1/options"] = map[string]any{"get": map[string]any{
"operationId": "get_options", "summary": "Every router option: type, default, description", "tags": []string{scopeRead},
"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, b.of(reflect.TypeOf(EditResponse{}))) {
paths[p] = ops
}
}
addCurlSamples(paths, "https://"+host)
if schema != nil { // the settings in the answers are the typed settings
if sr, ok := b.defs["SettingsResponse"].(map[string]any); ok {
sr["properties"].(map[string]any)["settings"] = schema
}
}
schemas := map[string]any{"Error": map[string]any{"type": "object", "properties": map[string]any{"error": map[string]any{"type": "string"}}, "required": []string{"error"}}}
for k, v := range b.defs {
schemas[k] = v
}
return map[string]any{
"openapi": "3.1.0",
"info": map[string]any{
"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,
"components": map[string]any{
"securitySchemes": map[string]any{"bearer": map[string]any{"type": "http", "scheme": "bearer", "description": "A token from the API access page (rtr_…)."}},
"schemas": schemas,
},
}
}