From 2f0fc25c7c21f91c1a043c6a69baa7026126d4a2 Mon Sep 17 00:00:00 2001 From: XingfenD Date: Mon, 7 Sep 2026 15:23:18 +0800 Subject: [PATCH] feat(web): typed api client with bearer token, 401 logout, binary channels --- web/src/api/client.ts | 152 ++++++++++++++++++++++++++++++++++++++++ web/src/api/types.ts | 51 ++++++++++++++ web/test/client.test.ts | 62 ++++++++++++++++ 3 files changed, 265 insertions(+) create mode 100644 web/src/api/client.ts create mode 100644 web/src/api/types.ts create mode 100644 web/test/client.test.ts diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..0e25577 --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,152 @@ +import type { Book, Library, Me, ProgressRow, User } from "./types"; + +export const TOKEN_KEY = "booklib.token"; +export const LOGOUT_EVENT = "booklib:logout"; + +// 无 localStorage 环境(vitest node)退化为内存,仅测试路径生效 +const mem = new Map(); +const hasLS = typeof localStorage !== "undefined"; + +export function getToken(): string | null { + return hasLS ? localStorage.getItem(TOKEN_KEY) : mem.get(TOKEN_KEY) ?? null; +} + +export function setToken(t: string | null): void { + if (hasLS) { + if (t) localStorage.setItem(TOKEN_KEY, t); + else localStorage.removeItem(TOKEN_KEY); + } else if (t) { + mem.set(TOKEN_KEY, t); + } else { + mem.delete(TOKEN_KEY); + } +} + +export class HttpError extends Error { + status: number; + code: string; + constructor(status: number, code: string, message: string) { + super(message); + this.name = "HttpError"; + this.status = status; + this.code = code; + } +} + +export async function toHttpError(res: Response): Promise { + let code = "http_" + res.status; + let message = res.statusText; + try { + const j = await res.json(); + if (j?.error?.code) code = j.error.code; + if (j?.error?.message) message = j.error.message; + } catch { + /* 非 JSON 错误体:用状态码兜底 */ + } + return new HttpError(res.status, code, message); +} + +function authHeaders(): Record { + const h: Record = {}; + const tok = getToken(); + if (tok) h.Authorization = "Bearer " + tok; + return h; +} + +function handle401(res: Response): void { + if (res.status !== 401) return; + setToken(null); + if (typeof window !== "undefined") window.dispatchEvent(new Event(LOGOUT_EVENT)); +} + +function full(path: string): string { + return path.startsWith("/api") ? path : "/api" + path; +} + +export interface ReqOpts { + method?: string; + body?: unknown; + form?: FormData; + keepalive?: boolean; + signal?: AbortSignal; +} + +export async function apiFetch(path: string, opts: ReqOpts = {}): Promise { + const headers = authHeaders(); + let body: BodyInit | undefined; + if (opts.form) { + body = opts.form; + } else if (opts.body !== undefined) { + headers["Content-Type"] = "application/json"; + body = JSON.stringify(opts.body); + } + const res = await fetch(full(path), { + method: opts.method ?? "GET", + headers, + body, + keepalive: opts.keepalive, + signal: opts.signal, + }); + handle401(res); + if (!res.ok) throw await toHttpError(res); + if (res.status === 204) return undefined as T; + return (await res.json()) as T; +} + +// 二进制/原文通道:封面、CBZ 页、PDF/EPUB 字节。同 401 语义,不解析 JSON +export async function apiRaw( + path: string, + opts: { signal?: AbortSignal; keepalive?: boolean } = {}, +): Promise { + const res = await fetch(full(path), { + headers: authHeaders(), + signal: opts.signal, + keepalive: opts.keepalive, + }); + handle401(res); + if (!res.ok) throw await toHttpError(res); + return res; +} + +export function formatPageUrl(fmt: string, n: number): string { + return fmt.replace("%d", String(n)); +} + +export const api = { + login: (username: string, password: string) => + apiFetch<{ token: string }>("/auth/login", { method: "POST", body: { username, password } }), + me: () => apiFetch("/auth/me"), + + listUsers: () => apiFetch("/users"), + createUser: (username: string, password: string, role: string) => + apiFetch("/users", { method: "POST", body: { username, password, role } }), + deleteUser: (id: number) => apiFetch(`/users/${id}`, { method: "DELETE" }), + + listLibraries: () => apiFetch("/libraries"), + createLibrary: (name: string, root_path: string) => + apiFetch("/libraries", { method: "POST", body: { name, root_path } }), + scanLibrary: (id: number) => apiFetch<{ accepted: boolean }>(`/libraries/${id}/scan`, { method: "POST" }), + uploadBook: (id: number, file: File) => { + const fd = new FormData(); + fd.append("file", file); + return apiFetch<{ accepted: boolean; path: string }>(`/libraries/${id}/upload`, { + method: "POST", + form: fd, + }); + }, + + listBooks: (params: { library?: number; q?: string } = {}) => { + const sp = new URLSearchParams(); + if (params.library) sp.set("library", String(params.library)); + if (params.q) sp.set("q", params.q); + const qs = sp.toString(); + return apiFetch(`/books${qs ? "?" + qs : ""}`); + }, + getBook: (id: number) => apiFetch(`/books/${id}`), + deleteBook: (id: number) => apiFetch(`/books/${id}`, { method: "DELETE" }), + pageCount: (pagesUrl: string) => apiFetch<{ count: number }>(pagesUrl), + + putProgress: (id: number, locator: Record, percent: number, keepalive = false) => + apiFetch(`/books/${id}/progress`, { method: "PUT", body: { locator, percent }, keepalive }), + listProgress: () => apiFetch("/progress"), +}; diff --git a/web/src/api/types.ts b/web/src/api/types.ts new file mode 100644 index 0000000..a01845b --- /dev/null +++ b/web/src/api/types.ts @@ -0,0 +1,51 @@ +export type Format = "cbz" | "pdf" | "epub" | "txt" | "md"; + +export interface Book { + id: number; + library_id: number; + path: string; + title: string; + format: Format; + size: number; + mtime: number; + pages: number; + state: "ready" | "error"; + error: string; + added_at: string; + percent: number; + cover_url: string; + library?: string; + pages_url?: string; + page_url_fmt?: string; + file_url?: string; +} + +export interface Library { + id: number; + name: string; + root_path: string; + created_at: string; +} + +export interface User { + id: number; + username: string; + role: "admin" | "member"; + created_at: string; +} + +export interface ProgressRow { + library_id: number; + library: string; + path: string; + title: string; + locator: Record; + percent: number; + updated_at: string; +} + +export interface Me { + id: number; + username: string; + role: "admin" | "member"; +} diff --git a/web/test/client.test.ts b/web/test/client.test.ts new file mode 100644 index 0000000..defd804 --- /dev/null +++ b/web/test/client.test.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { api, apiRaw, formatPageUrl, getToken, HttpError, setToken } from "../src/api/client"; + +const jres = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); + +beforeEach(() => setToken(null)); +afterEach(() => vi.unstubAllGlobals()); + +it("login 成功存 token,请求体带凭据", async () => { + const f = vi.fn(async (_url: any, _init: any) => jres(200, { token: "T" })); + vi.stubGlobal("fetch", f); + await expect(api.login("a", "b")).resolves.toEqual({ token: "T" }); + expect(f.mock.calls[0][0]).toBe("/api/auth/login"); + expect(String(f.mock.calls[0][1].body)).toContain('"username":"a"'); +}); + +it("401 清 token 并抛 HttpError(parsed code)", async () => { + setToken("T"); + vi.stubGlobal("fetch", async (_url: any, init: any) => { + expect(init.headers.Authorization).toBe("Bearer T"); + return jres(401, { error: { code: "unauthorized", message: "bad credentials" } }); + }); + const e = await api.login("a", "b").catch((e) => e); + expect(e).toBeInstanceOf(HttpError); + expect((e as HttpError).code).toBe("unauthorized"); + expect(getToken()).toBeNull(); +}); + +it("非 JSON 错误体回退 http_", async () => { + vi.stubGlobal("fetch", async () => new Response("oops", { status: 500 })); + const e = await api.listUsers().catch((e) => e); + expect((e as HttpError).code).toBe("http_500"); +}); + +it("204 无体 resolve undefined", async () => { + vi.stubGlobal("fetch", async () => new Response(null, { status: 204 })); + await expect(api.deleteBook(1)).resolves.toBeUndefined(); +}); + +it("相对路径补 /api 前缀,绝对 /api 原样;query 拼装", async () => { + const seen: string[] = []; + vi.stubGlobal("fetch", async (url: any) => { + seen.push(String(url)); + return jres(200, []); + }); + await api.listBooks({ library: 3, q: "x" }); + await apiRaw("/api/books/1/cover?v=h"); + expect(seen).toEqual(["/api/books?library=3&q=x", "/api/books/1/cover?v=h"]); +}); + +it("page_url_fmt %d 替换", () => { + expect(formatPageUrl("/api/books/9/pages/%d?v=h", 12)).toBe("/api/books/9/pages/12?v=h"); +}); + +it("putProgress keepalive 透传 + JSON body", async () => { + const f = vi.fn(async (_url: any, _init: any) => new Response(null, { status: 204 })); + vi.stubGlobal("fetch", f); + await api.putProgress(1, { page: 2 }, 0.5, true); + expect(f.mock.calls[0][1].keepalive).toBe(true); + expect(String(f.mock.calls[0][1].body)).toContain('"percent":0.5'); +});