Beam drafted 15 frontmatter'd docs (saved-filters CRUD, dashboard
sections/preferences/restore, system config/settings, series list + books);
Claude verified shapes against handlers and pinned the registry:
- saved-filters: SavedFilterResponse pinned by type (RFC3339 timestamps here,
unlike most of the API); create/update share CreateSavedFilterRequest
(resource_type oneof media-items|collections|devices)
- dashboard: preferences GET pin {hidden_collections, collection_order,
items_per_section}; PUT returns full UserDashboardPreferences row; sections
wrapper {sections}
- system: GET+PUT /api/system/config are admin-defined key/value maps — new
AnyJSONObject sentinel (gate shape-checks without key comparison; request
side too). UpdateSystemConfiguration returns {status, message} — verified,
not guessed. GET /api/system/settings returns []SettingEntry.
- series: {series[{name, library_id, book_count, total_in_series,
cover_paths, last_entry_at}], total, limit, offset}; books by
?name=&library_id= with family-lens 403
Dedupe: deleted dashboard.md, system/config.md, system/settings.md — each
covered the same endpoints as Beam's per-endpoint files (content merged by
the gate-verified shapes; old prose recoverable from git).
Beam clarifications confirmed: scanner routes are WO5 (docs exist);
progress/books-match are WO5.
328 lines
11 KiB
Go
328 lines
11 KiB
Go
package docs
|
|
|
|
import (
|
|
"encoding/json"
|
|
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/handlers"
|
|
"bookhoard/internal/services"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// AnyJSONObject marks responses whose keys are dynamic (admin-defined config
|
|
// maps etc.): the gate then only verifies the example is a JSON object.
|
|
type AnyJSONObject struct{}
|
|
|
|
// 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": {},
|
|
// --- devices (batch 1) ---
|
|
"GET /api/devices/:id": {
|
|
Response: handlers.DeviceInfo{},
|
|
},
|
|
"PUT /api/devices/:id/regenerate-token": {
|
|
Response: json.RawMessage(`{
|
|
"message": "", "auth_token": "",
|
|
"device": {
|
|
"id": "", "device_name": "", "device_type": "",
|
|
"last_sync": null, "last_seen": null,
|
|
"sync_enabled": false, "auto_sync": false,
|
|
"sync_frequency_minutes": 0, "created_at": "",
|
|
"device_metadata": null, "auth_token": ""
|
|
},
|
|
"sync_urls": {}
|
|
}`),
|
|
},
|
|
"GET /api/devices/:id/collections": {
|
|
Response: struct {
|
|
DeviceID string `json:"device_id"`
|
|
Mappings []handlers.DeviceMappingSummary `json:"mappings"`
|
|
Total int `json:"total"`
|
|
}{},
|
|
},
|
|
"PUT /api/devices/:id/collections/:collectionId": {
|
|
Request: handlers.UpdateDeviceMappingRequest{},
|
|
Response: json.RawMessage(`{
|
|
"id": "", "collection_id": "", "device_id": "",
|
|
"device_shelf_name": "", "sync_direction": "", "created_at": ""
|
|
}`),
|
|
},
|
|
"GET /api/devices/:id/sidecar/download": {
|
|
Response: json.RawMessage(`{
|
|
"version": "", "bookhoard": {}, "books": {},
|
|
"collections": [], "opds_enabled": false,
|
|
"sidecar_enabled": false, "last_updated": ""
|
|
}`),
|
|
},
|
|
"POST /api/devices/:deviceId/sync/link-book": {
|
|
Request: services.LinkBookRequest{},
|
|
Response: json.RawMessage(`{
|
|
"status": "",
|
|
"device_file_alias": {
|
|
"id": "", "media_item_id": "", "device_id": "",
|
|
"file_path": "", "file_sha256": "",
|
|
"confidence_score": 0.0
|
|
}
|
|
}`),
|
|
},
|
|
|
|
// --- libraries (batch 2) ---
|
|
"GET /api/libraries/types": {
|
|
Response: []database.LibraryTypes{},
|
|
},
|
|
"GET /api/libraries": {
|
|
Response: struct {
|
|
Data []database.ListLibrariesRow `json:"data"`
|
|
}{},
|
|
},
|
|
"GET /api/libraries/:id/folders": {
|
|
Response: []database.LibraryFolders{},
|
|
},
|
|
"GET /api/libraries/:id/issues/list": {
|
|
Response: []handlers.ProcessingIssueResponse{},
|
|
},
|
|
"GET /api/libraries/:id/issues/stats": {
|
|
Response: handlers.ProcessingIssueStats{},
|
|
},
|
|
"POST /api/libraries/:id/issues/:issueId/:mediaItemId/resolve": {
|
|
Response: json.RawMessage(`{"success": false, "message": ""}`),
|
|
},
|
|
"DELETE /api/libraries/:id/issues/:issueId": {
|
|
Response: json.RawMessage(`{"success": false, "message": ""}`),
|
|
},
|
|
"POST /api/libraries/:id/scan": {
|
|
Request: nil, // library_id injected from the path; no body used
|
|
Response: json.RawMessage(`{"message": "", "job_id": "", "status": ""}`),
|
|
},
|
|
"GET /api/libraries/scan-settings": {
|
|
Response: handlers.ScanSettingsResponse{},
|
|
},
|
|
"PUT /api/libraries/scan-settings": {
|
|
Request: handlers.UpdateScanSettingsRequest{},
|
|
// RawMessage pin (not the struct): the update handler ALWAYS sets
|
|
// Message, but the struct's omitempty would drop it from a
|
|
// zero-value marshal and the gate would reject correct docs.
|
|
Response: json.RawMessage(`{
|
|
"scan_poll_interval_seconds": 0,
|
|
"auto_scan_enabled": false,
|
|
"message": ""
|
|
}`),
|
|
},
|
|
|
|
// --- media-items batch 3a: reader-adjacent CRUD ---
|
|
"GET /api/media-items/:id/bookmarks": {
|
|
Response: []database.MediaBookmarks{},
|
|
},
|
|
"POST /api/media-items/:id/bookmarks": {
|
|
Request: handlers.CreateMediaBookmarkRequest{},
|
|
Response: database.MediaBookmarks{},
|
|
},
|
|
"PUT /api/media-items/:id/bookmarks/:bookmarkId": {
|
|
Request: handlers.UpdateMediaBookmarkRequest{},
|
|
Response: database.MediaBookmarks{},
|
|
},
|
|
"DELETE /api/media-items/:id/bookmarks/:bookmarkId": {},
|
|
// Review: the upsert binds an inline anonymous struct {review}; pinned
|
|
// by RawMessage. Empty review string = delete, returns {"review": null}.
|
|
"POST /api/media-items/:id/review": {
|
|
Request: json.RawMessage(`{"review": ""}`),
|
|
Response: database.MediaReviews{},
|
|
},
|
|
"PUT /api/media-items/:id/review": {
|
|
Request: json.RawMessage(`{"review": ""}`),
|
|
Response: database.MediaReviews{},
|
|
},
|
|
"GET /api/media-items/:id/review": {
|
|
Response: database.MediaReviews{},
|
|
},
|
|
"DELETE /api/media-items/:id/review": {
|
|
Response: json.RawMessage(`{"message": ""}`),
|
|
},
|
|
"GET /api/media-items/:id/notes/:noteId": {
|
|
Response: database.MediaNotes{},
|
|
},
|
|
"GET /api/media-items/:id/highlights/:highlightId": {
|
|
Response: database.MediaHighlights{},
|
|
},
|
|
"POST /api/media-items/:id/annotations/:annotationId/restore": {
|
|
Request: nil, // kind via ?annotation_type= or optional body
|
|
Response: json.RawMessage(`{"restored": false}`),
|
|
},
|
|
"DELETE /api/media-items/:id/annotations/:annotationId": {
|
|
Request: nil, // kind via required ?annotation_type=
|
|
Response: json.RawMessage(`{"purged": false}`),
|
|
},
|
|
|
|
// --- media-items batch 3b: management ---
|
|
"GET /api/media-items/:id/download": {
|
|
// Serves raw file bytes, not JSON — no examples by design.
|
|
},
|
|
"PUT /api/media-items/:id/audio-speed": {
|
|
Request: json.RawMessage(`{"speed": 0}`),
|
|
Response: json.RawMessage(`{"playback_speed": 0}`),
|
|
},
|
|
"POST /api/media-items/:id/rescan": {
|
|
// Returns the FULL media_items row (70+ columns); pin is an abridged
|
|
// key subset — the doc example declares the same subset and says so.
|
|
Response: json.RawMessage(`{
|
|
"id": "", "library_id": "", "title": "", "author": "",
|
|
"file_path": "", "mime_type": "", "scan_status": "", "updated_at": ""
|
|
}`),
|
|
},
|
|
"POST /api/media-items/purge-archived": {
|
|
Response: json.RawMessage(`{"purged": 0}`),
|
|
},
|
|
"POST /api/media-items/:id/unarchive": {},
|
|
"POST /api/media-items/:id/merge": {
|
|
Request: json.RawMessage(`{"target_id": ""}`),
|
|
Response: json.RawMessage(`{"message": ""}`),
|
|
},
|
|
"GET /api/media-items/facets": {
|
|
Response: json.RawMessage(`{"facet": "", "values": [{}]}`),
|
|
},
|
|
"GET /api/media-items/filter-count": {
|
|
Response: json.RawMessage(`{"count": 0}`),
|
|
},
|
|
|
|
// --- analytics + jobs (batch 4) ---
|
|
"GET /api/analytics/device-usage": {
|
|
Response: handlers.DeviceUsageResponse{},
|
|
},
|
|
"GET /api/analytics/popular-books": {
|
|
Response: handlers.PopularBooksResponse{},
|
|
},
|
|
"POST /api/jobs": {
|
|
// Inline anonymous request struct {type, params} — pinned by sample.
|
|
Request: json.RawMessage(`{"type": "", "params": {}}`),
|
|
Response: json.RawMessage(`{"message": "", "job_id": "", "type": "", "status": ""}`),
|
|
},
|
|
"GET /api/jobs/:jobId": {
|
|
Response: services.JobResult{},
|
|
},
|
|
|
|
// --- WO3 tail batch: saved-filters, dashboard, system, series ---
|
|
"GET /api/saved-filters": {
|
|
Response: []handlers.SavedFilterResponse{},
|
|
},
|
|
"GET /api/saved-filters/:id": {
|
|
Response: handlers.SavedFilterResponse{},
|
|
},
|
|
"POST /api/saved-filters": {
|
|
Request: handlers.CreateSavedFilterRequest{},
|
|
Response: handlers.SavedFilterResponse{},
|
|
},
|
|
"PUT /api/saved-filters/:id": {
|
|
Request: handlers.CreateSavedFilterRequest{}, // same struct reused for update
|
|
Response: handlers.SavedFilterResponse{},
|
|
},
|
|
"DELETE /api/saved-filters/:id": {},
|
|
"GET /api/dashboard/sections": {
|
|
Response: json.RawMessage(`{"sections": []}`),
|
|
},
|
|
"GET /api/dashboard/preferences": {
|
|
Response: json.RawMessage(`{
|
|
"hidden_collections": [], "collection_order": [],
|
|
"items_per_section": 0
|
|
}`),
|
|
},
|
|
"PUT /api/dashboard/preferences": {
|
|
Request: json.RawMessage(`{
|
|
"library_id": null, "hidden_collections": [],
|
|
"collection_order": [], "items_per_section": 0
|
|
}`),
|
|
Response: database.UserDashboardPreferences{},
|
|
},
|
|
"POST /api/dashboard/restore-system-collection": {
|
|
Request: json.RawMessage(`{"collection_name": "", "reset_type": ""}`),
|
|
Response: json.RawMessage(`{"message": ""}`),
|
|
},
|
|
"GET /api/system/config": {
|
|
// Admin-defined key/value map — arbitrary keys.
|
|
Response: AnyJSONObject{},
|
|
},
|
|
"PUT /api/system/config": {
|
|
Request: AnyJSONObject{}, // map[string]string of key -> new value
|
|
Response: json.RawMessage(`{"status": "", "message": ""}`),
|
|
},
|
|
"GET /api/system/settings": {
|
|
Response: []database.SettingEntry{},
|
|
},
|
|
"PUT /api/system/settings": {
|
|
Request: handlers.UpdateSettingRequest{},
|
|
Response: handlers.UpdateSettingResponse{},
|
|
},
|
|
"GET /api/series": {
|
|
Response: json.RawMessage(`{
|
|
"series": [{
|
|
"name": "", "library_id": "", "book_count": 0,
|
|
"total_in_series": 0, "cover_paths": [], "last_entry_at": ""
|
|
}],
|
|
"total": 0, "limit": 0, "offset": 0
|
|
}`),
|
|
},
|
|
"GET /api/series/books": {
|
|
Response: json.RawMessage(`{
|
|
"name": "", "library_id": "", "books": [], "total": 0
|
|
}`),
|
|
},
|
|
}
|