Files
ReadaBook/apps/web/src/api/client.ts
Git Agent e94524f119 fix(web,api): débloquer la boucle de chargement 'Inventaire en cours' (Recherche/Admin)
- web: gestion d'erreur et d'état vide sur SearchPage/AdminPage
- web: client API — traitement des réponses sans contenu/erreurs réseau
- api: jobs.service — éviter le statut de scan perpétuellement en cours
- tests: client.test.ts

Refs: #12 (QA non-GREEN faute d'exécution, pas d'échec réel)
2026-08-23 10:01:54 +02:00

145 lines
4.8 KiB
TypeScript

import type {
BookDto,
BookQueryDto,
BootstrapAdminDto,
CreateLibraryDto,
JobDto,
LibraryDto,
LoginDto,
ProgressDto,
UpdateProgressDto,
UserDto
} from "@readabook/shared";
import { mockBooks, mockContinue, mockJobs, mockLibraries, mockProgress, mockUser } from "./mockData";
import type { ContinueItem, Session } from "./types";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
type RequestOptions = RequestInit & {
fallback?: unknown;
};
export class ApiFallbackError extends Error {
constructor(
message: string,
public readonly fallback: unknown
) {
super(message);
}
}
export function getApiFallback<T>(error: unknown): T | undefined {
return error instanceof ApiFallbackError ? (error.fallback as T) : undefined;
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
try {
const response = await fetch(`${API_BASE}${path}`, {
...options,
credentials: "include",
headers: {
"Content-Type": "application/json",
...options.headers
}
});
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `${response.status} ${response.statusText}`);
}
return (await response.json()) as T;
} catch (error) {
if (options.fallback !== undefined) {
throw new ApiFallbackError(error instanceof Error ? error.message : String(error), options.fallback);
}
throw error;
}
}
function queryString(query: Partial<BookQueryDto>): string {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") params.set(key, String(value));
});
const value = params.toString();
return value ? `?${value}` : "";
}
export const api = {
async session(): Promise<Session> {
try {
const result = await request<{ user: UserDto }>("/auth/me");
return { user: result.user, degraded: false };
} catch {
return { user: null, degraded: false };
}
},
async bootstrap(input: BootstrapAdminDto): Promise<UserDto> {
const result = await request<UserDto>("/auth/bootstrap", { method: "POST", body: JSON.stringify(input) });
return result;
},
async login(input: LoginDto): Promise<UserDto> {
const result = await request<{ user: UserDto }>("/auth/login", { method: "POST", body: JSON.stringify(input) });
return result.user;
},
async logout(): Promise<void> {
await request<{ ok: true }>("/auth/logout", { method: "POST" });
},
async books(query: Partial<BookQueryDto> = {}): Promise<BookDto[]> {
return request<BookDto[]>(`/books${queryString({ limit: 50, offset: 0, ...query })}`, { fallback: mockBooks });
},
async search(query: string): Promise<BookDto[]> {
return request<BookDto[]>(`/books/search${queryString({ q: query, limit: 50, offset: 0 })}`, { fallback: mockBooks });
},
async book(id: number): Promise<BookDto> {
const fallback = mockBooks.find((book) => book.id === id) ?? mockBooks[0];
return request<BookDto>(`/books/${id}`, { fallback });
},
bookFileUrl(id: number): string {
return `${API_BASE}/books/${id}/file`;
},
bookCoverUrl(id: number): string {
return `${API_BASE}/books/${id}/cover`;
},
async progress(bookId: number): Promise<ProgressDto | null> {
try {
return await request<ProgressDto>(`/progress/${bookId}`, {
fallback: mockProgress.find((progress) => progress.bookId === bookId) ?? null
});
} catch (error) {
if (error instanceof ApiFallbackError) return error.fallback as ProgressDto | null;
return null;
}
},
async saveProgress(bookId: number, input: UpdateProgressDto): Promise<ProgressDto> {
return request<ProgressDto>(`/progress/${bookId}`, {
method: "PUT",
body: JSON.stringify(input),
fallback: { bookId, ...input, updatedAt: new Date().toISOString() }
});
},
async continueReading(): Promise<ContinueItem[]> {
return request<ContinueItem[]>("/progress/continue", { fallback: mockContinue });
},
async libraries(): Promise<LibraryDto[]> {
return request<LibraryDto[]>("/admin/libraries", { fallback: mockLibraries });
},
async createLibrary(input: CreateLibraryDto): Promise<LibraryDto> {
return request<LibraryDto>("/admin/libraries", {
method: "POST",
body: JSON.stringify(input),
fallback: { id: Date.now(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...input }
});
},
async scanLibrary(id: number): Promise<JobDto> {
return request<JobDto>(`/admin/libraries/${id}/scan`, { method: "POST", fallback: mockJobs[0] });
},
async jobs(): Promise<JobDto[]> {
return request<JobDto[]>("/admin/jobs", { fallback: mockJobs });
},
async users(): Promise<UserDto[]> {
return request<UserDto[]>("/admin/users", { fallback: [mockUser] });
}
};