Files
Mangalord/frontend/src/lib/api/client.ts
MechaCat02 58e637085d bugfix: don't JSON.parse empty 200/201 bodies (0.19.1)
$(addMangaToCollection crashed when the backend returned 201/200 with no body — the shared client only short-circuited 204. Now any empty body returns undefined.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:30:39 +02:00

91 lines
3.0 KiB
TypeScript

// All backend calls go through this module. Components and routes import
// the typed helpers below — they do not call fetch directly.
const BASE = import.meta.env?.VITE_API_BASE ?? '/api';
/**
* Builds an absolute URL to the streaming `/files/{key}` endpoint so
* components can use it directly in `<img src>` etc., without
* reconstructing the API base in each call site.
*/
export function fileUrl(key: string): string {
return `${BASE}/v1/files/${key}`;
}
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly code: string,
message: string
) {
super(message);
this.name = 'ApiError';
}
}
type ErrorEnvelope = { error?: { code?: unknown; message?: unknown } };
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
// Forward credentials (session cookie) explicitly so cross-origin
// deployments — those configured via CORS_ALLOWED_ORIGINS — keep
// working. For same-origin requests this is a no-op compared to the
// default 'same-origin', so the same-origin happy path is
// unchanged.
const res = await fetch(`${BASE}${path}`, { credentials: 'include', ...init });
if (!res.ok) {
let code = 'http_error';
let message = `${res.status} ${res.statusText}`;
const ct = res.headers.get('content-type') ?? '';
try {
if (ct.includes('application/json')) {
const body = (await res.json()) as ErrorEnvelope;
if (body?.error) {
if (typeof body.error.code === 'string' && body.error.code) {
code = body.error.code;
}
if (typeof body.error.message === 'string' && body.error.message) {
message = body.error.message;
}
}
} else {
const text = await res.text();
if (text) message = text;
}
} catch {
// Body wasn't parseable; keep the http_error fallback.
}
throw new ApiError(res.status, code, message);
}
// Any empty body (not just 204) returns undefined — the manga-add
// endpoint, for instance, signals create-vs-already-present via
// 201/200 with no body, and callers typed `request<void>` would
// otherwise blow up on `res.json()` parsing an empty string.
if (res.status === 204) {
return undefined as T;
}
const text = await res.text();
if (!text) {
return undefined as T;
}
return JSON.parse(text) as T;
}
export type Manga = {
id: string;
title: string;
status: MangaStatus;
alt_titles: string[];
description: string | null;
cover_image_path: string | null;
created_at: string;
updated_at: string;
};
export type MangaStatus = 'ongoing' | 'completed';
export type Page = {
limit: number;
offset: number;
total: number | null;
};