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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
2026-11-01 19:40:45 +01:00
co-authored by Claude Sonnet 5.5
parent 6d8e9d4166
commit 42ceb78d2b
6 changed files with 134 additions and 2 deletions
+1
View File
@@ -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)
+22
View File
@@ -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")
}
}
+73
View File
@@ -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 != "",
}})
}
+2 -1
View File
@@ -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))
+35
View File
@@ -0,0 +1,35 @@
{{define "content"}}
{{with .Data}}
<section class="panel">
<h2>Using the API</h2>
<p>Everything the MCP server offers, over plain HTTP. Base address <code>{{.Base}}</code>; the machine-readable description
is <a href="/api/openapi.json">/api/openapi.json</a> (OpenAPI 3.1, no token needed).</p>
<p>Every call needs a token on the <a href="/tokens">API access</a> page, sent as <code>Authorization: Bearer rtr_…</code>.
A <strong>read</strong> token may use the read operations; a <strong>change</strong> token also the others. Changes to the
settings are saved, never applied: you review them under <a href="/changes">Changes</a> and apply them yourself.</p>
<p>Errors are JSON, <code>{"error": "…"}</code>, 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}} <code>GET /api/v1/options</code> lists every router option with its type,
default and description: what <code>settings</code> may contain.{{end}}</p>
<p class="hint">Examples use <code>-k</code> because the certificate is self-signed unless you set up a
<a href="/certificate">certificate</a>; put your token in <code>$TOKEN</code>.</p>
</section>
<div class="cols">
{{range .Ops}}
<section class="panel">
<h2><code>{{.Method}} {{.Path}}</code> <span class="muted">{{.Summary}}</span></h2>
<p class="hint">Access: {{.Scope}}</p>
<p>{{.Description}}</p>
{{if .Params}}
<div class="scroll"><table>
<tr><th>Parameter</th><th>Type</th><th></th><th>Description</th></tr>
{{range .Params}}<tr><td><code>{{.Name}}</code></td><td>{{.Type}}</td><td>{{if .Required}}required{{end}}</td><td>{{.Description}}</td></tr>{{end}}
</table></div>
{{end}}
{{with .Body}}<p><strong>Body:</strong> {{.}}</p>{{end}}
<pre class="log">{{.Curl}}</pre>
</section>
{{end}}
</div>
{{end}}
{{end}}
+1 -1
View File
@@ -3,7 +3,7 @@
<section>
<p>An assistant or a script can read this router's state and logs and edit its settings through an
<a href="https://modelcontextprotocol.io">MCP</a> endpoint at <code>{{.URL}}</code> and, with the same features, a
REST API described by <a href="/api/openapi.json">OpenAPI</a> (<code>/api/v1/…</code>). It needs a token, sent as
REST API described by <a href="/api/openapi.json">OpenAPI</a> (<code>/api/v1/…</code>, see the <a href="/apireference">API reference</a>). It needs a token, sent as
<code>Authorization: Bearer …</code>. A <strong>read</strong> token reads state and settings; a <strong>change</strong>
token can also edit the settings. Edits are saved but never applied: you review them under
<a href="/changes">Changes</a> and apply them yourself. Secrets are never sent, and every call is logged below.</p>