Files
nixos-router/pkgs/router-webui/apiref.go
T
nilsandClaude Sonnet 5.5 64e08fb485 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
2026-11-01 20:09:20 +01:00

118 lines
4.0 KiB
Go

package main
import (
"fmt"
"net/http"
"slices"
"sort"
"strings"
)
// The API reference page: the REST operations as the OpenAPI document has
// them (both come from apiRoutes and the tools' schemas), each with a curl
// example for this router.
type apiParam struct {
Name, Type, Description string
Required bool
}
type apiDoc struct {
Method, Path, Summary, Scope, Description string
Params []apiParam
Body string // what the request body is, if any
Curl string
}
// apiExamples are the query strings of the examples (what a person would try).
var apiExamples = map[string]string{
"get_logs": "?unit=router-pppoe&lines=50&grep=connect",
}
func apiReference(host string) []apiDoc {
var docs []apiDoc
for _, rt := range apiRoutes {
tool := toolNamed(rt.tool)
d := apiDoc{Method: rt.method, Path: rt.path, Summary: rt.summary, Scope: tool.scope, Description: tool.description}
url := "https://" + host + rt.path
switch rt.method {
case "GET":
props, _ := tool.schema["properties"].(map[string]any)
required, _ := tool.schema["required"].([]string)
for name, p := range props {
pm, _ := p.(map[string]any)
typ, _ := pm["type"].(string)
desc, _ := pm["description"].(string)
d.Params = append(d.Params, apiParam{name, typ, desc, slices.Contains(required, name)})
}
slices.SortFunc(d.Params, func(a, b apiParam) int {
if a.Required != b.Required {
if a.Required {
return -1
}
return 1
}
return strings.Compare(a.Name, b.Name)
})
d.Curl = fmt.Sprintf("curl -sk -H \"Authorization: Bearer $TOKEN\" '%s%s'", url, apiExamples[rt.tool])
case "PATCH":
d.Params = []apiParam{{"If-Match (header)", "string", "The ETag from GET /api/v1/settings; the change is refused (409) if the settings changed since.", true}}
d.Body = "A JSON merge patch (RFC 7386) on the settings: objects merge, a value replaces, null removes a key, arrays are replaced whole. Content-Type application/merge-patch+json or application/json."
d.Curl = fmt.Sprintf("curl -sk -X PATCH -H \"Authorization: Bearer $TOKEN\" -H 'If-Match: \"ETAG\"' \\\n -H 'Content-Type: application/merge-patch+json' \\\n -d '{\"router\":{\"ddns\":{\"ipv6\":false}}}' %s", url)
default:
d.Curl = fmt.Sprintf("curl -sk -X %s -H \"Authorization: Bearer $TOKEN\" %s", rt.method, url)
}
docs = append(docs, d)
}
return docs
}
func (a *app) apiReference(w http.ResponseWriter, r *http.Request, sess session) {
a.render(w, "apiref", page{Title: "API reference", CSRF: sess.csrf, LoggedIn: true, Data: map[string]any{
"Base": "https://" + r.Host, "Ops": apiReference(r.Host), "HasOptions": a.optionsFile != "",
"Areas": settingsAreas(a.optionsFile), "Host": r.Host,
}})
}
type areaOption struct{ Name, Type, Default, Description string }
type areaDoc struct {
Path string
Options []areaOption
}
// settingsAreas lists the areas below router/ with their options (from the
// option documentation): what a person can read and write there.
func settingsAreas(optionsFile string) []areaDoc {
if optionsFile == "" {
return nil
}
entries, err := loadOptions(optionsFile)
if err != nil {
return nil
}
byArea := map[string][]areaOption{}
for _, e := range entries {
if len(e.Loc) < 3 || e.Loc[0] != "router" {
continue
}
desc := strings.TrimSpace(e.Description)
if i := strings.Index(desc, "\n\n"); i > 0 {
desc = desc[:i]
}
desc = strings.Join(strings.Fields(desc), " ")
if len(desc) > 300 {
desc = desc[:300] + "…"
}
name := strings.Join(e.Loc[2:], ".")
byArea[e.Loc[1]] = append(byArea[e.Loc[1]], areaOption{name, e.Type, literal(e.Default), desc})
}
var areas []areaDoc
for k, opts := range byArea {
sort.Slice(opts, func(i, j int) bool { return opts[i].Name < opts[j].Name })
areas = append(areas, areaDoc{Path: "/api/v1/settings/router/" + k, Options: opts})
}
sort.Slice(areas, func(i, j int) bool { return areas[i].Path < areas[j].Path })
return areas
}