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:
committed by
Kristian Wind
co-authored by
Claude Opus 5
parent
1424f43a40
commit
cb77990458
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user