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 { 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( path: string, options: IOptions, searchParams?: URLSearchParams ): Promise { // `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; }