feat(web): typed api client with bearer token, 401 logout, binary channels
This commit is contained in:
@@ -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<string, string>();
|
||||||
|
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<HttpError> {
|
||||||
|
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<string, string> {
|
||||||
|
const h: Record<string, string> = {};
|
||||||
|
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<T>(path: string, opts: ReqOpts = {}): Promise<T> {
|
||||||
|
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<Response> {
|
||||||
|
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<Me>("/auth/me"),
|
||||||
|
|
||||||
|
listUsers: () => apiFetch<User[]>("/users"),
|
||||||
|
createUser: (username: string, password: string, role: string) =>
|
||||||
|
apiFetch<User>("/users", { method: "POST", body: { username, password, role } }),
|
||||||
|
deleteUser: (id: number) => apiFetch<void>(`/users/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
listLibraries: () => apiFetch<Library[]>("/libraries"),
|
||||||
|
createLibrary: (name: string, root_path: string) =>
|
||||||
|
apiFetch<Library>("/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<Book[]>(`/books${qs ? "?" + qs : ""}`);
|
||||||
|
},
|
||||||
|
getBook: (id: number) => apiFetch<Book>(`/books/${id}`),
|
||||||
|
deleteBook: (id: number) => apiFetch<void>(`/books/${id}`, { method: "DELETE" }),
|
||||||
|
pageCount: (pagesUrl: string) => apiFetch<{ count: number }>(pagesUrl),
|
||||||
|
|
||||||
|
putProgress: (id: number, locator: Record<string, unknown>, percent: number, keepalive = false) =>
|
||||||
|
apiFetch<void>(`/books/${id}/progress`, { method: "PUT", body: { locator, percent }, keepalive }),
|
||||||
|
listProgress: () => apiFetch<ProgressRow[]>("/progress"),
|
||||||
|
};
|
||||||
@@ -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<string, unknown>;
|
||||||
|
percent: number;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Me {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
role: "admin" | "member";
|
||||||
|
}
|
||||||
@@ -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_<status>", 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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user