From 42ceb78d2bb442a10969430df03df1b958e9714e Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sun, 1 Nov 2026 19:40:45 +0100 Subject: [PATCH] Add an API reference page to the web UI /apireference lists the REST operations (from the same route list and tool schemas as the OpenAPI document) with access, parameters and a curl example for this router. Linked from the API access page. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F --- SPECS.md | 1 + pkgs/router-webui/api_test.go | 22 ++++++++ pkgs/router-webui/apiref.go | 73 +++++++++++++++++++++++++ pkgs/router-webui/main.go | 3 +- pkgs/router-webui/templates/apiref.html | 35 ++++++++++++ pkgs/router-webui/templates/tokens.html | 2 +- 6 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 pkgs/router-webui/apiref.go create mode 100644 pkgs/router-webui/templates/apiref.html diff --git a/SPECS.md b/SPECS.md index 1de82cc..7cee500 100644 --- a/SPECS.md +++ b/SPECS.md @@ -124,6 +124,7 @@ Learned along the way: - `/api/v1/…` with an OpenAPI 3.1 description at `GET /api/openapi.json` (no token: it describes the interface only). Same tokens, scopes, origin check, limits and audit log as MCP. - **One implementation**: each operation runs an MCP tool (`apiRoutes` in `api.go` maps method and path to a tool; query parameters or `If-Match` and the body become its arguments), and `TestOpenAPI` checks that every tool has an operation. Status, settings (`ETag` header), `PATCH /settings` (merge patch, `If-Match`; 409 when stale), `settings/check`, `changes` (GET the diff as text, DELETE to discard), `interfaces`, `dhcp-leases`, `update`, `apply-log`, `logs`, `options`. +- **API reference** page in the web UI (`/apireference`, linked from API access): each operation with its access, parameters and a `curl` example for this router, from the same route list as the OpenAPI document. No "try it" form. - Errors are `{"error": "…"}` with 400 (refused or bad argument), 401, 403 (scope), 409 (stale ETag), 500. ## Interface names (confirmed) diff --git a/pkgs/router-webui/api_test.go b/pkgs/router-webui/api_test.go index dd3d488..3c1f0f0 100644 --- a/pkgs/router-webui/api_test.go +++ b/pkgs/router-webui/api_test.go @@ -234,3 +234,25 @@ func TestTokenEditNote(t *testing.T) { t.Fatalf("stale note: %q", note) } } + +func TestAPIReference(t *testing.T) { + a, _ := newTestApp(t) + h := a.routes() + if rec := do(h, "GET", "/apireference", nil, nil); rec.Code != http.StatusSeeOther { + t.Fatalf("without a session: %d", rec.Code) + } + cookie := login(t, h) + rec := do(h, "GET", "/apireference", nil, cookie) + body := rec.Body.String() + if rec.Code != 200 { + t.Fatalf("reference: %d", rec.Code) + } + for _, rt := range apiRoutes { + if !strings.Contains(body, rt.path) { + t.Errorf("%s is not on the page", rt.path) + } + } + if !strings.Contains(body, "curl -sk") || !strings.Contains(body, "If-Match") || !strings.Contains(body, "unit=router-pppoe") { + t.Errorf("examples missing") + } +} diff --git a/pkgs/router-webui/apiref.go b/pkgs/router-webui/apiref.go new file mode 100644 index 0000000..7f31d1c --- /dev/null +++ b/pkgs/router-webui/apiref.go @@ -0,0 +1,73 @@ +package main + +import ( + "fmt" + "net/http" + "slices" + "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 != "", + }}) +} diff --git a/pkgs/router-webui/main.go b/pkgs/router-webui/main.go index ce0a6c5..a35eda0 100644 --- a/pkgs/router-webui/main.go +++ b/pkgs/router-webui/main.go @@ -268,7 +268,7 @@ func newApp(sys System, metrics *Collector, dev bool) *app { func (a *app) parseTemplates() (map[string]*template.Template, error) { files := map[string][]string{"overview": {"templates/dashboard.html"}} pages := map[string]*template.Template{} - for _, page := range []string{"login", "overview", "network", "dhcp", "dns", "wireguard", "firewall", "ddns", "dnsproviders", "certificate", "advanced", "update", "tokens", "changes"} { + for _, page := range []string{"login", "overview", "network", "dhcp", "dns", "wireguard", "firewall", "ddns", "dnsproviders", "certificate", "advanced", "update", "tokens", "changes", "apiref"} { t, err := template.New("layout.html").Funcs(a.funcs). ParseFS(assets, append([]string{"templates/layout.html", "templates/" + page + ".html"}, files[page]...)...) if err != nil { @@ -341,6 +341,7 @@ func (a *app) routes() http.Handler { mux.HandleFunc("/mcp", a.mcp) a.registerAPI(mux) mux.HandleFunc("GET /tokens", a.auth(a.tokensPage)) + mux.HandleFunc("GET /apireference", a.auth(a.apiReference)) mux.HandleFunc("POST /tokens", a.auth(a.tokenCreate)) mux.HandleFunc("POST /tokens/revoke", a.auth(a.tokenRevoke)) mux.HandleFunc("GET /changes", a.auth(a.changesPage)) diff --git a/pkgs/router-webui/templates/apiref.html b/pkgs/router-webui/templates/apiref.html new file mode 100644 index 0000000..09050d2 --- /dev/null +++ b/pkgs/router-webui/templates/apiref.html @@ -0,0 +1,35 @@ +{{define "content"}} +{{with .Data}} +
+

Using the API

+

Everything the MCP server offers, over plain HTTP. Base address {{.Base}}; the machine-readable description + is /api/openapi.json (OpenAPI 3.1, no token needed).

+

Every call needs a token on the API access page, sent as Authorization: Bearer rtr_…. + A read token may use the read operations; a change token also the others. Changes to the + settings are saved, never applied: you review them under Changes and apply them yourself.

+

Errors are JSON, {"error": "…"}, with the status 400 (refused or a bad argument), 401 (no or an expired token), + 403 (the token's access is too low), 409 (stale ETag) or 500. Results are JSON, except the pending changes and the + journal, which are plain text.{{if .HasOptions}} GET /api/v1/options lists every router option with its type, + default and description: what settings may contain.{{end}}

+

Examples use -k because the certificate is self-signed unless you set up a + certificate; put your token in $TOKEN.

+
+
+{{range .Ops}} +
+

{{.Method}} {{.Path}} {{.Summary}}

+

Access: {{.Scope}}

+

{{.Description}}

+{{if .Params}} +
+ + {{range .Params}}{{end}} +
ParameterTypeDescription
{{.Name}}{{.Type}}{{if .Required}}required{{end}}{{.Description}}
+{{end}} +{{with .Body}}

Body: {{.}}

{{end}} +
{{.Curl}}
+
+{{end}} +
+{{end}} +{{end}} diff --git a/pkgs/router-webui/templates/tokens.html b/pkgs/router-webui/templates/tokens.html index 3cc9314..e43f2af 100644 --- a/pkgs/router-webui/templates/tokens.html +++ b/pkgs/router-webui/templates/tokens.html @@ -3,7 +3,7 @@

An assistant or a script can read this router's state and logs and edit its settings through an MCP endpoint at {{.URL}} and, with the same features, a - REST API described by OpenAPI (/api/v1/…). It needs a token, sent as + REST API described by OpenAPI (/api/v1/…, see the API reference). It needs a token, sent as Authorization: Bearer …. A read token reads state and settings; a change token can also edit the settings. Edits are saved but never applied: you review them under Changes and apply them yourself. Secrets are never sent, and every call is logged below.