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:
@@ -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.
|
- `/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`.
|
- **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.
|
- Errors are `{"error": "…"}` with 400 (refused or bad argument), 401, 403 (scope), 409 (stale ETag), 500.
|
||||||
|
|
||||||
## Interface names (confirmed)
|
## Interface names (confirmed)
|
||||||
|
|||||||
@@ -234,3 +234,25 @@ func TestTokenEditNote(t *testing.T) {
|
|||||||
t.Fatalf("stale note: %q", note)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 != "",
|
||||||
|
}})
|
||||||
|
}
|
||||||
@@ -268,7 +268,7 @@ func newApp(sys System, metrics *Collector, dev bool) *app {
|
|||||||
func (a *app) parseTemplates() (map[string]*template.Template, error) {
|
func (a *app) parseTemplates() (map[string]*template.Template, error) {
|
||||||
files := map[string][]string{"overview": {"templates/dashboard.html"}}
|
files := map[string][]string{"overview": {"templates/dashboard.html"}}
|
||||||
pages := map[string]*template.Template{}
|
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).
|
t, err := template.New("layout.html").Funcs(a.funcs).
|
||||||
ParseFS(assets, append([]string{"templates/layout.html", "templates/" + page + ".html"}, files[page]...)...)
|
ParseFS(assets, append([]string{"templates/layout.html", "templates/" + page + ".html"}, files[page]...)...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -341,6 +341,7 @@ func (a *app) routes() http.Handler {
|
|||||||
mux.HandleFunc("/mcp", a.mcp)
|
mux.HandleFunc("/mcp", a.mcp)
|
||||||
a.registerAPI(mux)
|
a.registerAPI(mux)
|
||||||
mux.HandleFunc("GET /tokens", a.auth(a.tokensPage))
|
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", a.auth(a.tokenCreate))
|
||||||
mux.HandleFunc("POST /tokens/revoke", a.auth(a.tokenRevoke))
|
mux.HandleFunc("POST /tokens/revoke", a.auth(a.tokenRevoke))
|
||||||
mux.HandleFunc("GET /changes", a.auth(a.changesPage))
|
mux.HandleFunc("GET /changes", a.auth(a.changesPage))
|
||||||
|
|||||||
@@ -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}}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<section>
|
<section>
|
||||||
<p>An assistant or a script can read this router's state and logs and edit its settings through an
|
<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
|
<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>
|
<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
|
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>
|
<a href="/changes">Changes</a> and apply them yourself. Secrets are never sent, and every call is logged below.</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user