feat(docs): drift gate — docs frontmatter validated against real routes
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.
This commit is contained in:
1 parent
994e0f86c4
commit
cc893f758e
8 files changed
+824
-168
No files matched your search
@@ -1,27 +1,28 @@
|
|||||||
|
---
|
||||||
|
method: DELETE
|
||||||
|
path: /api/collections/:id
|
||||||
|
auth: jwt
|
||||||
|
area: collections
|
||||||
|
---
|
||||||
|
<!-- source: internal/handlers/collections.go:464 CollectionHandler.DeleteCollection -->
|
||||||
|
|
||||||
# Delete Collection
|
# 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}`
|
Success is **204 No Content** — there is no response body. (htmx requests
|
||||||
**Auth**: Required
|
receive a small HTML redirect snippet instead; that behavior is web-internal.)
|
||||||
**Content-Type**: `application/json`
|
|
||||||
|
|
||||||
## Path Parameters
|
## Example request
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
```text
|
||||||
| --------- | ------------- | -------- | --------------- |
|
DELETE /api/collections/550e8400-e29b-41d4-a716-446655440000
|
||||||
| id | string (UUID) | Yes | Collection UUID |
|
```
|
||||||
|
|
||||||
## Response (204 No Content)
|
## Errors
|
||||||
|
|
||||||
Collection deleted successfully. No response body.
|
| Status | Body | When |
|
||||||
|
| ------ | ---- | ---- |
|
||||||
## Error Responses
|
| 400 | `{"error": "invalid collection id"}` | malformed id |
|
||||||
|
| 500 | `{"error": "..."}` | unexpected server error |
|
||||||
| Code | Description |
|
|
||||||
| ---- | ----------------------- |
|
|
||||||
| 401 | Authentication required |
|
|
||||||
| 404 | Collection not found |
|
|
||||||
| 500 | Internal server error |
|
|
||||||
|
|
||||||
## Try It Out
|
|
||||||
@@ -1,54 +1,74 @@
|
|||||||
|
---
|
||||||
|
method: GET
|
||||||
|
path: /api/collections/:id
|
||||||
|
auth: jwt
|
||||||
|
area: collections
|
||||||
|
---
|
||||||
|
<!-- source: internal/handlers/collections.go:243 CollectionHandler.GetCollection -->
|
||||||
|
|
||||||
# Get Collection
|
# 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}`
|
## Example request
|
||||||
**Auth**: Required
|
|
||||||
**Content-Type**: `application/json`
|
|
||||||
|
|
||||||
## Path Parameters
|
```text
|
||||||
|
GET /api/collections/550e8400-e29b-41d4-a716-446655440000
|
||||||
|
```
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
## Example response
|
||||||
| --------- | ------------- | -------- | --------------- |
|
|
||||||
| id | string (UUID) | Yes | Collection UUID |
|
|
||||||
|
|
||||||
## Query Parameters
|
```json example-response
|
||||||
|
|
||||||
| 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
|
|
||||||
{
|
{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"name": "Science Fiction",
|
"user_id": "8f14e45f-ceea-467f-a429-0b21bfc0b1a2",
|
||||||
"description": "My sci-fi collection",
|
"name": "To Read",
|
||||||
"color": "#FF5733",
|
"description": "Books I want to read soon",
|
||||||
"icon": "🚀",
|
"color": "#00FF00",
|
||||||
"books": [
|
"icon": "📖",
|
||||||
{
|
"auto_assign_rules": null,
|
||||||
"media_item_id": "660e8400-e29b-41d4-a716-446655440000",
|
"view_settings": { "kobo": { "view_mode": "grid" } },
|
||||||
"title": "Foundation",
|
"books": [],
|
||||||
"author": "Isaac Asimov",
|
"book_count": 0,
|
||||||
"cover_image_path": "/covers/foundation.jpg"
|
"is_smart": false,
|
||||||
}
|
"up_next": [],
|
||||||
],
|
"created_at": "2026-10-24 13:57:01.81234 +0000 UTC"
|
||||||
"auto_assign_rules": [],
|
|
||||||
"view_settings": {},
|
|
||||||
"created_at": "2026-02-01T10:00:00Z"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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 |
|
## Query parameters
|
||||||
| ---- | ----------------------- |
|
|
||||||
| 401 | Authentication required |
|
|
||||||
| 404 | Collection not found |
|
|
||||||
| 500 | Internal server error |
|
|
||||||
|
|
||||||
## 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 |
|
||||||
@@ -1,57 +1,77 @@
|
|||||||
|
---
|
||||||
|
method: GET
|
||||||
|
path: /api/collections
|
||||||
|
auth: jwt
|
||||||
|
area: collections
|
||||||
|
---
|
||||||
|
<!-- source: internal/handlers/collections.go:153 CollectionHandler.GetCollections -->
|
||||||
|
|
||||||
# List 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`
|
## Example request
|
||||||
**Auth**: Required
|
|
||||||
**Content-Type**: `application/json`
|
|
||||||
|
|
||||||
## Query Parameters
|
```text
|
||||||
|
GET /api/collections?include_auto=true&sort_by=name
|
||||||
|
```
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
## Example response
|
||||||
| --------- | ------- | -------- | --------------------------------------------- |
|
|
||||||
| limit | integer | No | Number of collections to return (default: 50) |
|
|
||||||
| offset | integer | No | Number of collections to skip (default: 0) |
|
|
||||||
|
|
||||||
## Response (200 OK)
|
```json example-response
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
{
|
||||||
"collections": [
|
"collections": [
|
||||||
{
|
{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"name": "Science Fiction",
|
"name": "To Read",
|
||||||
"description": "My sci-fi collection",
|
"description": "Books I want to read soon",
|
||||||
"color": "#FF5733",
|
"color": "#00FF00",
|
||||||
"icon": "🚀",
|
"icon": "📖",
|
||||||
"auto_assign_rules": [
|
"auto_assign_rules": null,
|
||||||
{
|
"created_at": "2026-10-24 13:57:01.81234 +0000 UTC",
|
||||||
"field": "genre",
|
"book_count": 12,
|
||||||
"operator": "equals",
|
"is_smart": false
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"total": 1
|
"total": 1
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Error Responses
|
`created_at` uses Go's `time.String()` format, not RFC3339.
|
||||||
|
|
||||||
| Code | Description |
|
## Query parameters
|
||||||
| ---- | ----------------------- |
|
|
||||||
| 401 | Authentication required |
|
|
||||||
| 500 | Internal server error |
|
|
||||||
|
|
||||||
## 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 |
|
||||||
@@ -1,63 +1,70 @@
|
|||||||
|
---
|
||||||
|
method: PUT
|
||||||
|
path: /api/collections/:id
|
||||||
|
auth: jwt
|
||||||
|
area: collections
|
||||||
|
---
|
||||||
|
<!-- source: internal/handlers/collections.go:399 CollectionHandler.UpdateCollection -->
|
||||||
|
|
||||||
# Update Collection
|
# 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}`
|
The playlist toggles `hide_finished` / `show_up_next` are accepted as
|
||||||
**Auth**: Required
|
top-level single-element arrays (an htmx form artifact — the LAST value wins)
|
||||||
**Content-Type**: `application/json`
|
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 |
|
```json example-request
|
||||||
| --------- | ------------- | -------- | --------------- |
|
|
||||||
| 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
|
|
||||||
{
|
{
|
||||||
"name": "Sci-Fi Favorites",
|
"name": "To Read (2026)",
|
||||||
"description": "Updated description",
|
"description": "Updated description",
|
||||||
"color": "#7aa2f7",
|
"color": "#33CCFF",
|
||||||
"icon": "🌟"
|
"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",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"name": "Sci-Fi Favorites",
|
"user_id": "8f14e45f-ceea-467f-a429-0b21bfc0b1a2",
|
||||||
|
"name": "To Read (2026)",
|
||||||
"description": "Updated description",
|
"description": "Updated description",
|
||||||
"color": "#7aa2f7",
|
"color": "#33CCFF",
|
||||||
"icon": "🌟",
|
"icon": "📚",
|
||||||
"auto_assign_rules": [],
|
"auto_assign_rules": null,
|
||||||
"view_settings": {},
|
"view_settings": { "hide_finished": true, "show_up_next": true },
|
||||||
"created_at": "2026-02-01T10:00:00Z"
|
"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 |
|
## Request fields
|
||||||
| ---- | ----------------------------------- |
|
|
||||||
| 400 | Invalid request (validation failed) |
|
|
||||||
| 401 | Authentication required |
|
|
||||||
| 404 | Collection not found |
|
|
||||||
| 500 | Internal server error |
|
|
||||||
|
|
||||||
## 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 |
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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": {},
|
||||||
|
}
|
||||||
@@ -75,6 +75,30 @@ type UpdateDeviceMappingRequest struct {
|
|||||||
SyncDirection string `json:"sync_direction" validate:"required,oneof=bidirectional book_to_device device_to_book none"`
|
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 {
|
type SectionData struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
CollectionID string `json:"collection_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()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
type CollectionResponse struct {
|
response := make([]CollectionSummary, 0, len(collections))
|
||||||
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))
|
|
||||||
for _, col := range collections {
|
for _, col := range collections {
|
||||||
isSmart := h.collectionService.IsSmartCollection(col.AutoAssignRules)
|
isSmart := h.collectionService.IsSmartCollection(col.AutoAssignRules)
|
||||||
if !includeAuto && isSmart {
|
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,
|
ID: col.ID.Bytes,
|
||||||
Name: col.Name,
|
Name: col.Name,
|
||||||
Description: utils.PgTextString(col.Description),
|
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()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
type CollectionResponse struct {
|
response := make([]BookCollectionSummary, 0, len(collections))
|
||||||
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))
|
|
||||||
for _, col := range collections {
|
for _, col := range collections {
|
||||||
response = append(response, CollectionResponse{
|
response = append(response, BookCollectionSummary{
|
||||||
ID: col.ID.Bytes,
|
ID: col.ID.Bytes,
|
||||||
Name: col.Name,
|
Name: col.Name,
|
||||||
Description: utils.PgTextString(col.Description),
|
Description: utils.PgTextString(col.Description),
|
||||||
|
|||||||
Reference in new issue
Block a user