150 lines
4.2 KiB
TypeScript
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;
|
|
}
|