From cc893f758ece52b6c9d9f41577baa76ee604abd2 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 24 Oct 2026 14:41:02 -0400 Subject: [PATCH] =?UTF-8?q?feat(docs):=20drift=20gate=20=E2=80=94=20docs?= =?UTF-8?q?=20frontmatter=20validated=20against=20real=20routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every API doc that declares frontmatter (method/path/auth/area) is now checked by a unit test against three sources of truth: 1. Route registration: internal/router/*.go is parsed the way echo resolves it (group prefixes concatenate; scanner.go's parameterized groups seeded). Doc paths that no longer exist fail. (This is the check that would have caught the /api/api/admin/hash-conflicts split.) 2. Auth: frontmatter auth label must match the middleware chain (none|jwt|jwt+admin|device-token|optional-jwt). 3. Shapes: internal/docs/schema.go registry — example-request decoded with unknown fields DISALLOWED (typo guard), required-by-validate fields must appear (staleness guard), example-response top-level key set compared both directions against the marshaled handler type (or a RawMessage pin for hand-built map responses). Five collections docs are the pilots with real handler shapes; notable corrections they pin: update returns 201 (not 200), list excludes smart collections unless include_auto=true, rule priority is 1-10 higher-wins (docs claimed 1=highest), created_at is Go time.String() not RFC3339, hide_finished/show_up_next toggles documented. CollectionResponse hoisted out of GetCollections/GetBookCollections as CollectionSummary / BookCollectionSummary so the registry can pin real types. The gate fails on missing registry entries by design — that is the maintenance contract. Known pre-existing (not this commit): vet copy-lock warnings in media.go. --- .../api/collections/delete_collection.md | 41 +- .../api/collections/get_collection.md | 102 +++-- .../api/collections/list_collections.md | 98 +++-- .../api/collections/update_collection.md | 95 +++-- internal/docs/drift_test.go | 386 ++++++++++++++++++ internal/docs/frontmatter.go | 146 +++++++ internal/docs/schema.go | 72 ++++ internal/handlers/collections.go | 52 +-- 8 files changed, 824 insertions(+), 168 deletions(-) create mode 100644 internal/docs/drift_test.go create mode 100644 internal/docs/frontmatter.go create mode 100644 internal/docs/schema.go diff --git a/docs/developer/api/collections/delete_collection.md b/docs/developer/api/collections/delete_collection.md index c73dfd7b..eb3f5395 100644 --- a/docs/developer/api/collections/delete_collection.md +++ b/docs/developer/api/collections/delete_collection.md @@ -1,27 +1,28 @@ +--- +method: DELETE +path: /api/collections/:id +auth: jwt +area: collections +--- + + # Delete Collection -Delete a collection. Books are NOT deleted. +Deletes a collection. Membership rows go with it; the books themselves are +untouched. -**Endpoint**: `DELETE /api/collections/{id}` -**Auth**: Required -**Content-Type**: `application/json` +Success is **204 No Content** — there is no response body. (htmx requests +receive a small HTML redirect snippet instead; that behavior is web-internal.) -## Path Parameters +## Example request -| Parameter | Type | Required | Description | -| --------- | ------------- | -------- | --------------- | -| id | string (UUID) | Yes | Collection UUID | +```text +DELETE /api/collections/550e8400-e29b-41d4-a716-446655440000 +``` -## Response (204 No Content) +## Errors -Collection deleted successfully. No response body. - -## Error Responses - -| Code | Description | -| ---- | ----------------------- | -| 401 | Authentication required | -| 404 | Collection not found | -| 500 | Internal server error | - -## Try It Out +| Status | Body | When | +| ------ | ---- | ---- | +| 400 | `{"error": "invalid collection id"}` | malformed id | +| 500 | `{"error": "..."}` | unexpected server error | diff --git a/docs/developer/api/collections/get_collection.md b/docs/developer/api/collections/get_collection.md index 5a5f483b..e5b02ab0 100644 --- a/docs/developer/api/collections/get_collection.md +++ b/docs/developer/api/collections/get_collection.md @@ -1,54 +1,74 @@ +--- +method: GET +path: /api/collections/:id +auth: jwt +area: collections +--- + + # Get Collection -Get single collection with all books. +Returns one collection with its books and, for smart collections, the +computed Up Next entries. -**Endpoint**: `GET /api/collections/{id}` -**Auth**: Required -**Content-Type**: `application/json` +## Example request -## Path Parameters +```text +GET /api/collections/550e8400-e29b-41d4-a716-446655440000 +``` -| Parameter | Type | Required | Description | -| --------- | ------------- | -------- | --------------- | -| id | string (UUID) | Yes | Collection UUID | +## Example response -## Query Parameters - -| Parameter | Type | Required | Description | -| ------------- | ------- | -------- | ----------------------------------------- | -| include_books | boolean | No | Include books in response (default: true) | -| limit | integer | No | Number of books to return (default: 50) | -| offset | integer | No | Number of books to skip (default: 0) | - -## Response (200 OK) - -```json +```json example-response { "id": "550e8400-e29b-41d4-a716-446655440000", - "name": "Science Fiction", - "description": "My sci-fi collection", - "color": "#FF5733", - "icon": "🚀", - "books": [ - { - "media_item_id": "660e8400-e29b-41d4-a716-446655440000", - "title": "Foundation", - "author": "Isaac Asimov", - "cover_image_path": "/covers/foundation.jpg" - } - ], - "auto_assign_rules": [], - "view_settings": {}, - "created_at": "2026-02-01T10:00:00Z" + "user_id": "8f14e45f-ceea-467f-a429-0b21bfc0b1a2", + "name": "To Read", + "description": "Books I want to read soon", + "color": "#00FF00", + "icon": "📖", + "auto_assign_rules": null, + "view_settings": { "kobo": { "view_mode": "grid" } }, + "books": [], + "book_count": 0, + "is_smart": false, + "up_next": [], + "created_at": "2026-10-24 13:57:01.81234 +0000 UTC" } ``` -## Error Responses +`created_at` uses Go's `time.String()` format, not RFC3339. `books` and +`up_next` are book-card arrays; `up_next` items carry +`id`, `title`, `author`, `cover`, `format`, `progress`. -| Code | Description | -| ---- | ----------------------- | -| 401 | Authentication required | -| 404 | Collection not found | -| 500 | Internal server error | +## Query parameters -## Try It Out +| Param | Type | Notes | +| ----- | ---- | ----- | +| library_id | uuid | optional; scopes items/counts to one library (invalid values are rejected with 400) | + +## Response fields + +| Field | Type | Notes | +| ----- | ---- | ----- | +| id | string (uuid) | | +| user_id | string (uuid) | owner | +| name | string | | +| description | string | | +| color | string | | +| icon | string | | +| auto_assign_rules | array \| null | | +| view_settings | object | | +| books | array | book cards in this collection | +| book_count | number | length of `books` | +| is_smart | boolean | | +| up_next | array | smart collections only: computed next-up entries | +| created_at | string | | + +## Errors + +| Status | Body | When | +| ------ | ---- | ---- | +| 400 | `{"error": "invalid collection id"}` / `{"error": "invalid library_id"}` | malformed id(s) | +| 404 | `{"error": "collection not found"}` | no such collection | +| 500 | `{"error": "..."}` | unexpected server error | diff --git a/docs/developer/api/collections/list_collections.md b/docs/developer/api/collections/list_collections.md index 17ee02f5..73a50e35 100644 --- a/docs/developer/api/collections/list_collections.md +++ b/docs/developer/api/collections/list_collections.md @@ -1,57 +1,77 @@ +--- +method: GET +path: /api/collections +auth: jwt +area: collections +--- + + # List Collections -Get all collections for the authenticated user. +Lists the signed-in user's collections with live book counts. Smart +(rule-driven) collections are **excluded by default** so legacy readers never +see stale membership — pass `include_auto=true` to include them. -**Endpoint**: `GET /api/collections` -**Auth**: Required -**Content-Type**: `application/json` +## Example request -## Query Parameters +```text +GET /api/collections?include_auto=true&sort_by=name +``` -| Parameter | Type | Required | Description | -| --------- | ------- | -------- | --------------------------------------------- | -| limit | integer | No | Number of collections to return (default: 50) | -| offset | integer | No | Number of collections to skip (default: 0) | +## Example response -## Response (200 OK) - -```json +```json example-response { "collections": [ { "id": "550e8400-e29b-41d4-a716-446655440000", - "name": "Science Fiction", - "description": "My sci-fi collection", - "color": "#FF5733", - "icon": "🚀", - "auto_assign_rules": [ - { - "field": "genre", - "operator": "equals", - "value": "Science Fiction", - "priority": 1, - "enabled": true - } - ], - "view_settings": { - "kobo": { - "view_mode": "grid", - "sort_order": "name", - "items_per_page": 24 - } - }, - "created_at": "2026-02-01T10:00:00Z" + "name": "To Read", + "description": "Books I want to read soon", + "color": "#00FF00", + "icon": "📖", + "auto_assign_rules": null, + "created_at": "2026-10-24 13:57:01.81234 +0000 UTC", + "book_count": 12, + "is_smart": false } ], "total": 1 } ``` -## Error Responses +`created_at` uses Go's `time.String()` format, not RFC3339. -| Code | Description | -| ---- | ----------------------- | -| 401 | Authentication required | -| 500 | Internal server error | +## Query parameters -## Try It Out +| Param | Type | Default | Notes | +| ----- | ---- | ------- | ----- | +| include_auto | boolean | `false` | include smart collections | +| sort_by | string | — | `name` sorts alphabetically; otherwise service order | +| library_id | uuid | — | scopes book counts to one library (smart collections only) | + +## Response fields + +| Field | Type | Notes | +| ----- | ---- | ----- | +| collections | array | `CollectionSummary` objects, see below | +| total | number | length of `collections` | + +### Collection item + +| Field | Type | Notes | +| ----- | ---- | ----- | +| id | string (uuid) | | +| name | string | | +| description | string | | +| color | string | | +| icon | string | | +| auto_assign_rules | array \| null | raw rules JSON; non-null implies smart | +| created_at | string | Go `time.String()` format | +| book_count | number | live count; respects `library_id` for smart collections | +| is_smart | boolean | | + +## Errors + +| Status | Body | When | +| ------ | ---- | ---- | +| 500 | `{"error": "..."}` | unexpected server error | diff --git a/docs/developer/api/collections/update_collection.md b/docs/developer/api/collections/update_collection.md index e527dce6..c65e65d4 100644 --- a/docs/developer/api/collections/update_collection.md +++ b/docs/developer/api/collections/update_collection.md @@ -1,63 +1,70 @@ +--- +method: PUT +path: /api/collections/:id +auth: jwt +area: collections +--- + + # Update Collection -Update collection details. +Updates name/metadata, replaces the rule set, and merges view settings. +Note the quirk: **the success status is 201**, not 200 (historical). -**Endpoint**: `PUT /api/collections/{id}` -**Auth**: Required -**Content-Type**: `application/json` +The playlist toggles `hide_finished` / `show_up_next` are accepted as +top-level single-element arrays (an htmx form artifact — the LAST value wins) +and are merged into `view_settings` server-side. JSON clients may prefer to +set `view_settings.hide_finished` / `view_settings.show_up_next` directly. -## Path Parameters +## Example request -| Parameter | Type | Required | Description | -| --------- | ------------- | -------- | --------------- | -| id | string (UUID) | Yes | Collection UUID | - -## Request Body - -All fields are optional. Include only fields you want to update. - -| Field | Type | Required | Description | -| ----------------- | ------ | -------- | ----------------------------------------------- | -| name | string | No | Collection name (max 255 chars) | -| description | string | No | Collection description | -| color | string | No | Hex color code (e.g., "#FF5733") | -| icon | string | No | Emoji icon (e.g., "🚀", "📖") | -| auto_assign_rules | array | No | Array of rule objects (replaces existing rules) | -| view_settings | object | No | Per-device display preferences | - -### Example Request - -```json +```json example-request { - "name": "Sci-Fi Favorites", + "name": "To Read (2026)", "description": "Updated description", - "color": "#7aa2f7", - "icon": "🌟" + "color": "#33CCFF", + "icon": "📚", + "auto_assign_rules": [], + "view_settings": { "hide_finished": true }, + "hide_finished": ["true"], + "show_up_next": ["true"] } ``` -## Response (200 OK) +## Example response -```json +```json example-response { "id": "550e8400-e29b-41d4-a716-446655440000", - "name": "Sci-Fi Favorites", + "user_id": "8f14e45f-ceea-467f-a429-0b21bfc0b1a2", + "name": "To Read (2026)", "description": "Updated description", - "color": "#7aa2f7", - "icon": "🌟", - "auto_assign_rules": [], - "view_settings": {}, - "created_at": "2026-02-01T10:00:00Z" + "color": "#33CCFF", + "icon": "📚", + "auto_assign_rules": null, + "view_settings": { "hide_finished": true, "show_up_next": true }, + "created_at": "2026-10-24 13:57:01.81234 +0000 UTC" } ``` -## Error Responses +`created_at` uses Go's `time.String()` format, not RFC3339. -| Code | Description | -| ---- | ----------------------------------- | -| 400 | Invalid request (validation failed) | -| 401 | Authentication required | -| 404 | Collection not found | -| 500 | Internal server error | +## Request fields -## Try It Out +| Field | Type | Required | Notes | +| ----- | ---- | -------- | ----- | +| name | string | yes | | +| description | string | no | | +| color | string | no | | +| icon | string | no | | +| auto_assign_rules | array | no | replaces the whole rule set (rule object shape: see Create Collection) | +| view_settings | object | no | merged into stored settings | +| hide_finished | array | no | single-element string array `["true"]`/`["false"]`; merged into view_settings | +| show_up_next | array | no | same shape as `hide_finished` | + +## Errors + +| Status | Body | When | +| ------ | ---- | ---- | +| 400 | `{"error": "invalid collection id"}` / `{"error": "invalid request"}` | malformed id or body | +| 500 | `{"error": "..."}` | unexpected server error | diff --git a/internal/docs/drift_test.go b/internal/docs/drift_test.go new file mode 100644 index 00000000..520dc66c --- /dev/null +++ b/internal/docs/drift_test.go @@ -0,0 +1,386 @@ +package docs + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "reflect" + "regexp" + "strings" + "testing" +) + +// The drift gate: every doc that declares an endpoint in frontmatter must +// (1) match a real route registration, (2) match its middleware-derived auth +// label, and (3) have request/response examples compatible with the schema +// registry. Docs without frontmatter (overviews, contracts) are out of scope. +// +// Run verbose to see UNVERIFIED markers left by drafting passes: +// +// go test ./internal/docs/ -run TestDocsMatchRoutes -v + +// ---------- route extraction ---------- + +type extractedRoute struct { + method string + path string + middleware []string +} + +var ( + groupRe = regexp.MustCompile(`^(\w+)\s*:=\s*(\S+)\.Group\("([^"]*)"(?:,\s*(.+))?\)`) + echoRouteRe = regexp.MustCompile(`^(?:e|cfg\.Echo)\.(GET|POST|PUT|PATCH|DELETE)\("([^"]*)"(?:,\s*(.+))?\)`) + groupRouteRe = regexp.MustCompile(`^(\w+)\.(GET|POST|PUT|PATCH|DELETE)\("([^"]*)"(?:,\s*(.+))?\)`) +) + +// extractRoutesFromRouterSource parses internal/router/*.go the way the echo +// router resolves them: group prefixes concatenate, and some files receive +// their group as a function parameter (registerScannerRoutes(protected, ...)). +// This is a test — if it drifts from reality, a doc vs route mismatch shows up +// in live behavior; treat failures here as "the parser needs updating", never +// as license to edit docs to match the parser. +func extractRoutesFromRouterSource(t *testing.T) map[string]extractedRoute { + t.Helper() + type group struct { + prefix string + middleware []string + } + groups := map[string]group{ + "e": {}, + "cfg.Echo": {}, + } + + files, err := filepath.Glob("../router/*.go") + if err != nil || len(files) == 0 { + t.Fatalf("cannot read router sources: %v", err) + } + + routes := map[string]extractedRoute{} + add := func(method, path string, mw []string) { + routes[method+" "+path] = extractedRoute{method, path, mw} + } + + for _, file := range files { + base := filepath.Base(file) + if strings.HasSuffix(base, "_test.go") { + continue + } + // registerScannerRoutes receives its groups as parameters; seed the + // names the call site actually passes (router.go: protected=/api jwt, + // admin=/api jwt+admin). + if base == "scanner.go" { + groups["protected"] = group{prefix: "/api", middleware: []string{"jwt"}} + groups["admin"] = group{prefix: "/api", middleware: []string{"jwt", "admin"}} + } + raw, err := os.ReadFile(file) + if err != nil { + t.Fatalf("read %s: %v", file, err) + } + for _, line := range strings.Split(string(raw), "\n") { + s := strings.TrimSpace(line) + + if m := groupRe.FindStringSubmatch(s); m != nil { + varname, parent, prefix, mwArg := m[1], m[2], m[3], m[4] + parentInfo := groups[parent] + g := group{ + prefix: strings.TrimSuffix(parentInfo.prefix+prefix, "/"), + middleware: append(append([]string{}, parentInfo.middleware...), splitMW(mwArg)...), + } + groups[varname] = g + continue + } + + if m := echoRouteRe.FindStringSubmatch(s); m != nil { + add(m[1], m[2], splitMW(m[3])) + continue + } + + if m := groupRouteRe.FindStringSubmatch(s); m != nil { + g, ok := groups[m[1]] + if !ok { + continue + } + full := strings.TrimSuffix(g.prefix+m[3], "/") + add(m[2], full, append(append([]string{}, g.middleware...), splitMW(m[4])...)) + } + } + } + return routes +} + +func splitMW(s string) []string { + if strings.TrimSpace(s) == "" { + return nil + } + var out []string + for _, part := range strings.Split(s, ",") { + out = append(out, strings.TrimSpace(part)) + } + return out +} + +// authLabel reduces a middleware chain to the frontmatter auth vocabulary. +func authLabel(chain []string) string { + has := func(needle string) bool { + for _, c := range chain { + if strings.Contains(c, needle) { + return true + } + } + return false + } + switch { + case has("AdminMiddleware"): + return "jwt+admin" + case has("DeviceAuthMiddleware"): + return "device-token" + case has("createOptionalAuthMiddleware"): + return "optional-jwt" + case has("jwt"): + return "jwt" + default: + return "none" + } +} + +// parseHandlerMiddleware extracts per-route middleware markers from the +// registration's argument list (wrappers like rateLimitMiddleware(handler)). +func parseHandlerMiddleware(rest string) []string { + if rest == "" { + return nil + } + var mw []string + if strings.HasPrefix(strings.TrimSpace(rest), "rateLimitMiddleware") { + mw = append(mw, "rateLimit") + } + if strings.Contains(rest, "AdminMiddleware") { + mw = append(mw, "admin") + } + if strings.Contains(rest, "DeviceAuthMiddleware") { + mw = append(mw, "device-token") + } + if strings.Contains(rest, "createJWTMiddleware") { + mw = append(mw, "jwt") + } + return mw +} + +// ---------- the gate ---------- + +func TestDocsMatchRoutes(t *testing.T) { + routes := extractRoutesFromRouterSource(t) + + docsDir := "../../docs/developer/api" + docs, err := LoadAPIDocs(docsDir) + if err != nil { + t.Fatalf("walk %s: %v", docsDir, err) + } + if len(docs) == 0 { + t.Fatal("no frontmatter'd endpoint docs found — the gate is checking nothing") + } + + for _, doc := range docs { + key := doc.Frontmatter.Method + " " + doc.Frontmatter.Path + + route, ok := routes[key] + if !ok { + t.Errorf("%s: frontmatter declares %q but no such route is registered (wrong path, method, or the route moved)", doc.RelPath, key) + continue + } + + if want := authLabel(route.middleware); doc.Frontmatter.Auth != want { + t.Errorf("%s: frontmatter auth=%q but route middleware resolves to %q", doc.RelPath, doc.Frontmatter.Auth, want) + } + + for _, marker := range doc.Unverified { + t.Logf("UNVERIFIED in %s: %s", doc.RelPath, marker) + } + + schema, registered := Schemas[key] + if !registered { + t.Errorf("%s: route %q has no internal/docs schema registry entry — add one in the same change", doc.RelPath, key) + continue + } + + validateRequestExample(t, &doc, schema) + validateResponseExample(t, &doc, schema) + } +} + +func validateRequestExample(t *testing.T, doc *APIDoc, schema Schema) { + t.Helper() + if schema.Request == nil { + if len(doc.ExampleRequest) > 0 { + t.Errorf("%s: route takes no request body but the doc has an example-request block", doc.RelPath) + } + return + } + if len(doc.ExampleRequest) == 0 { + t.Errorf("%s: no example-request block — required for routes with a body", doc.RelPath) + return + } + + // Typo guard: field names must exist on the bind struct. + dec := json.NewDecoder(bytes.NewReader(doc.ExampleRequest)) + dec.DisallowUnknownFields() + v := reflect.New(reflect.TypeOf(schema.Request)).Interface() + if err := dec.Decode(v); err != nil { + t.Errorf("%s: example-request is not compatible with %T: %v", doc.RelPath, schema.Request, err) + return + } + + // Staleness guard: required fields must be present in the example. + for _, field := range requiredJSONFields(reflect.TypeOf(schema.Request)) { + if !jsonHasKey(doc.ExampleRequest, field) { + t.Errorf("%s: example-request missing required field %q", doc.RelPath, field) + } + } +} + +func validateResponseExample(t *testing.T, doc *APIDoc, schema Schema) { + t.Helper() + if schema.Response == nil { + if doc.HasExampleResponse { + t.Errorf("%s: route returns no JSON body but the doc has an example-response block", doc.RelPath) + } + return + } + if !doc.HasExampleResponse { + t.Errorf("%s: no example-response block — required for JSON routes", doc.RelPath) + return + } + + want := responseTopLevelKeys(t, schema.Response) + got := topLevelKeys(doc.ExampleResponse) + + for _, k := range sortedKeys(want) { + if !got[k] { + t.Errorf("%s: example-response missing key %q (the handler emits it)", doc.RelPath, k) + } + } + for _, k := range sortedKeys(got) { + if !want[k] { + t.Errorf("%s: example-response has key %q but the handler does not emit it", doc.RelPath, k) + } + } +} + +// requiredJSONFields lists json field names of struct type whose validate tag +// marks them required. +func requiredJSONFields(rt reflect.Type) []string { + if rt.Kind() == reflect.Ptr { + rt = rt.Elem() + } + var out []string + for i := 0; i < rt.NumField(); i++ { + f := rt.Field(i) + jsonTag := strings.Split(f.Tag.Get("json"), ",")[0] + if jsonTag == "" || jsonTag == "-" { + continue + } + for _, rule := range strings.Split(f.Tag.Get("validate"), ",") { + if strings.TrimSpace(rule) == "required" { + out = append(out, jsonTag) + break + } + } + } + return out +} + +func jsonHasKey(data []byte, key string) bool { + var m map[string]json.RawMessage + if err := json.Unmarshal(data, &m); err != nil { + return false + } + _, ok := m[key] + return ok +} + +func topLevelKeys(data []byte) map[string]bool { + var m map[string]json.RawMessage + if err := json.Unmarshal(data, &m); err != nil { + return nil + } + out := make(map[string]bool, len(m)) + for k := range m { + out[k] = true + } + return out +} + +// responseTopLevelKeys derives the expected top-level key set from the +// registered Response: marshal a struct (or its element for slices), or use a +// RawMessage pin verbatim. +func responseTopLevelKeys(t *testing.T, resp any) map[string]bool { + t.Helper() + if raw, ok := resp.(json.RawMessage); ok { + return topLevelKeys(raw) + } + rt := reflect.TypeOf(resp) + if rt == nil { + return nil + } + if rt.Kind() == reflect.Slice { + if rt.Elem().Kind() == reflect.Uint8 { + return topLevelKeys(reflect.ValueOf(resp).Bytes()) // []byte payloads + } + rt = rt.Elem() + } + if rt.Kind() == reflect.Ptr { + rt = rt.Elem() + } + if rt.Kind() != reflect.Struct { + t.Fatalf("schema response %T: unsupported kind %s (use a struct, slice of struct, or json.RawMessage)", resp, rt.Kind()) + } + b, err := json.Marshal(reflect.New(rt).Interface()) + if err != nil { + t.Fatalf("marshal schema response %T: %v", resp, err) + } + return topLevelKeys(b) +} + +func sortedKeys(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + // deterministic order for stable failure messages + for i := 1; i < len(out); i++ { + for j := i; j > 0 && out[j] < out[j-1]; j-- { + out[j], out[j-1] = out[j-1], out[j] + } + } + return out +} + +// Sanity: the extraction itself must find the pilot routes, otherwise the +// gate would pass vacuously if the parser ever broke. +func TestRouteExtractionFindsPilots(t *testing.T) { + routes := extractRoutesFromRouterSource(t) + for _, key := range []string{ + "POST /api/collections", + "GET /api/collections", + "GET /api/collections/:id", + "PUT /api/collections/:id", + "DELETE /api/collections/:id", + "GET /api/admin/hash-conflicts", + "POST /api/admin/hash-conflicts/:id/resolve", + "POST /api/sync/kobo/:token/markup", + "POST /api/auth/login", + } { + if _, ok := routes[key]; !ok { + t.Errorf("route extraction missing %q (parser drift — fix extractRoutesFromRouterSource, do not edit docs to match)", key) + } + } + if label := authLabel(routes["GET /api/admin/hash-conflicts"].middleware); label != "jwt+admin" { + t.Errorf("hash-conflicts auth label = %q, want jwt+admin", label) + } + if label := authLabel(routes["POST /api/sync/kobo/:token/markup"].middleware); label != "device-token" { + t.Errorf("kobo markup auth label = %q, want device-token", label) + } + if label := authLabel(routes["POST /api/auth/login"].middleware); label != "none" { + t.Errorf("login auth label = %q, want none (public, rate-limited)", label) + } +} diff --git a/internal/docs/frontmatter.go b/internal/docs/frontmatter.go new file mode 100644 index 00000000..fdfb8f9f --- /dev/null +++ b/internal/docs/frontmatter.go @@ -0,0 +1,146 @@ +package docs + +import ( + "os" + "path/filepath" + "strings" +) + +// DocFrontmatter is the machine-readable identity header of an API doc page. +// It is the contract the drift gate (drift_test.go) checks against the real +// router registrations and the schema registry (schema.go). +type DocFrontmatter struct { + Method string // GET|POST|PUT|PATCH|DELETE + Path string // Echo syntax, e.g. /api/collections/:id + Auth string // none|jwt|jwt+admin|device-token|optional-jwt + Area string // sidebar area, e.g. collections +} + +// APIDoc is one endpoint doc file: its frontmatter plus the labeled example +// blocks extracted from the body. +type APIDoc struct { + RelPath string + Frontmatter DocFrontmatter + ExampleRequest []byte + ExampleResponse []byte + HasExampleResponse bool + Unverified []string // UNVERIFIED markers found in the body +} + +// parseFrontmatter splits a leading "---" delimited block of flat key:value +// lines off content. Recognized keys: method, path, auth, area. Returns the +// frontmatter and the remaining body; hasFM is false when no well-formed +// block is present (prose pages like overviews have none and are skipped by +// the gate). +func parseFrontmatter(content string) (fm DocFrontmatter, body string, hasFM bool) { + lines := strings.Split(content, "\n") + if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" { + return fm, content, false + } + end := -1 + for i := 1; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) == "---" { + end = i + break + } + } + if end == -1 { + return fm, content, false + } + + for _, line := range lines[1:end] { + key, value, ok := strings.Cut(line, ":") + if !ok { + continue + } + key = strings.ToLower(strings.TrimSpace(key)) + value = strings.TrimSpace(value) + switch key { + case "method": + fm.Method = strings.ToUpper(value) + case "path": + fm.Path = value + case "auth": + fm.Auth = strings.ToLower(value) + case "area": + fm.Area = strings.ToLower(value) + } + } + if fm.Method == "" || fm.Path == "" { + return fm, content, false // identity is the minimum for a gateable doc + } + return fm, strings.Join(lines[end+1:], "\n"), true +} + +// extractExampleBlocks pulls fenced code blocks tagged +// "json example-request" / "json example-response" out of a doc body, and +// counts UNVERIFIED markers. +func extractExampleBlocks(body string, doc *APIDoc) { + lines := strings.Split(body, "\n") + for i := 0; i < len(lines); i++ { + fence := strings.TrimSpace(lines[i]) + switch { + case fence == "```json example-request": + doc.ExampleRequest = extractUntilClose(lines, i+1) + i = skipBlock(lines, i+1) + case fence == "```json example-response": + doc.ExampleResponse = extractUntilClose(lines, i+1) + doc.HasExampleResponse = true + i = skipBlock(lines, i+1) + } + if strings.Contains(lines[i], "UNVERIFIED:") { + doc.Unverified = append(doc.Unverified, strings.TrimSpace(lines[i])) + } + } +} + +func extractUntilClose(lines []string, start int) []byte { + var b strings.Builder + for i := start; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) == "```" { + break + } + b.WriteString(lines[i]) + b.WriteString("\n") + } + return []byte(b.String()) +} + +// skipBlock returns the index of the closing fence line so the outer loop +// resumes after it. +func skipBlock(lines []string, start int) int { + for i := start; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) == "```" { + return i + } + } + return len(lines) - 1 +} + +// LoadAPIDocs walks dir recursively and returns every markdown file carrying +// well-formed endpoint frontmatter. Prose pages (overviews, contracts) are +// skipped: the gate only checks docs that claim to describe an endpoint. +func LoadAPIDocs(dir string) ([]APIDoc, error) { + var docs []APIDoc + err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() || !strings.HasSuffix(path, ".md") { + return err + } + raw, err := os.ReadFile(path) + if err != nil { + return err + } + fm, body, hasFM := parseFrontmatter(string(raw)) + if !hasFM { + return nil + } + doc := APIDoc{ + RelPath: path, + Frontmatter: fm, + } + extractExampleBlocks(body, &doc) + docs = append(docs, doc) + return nil + }) + return docs, err +} diff --git a/internal/docs/schema.go b/internal/docs/schema.go new file mode 100644 index 00000000..9ec4ea8d --- /dev/null +++ b/internal/docs/schema.go @@ -0,0 +1,72 @@ +package docs + +import ( + "encoding/json" + + "bookhoard/internal/handlers" +) + +// Schema pins the machine-checkable contract for one route. The drift gate +// (drift_test.go) validates every frontmatter'd doc against the entry for its +// "METHOD path" key. +// +// MAINTENANCE CONTRACT: every doc with frontmatter MUST have a registry +// entry, and every new/changed route that ships a frontmatter'd doc updates +// its entry in the same commit. The gate fails loudly on a missing entry — +// that is the discipline, not an inconvenience. +// +// Request: the struct the handler Binds into (nil for bodyless routes). +// - example-request is decoded with unknown fields DISALLOWED (typo guard) +// - fields whose validate tag contains "required" must appear in the +// example (staleness guard) +// +// Response: what the handler passes to c.JSON. +// - struct / slice of struct: marshaled and its TOP-LEVEL key set compared +// against example-response in both directions +// - json.RawMessage: hand-built map responses pin their key set as a +// sample; promote to a struct when the endpoint is next touched +// - zero value (Schema{}): no JSON body expected (e.g. 204) — the doc must +// not carry an example-response block +type Schema struct { + Request any + Response any +} + +// Schemas is keyed by "METHOD path" in Echo syntax. Sample values inside +// RawMessage pins use empty strings / zero values — only the KEY SET is +// checked, never the values. +var Schemas = map[string]Schema{ + // --- collections (pilot area) --- + "POST /api/collections": { + Request: handlers.CreateCollectionRequest{}, + Response: json.RawMessage(`{ + "id": "", "user_id": "", "name": "", "description": "", + "color": "", "icon": "", "collection_type": "", + "auto_assign_rules": null, "view_settings": {}, + "book_count": 0, "created_at": "" + }`), + }, + "GET /api/collections": { + Response: struct { + Collections []handlers.CollectionSummary `json:"collections"` + Total int `json:"total"` + }{}, + }, + "GET /api/collections/:id": { + Response: json.RawMessage(`{ + "id": "", "user_id": "", "name": "", "description": "", + "color": "", "icon": "", "auto_assign_rules": null, + "view_settings": {}, "books": [], "book_count": 0, + "is_smart": false, "up_next": [], "created_at": "" + }`), + }, + "PUT /api/collections/:id": { + Request: handlers.UpdateCollectionRequest{}, + Response: json.RawMessage(`{ + "id": "", "user_id": "", "name": "", "description": "", + "color": "", "icon": "", "auto_assign_rules": null, + "view_settings": {}, "created_at": "" + }`), + }, + "DELETE /api/collections/:id": {}, +} diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go index f0ba5a2c..09839ae7 100644 --- a/internal/handlers/collections.go +++ b/internal/handlers/collections.go @@ -75,6 +75,30 @@ type UpdateDeviceMappingRequest struct { SyncDirection string `json:"sync_direction" validate:"required,oneof=bidirectional book_to_device device_to_book none"` } +// CollectionSummary is one entry in GET /api/collections. Package-level so +// the docs schema registry (internal/docs) can pin the response shape. +type CollectionSummary struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Color string `json:"color"` + Icon string `json:"icon"` + AutoAssignRules json.RawMessage `json:"auto_assign_rules"` + CreatedAt string `json:"created_at"` + BookCount int `json:"book_count"` + IsSmart bool `json:"is_smart"` +} + +// BookCollectionSummary is one entry in GET /api/collections/:id/books — +// the collections a book belongs to (list view: no rules/count fields). +type BookCollectionSummary struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Color string `json:"color"` + Icon string `json:"icon"` +} + type SectionData struct { ID string `json:"id"` CollectionID string `json:"collection_id"` @@ -160,19 +184,7 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } - type CollectionResponse struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Color string `json:"color"` - Icon string `json:"icon"` - AutoAssignRules json.RawMessage `json:"auto_assign_rules"` - CreatedAt string `json:"created_at"` - BookCount int `json:"book_count"` - IsSmart bool `json:"is_smart"` - } - - response := make([]CollectionResponse, 0, len(collections)) + response := make([]CollectionSummary, 0, len(collections)) for _, col := range collections { isSmart := h.collectionService.IsSmartCollection(col.AutoAssignRules) if !includeAuto && isSmart { @@ -211,7 +223,7 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error { } } - response = append(response, CollectionResponse{ + response = append(response, CollectionSummary{ ID: col.ID.Bytes, Name: col.Name, Description: utils.PgTextString(col.Description), @@ -768,17 +780,9 @@ func (h *CollectionHandler) GetBookCollections(c *echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } - type CollectionResponse struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Color string `json:"color"` - Icon string `json:"icon"` - } - - response := make([]CollectionResponse, 0, len(collections)) + response := make([]BookCollectionSummary, 0, len(collections)) for _, col := range collections { - response = append(response, CollectionResponse{ + response = append(response, BookCollectionSummary{ ID: col.ID.Bytes, Name: col.Name, Description: utils.PgTextString(col.Description),