docs(api): an OpenAPI spec, checked against the router

The brief asked for one. docs/api.md is complete and hand-written, and
there was nothing a tool could read.

Written by hand rather than generated from annotations, which would have
meant every handler carrying a second copy of itself in a comment. The
risk with a hand-written spec is that it goes stale, so it does not
depend on anybody remembering: openapi_test.go walks the chi router and
fails both on a route nothing describes and on a description with no
route behind it. CI separately checks it is a valid OpenAPI document.

The test reads the file with a forty-line scanner rather than pulling in
a YAML library. It needs two things — which paths are listed and which
methods each has — and both sit at fixed indentation in a document the
test also owns, which is worth more than a dependency in a project whose
whole shape is one static binary.

The CalDAV routes are excluded deliberately: PROPFIND and REPORT are
outside what OpenAPI can describe, and docs/caldav.md covers them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-17 10:56:33 +02:00
committed by Kristian Wind
co-authored by Claude Opus 5
parent 1424f43a40
commit cb77990458
5 changed files with 2399 additions and 0 deletions
+13
View File
@@ -45,6 +45,19 @@ jobs:
- name: Build
run: CGO_ENABLED=0 go build ./...
spec:
name: OpenAPI is valid
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
# Structural validity only. That the spec matches the *router* is checked by
# a Go test, which is the half that actually goes stale.
- run: npx --yes @redocly/cli@latest lint docs/openapi.yaml
e2e:
name: End-to-end
runs-on: ubuntu-latest
+10
View File
@@ -19,6 +19,16 @@ curl -H "Authorization: Bearer vrd_..." https://todo.example.dk/api/v1/tasks
Make one under **Settings → API tokens**. It carries your permissions exactly —
there is no separate service identity.
## The machine-readable spec
[`openapi.yaml`](openapi.yaml) describes the same API as an OpenAPI 3.1 document —
for generating a client, for importing into Bruno or Insomnia, or for reading in a
tool that renders it.
It is written by hand, and a test walks the router on every build and fails if an
endpoint is routed but not described, or described but no longer routed. So it is
as current as this page is, which is to say: as current as CI is green.
## Errors
Every failure has the same shape:
+2216
View File
File diff suppressed because it is too large Load Diff
+158
View File
@@ -0,0 +1,158 @@
package httpapi
import (
"bufio"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/config"
"github.com/kristianwind/verdande/internal/store"
)
// docs/openapi.yaml is written by hand, which means it can drift from the router
// the moment somebody adds an endpoint. This walks the real router and compares
// the two in both directions: a route with nothing written about it, and a path
// documented that no longer exists.
//
// The spec is read with a small scanner rather than a YAML library. It needs two
// things — which paths are listed and which methods each one has — and both live
// at fixed indentation in a document this file also owns. That is worth more than
// a dependency in a project whose whole shape is "one static binary".
// specRoutes reads "method path" pairs out of the `paths:` section.
func specRoutes(t *testing.T) map[string]bool {
t.Helper()
f, err := os.Open(filepath.Join("..", "..", "docs", "openapi.yaml"))
if err != nil {
t.Fatalf("open spec: %v", err)
}
defer f.Close()
methods := map[string]bool{
"get": true, "post": true, "put": true, "patch": true, "delete": true,
"head": true, "options": true, "trace": true,
}
routes := map[string]bool{}
inPaths := false
path := ""
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "#") || strings.TrimSpace(line) == "" {
continue
}
// A top-level key ends the paths section.
if !strings.HasPrefix(line, " ") {
inPaths = strings.HasPrefix(line, "paths:")
continue
}
if !inPaths {
continue
}
switch {
// Two spaces: a path.
case strings.HasPrefix(line, " /"):
path = strings.TrimSuffix(strings.TrimSpace(line), ":")
// Four spaces: a key under it, which is a method or `parameters`.
case path != "" && strings.HasPrefix(line, " ") && !strings.HasPrefix(line, " "):
key := strings.TrimSuffix(strings.TrimSpace(line), ":")
if methods[key] {
routes[strings.ToUpper(key)+" "+path] = true
}
}
}
if err := scanner.Err(); err != nil {
t.Fatalf("read spec: %v", err)
}
if len(routes) == 0 {
t.Fatal("no paths were read out of the spec — the scanner and the file disagree")
}
return routes
}
// undocumented is what the spec is not expected to describe.
func undocumented(method, path string) bool {
switch {
// The SPA fallback and static assets: not an API.
case path == "/*", path == "/":
return true
// CalDAV speaks PROPFIND and REPORT, which OpenAPI has no way to describe.
// The discovery URL is in the spec; the rest is documented in docs/caldav.md.
case strings.HasPrefix(path, "/caldav"):
return true
// chi registers OPTIONS and HEAD of its own accord in places; neither carries
// behaviour worth writing down.
case method == http.MethodOptions || method == http.MethodHead:
return true
}
return false
}
func TestOpenAPICoversEveryRoute(t *testing.T) {
spec := specRoutes(t)
// The server itself rather than the test harness's httptest wrapper: this
// needs the router, and nothing here sends a request.
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
srv := New(&config.Config{
BaseURL: "http://127.0.0.1", DataDir: t.TempDir(),
SessionTTL: time.Hour, InviteTTL: time.Hour, ResetTTL: time.Hour,
}, db, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
routed := map[string]bool{}
err = chi.Walk(srv.router,
func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
// chi writes the index of a sub-router as a trailing slash; the spec
// names it without one.
if route != "/" {
route = strings.TrimSuffix(route, "/")
}
if !undocumented(method, route) {
routed[method+" "+route] = true
}
return nil
})
if err != nil {
t.Fatalf("walk routes: %v", err)
}
var missing, stale []string
for route := range routed {
if !spec[route] {
missing = append(missing, route)
}
}
for route := range spec {
if !routed[route] {
stale = append(stale, route)
}
}
sort.Strings(missing)
sort.Strings(stale)
for _, route := range missing {
t.Errorf("routed but not in docs/openapi.yaml: %s", route)
}
for _, route := range stale {
t.Errorf("in docs/openapi.yaml but not routed: %s", route)
}
}
+2
View File
@@ -56,6 +56,8 @@ nav:
- Mail to task: mail.md
- Claude (MCP): mcp.md
- AI features: ai.md
# openapi.yaml is deliberately not in the nav: MkDocs copies it to the site as a
# file, and api.md links to it. A nav entry expects a page to render.
- Reference:
- API: api.md