Skip to content

Commit eed2e0c

Browse files
authored
Merge pull request #44 from JeremiahM37/feat/openapi-spec
Add OpenAPI 3.1 spec at /api/openapi.json
2 parents 23ebc7c + 3aafc4a commit eed2e0c

4 files changed

Lines changed: 398 additions & 0 deletions

File tree

internal/api/openapi.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package api
2+
3+
import (
4+
_ "embed"
5+
"net/http"
6+
)
7+
8+
//go:embed openapi.json
9+
var openapiSpec []byte
10+
11+
// handleOpenAPI serves the OpenAPI 3.1 spec describing Librarr's HTTP API.
12+
// AI agents and tooling can introspect this to discover endpoints, request
13+
// shapes, and response shapes without prior knowledge of the codebase.
14+
func (s *Server) handleOpenAPI(w http.ResponseWriter, _ *http.Request) {
15+
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
16+
w.Header().Set("Cache-Control", "public, max-age=600")
17+
w.WriteHeader(http.StatusOK)
18+
_, _ = w.Write(openapiSpec)
19+
}

internal/api/openapi.json

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
{
2+
"openapi": "3.1.0",
3+
"info": {
4+
"title": "Librarr API",
5+
"description": "Self-hosted book, audiobook, and manga search and download manager — the *arr for books. The HTTP/JSON API powers both the SPA and any external tooling (CLI scripts, AI agents, *arr-family integrations).",
6+
"version": "1.x",
7+
"license": {"name": "MIT", "identifier": "MIT"}
8+
},
9+
"servers": [
10+
{"url": "http://localhost:5050", "description": "Default local install"}
11+
],
12+
"components": {
13+
"securitySchemes": {
14+
"apiKey": {
15+
"type": "apiKey",
16+
"in": "header",
17+
"name": "X-Api-Key",
18+
"description": "API key for programmatic access. Also accepted via ?apikey= query parameter."
19+
},
20+
"session": {
21+
"type": "apiKey",
22+
"in": "cookie",
23+
"name": "librarr_session",
24+
"description": "Set by POST /api/login. Used by the SPA."
25+
}
26+
},
27+
"schemas": {
28+
"Error": {
29+
"type": "object",
30+
"required": ["success", "error"],
31+
"properties": {
32+
"success": {"type": "boolean", "enum": [false]},
33+
"error": {"type": "string"}
34+
}
35+
},
36+
"SearchResult": {
37+
"type": "object",
38+
"description": "A single search hit. Fields are populated per driver — direct-download drivers omit seeders/leechers; torrent drivers usually omit MD5.",
39+
"properties": {
40+
"title": {"type": "string"},
41+
"author": {"type": "string"},
42+
"source": {"type": "string", "description": "Driver identifier (e.g. annas, gutenberg, prowlarr)."},
43+
"size": {"type": "integer", "format": "int64"},
44+
"size_human": {"type": "string"},
45+
"seeders": {"type": "integer"},
46+
"leechers": {"type": "integer"},
47+
"magnet_url": {"type": "string"},
48+
"download_url": {"type": "string", "format": "uri"},
49+
"md5": {"type": "string"},
50+
"info_hash": {"type": "string"},
51+
"url": {"type": "string", "format": "uri"},
52+
"cover_url": {"type": "string", "format": "uri"},
53+
"format": {"type": "string", "description": "epub, pdf, mobi, mp3, m4b, cbz, etc."},
54+
"score": {"type": "integer", "description": "0-100 confidence score."},
55+
"media_type": {"type": "string", "enum": ["ebook", "audiobook", "manga"]}
56+
}
57+
},
58+
"WishlistItem": {
59+
"type": "object",
60+
"required": ["title"],
61+
"properties": {
62+
"id": {"type": "integer", "format": "int64"},
63+
"title": {"type": "string"},
64+
"author": {"type": "string"},
65+
"media_type": {"type": "string", "enum": ["ebook", "audiobook", "manga"]}
66+
}
67+
},
68+
"PaginatedList": {
69+
"type": "object",
70+
"description": "Standard list response shape used across /api/library, /api/activity, etc.",
71+
"properties": {
72+
"items": {"type": "array", "items": {"type": "object"}},
73+
"total": {"type": "integer"},
74+
"limit": {"type": "integer"},
75+
"offset": {"type": "integer"}
76+
}
77+
}
78+
}
79+
},
80+
"security": [{"apiKey": []}, {"session": []}],
81+
"paths": {
82+
"/api/health": {
83+
"get": {
84+
"summary": "Health check",
85+
"description": "Liveness probe. Returns 200 if the binary is running. No auth required.",
86+
"security": [],
87+
"responses": {"200": {"description": "OK"}}
88+
}
89+
},
90+
"/api/openapi.json": {
91+
"get": {
92+
"summary": "This OpenAPI spec",
93+
"description": "Returns this OpenAPI 3.1 document. AI agents can fetch this to discover endpoints.",
94+
"security": [],
95+
"responses": {
96+
"200": {
97+
"description": "OpenAPI 3.1 document",
98+
"content": {"application/json": {"schema": {"type": "object"}}}
99+
}
100+
}
101+
}
102+
},
103+
"/api/auth/status": {
104+
"get": {"summary": "Current auth state"}
105+
},
106+
"/api/login": {"post": {"summary": "Session login", "security": []}},
107+
"/api/login/totp": {"post": {"summary": "TOTP 2FA verification step", "security": []}},
108+
"/api/logout": {"post": {"summary": "End session"}},
109+
"/api/register": {"post": {"summary": "Register new user (requires invite code when configured)", "security": []}},
110+
111+
"/api/search": {
112+
"get": {
113+
"summary": "Search ebooks across all configured sources",
114+
"parameters": [
115+
{"name": "q", "in": "query", "required": true, "schema": {"type": "string"}, "description": "Search query."},
116+
{"name": "author", "in": "query", "required": false, "schema": {"type": "string"}}
117+
],
118+
"responses": {
119+
"200": {
120+
"description": "Search results",
121+
"content": {
122+
"application/json": {
123+
"schema": {
124+
"type": "object",
125+
"properties": {
126+
"results": {"type": "array", "items": {"$ref": "#/components/schemas/SearchResult"}},
127+
"search_time_ms": {"type": "integer"},
128+
"sources": {"type": "array", "items": {"type": "object"}}
129+
}
130+
}
131+
}
132+
}
133+
}
134+
}
135+
}
136+
},
137+
"/api/search/audiobooks": {
138+
"get": {
139+
"summary": "Search audiobooks",
140+
"parameters": [
141+
{"name": "q", "in": "query", "required": true, "schema": {"type": "string"}},
142+
{"name": "author", "in": "query", "required": false, "schema": {"type": "string"}}
143+
]
144+
}
145+
},
146+
"/api/search/manga": {
147+
"get": {
148+
"summary": "Search manga",
149+
"parameters": [{"name": "q", "in": "query", "required": true, "schema": {"type": "string"}}]
150+
}
151+
},
152+
153+
"/api/sources": {
154+
"get": {
155+
"summary": "List configured search sources + health",
156+
"description": "Returns each registered driver with enabled state, circuit-breaker status, last success/fail counters. Useful for AI agents debugging why a source isn't returning results."
157+
}
158+
},
159+
160+
"/api/library": {
161+
"get": {
162+
"summary": "List ebooks in the library",
163+
"parameters": [
164+
{"name": "limit", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 500, "default": 50}},
165+
{"name": "offset", "in": "query", "schema": {"type": "integer", "minimum": 0, "default": 0}},
166+
{"name": "type", "in": "query", "schema": {"type": "string", "enum": ["ebook", "audiobook", "manga"]}},
167+
{"name": "tag", "in": "query", "schema": {"type": "string"}, "description": "Filter by tag name."}
168+
],
169+
"responses": {
170+
"200": {
171+
"description": "Paginated library items",
172+
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PaginatedList"}}}
173+
}
174+
}
175+
}
176+
},
177+
"/api/library/audiobooks": {"get": {"summary": "List audiobooks (proxied from Audiobookshelf when configured)"}},
178+
"/api/library/manga": {"get": {"summary": "List manga"}},
179+
"/api/library/book/{id}": {"delete": {"summary": "Remove an ebook from the library", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer"}}]}},
180+
"/api/library/audiobook/{id}": {"delete": {"summary": "Remove an audiobook", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer"}}]}},
181+
"/api/library/{id}/tags": {"get": {"summary": "List tags for a library item"}, "post": {"summary": "Add a tag to a library item"}},
182+
"/api/library/{id}/tags/{tagId}": {"delete": {"summary": "Remove a tag from a library item"}},
183+
184+
"/api/wishlist": {
185+
"get": {"summary": "List wishlist items"},
186+
"post": {
187+
"summary": "Add a wishlist item",
188+
"requestBody": {
189+
"required": true,
190+
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/WishlistItem"}}}
191+
},
192+
"responses": {
193+
"201": {"description": "Created"},
194+
"400": {"description": "Validation error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}
195+
}
196+
}
197+
},
198+
"/api/wishlist/{id}": {
199+
"delete": {
200+
"summary": "Delete a wishlist item",
201+
"parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "integer"}}]
202+
}
203+
},
204+
205+
"/api/requests": {
206+
"get": {"summary": "List requests"},
207+
"post": {"summary": "Create a request"}
208+
},
209+
"/api/requests/{id}": {"get": {"summary": "Get a request"}, "delete": {"summary": "Delete a request"}},
210+
"/api/requests/{id}/search": {"post": {"summary": "Trigger a search for a request"}},
211+
"/api/requests/{id}/download": {"post": {"summary": "Trigger a download for a request"}},
212+
213+
"/api/downloads": {
214+
"get": {"summary": "List active and recent downloads"}
215+
},
216+
"/api/downloads/torrent/{hash}": {"delete": {"summary": "Remove a torrent download"}},
217+
"/api/downloads/novel/{jobID}": {"delete": {"summary": "Remove a novel download job"}},
218+
"/api/downloads/jobs/{id}/retry": {"post": {"summary": "Retry a failed download"}},
219+
"/api/downloads/clear": {"post": {"summary": "Clear finished downloads"}},
220+
221+
"/api/quality-profiles": {"get": {"summary": "List quality profiles"}, "post": {"summary": "Create quality profile"}},
222+
"/api/quality-profiles/{id}": {"delete": {"summary": "Delete quality profile"}},
223+
"/api/quality-profiles/default": {"get": {"summary": "Default profile"}},
224+
"/api/release-profiles": {"get": {"summary": "List release profiles"}, "post": {"summary": "Create"}},
225+
"/api/release-profiles/{id}": {"delete": {"summary": "Delete release profile"}},
226+
"/api/blocklist": {"get": {"summary": "List blocklist entries"}, "post": {"summary": "Add an entry"}},
227+
"/api/blocklist/{id}": {"delete": {"summary": "Remove an entry"}},
228+
"/api/tags": {"get": {"summary": "List tags"}, "post": {"summary": "Create a tag"}},
229+
"/api/tags/{id}": {"delete": {"summary": "Delete a tag"}},
230+
231+
"/api/users": {"get": {"summary": "List users (admin only)"}},
232+
"/api/users/{id}": {
233+
"patch": {"summary": "Update user role/status (admin only)"},
234+
"delete": {"summary": "Delete user (admin only). Refuses to delete the last admin (HTTP 409)."}
235+
},
236+
"/api/invites": {"get": {"summary": "List invites (admin)"}, "post": {"summary": "Create invite (admin)"}},
237+
"/api/invites/{id}": {"delete": {"summary": "Revoke invite (admin)"}},
238+
239+
"/api/totp/setup": {"post": {"summary": "Generate TOTP secret + QR code"}},
240+
"/api/totp/verify": {"post": {"summary": "Verify TOTP code and enable 2FA"}},
241+
"/api/totp/disable": {"post": {"summary": "Disable TOTP"}},
242+
"/api/totp/status": {"get": {"summary": "Check if TOTP is enabled"}},
243+
244+
"/api/notifications": {"get": {"summary": "List notifications"}},
245+
"/api/notifications/{id}": {"delete": {"summary": "Delete notification"}},
246+
"/api/notifications/{id}/read": {"put": {"summary": "Mark as read"}},
247+
"/api/notifications/read-all": {"put": {"summary": "Mark all as read"}},
248+
"/api/notifications/unread": {"get": {"summary": "Unread count"}},
249+
250+
"/api/webhooks": {"get": {"summary": "List webhooks"}, "post": {"summary": "Create webhook"}},
251+
"/api/webhooks/{id}": {"delete": {"summary": "Delete webhook"}},
252+
"/api/webhooks/test": {"post": {"summary": "Send a test payload"}},
253+
254+
"/api/stats": {"get": {"summary": "Library statistics"}},
255+
"/api/activity": {"get": {"summary": "Recent activity log"}},
256+
"/api/history": {"get": {"summary": "Reading history"}},
257+
"/api/history/{id}": {"delete": {"summary": "Delete history entry"}},
258+
"/api/history/stats": {"get": {"summary": "Reading-history statistics"}},
259+
260+
"/api/authors": {"get": {"summary": "List monitored authors"}},
261+
"/api/authors/{id}": {"delete": {"summary": "Unmonitor an author"}},
262+
"/api/series": {"get": {"summary": "List series with completeness"}},
263+
"/api/series/{name}/missing": {"get": {"summary": "Missing volumes in a series"}},
264+
265+
"/api/settings": {"get": {"summary": "Get configuration"}, "post": {"summary": "Update configuration (admin)"}},
266+
"/api/config": {"get": {"summary": "Public-safe config subset"}},
267+
"/api/scheduler/status": {"get": {"summary": "Wishlist scheduler status"}},
268+
"/api/admin/dashboard": {"get": {"summary": "Admin dashboard data"}},
269+
"/api/admin/health": {"get": {"summary": "Admin health summary"}},
270+
"/api/admin/activity": {"get": {"summary": "Admin-level activity log"}},
271+
"/api/admin/bulk/retry": {"post": {"summary": "Bulk retry failed downloads"}},
272+
"/api/admin/bulk/cancel": {"post": {"summary": "Bulk cancel active downloads"}},
273+
"/api/backup": {"get": {"summary": "Download a database backup"}},
274+
"/api/backup/list": {"get": {"summary": "List existing backups"}},
275+
"/api/restore": {"post": {"summary": "Restore from a backup file (admin)"}},
276+
277+
"/api/import/csv": {"post": {"summary": "Import wishlist from a Goodreads/StoryGraph CSV"}},
278+
"/api/import/goodreads": {"post": {"summary": "Import Goodreads shelves"}},
279+
"/api/import/storygraph": {"post": {"summary": "Import StoryGraph reading list"}},
280+
"/api/export/library": {"get": {"summary": "Export library as JSON/CSV"}},
281+
"/api/export/wishlist": {"get": {"summary": "Export wishlist as JSON/CSV"}},
282+
"/api/export/requests": {"get": {"summary": "Export requests as JSON/CSV"}},
283+
284+
"/torznab/api": {
285+
"get": {
286+
"summary": "Torznab indexer API",
287+
"description": "Lets Prowlarr / Readarr / other *arr apps use Librarr as a Torznab indexer.",
288+
"parameters": [
289+
{"name": "t", "in": "query", "schema": {"type": "string", "enum": ["caps", "search", "book", "audio"]}},
290+
{"name": "q", "in": "query", "schema": {"type": "string"}},
291+
{"name": "title", "in": "query", "schema": {"type": "string"}},
292+
{"name": "author", "in": "query", "schema": {"type": "string"}},
293+
{"name": "apikey", "in": "query", "schema": {"type": "string"}, "description": "Required when TORZNAB_API_KEY is set."}
294+
],
295+
"responses": {
296+
"200": {"description": "Torznab XML (RSS feed or caps document)", "content": {"application/xml": {}}}
297+
}
298+
}
299+
},
300+
"/api": {
301+
"get": {
302+
"summary": "Torznab alias for Prowlarr indexer discovery",
303+
"description": "Prowlarr probes /api during indexer discovery; this is the same handler as /torznab/api."
304+
}
305+
},
306+
"/opds": {
307+
"get": {
308+
"summary": "OPDS 1.2 catalog root",
309+
"description": "Browse your library from e-reader apps (KOReader, Moon+ Reader, Librera).",
310+
"security": [],
311+
"responses": {"200": {"description": "Atom feed", "content": {"application/atom+xml": {}}}}
312+
}
313+
},
314+
"/metrics": {
315+
"get": {"summary": "Prometheus metrics", "security": []}
316+
}
317+
}
318+
}

internal/api/openapi_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"net/http/httptest"
6+
"strings"
7+
"testing"
8+
)
9+
10+
// TestHandleOpenAPI confirms the embedded OpenAPI spec is well-formed JSON
11+
// and that key fields are present. Catches regressions where a bad edit to
12+
// openapi.json gets shipped.
13+
func TestHandleOpenAPI(t *testing.T) {
14+
req := httptest.NewRequest("GET", "/api/openapi.json", nil)
15+
rr := httptest.NewRecorder()
16+
s := &Server{}
17+
s.handleOpenAPI(rr, req)
18+
19+
if rr.Code != 200 {
20+
t.Fatalf("HTTP %d", rr.Code)
21+
}
22+
if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
23+
t.Errorf("Content-Type = %q, want application/json", ct)
24+
}
25+
26+
var spec struct {
27+
OpenAPI string `json:"openapi"`
28+
Info map[string]interface{} `json:"info"`
29+
Paths map[string]interface{} `json:"paths"`
30+
Components struct {
31+
Schemas map[string]interface{} `json:"schemas"`
32+
SecuritySchemes map[string]interface{} `json:"securitySchemes"`
33+
} `json:"components"`
34+
}
35+
if err := json.Unmarshal(rr.Body.Bytes(), &spec); err != nil {
36+
t.Fatalf("response body is not valid JSON: %v", err)
37+
}
38+
if !strings.HasPrefix(spec.OpenAPI, "3.") {
39+
t.Errorf("openapi field = %q, want 3.x", spec.OpenAPI)
40+
}
41+
if title, _ := spec.Info["title"].(string); !strings.Contains(title, "Librarr") {
42+
t.Errorf("info.title = %q, want to contain Librarr", title)
43+
}
44+
if len(spec.Paths) < 50 {
45+
t.Errorf("expected at least 50 documented paths, got %d", len(spec.Paths))
46+
}
47+
// Spot-check the major endpoints every AI agent will care about exist.
48+
for _, p := range []string{"/api/health", "/api/search", "/api/library", "/api/wishlist", "/torznab/api"} {
49+
if _, ok := spec.Paths[p]; !ok {
50+
t.Errorf("expected path %q in spec", p)
51+
}
52+
}
53+
if _, ok := spec.Components.SecuritySchemes["apiKey"]; !ok {
54+
t.Error("expected apiKey securityScheme")
55+
}
56+
}

0 commit comments

Comments
 (0)