docs(libraries): Work Order 3 batch 2 — 10 missing endpoints + array-response gate support
New frontmatter'd docs with handler-compatible shapes:
- get_library_types.md (public; []LibraryTypes)
- list_libraries.md (admin; {data: ListLibrariesRow})
- get_library_folders.md (admin; []LibraryFolders)
- list/get stats/resolve/delete processing issues (admin; family-lens 403s
documented)
- scan_library.md (202 + 409 coalescing with active_job_id)
- get/update scan-settings (ScanSettingsResponse)
Gate improvements forced by this batch:
- Array responses: registered slice -> doc example must be a JSON array; key
set compared against the element type
- RawMessage pins may now be arrays
- update scan-settings uses a RawMessage pin: handler always sets Message but
the struct's omitempty would reject correct docs via zero-value marshal
Registry: sqlc models pinned directly (LibraryTypes, LibraryFolders,
ListLibrariesRow — json tags verified in the generated code).
This commit is contained in:
1 parent
c4b62af213
commit
85cdaf7196
12 files changed
+423
-11
No files matched your search
@@ -0,0 +1,29 @@
|
||||
---
|
||||
method: DELETE
|
||||
path: /api/libraries/:id/issues/:issueId
|
||||
auth: jwt+admin
|
||||
area: libraries
|
||||
---
|
||||
<!-- source: internal/handlers/processing_issues.go:183 ProcessingIssuesHandler.DeleteProcessingIssue -->
|
||||
|
||||
# Delete Processing Issue
|
||||
|
||||
Permanently deletes a processing issue record (distinct from resolving it).
|
||||
|
||||
## Example response
|
||||
|
||||
```json example-response
|
||||
{
|
||||
"success": true,
|
||||
"message": "Issue deleted"
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Body | When |
|
||||
| ------ | ---- | ---- |
|
||||
| 400 | `{"error": "Invalid issue ID"}` | malformed id |
|
||||
| 403 | — | issue's library not in requester's visible set |
|
||||
| 404 | — | no such issue |
|
||||
| 500 | `{"error": "Failed to delete issue"}` | unexpected server error |
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
method: GET
|
||||
path: /api/libraries/:id/folders
|
||||
auth: jwt+admin
|
||||
area: libraries
|
||||
---
|
||||
<!-- source: internal/handlers/library.go:216 LibraryHandler.GetLibraryFolders -->
|
||||
|
||||
# Get Library Folders
|
||||
|
||||
Lists the filesystem folders configured for a library.
|
||||
|
||||
## Example response
|
||||
|
||||
```json example-response
|
||||
[
|
||||
{
|
||||
"id": "aa0e8400-e29b-41d4-a716-446655440000",
|
||||
"library_id": "990e8400-e29b-41d4-a716-446655440000",
|
||||
"folder_path": "/books/manga",
|
||||
"created_at": "2026-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Body | When |
|
||||
| ------ | ---- | ---- |
|
||||
| 400 | `{"error": "invalid library id"}` | malformed id |
|
||||
| 401 / 403 | — | missing JWT / not an admin |
|
||||
| 500 | `{"error": "..."}` | unexpected server error |
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
method: GET
|
||||
path: /api/libraries/types
|
||||
auth: none
|
||||
area: libraries
|
||||
---
|
||||
<!-- source: internal/handlers/library.go:51 LibraryHandler.GetLibraryTypes -->
|
||||
|
||||
# Get Library Types
|
||||
|
||||
Public endpoint: lists the library types the server supports (ebooks, comics,
|
||||
audiobooks, …) with their allowed file extensions. Used by the create-library
|
||||
form before login is required.
|
||||
|
||||
## Example response
|
||||
|
||||
```json example-response
|
||||
[
|
||||
{
|
||||
"id": "880e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "ebooks",
|
||||
"description": "Electronic books",
|
||||
"allowed_extensions": [".epub", ".pdf", ".mobi"],
|
||||
"created_at": "2026-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Body | When |
|
||||
| ------ | ---- | ---- |
|
||||
| 500 | `{"error": "..."}` | unexpected server error |
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
method: GET
|
||||
path: /api/libraries/:id/issues/stats
|
||||
auth: jwt+admin
|
||||
area: libraries
|
||||
---
|
||||
<!-- source: internal/handlers/processing_issues.go:128 ProcessingIssuesHandler.GetProcessingIssueStats -->
|
||||
|
||||
# Get Processing Issue Stats
|
||||
|
||||
Severity counts for a library's processing issues.
|
||||
|
||||
## Example response
|
||||
|
||||
```json example-response
|
||||
{
|
||||
"error_count": 2,
|
||||
"warning_count": 5,
|
||||
"info_count": 1
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Body | When |
|
||||
| ------ | ---- | ---- |
|
||||
| 403 | — | library not in requester's visible set (family lens) |
|
||||
| 404 | — | no such library |
|
||||
| 500 | `{"error": "Failed to fetch stats"}` | unexpected server error |
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
method: GET
|
||||
path: /api/libraries/scan-settings
|
||||
auth: jwt+admin
|
||||
area: libraries
|
||||
---
|
||||
<!-- source: internal/handlers/system_settings.go:253 SystemSettingsHandler.GetScanSettings -->
|
||||
|
||||
# Get Scan Settings
|
||||
|
||||
Returns the global scan poll interval and auto-scan flag (system settings,
|
||||
admin-scoped). Defaults to 1800s / enabled when unset.
|
||||
|
||||
## Example response
|
||||
|
||||
```json example-response
|
||||
{
|
||||
"scan_poll_interval_seconds": 1800,
|
||||
"auto_scan_enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
`message` appears only in the update response (`omitempty`).
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Body | When |
|
||||
| ------ | ---- | ---- |
|
||||
| 401 / 403 | — | missing JWT / not an admin |
|
||||
| 500 | `{"error": "..."}` | unexpected server error |
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
method: GET
|
||||
path: /api/libraries
|
||||
auth: jwt+admin
|
||||
area: libraries
|
||||
---
|
||||
<!-- source: internal/handlers/library.go:102 LibraryHandler.ListLibraries -->
|
||||
|
||||
# List Libraries (Admin)
|
||||
|
||||
Lists **all** libraries regardless of per-user visibility. (Non-admin users
|
||||
see their subset via `GET /api/libraries/visibility`.)
|
||||
|
||||
## Example response
|
||||
|
||||
```json example-response
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "990e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "Manga",
|
||||
"description": null,
|
||||
"library_type_id": "880e8400-e29b-41d4-a716-446655440000",
|
||||
"created_by_admin_id": "92413c3a-e1b8-4fff-b3f9-95c74478d19a",
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-01T00:00:00Z",
|
||||
"type_name": "manga",
|
||||
"type_description": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Nullable fields (`description`, `type_description`) are JSON `null` when
|
||||
unset. `type_name`/`type_description` come from the joined library type.
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Body | When |
|
||||
| ------ | ---- | ---- |
|
||||
| 401 | — | missing/invalid JWT |
|
||||
| 403 | — | not an admin |
|
||||
| 500 | `{"error": "..."}` | unexpected server error |
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
method: GET
|
||||
path: /api/libraries/:id/issues/list
|
||||
auth: jwt+admin
|
||||
area: libraries
|
||||
---
|
||||
<!-- source: internal/handlers/processing_issues.go:83 ProcessingIssuesHandler.ListProcessingIssues -->
|
||||
|
||||
# List Processing Issues
|
||||
|
||||
Lists scan/processing issues for a library (import failures, metadata
|
||||
problems). Row-less import failures have no media item — their
|
||||
`media_item_id` is `""` and `title` falls back to the file path.
|
||||
|
||||
## Example response
|
||||
|
||||
```json example-response
|
||||
[
|
||||
{
|
||||
"id": "bb0e8400-e29b-41d4-a716-446655440000",
|
||||
"media_item_id": "cc0e8400-e29b-41d4-a716-446655440000",
|
||||
"title": "1984",
|
||||
"file_path": "/books/1984.epub",
|
||||
"format_group": "text",
|
||||
"library_type_name": "ebooks",
|
||||
"issue_type": "import_failed",
|
||||
"issue_description": "unsupported format",
|
||||
"severity": "error",
|
||||
"created_at": "2026-10-24T13:57:01Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Body | When |
|
||||
| ------ | ---- | ---- |
|
||||
| 403 | — | library not in requester's visible set (family lens) |
|
||||
| 404 | — | no such library |
|
||||
| 500 | `{"error": "Failed to fetch issues"}` | unexpected server error |
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
method: POST
|
||||
path: /api/libraries/:id/issues/:issueId/:mediaItemId/resolve
|
||||
auth: jwt+admin
|
||||
area: libraries
|
||||
---
|
||||
<!-- source: internal/handlers/processing_issues.go:150 ProcessingIssuesHandler.ResolveProcessingIssue -->
|
||||
|
||||
# Resolve Processing Issue
|
||||
|
||||
Marks one processing issue as resolved. The issue's library must be in the
|
||||
requester's visible set.
|
||||
|
||||
## Example response
|
||||
|
||||
```json example-response
|
||||
{
|
||||
"success": true,
|
||||
"message": "Issue marked as resolved"
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Body | When |
|
||||
| ------ | ---- | ---- |
|
||||
| 400 | `{"error": "Invalid issue ID"}` / `{"error": "Invalid media item ID"}` | malformed path params |
|
||||
| 403 | — | issue's library not in requester's visible set |
|
||||
| 404 | — | no such issue |
|
||||
| 500 | `{"error": "Failed to resolve issue"}` | unexpected server error |
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
method: POST
|
||||
path: /api/libraries/:id/scan
|
||||
auth: jwt+admin
|
||||
area: libraries
|
||||
---
|
||||
<!-- source: internal/router/library.go:43 (wraps handlers/scanner.go:34 Handler.ScanLibrary) -->
|
||||
|
||||
# Scan Library
|
||||
|
||||
Enqueues a full scan of one library's configured folders. The library id comes
|
||||
from the path — no request body is needed (the generic `POST /api/scanner/scan`
|
||||
variant is the one that takes `folder_paths`).
|
||||
|
||||
Scans are **coalesced**: if a scan for this library is already pending or
|
||||
running you get **409** with the active job id instead of a second scan.
|
||||
|
||||
## Example response (202 Accepted)
|
||||
|
||||
```json example-response
|
||||
{
|
||||
"message": "scan job enqueued",
|
||||
"job_id": "dd0e8400-e29b-41d4-a716-446655440000",
|
||||
"status": "pending"
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Body | When |
|
||||
| ------ | ---- | ---- |
|
||||
| 400 | `{"error": "invalid library id"}` / `{"error": "library has no folders configured"}` | malformed id / empty library |
|
||||
| 404 | `{"error": "library not found or has no folders"}` | unknown library |
|
||||
| 409 | `{"error": "a scan for this library is already pending or running", "active_job_id": "..."}` | scan coalescing |
|
||||
| 500 | `{"error": "failed to enqueue scan job: ..."}` | queue failure |
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
method: PUT
|
||||
path: /api/libraries/scan-settings
|
||||
auth: jwt+admin
|
||||
area: libraries
|
||||
---
|
||||
<!-- source: internal/handlers/system_settings.go:210 SystemSettingsHandler.UpdateScanSettings -->
|
||||
|
||||
# Update Scan Settings
|
||||
|
||||
Sets the global scan poll interval and auto-scan flag, then reloads the
|
||||
settings registry so running code picks the new values up.
|
||||
|
||||
## Example request
|
||||
|
||||
```json example-request
|
||||
{
|
||||
"scan_poll_interval_seconds": 3600,
|
||||
"auto_scan_enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
## Example response
|
||||
|
||||
```json example-response
|
||||
{
|
||||
"scan_poll_interval_seconds": 3600,
|
||||
"auto_scan_enabled": true,
|
||||
"message": "scan settings updated successfully"
|
||||
}
|
||||
```
|
||||
|
||||
## Request fields
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
| ----- | ---- | -------- | ----- |
|
||||
| scan_poll_interval_seconds | number | yes | 1–3600 |
|
||||
| auto_scan_enabled | boolean | no | defaults false when omitted |
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Body | When |
|
||||
| ------ | ---- | ---- |
|
||||
| 400 | `{"error": "..."}` | validation failure |
|
||||
| 401 / 403 | — | missing JWT / not an admin |
|
||||
| 404 | `{"error": "system setting not found"}` | settings rows missing from DB |
|
||||
| 500 | `{"error": "..."}` | unexpected server error |
|
||||
@@ -251,8 +251,24 @@ func validateResponseExample(t *testing.T, doc *APIDoc, schema Schema) {
|
||||
return
|
||||
}
|
||||
|
||||
want := responseTopLevelKeys(t, schema.Response)
|
||||
got := topLevelKeys(doc.ExampleResponse)
|
||||
want, isSlice := responseTopLevelKeys(t, schema.Response)
|
||||
|
||||
var got map[string]bool
|
||||
if isSlice {
|
||||
// Array responses: compare the first sample element's keys.
|
||||
var arr []json.RawMessage
|
||||
if err := json.Unmarshal(doc.ExampleResponse, &arr); err != nil {
|
||||
t.Errorf("%s: example-response must be a JSON array for this route (handler returns a slice)", doc.RelPath)
|
||||
return
|
||||
}
|
||||
if len(arr) == 0 {
|
||||
t.Errorf("%s: example-response array needs at least one sample element to check keys", doc.RelPath)
|
||||
return
|
||||
}
|
||||
got = topLevelKeys(arr[0])
|
||||
} else {
|
||||
got = topLevelKeys(doc.ExampleResponse)
|
||||
}
|
||||
|
||||
for _, k := range sortedKeys(want) {
|
||||
if !got[k] {
|
||||
@@ -312,21 +328,25 @@ func topLevelKeys(data []byte) map[string]bool {
|
||||
|
||||
// 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 {
|
||||
// RawMessage pin verbatim. The second return reports that the handler emits a
|
||||
// JSON ARRAY (slice response) — the doc's example must then be an array too.
|
||||
func responseTopLevelKeys(t *testing.T, resp any) (map[string]bool, bool) {
|
||||
t.Helper()
|
||||
if raw, ok := resp.(json.RawMessage); ok {
|
||||
return topLevelKeys(raw)
|
||||
trimmed := bytes.TrimLeft(raw, " \t\r\n")
|
||||
return topLevelKeys(raw), len(trimmed) > 0 && trimmed[0] == '['
|
||||
}
|
||||
rt := reflect.TypeOf(resp)
|
||||
if rt == nil {
|
||||
return nil
|
||||
return nil, false
|
||||
}
|
||||
isSlice := false
|
||||
if rt.Kind() == reflect.Slice {
|
||||
if rt.Elem().Kind() == reflect.Uint8 {
|
||||
return topLevelKeys(reflect.ValueOf(resp).Bytes()) // []byte payloads
|
||||
return topLevelKeys(reflect.ValueOf(resp).Bytes()), false // []byte payloads
|
||||
}
|
||||
rt = rt.Elem()
|
||||
isSlice = true
|
||||
}
|
||||
if rt.Kind() == reflect.Ptr {
|
||||
rt = rt.Elem()
|
||||
@@ -338,7 +358,7 @@ func responseTopLevelKeys(t *testing.T, resp any) map[string]bool {
|
||||
if err != nil {
|
||||
t.Fatalf("marshal schema response %T: %v", resp, err)
|
||||
}
|
||||
return topLevelKeys(b)
|
||||
return topLevelKeys(b), isSlice
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]bool) []string {
|
||||
|
||||
+47
-3
@@ -3,6 +3,7 @@ package docs
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/handlers"
|
||||
"bookhoard/internal/services"
|
||||
)
|
||||
@@ -89,9 +90,9 @@ var Schemas = map[string]Schema{
|
||||
},
|
||||
"GET /api/devices/:id/collections": {
|
||||
Response: struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Mappings []handlers.DeviceMappingSummary `json:"mappings"`
|
||||
Total int `json:"total"`
|
||||
DeviceID string `json:"device_id"`
|
||||
Mappings []handlers.DeviceMappingSummary `json:"mappings"`
|
||||
Total int `json:"total"`
|
||||
}{},
|
||||
},
|
||||
"PUT /api/devices/:id/collections/:collectionId": {
|
||||
@@ -119,4 +120,47 @@ var Schemas = map[string]Schema{
|
||||
}
|
||||
}`),
|
||||
},
|
||||
|
||||
// --- 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": ""
|
||||
}`),
|
||||
},
|
||||
}
|
||||
Reference in new issue
Block a user