package docs import ( "encoding/json" "bookhoard/internal/database" "bookhoard/internal/handlers" "bookhoard/internal/services" "bookhoard/internal/sync" ) // 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 }`), }, // --- Beam batch 2: conflicts + queue backfill --- "GET /api/conflicts": { Response: handlers.ConflictListResponse{}, }, "GET /api/conflicts/:id": { Response: handlers.ConflictDetailResponse{}, }, "DELETE /api/conflicts/:id": {}, "POST /api/conflicts/:id/resolve": { Request: handlers.ConflictResolutionRequest{}, Response: handlers.ConflictResolveResponse{}, }, "POST /api/conflicts/dismiss-all": { // deleted is an INT count of dismissed rows. Response: json.RawMessage(`{"deleted": 0}`), }, "POST /api/conflicts/bulk-resolve": { Request: handlers.BulkResolveRequest{}, Response: handlers.BulkResolveResponse{}, }, "POST /api/conflicts/bulk-dismiss": { // Inline anonymous request struct {conflict_ids} — pinned by sample. Request: json.RawMessage(`{"conflict_ids": []}`), Response: handlers.BulkResolveResponse{}, }, // NOTE: queue param names are :device_id / :item_id in the router — // first registry draft used :id and the gate caught it. "GET /api/queue/devices/:device_id/stats": { Response: handlers.QueueStatsResponse{}, }, "GET /api/queue/devices/:device_id/items": { Response: json.RawMessage(`{"items": [{}], "count": 0}`), }, "GET /api/queue/items": { Response: json.RawMessage(`{"items": [{}], "count": 0}`), }, "POST /api/queue/items/:item_id/retry": { Response: json.RawMessage(`{ "message": "", "item_id": "", "status": "", "attempts": 0, "max_attempts": 0 }`), }, "DELETE /api/queue/items/:item_id": {}, "DELETE /api/queue/devices/:device_id/clear": { Response: json.RawMessage(`{"message": ""}`), }, // --- Beam batch 3: auth core + devices core --- "POST /api/auth/login": { Request: handlers.LoginRequest{}, Response: handlers.AuthResponse{}, }, "POST /api/auth/register": { Request: handlers.RegisterRequest{}, Response: handlers.AuthResponse{}, }, "POST /api/auth/refresh": { Request: handlers.RefreshTokenRequest{}, Response: handlers.RefreshTokenResponse{}, }, "POST /api/auth/logout": { Response: json.RawMessage(`{"message": ""}`), }, "GET /api/auth/profile": { Response: handlers.UserProfile{}, }, "GET /api/devices": { Response: handlers.DeviceListResponse{}, }, "PUT /api/devices/:id": { Request: handlers.DeviceUpdateRequest{}, Response: json.RawMessage(`{ "device_updated": false, "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": "" } }`), }, "DELETE /api/devices/:id": {}, "GET /api/devices/:id/shelves": { Response: json.RawMessage(`{ "status": "", "device_id": "", "shelf_name": "", "books": [{}], "total": 0 }`), }, "POST /api/devices/:id/shelves": { Request: handlers.AddToShelfRequest{}, Response: json.RawMessage(`{ "status": "", "added_count": 0, "shelf_name": "", "total_count": 0 }`), }, "DELETE /api/devices/:id/shelves": { Response: json.RawMessage(`{"status": "", "message": ""}`), }, "DELETE /api/devices/:id/shelves/clear": { Response: json.RawMessage(`{"status": "", "message": ""}`), }, "GET /api/devices/:id/sidecar": { Response: handlers.SidecarConfig{}, }, "POST /api/devices/register": { Request: handlers.DeviceRegistrationRequest{}, Response: handlers.DeviceRegistrationResponse{}, }, "POST /api/devices/register/status": { Request: handlers.DeviceAuthStatusRequest{}, Response: handlers.DeviceAuthStatusResponse{}, }, // --- Beam batch 4: media-items core --- "GET /api/media-items": { Response: json.RawMessage(`{"data": [{}]}`), }, "GET /api/media-items/:id": { // Hand-built ~44-key map — pinned verbatim from the handler. Response: json.RawMessage(`{ "id": "", "library_id": "", "title": "", "subtitle": "", "author": "", "authors": [], "isbn": "", "description": "", "file_path": "", "file_size": 0, "mime_type": "", "cover_image_path": "", "series": "", "series_number": 0, "tags": [], "asin": "", "date_published": "", "publisher": "", "publishers": [], "explicit": false, "contributors": [], "language": "", "edition": "", "page_count": 0, "genre": "", "created_at": "", "updated_at": "", "format_group": "", "format_mimetype": "", "narrators": [], "abridged": false, "manga_type": "", "reading_direction": "", "series_count": 0, "volume": 0, "imprint": "", "age_rating": "", "web_url": "", "metadata_notes": "", "community_rating": 0, "story_arc": "", "is_black_and_white": false, "alternate_info": "", "scan_information": "", "summary": "", "library_type_name": "" }`), }, "POST /api/media-items": { Request: handlers.CreateMediaItemRequest{}, Response: database.MediaItems{}, }, "PUT /api/media-items/:id": { Request: handlers.UpdateMediaItemRequest{}, Response: database.MediaItems{}, }, "DELETE /api/media-items/:id": {}, "GET /api/media-items/search": { Response: []database.SearchMediaItemsRow{}, }, "POST /api/media-items/:id/rating": { // NOTE: rating scale is 1-10 int (odd = half-star). Request: json.RawMessage(`{"rating": 0}`), Response: database.MediaRatings{}, }, "PUT /api/media-items/:id/rating": { Request: json.RawMessage(`{"rating": 0}`), Response: database.MediaRatings{}, }, "GET /api/media-items/:id/rating": { Response: database.MediaRatings{}, // absent -> {"rating": null} }, "DELETE /api/media-items/:id/rating": { Response: json.RawMessage(`{"message": ""}`), }, "GET /api/media-items/:id/progress": { Response: json.RawMessage(`{ "id": null, "media_item_id": null, "user_id": null, "current_page": 0, "total_pages": null, "last_read_at": null, "percentage": null, "character_offset": null, "epubcfi": null, "chapter": null, "chapter_progress": null, "format_group": null, "total_characters": null, "chapter_count": null, "last_sync_device": null, "last_sync_source": null, "last_sync_timestamp": null, "audio_offset_seconds": null, "reading_mode": null, "viewport_x": null, "viewport_y": null, "zoom_level": null, "scroll_position_x": null, "scroll_position_y": null, "panel_number": null, "css_selector": null, "anchor_href": null, "char_offset": null, "context_text": null }`), }, "PUT /api/media-items/:id/progress": { Request: sync.SaveProgressRequest{}, Response: database.ReadingProgress{}, }, // --- Beam batch 5: libraries --- "POST /api/libraries": { Request: handlers.CreateLibraryRequest{}, Response: database.Libraries{}, }, "GET /api/libraries/:id": { Response: database.Libraries{}, }, "PUT /api/libraries/:id": { Request: handlers.UpdateLibraryRequest{}, Response: database.Libraries{}, }, "DELETE /api/libraries/:id": {}, "POST /api/libraries/:id/folders": { Request: handlers.AddLibraryFolderRequest{}, Response: database.LibraryFolders{}, }, "DELETE /api/libraries/:id/folders": { // Handler REUSES AddLibraryFolderRequest ({folder_path}) on delete. Request: handlers.AddLibraryFolderRequest{}, }, "GET /api/libraries/visibility": { Response: []database.GetUserVisibleLibrariesRow{}, }, "POST /api/libraries/visibility": { Request: handlers.SetLibraryVisibilityRequest{}, Response: database.LibraryVisibility{}, }, "GET /api/libraries/browse": { Response: json.RawMessage(`{ "current_path": "", "parent_path": "", "directories": [{}] }`), }, "GET /api/libraries/:id/stats": { Response: services.LibraryStats{}, }, "GET /api/libraries/:id/media-items": { // Delegates to ListMediaItems with library_id injected. Response: json.RawMessage(`{"data": [{}]}`), }, // --- Beam batch 6: kobo/koreader (device-token) + collections missing --- "POST /api/sync/kobo/:token/markup": { Request: AnyJSONObject{}, // kobo protocol payload (ReadingSync/BookmarkSync) Response: handlers.KoboSyncStatus{}, // kobo firmware naming: Status/MarkupsSynced }, "POST /api/sync/kobo/:token/bookmark": { Request: AnyJSONObject{}, // kobo bookmark payload Response: handlers.KoboSyncStatus{}, }, "POST /api/sync/kobo/:token/v1/analytics/gettests": { Request: AnyJSONObject{}, // kobo analytics POST body // A/B test payload — dynamic keys (tests/features/configuration). Response: AnyJSONObject{}, }, "GET /api/sync/kobo/:token/v1/initialization": { // NOTE: returns the library sync payload, not the init response. Response: handlers.KoboLibraryResponse{}, }, "POST /api/sync/kobo/:token/sync-from-server": { Request: AnyJSONObject{}, // kobo sync POST body Response: handlers.KoboServerSyncResponse{}, }, "POST /api/sync/koreader/progress": { Request: handlers.KOReaderProgressRequest{}, Response: handlers.KOReaderSyncResponse{}, }, "GET /api/sync/koreader/resolve": { Response: handlers.KOReaderResolveResponse{}, }, "GET /api/sync/koreader/metadata/:uuid": { // Hand-built map in the handler. Response: AnyJSONObject{}, }, "GET /api/sync/koreader/library": { Response: handlers.KOReaderLibraryResponse{}, }, "POST /api/sync/koreader/bookmarks": { Request: AnyJSONObject{}, // {highlights: [...]} inline struct in handler Response: AnyJSONObject{}, // hand-built map in the handler }, "PUT /api/collections/:id/items/order": { // Inline request {item_ids} — pinned by sample. Request: json.RawMessage(`{"item_ids": []}`), Response: json.RawMessage(`{"status": "reordered"}`), }, "PUT /api/collections/:id/rules/:ruleId": { // Inline anonymous request struct — pinned by sample. Request: json.RawMessage(`{"rule": {}}`), Response: json.RawMessage(`{ "id": "", "collection_id": "", "field": "", "operator": "", "value": "", "priority": 0, "enabled": false, "is_smart": false }`), }, }