kemono2/client/src/lib/api/fetch.ts
2025-04-11 00:54:15 +02:00

150 lines
4.2 KiB
TypeScript

import { logoutAccount } from "#entities/account";
import { HTTP_STATUS, mergeHeaders } from "#lib/http";
import { customFetch } from "#lib/fetch";
import { APIError } from "#lib/api";
const urlBase = `/api/v1`;
const jsonHeaders = new Headers();
jsonHeaders.append("Content-Type", "application/json");
/**
* TODO: discriminated union with JSONable body signature
*/
interface IOptions extends Omit<RequestInit, "headers"> {
method: "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "CONNECT" | "OPTIONS" | "TRACE" | "PATCH";
body?: any;
headers?: Headers;
}
// TODO: Make this not throw.
/**
* Generic request for Kemono API.
* @param path
* A path to the endpoint, realtive to the base API path.
*/
export async function apiFetch<ReturnShape>(
path: string,
options: IOptions,
searchParams?: URLSearchParams
): Promise<ReturnShape> {
// `URL` constructor requires a full origin
// to be present in either of arguments
// but the argument for `fetch()` accepts relative paths just fine
// so we are doing some gymnastics in order not to depend
// on browser context (does not exist on server)
// or an env variable (not needed if the origin is the same).
const url = new URL(`${urlBase}${path}`, "https://example.com");
url.search = !searchParams ? "" : String(searchParams);
url.searchParams.sort();
const apiPath = `${url.pathname}${
// `URL.search` param includes `?` even with no params
// so we include it conditionally
searchParams?.size !== 0 ? url.search : ""
}`;
let finalOptions: RequestInit;
{
if (!options.body) {
finalOptions = {
...options,
credentials: "same-origin",
};
} else {
const jsonBody = JSON.stringify(options.body);
finalOptions = {
...options,
headers: options.headers
? mergeHeaders(options.headers, jsonHeaders)
: jsonHeaders,
body: jsonBody,
credentials: "same-origin",
};
}
}
const request = new Request(apiPath, finalOptions);
const response = await customFetch(request);
if (!response.ok) {
// server logged the account out
if (response.status === 401) {
await logoutAccount(true);
throw new APIError(
`Failed to fetch from API due to lack of credentials. Reason: ${response.status} - ${response.statusText}.`,
{ request, response }
);
}
if (response.status === 400 || response.status === 422) {
let body: string | undefined;
// doing it this way because response doesn't allow
// parsing body several times
// and cloning response is a bit too much
const text = (await response.text()).trim();
try {
const json = JSON.parse(text);
body = JSON.stringify(json);
} catch (error) {
body = text;
}
throw new APIError(
`Failed to fetch from API due to client inputs. Reason: ${
response.status
} - ${response.statusText}.${!body ? "" : ` ${body}`}`,
{ request, response }
);
}
if (response.status === 404) {
let body: string | undefined;
// doing it this way because response doesn't allow
// parsing body several times
// and cloning response is a bit too much
const text = (await response.text()).trim();
try {
const json = JSON.parse(text);
body = JSON.stringify(json);
} catch (error) {
body = text;
}
throw new APIError(
`Failed to fetch from API because path "${
response.url
}" doesn't exist. Reason: ${response.status} - ${response.statusText}.${
!body ? "" : ` ${body}`
}`,
{ request, response }
);
}
if (response.status === HTTP_STATUS.SERVICE_UNAVAILABLE) {
throw new APIError("API is in maintenance or not available.", {
request,
response,
});
}
if (response.status >= 500) {
throw new APIError("Failed to fetch from API due to server error.", {
request,
response,
});
}
throw new APIError("Failed to fetch from API for unknown reasons.", {
request,
response,
});
}
const resultBody: ReturnShape = await response.json();
return resultBody;
}