@zennomi/mangadex-full-api
Version:
A MangaDex api based around the official API.
357 lines (325 loc) • 12.4 kB
text/typescript
import IDObject from '../internal/IDObject';
import APIResponseError from './APIResponseError';
import AuthError from './AuthError';
import type { CheckResponseSchema, ErrorResponseSchema } from '../types/schema';
import type { IAuthClient } from '../types/helpers';
type ParameterObj = {
[x: string]:
| string
| string[]
| number
| number[]
| boolean
| { [x: string]: string | number }
| IDObject
| IDObject[]
| undefined;
};
type ListResponse = { data: { id: string }[]; limit: number; offset: number; total: number };
type CustomRequestInit = Omit<RequestInit, 'headers'> & { headers?: Record<string, string>; noAuth?: boolean };
class NetworkStateManager {
static useDebugServerValue = false;
static activeClient: IAuthClient | undefined;
}
/**
* If true the debug (sandbox) MangaDex domain wil be used instead of the default one.
* {@link https://sandbox.mangadex.dev}
*/
export function useDebugServer(val: boolean) {
NetworkStateManager.useDebugServerValue = val;
}
/**
* Returns if the debug (sandbox) MangaDex domain is in use
*/
export function isDebugServerInUse() {
return NetworkStateManager.useDebugServerValue;
}
/**
* Sets the AuthClient to be used by API calls
* @param client - The signed-in OAuth or legacy AuthClient
*/
export function setActiveAuthClient(client: IAuthClient) {
NetworkStateManager.activeClient = client;
}
/**
* Removes the current active AuthClient so no further API calls are done with user authorization
*/
export function clearActiveAuthClient() {
NetworkStateManager.activeClient = undefined;
}
/**
* Returns the current auth client or null if there is none
*/
export function getActiveAuthClient() {
return NetworkStateManager.activeClient ?? null;
}
/**
* Performs a fetch request to MangaDex and parses the response as JSON.
*/
export async function fetchMD<T extends object>(
endpoint: string,
params?: ParameterObj,
requestInit: CustomRequestInit = {},
): Promise<T> {
const domain = NetworkStateManager.useDebugServerValue ? 'https://api.mangadex.dev' : 'https://api.mangadex.org/';
const url = buildURL(domain, endpoint, params);
if (NetworkStateManager.activeClient && !requestInit.noAuth) {
const sessionToken = await NetworkStateManager.activeClient.getSessionToken();
if (requestInit.headers === undefined) requestInit.headers = {};
requestInit.headers['authorization'] = `Bearer ${sessionToken}`;
}
const res = await fetch(url, requestInit);
// Raise error if response isn't JSON
const contentType = res.headers.get('content-type');
if (!contentType?.toLowerCase().includes('json')) {
let errInfo = `${res.statusText} (${res.status}) Response was an unexpected content type: ${
contentType ?? 'Unspecified Type'
}.`;
try {
let text = await res.text();
if (text.length > 128) text = text.slice(0, 128);
errInfo += `\nStart of Body: ${text}`;
} catch (_) {}
throw new APIResponseError(errInfo);
}
// Raise error if response is a MD error
const data = (await res.json()) as ErrorResponseSchema | T;
if ('result' in data && data.result !== 'ok') {
throw new APIResponseError(data);
}
// Raise error if error status code was given
if (res.status >= 400) {
throw new APIResponseError(`${res.statusText} (${res.status})`);
}
return data as T;
}
/**
* Same as {@link fetchMD}, but returns the 'data' property of the response instead
*/
export async function fetchMDData<T extends { data: unknown }>(
endpoint: string,
params?: ParameterObj,
requestInit?: CustomRequestInit,
): Promise<T['data']> {
const res = await fetchMD<T>(endpoint, params, requestInit);
return res.data;
}
/**
* Same as {@link fetchMDData} but is designed specifically for search requests. This means that
* multiple requests will be used for extreme limits.
*/
export async function fetchMDSearch<T extends ListResponse>(
endpoint: string,
params: ParameterObj & { limit?: number; offset?: number } = {},
requestInit?: CustomRequestInit,
maxLimit = 100,
defaultLimit = 10,
): Promise<T['data']> {
// Setup initial limit and offset values:
const MAX_POSSIBLE_RESULTS = 10000; // Hard limit for any endpoint is 10000 total results
let targetLimit = Math.min(params.limit ?? defaultLimit, MAX_POSSIBLE_RESULTS);
const initialOffset = params.offset ?? 0;
if (initialOffset >= MAX_POSSIBLE_RESULTS || targetLimit <= 0) return [];
if (initialOffset > MAX_POSSIBLE_RESULTS - Math.min(maxLimit, targetLimit)) {
// Make limit smaller to avoid bounds error if offset is close to MAX_POSSIBLE_RESULTS
targetLimit = MAX_POSSIBLE_RESULTS - initialOffset;
}
// Get one result to find out how many total results there are
const firstResponse = await fetchMD<T>(
endpoint,
{ ...params, limit: Math.min(targetLimit, maxLimit) },
requestInit,
);
// Return immediately if multiple requests aren't needed, or if the result contains all possible results
if (targetLimit <= maxLimit || firstResponse.total <= firstResponse.data.length + initialOffset) {
return firstResponse.data;
}
// Lower the limit if there aren't that many results
targetLimit = Math.min(targetLimit, firstResponse.total);
// Create an array of requests with each request having the maximum limit until the target limit is reached
const promises: Promise<T['data']>[] = [];
for (let offset = initialOffset + maxLimit; offset < targetLimit; offset += maxLimit) {
const limitForThisRequest = Math.min(targetLimit - offset, maxLimit);
promises.push(fetchMDData<T>(endpoint, { ...params, limit: limitForThisRequest, offset: offset }, requestInit));
}
const newResults = await Promise.all(promises);
return firstResponse.data.concat(...newResults);
}
/**
* Will request a list of objects by an array of their ids (or similar) as a query parameter. This
* function also accepts extra parameters in the same format as {@link fetchMDSearch}.
*/
export async function fetchMDByArrayParam<T extends ListResponse>(
endpoint: string,
arr: (string | IDObject)[],
extraParams: ParameterObj = {},
arrayParam = 'ids',
paramLimit = 100,
requestInit?: CustomRequestInit,
): Promise<T['data']> {
const idArray = arr.map((elem) => (elem instanceof IDObject ? elem.id : elem));
const promises = [];
for (let i = 0; i < idArray.length; i += paramLimit) {
promises.push(
fetchMDData<T>(
endpoint,
{ ...extraParams, [arrayParam]: idArray.slice(i, i + paramLimit), limit: paramLimit },
requestInit,
),
);
}
const results = await Promise.all(promises);
// Reorder results so that they're in the same order as the id array
const sortedResults = results.flat();
sortedResults.sort((a, b) => idArray.indexOf(a.id) - idArray.indexOf(b.id));
return sortedResults;
}
/**
* Same as {@link fetchMD}, but it instead performs a request with a JSON body
*/
export async function fetchMDWithBody<T extends object>(
endpoint: string,
body: object,
params?: ParameterObj,
method = 'POST',
requestInit: CustomRequestInit = {},
): Promise<T> {
const headers = requestInit.headers !== undefined ? requestInit.headers : {};
headers['Content-Type'] = 'application/json';
return fetchMD<T>(endpoint, params, {
body: JSON.stringify(body),
method: method,
headers: headers,
});
}
/**
* Same as {@link fetchMDData}, but it instead performs a request with a JSON body
*/
export async function fetchMDDataWithBody<T extends { data: unknown }>(
endpoint: string,
body: object,
params?: ParameterObj,
method = 'POST',
requestInit: CustomRequestInit = {},
): Promise<T['data']> {
const res = await fetchMDWithBody<T>(endpoint, body, params, method, requestInit);
return res['data'];
}
/**
* Performs a POST fetch request to api.mangadex.network with a JSON body
*/
export async function postToMDNetwork(endpoint: string, body: object, params?: ParameterObj): Promise<void> {
const url = buildURL('https://api.mangadex.network', endpoint, params);
const res = await fetch(url, {
body: JSON.stringify(body),
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
if (!res.ok) throw new APIResponseError(`${res.status} ${res.statusText}`);
}
export async function fetchMDWithFormData<T extends object>(
endpoint: string,
body: Record<
string,
| string
| string[]
| Blob
| Blob[]
| { data: string | Blob; name: string }
| { data: string | Blob; name: string }[]
| undefined
| null
>,
params?: ParameterObj,
method = 'POST',
requestInit: CustomRequestInit = {},
): Promise<T> {
const formdata = new FormData();
const appendItem = (name: string, item: Blob | string | { data: string | Blob; name: string }) => {
if (typeof item !== 'string' && 'data' in item && 'name' in item) {
formdata.append(name, item.data as Blob, item.name);
} else {
formdata.append(name, item);
}
};
for (const [name, value] of Object.entries(body)) {
if (value) {
// MD accepts array values as name + index, not name[]
if (Array.isArray(value)) value.forEach((v, i) => appendItem(name + i, v));
else appendItem(name, value);
}
}
return await fetchMD<T>(endpoint, params, {
...requestInit,
method: method,
body: formdata,
});
}
/**
* Generate a url from a base domain, path, and parameter object
*/
export function buildURL(base: string, path?: string, params?: ParameterObj): URL {
const url = path ? new URL(path, base) : new URL(base);
if (!params) return url;
for (const [name, value] of Object.entries(params)) {
if (Array.isArray(value)) {
for (let i of value) {
if (i instanceof IDObject) i = i.id;
url.searchParams.append(`${name}[]`, i.toString());
}
} else if (typeof value === 'object') {
if (value instanceof IDObject) {
url.searchParams.append(name, value.id.toString());
} else {
const valueEntries = Object.entries(value);
for (const [k, v] of valueEntries) {
url.searchParams.append(`${name}[${k}]`, v.toString());
}
}
} else if (value !== undefined) {
url.searchParams.append(name, value.toString());
}
}
return url;
}
/**
* Checks if the current user is correctly authorized or if the specified session token is valid.
*/
export async function performAuthCheck(sessionToken?: string): Promise<boolean> {
try {
let options: CustomRequestInit | undefined = undefined;
if (sessionToken !== undefined) {
options = {
headers: {
authorization: `Bearer ${sessionToken}`,
},
noAuth: true,
};
}
const res = await fetchMD<CheckResponseSchema>('/auth/check', undefined, options);
return res.isAuthenticated;
} catch (err) {
if (err instanceof APIResponseError) return false;
else throw err;
}
}
/**
* Send a URL-encoded POST request to the MangaDex auth server
*/
export async function fetchMDAuth<T extends object>(endpoint: string, body: Record<string, string>): Promise<T> {
const params = new URLSearchParams();
for (const [name, value] of Object.entries(body)) params.append(name, value);
const domain = `https://auth.mangadex.${isDebugServerInUse() ? 'dev' : 'org'}`;
const url = new URL(endpoint, domain);
const res = await fetch(url, {
body: params,
method: 'POST',
});
if (res.status >= 400) {
throw new AuthError(`${res.statusText} (${res.status})`);
}
const resBody: T = await res.json();
return resBody;
}