Files
beavergram/frontend/src/lib/api/client.ts
T

156 lines
4.0 KiB
TypeScript

import { accounts } from "#lib/stores/accounts.svelte.js";
import { auth } from "#lib/stores/auth.svelte.js";
import { PUBLIC_API_BASE_URL } from "$app/env/public";
import { goto } from "$app/navigation";
const BASE = PUBLIC_API_BASE_URL;
const MAX_LIMIT = 500;
export class ApiError extends Error {
status: number;
detail: string;
constructor(status: number, detail: string) {
super(detail);
this.name = "ApiError";
this.status = status;
this.detail = detail;
}
}
type QueryValue = string | number | boolean | null | undefined;
interface RequestOptions {
account?: boolean;
body?: unknown;
method?: string;
query?: Record<string, QueryValue>;
}
function buildQuery(
query: Record<string, QueryValue> | undefined,
withAccount: boolean
): string {
const params = new URLSearchParams();
if (withAccount && accounts.selectedId !== null) {
params.set("account_id", String(accounts.selectedId));
}
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value === null || value === undefined) {
continue;
}
const clamped =
key === "limit" && typeof value === "number"
? Math.min(value, MAX_LIMIT)
: value;
params.set(key, String(clamped));
}
}
const text = params.toString();
return text ? `?${text}` : "";
}
function authHeaders(): Record<string, string> {
return auth.token ? { Authorization: `Bearer ${auth.token}` } : {};
}
async function handleError(response: Response): Promise<never> {
if (response.status === 401) {
auth.logout();
await goto("/login");
}
let detail = response.statusText;
try {
const data = await response.json();
if (data && typeof data.detail === "string") {
({ detail } = data);
}
} catch {
detail = response.statusText;
}
throw new ApiError(response.status, detail);
}
export async function request<T>(
path: string,
options: RequestOptions = {}
): Promise<T> {
const { method = "GET", query, body, account = false } = options;
const headers: Record<string, string> = authHeaders();
if (body !== undefined) {
headers["Content-Type"] = "application/json";
}
const response = await fetch(`${BASE}${path}${buildQuery(query, account)}`, {
body: body === undefined ? undefined : JSON.stringify(body),
headers,
method,
});
if (!response.ok) {
return handleError(response);
}
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}
function streamUrl(path: string): string {
const query = auth.token ? `?token=${encodeURIComponent(auth.token)}` : "";
return `${BASE}${path}${query}`;
}
export function mediaUrl(mediaId: number): string {
return streamUrl(`/media/${mediaId}`);
}
export function trashedUrl(storageKey: string): string {
return streamUrl(`/storage/trash/${storageKey}/file`);
}
export type MediaResult =
| { state: "ready"; url: string; mime: string | null }
| { state: "not-downloaded" }
| { state: "missing" };
export async function requestMedia(mediaId: number): Promise<MediaResult> {
const response = await fetch(`${BASE}/media/${mediaId}`, {
headers: authHeaders(),
});
if (response.status === 409) {
return { state: "not-downloaded" };
}
if (response.status === 404) {
return { state: "missing" };
}
if (!response.ok) {
return handleError(response);
}
const blob = await response.blob();
return {
mime: response.headers.get("Content-Type"),
state: "ready",
url: URL.createObjectURL(blob),
};
}
export async function requestMediaVersion(
versionId: number
): Promise<MediaResult> {
const response = await fetch(`${BASE}/media/version/${versionId}`, {
headers: authHeaders(),
});
if (response.status === 404) {
return { state: "missing" };
}
if (!response.ok) {
return handleError(response);
}
const blob = await response.blob();
return {
mime: response.headers.get("Content-Type"),
state: "ready",
url: URL.createObjectURL(blob),
};
}