Files
nixos-router/pkgs/router-webui/areas.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

288 lines
10 KiB
Go

package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"sort"
"strings"
"time"
)
// Area paths: /api/v1/settings/router/firewall, .../router/lans/lan, ... read,
// replace (PUT), merge into (PATCH) or remove (DELETE) one part of the
// settings. They edit the same saved settings as PATCH /api/v1/settings, with
// the same ETag and the same limits (editSettings), and check what is written
// against the schema made from the option documentation.
func (a *app) settingsSchema() map[string]any {
a.schemaOnce.Do(func() { a.schemaVal = settingsSchema(a.optionsFile) })
return a.schemaVal
}
func (a *app) registerAreas(mux *http.ServeMux) {
for _, m := range []string{"GET", "PUT", "PATCH", "DELETE"} {
mux.HandleFunc(m+" /api/v1/settings/{path...}", a.areaHandler(m))
}
}
// areaSegments splits an area path; only router and system exist.
func areaSegments(p string) ([]string, error) {
segs := strings.Split(strings.Trim(p, "/"), "/")
if segs[0] != "router" && segs[0] != "system" {
return nil, errors.New("settings areas are below router/ and system/")
}
for _, s := range segs {
if s == "" || len(s) > 128 || strings.ContainsFunc(s, func(r rune) bool { return r < 0x20 || r == 0x7f }) {
return nil, errors.New("bad path")
}
}
return segs, nil
}
func getPath(v any, segs []string) (any, bool) {
for _, s := range segs {
m, ok := v.(map[string]any)
if !ok {
return nil, false
}
if v, ok = m[s]; !ok {
return nil, false
}
}
return v, true
}
func setPath(root map[string]any, segs []string, val any) error {
m := root
for i, s := range segs[:len(segs)-1] {
next, ok := m[s]
if !ok || next == nil {
child := map[string]any{}
m[s] = child
m = child
continue
}
child, ok := next.(map[string]any)
if !ok {
return fmt.Errorf("%s is not an object, so nothing can be set below it", strings.Join(segs[:i+1], "/"))
}
m = child
}
m[segs[len(segs)-1]] = val
return nil
}
func delPath(root map[string]any, segs []string) {
parent, ok := getPath(root, segs[:len(segs)-1])
if m, isMap := parent.(map[string]any); ok && isMap {
delete(m, segs[len(segs)-1])
}
}
func (a *app) areaHandler(method string) 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
}
scope := scopeChange
if method == http.MethodGet {
scope = scopeRead
}
if !tok.allows(scope) {
apiError(w, http.StatusForbidden, "this token may not "+scope+" (scope)")
return
}
segs, err := areaSegments(r.PathValue("path"))
if err != nil {
apiError(w, http.StatusNotFound, err.Error())
return
}
var area map[string]any
if schema := a.settingsSchema(); schema != nil {
if area, err = resolveSchema(schema, segs); err != nil {
apiError(w, http.StatusNotFound, err.Error())
return
}
}
var body []byte
if method == http.MethodPut || method == http.MethodPatch {
if body, err = io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)); err != nil {
apiError(w, http.StatusRequestEntityTooLarge, "request too large")
return
}
}
res, status, err := a.area(method, segs, area, body, strings.Trim(strings.TrimPrefix(r.Header.Get("If-Match"), "W/"), `"`))
entry := AuditEntry{Time: time.Now().UTC(), Token: tok.Name, Tool: method + " /" + strings.Join(segs, "/"), Args: string(body)}
if err != nil {
entry.Error = err.Error()
}
a.sys.audit(entry)
if err != nil {
apiError(w, status, err.Error())
return
}
if method != http.MethodGet {
if r, ok := res.(EditResponse); ok {
w.Header().Set("ETag", `"`+r.ETag+`"`)
a.sys.noteTokenEdit(tok.Name, r.ETag)
}
} else if data, err := os.ReadFile(a.sys.settingsPath()); err == nil {
w.Header().Set("ETag", `"`+etag(data)+`"`)
}
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(res)
}
}
// area does one operation; the status is for an error.
func (a *app) area(method string, segs []string, area map[string]any, body []byte, ifMatch string) (any, int, error) {
if method == http.MethodGet {
data, err := os.ReadFile(a.sys.settingsPath())
if err != nil {
return nil, http.StatusInternalServerError, err
}
doc, err := parseSettings(data)
if err != nil {
return nil, http.StatusInternalServerError, err
}
v, ok := getPath(map[string]any(doc), segs)
if !ok {
return nil, http.StatusNotFound, errors.New("not set in the settings (its defaults apply)")
}
return v, 0, nil
}
if ifMatch == "" {
return nil, http.StatusBadRequest, errors.New("send the settings' ETag in If-Match (GET /api/v1/settings)")
}
var val any
if method != http.MethodDelete {
if err := json.Unmarshal(body, &val); err != nil {
return nil, http.StatusBadRequest, errors.New("the body must be JSON")
}
}
path := "/" + strings.Join(segs, "/")
res, err := a.editSettings(ifMatch, func(next Settings) (Settings, error) {
root := map[string]any(next)
switch method {
case http.MethodDelete:
delPath(root, segs)
return next, nil
case http.MethodPatch:
patch, ok := val.(map[string]any)
if !ok {
return nil, argError("a PATCH body is a JSON merge patch (an object); use PUT to set a value")
}
cur, _ := getPath(root, segs)
if cur == nil {
cur = map[string]any{}
}
if _, ok := cur.(map[string]any); !ok {
return nil, argError(path + " is not an object; use PUT to replace it")
}
val = mergePatch(cur, patch)
case http.MethodPut:
if val == nil {
return nil, argError("use DELETE to remove a setting")
}
}
if area != nil {
if errs := validate(val, area, path); len(errs) > 0 {
if len(errs) > 5 {
errs = append(errs[:5], fmt.Sprintf("… and %d more", len(errs)-5))
}
return nil, argError(strings.Join(errs, "; "))
}
}
return next, setPath(root, segs, val)
})
switch {
case errors.Is(err, errStale):
return nil, http.StatusConflict, err
case err != nil:
return nil, http.StatusBadRequest, err
}
return res, 0, nil
}
// areaOps lists the paths of the areas for the OpenAPI document: the options
// to a depth of three (router/lans/{name}); deeper paths work the same.
func areaPaths(schema, edited map[string]any) map[string]any {
paths := map[string]any{}
var emit func(parts []string, s map[string]any)
emit = func(parts []string, s map[string]any) {
path := "/api/v1/settings/" + strings.Join(parts, "/")
node := unwrapNullable(s)
id := strings.NewReplacer("/", "_", "{", "", "}", "").Replace(strings.Join(parts, "_"))
params := []any{}
for _, p := range parts {
if p == "{name}" {
params = append(params, map[string]any{"name": "name", "in": "path", "required": true, "schema": map[string]any{"type": "string"}, "description": "The name of the entry (the key in the map)."})
}
}
withParams := func(extra ...any) []any { return append(append([]any{}, params...), extra...) }
errs := func(codes ...string) map[string]any {
m := map[string]any{"401": map[string]any{"description": "No or an unknown token."}, "403": map[string]any{"description": "The token's scope does not allow this."}}
for _, c := range codes {
m[c] = map[string]any{"description": map[string]string{"400": "Refused: the value does not fit the option's type (see the message).", "404": "Not set (its defaults apply), or no such option.", "409": "The settings changed since the ETag was read."}[c], "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"$ref": "#/components/schemas/Error"}}}}
}
return m
}
ifMatch := map[string]any{"name": "If-Match", "in": "header", "required": true, "schema": map[string]any{"type": "string"}, "description": "The ETag from the last read of the settings or of any part of them (the `ETag` header). The edit is refused with 409 if the settings changed since."}
jsonBody := func(sch map[string]any) map[string]any {
return map[string]any{"required": true, "content": map[string]any{"application/json": map[string]any{"schema": sch}}}
}
ok200 := func(desc string, sch map[string]any) map[string]any {
r := errs("400", "404", "409")
r["200"] = map[string]any{"description": desc, "headers": etagHeader, "content": map[string]any{"application/json": map[string]any{"schema": sch}}}
return r
}
sec := []any{map[string]any{"bearer": []string{}}}
ops := map[string]any{
"get": map[string]any{"operationId": "get_" + id, "summary": "Read " + strings.Join(parts, "/"), "tags": []string{"settings"}, "security": sec,
"parameters": params, "responses": ok200("The value.", s), "x-scope": scopeRead},
"put": map[string]any{"operationId": "put_" + id, "summary": "Replace " + strings.Join(parts, "/"), "tags": []string{"settings"}, "security": sec,
"parameters": withParams(ifMatch), "requestBody": jsonBody(node), "responses": ok200("Saved, not applied: the result has the new ETag and the difference.", edited), "x-scope": scopeChange},
"delete": map[string]any{"operationId": "delete_" + id, "summary": "Remove " + strings.Join(parts, "/") + " (its defaults apply again)", "tags": []string{"settings"}, "security": sec,
"parameters": withParams(ifMatch), "responses": ok200("Saved, not applied.", edited), "x-scope": scopeChange},
}
if node["type"] == "object" {
ops["patch"] = map[string]any{"operationId": "patch_" + id, "summary": "Merge into " + strings.Join(parts, "/"), "tags": []string{"settings"}, "security": sec,
"parameters": withParams(ifMatch), "requestBody": jsonBody(map[string]any{"type": "object", "description": "A JSON merge patch (RFC 7386): objects merge, a value replaces, null removes a key, arrays are replaced whole."}),
"responses": ok200("Saved, not applied.", edited), "x-scope": scopeChange}
}
paths[path] = ops
if len(parts) >= 3 {
return
}
props, _ := node["properties"].(map[string]any)
keys := make([]string, 0, len(props))
for k := range props {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if c, ok := props[k].(map[string]any); ok {
emit(append(append([]string{}, parts...), k), c)
}
}
if add, ok := node["additionalProperties"].(map[string]any); ok && node["type"] == "object" {
emit(append(append([]string{}, parts...), "{name}"), add)
}
}
props, _ := schema["properties"].(map[string]any)
for _, top := range []string{"router", "system"} {
if s, ok := props[top].(map[string]any); ok {
emit([]string{top}, s)
}
}
return paths
}