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:
+111
-97
@@ -1,117 +1,131 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"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.
|
||||
// The API reference is Redoc (static/redoc.standalone.js) showing
|
||||
// /api/openapi.json, so what is written below is the text of that document.
|
||||
|
||||
type apiParam struct {
|
||||
Name, Type, Description string
|
||||
Required bool
|
||||
}
|
||||
const apiIntro = `The router's management API. It does what the MCP server does: read the router's state and logs, and edit its saved settings.
|
||||
|
||||
type apiDoc struct {
|
||||
Method, Path, Summary, Scope, Description string
|
||||
Params []apiParam
|
||||
Body string // what the request body is, if any
|
||||
Curl string
|
||||
}
|
||||
# Authentication
|
||||
|
||||
// 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",
|
||||
}
|
||||
Every call needs a token from the *API access* page of the web UI, sent as ` + "`Authorization: Bearer rtr_…`" + `.
|
||||
A **read** token may use the read operations, a **change** token also the others. Tokens expire (30 days unless
|
||||
chosen otherwise) and can be revoked. This document and the page showing it need no token.
|
||||
|
||||
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)})
|
||||
# Changes are saved, never applied
|
||||
|
||||
An edit only changes the *saved* settings. A person reviews them on the *Changes* page of the web UI and applies
|
||||
them there; until then the router keeps running as it was. ` + "`GET /api/v1/changes`" + ` shows what differs and
|
||||
` + "`DELETE /api/v1/changes`" + ` throws it away. Check an edit before asking a person to apply it with
|
||||
` + "`GET /api/v1/settings/check`" + `.
|
||||
|
||||
# ETags: editing without overwriting someone else
|
||||
|
||||
The settings can be edited by several parties at once: people in the web UI, other tokens, other scripts. An
|
||||
**ETag** is a label for one version of the settings. It changes whenever they change.
|
||||
|
||||
1. Read the settings (or any part of them) with ` + "`GET`" + `. The response has an ` + "`ETag`" + ` header, for example ` + "`\"3f9a1c…\"`" + `.
|
||||
2. Send your edit with ` + "`If-Match: \"3f9a1c…\"`" + `: "apply this only if the settings are still that version".
|
||||
3. If somebody saved a change in between, the ETag no longer matches. Nothing is written and the answer is
|
||||
**409 Conflict**. Read again, redo the edit on the new settings, and retry.
|
||||
4. If it matches, the edit is saved and the response carries the **new ETag** for your next edit.
|
||||
|
||||
Every operation that changes the settings (` + "`PATCH`, `PUT`, `DELETE`" + ` below ` + "`/api/v1/settings`" + `) needs
|
||||
` + "`If-Match`" + `; without it the answer is 400. All parts of the settings share the one ETag.
|
||||
|
||||
# Errors
|
||||
|
||||
Errors are JSON, ` + "`{\"error\": \"…\"}`" + `: **400** refused (the message says why), **401** no, unknown or expired token,
|
||||
**403** the token's access is too low, **404** not set or no such option, **409** stale ETag, **500** the router failed.
|
||||
Results are JSON, except the pending changes and the journal, which are plain text.`
|
||||
|
||||
const settingsAreasText = `The settings are a tree, and every part of it has its own path below ` + "`/api/v1/settings`" + `:
|
||||
` + "`router/firewall`, `router/lans/{name}` (one LAN, by its name), `router/dns/hosts`, `system`" + `, down to a single option
|
||||
like ` + "`router/wan/interface`" + `. The documented paths go three levels deep; deeper ones work the same way. Lists are replaced
|
||||
whole, their elements have no paths of their own.
|
||||
|
||||
- ` + "`GET`" + ` reads a part (404: not set, so its defaults apply),
|
||||
- ` + "`PUT`" + ` replaces it, ` + "`PATCH`" + ` merges into an object, ` + "`DELETE`" + ` removes it,
|
||||
- all three edits need ` + "`If-Match`" + ` (see *ETags* above) and are saved, not applied.
|
||||
|
||||
Every value is checked against the option's type before it is saved; a wrong type, a number out of range or a key
|
||||
that is no option is refused with its place, for example ` + "`/router/dns/domain: expected a string`" + `.
|
||||
` + "`GET /api/v1/options`" + ` lists every option with its type, default and description.`
|
||||
|
||||
// addCurlSamples gives every operation a curl example for this router.
|
||||
func addCurlSamples(paths map[string]any, base string) {
|
||||
for path, v := range paths {
|
||||
ops, _ := v.(map[string]any)
|
||||
for method, o := range ops {
|
||||
op, _ := o.(map[string]any)
|
||||
if op == nil {
|
||||
continue
|
||||
}
|
||||
slices.SortFunc(d.Params, func(a, b apiParam) int {
|
||||
if a.Required != b.Required {
|
||||
if a.Required {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
url := base + strings.NewReplacer("{name}", "NAME").Replace(path)
|
||||
var b strings.Builder
|
||||
b.WriteString("curl -sk")
|
||||
if method != "get" {
|
||||
b.WriteString(" -X " + strings.ToUpper(method))
|
||||
}
|
||||
b.WriteString(` -H "Authorization: Bearer $TOKEN"`)
|
||||
if hasHeaderParam(op, "If-Match") {
|
||||
b.WriteString(` \
|
||||
-H 'If-Match: "ETAG"'`)
|
||||
}
|
||||
if op["requestBody"] != nil {
|
||||
ct := "application/json"
|
||||
if method == "patch" && path == "/api/v1/settings" {
|
||||
ct = "application/merge-patch+json"
|
||||
}
|
||||
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)
|
||||
fmt.Fprintf(&b, " \\\n -H 'Content-Type: %s'", ct)
|
||||
if method == "patch" && path == "/api/v1/settings" {
|
||||
b.WriteString(` \
|
||||
-d '{"router":{"ddns":{"ipv6":false}}}'`)
|
||||
} else {
|
||||
b.WriteString(" \\\n -d @value.json")
|
||||
}
|
||||
}
|
||||
q := ""
|
||||
if op["operationId"] == "get_logs" {
|
||||
q = "?unit=router-pppoe&lines=50&grep=connect"
|
||||
}
|
||||
fmt.Fprintf(&b, " \\\n '%s%s'", url, q)
|
||||
op["x-codeSamples"] = []any{map[string]any{"lang": "Shell", "label": "curl", "source": b.String()}}
|
||||
}
|
||||
docs = append(docs, d)
|
||||
}
|
||||
return docs
|
||||
}
|
||||
|
||||
func hasHeaderParam(op map[string]any, name string) bool {
|
||||
ps, _ := op["parameters"].([]any)
|
||||
for _, p := range ps {
|
||||
if m, _ := p.(map[string]any); m != nil && m["in"] == "header" && m["name"] == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// apiReference serves the page that shows the OpenAPI document with Redoc.
|
||||
// Redoc writes its styles from script, so this page (and only it) allows
|
||||
// styles carrying a nonce, and the worker it searches in.
|
||||
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
|
||||
raw := make([]byte, 16)
|
||||
_, _ = rand.Read(raw)
|
||||
nonce := base64.RawStdEncoding.EncodeToString(raw)
|
||||
h := w.Header()
|
||||
h.Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self' 'nonce-"+nonce+"'; worker-src blob:; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'")
|
||||
h.Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>API reference</title><link rel="icon" href="data:,"><link rel="stylesheet" href="/static/apiref.css"></head>
|
||||
<body><div id="redoc" data-spec="/api/openapi.json" data-nonce="%s"></div>
|
||||
<script src="/static/redoc.standalone.js"></script><script src="/static/apiref.js"></script></body></html>
|
||||
`, nonce)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user