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>
159 lines
4.4 KiB
Go
159 lines
4.4 KiB
Go
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)
|
|
}
|
|
}
|