UNPKG

@gw2api/fetch

Version:

Tiny wrapper around fetch that returns type-safe responses

132 lines (103 loc) 4.77 kB
import type { AuthenticatedOptions, EndpointType, KnownEndpoint, LocalizedOptions, OptionsByEndpoint } from '@gw2api/types/endpoints'; import { SchemaVersion } from '@gw2api/types/schema'; type RequiredKeys<T> = { [K in keyof T]-?: {} extends Pick<T, K> ? never : K }[keyof T]; // if OptionsByEndpoint<Url> has no required keys, make the options parameter optional type Args<Url extends string, Schema extends SchemaVersion> = RequiredKeys<OptionsByEndpoint<Url>> extends never ? [endpoint: Url, options?: FetchGw2ApiOptions<Schema> & OptionsByEndpoint<Url> & FetchOptions] : [endpoint: Url, options: FetchGw2ApiOptions<Schema> & OptionsByEndpoint<Url> & FetchOptions] export async function fetchGw2Api< Url extends KnownEndpoint | (string & {}), Schema extends SchemaVersion = undefined >( ...[endpoint, options]: Args<Url, Schema> ): Promise<EndpointType<Url, Schema>> { const url = new URL(endpoint, 'https://api.guildwars2.com/'); if(options.schema) { url.searchParams.set('v', options.schema); } if(hasLanguage(options)) { url.searchParams.set('lang', options.language); } if(hasAccessToken(options)) { url.searchParams.set('access_token', options.accessToken); } // build request let request = new Request(url, { // The GW2 API never uses redirects, so we want to error if we encounter one. // We use `manual` instead of `error` here so we can throw our own `Gw2ApiError` with the response attached redirect: 'manual', // set signal and cache from options signal: options.signal, cache: options.cache }); // if there is a onRequest handler registered, let it modify the request if(options.onRequest) { request = await options.onRequest(request); if(!(request instanceof Request)) { throw new Error(`onRequest has to return a Request`); } } // call the API const response = await fetch(request); // call onResponse handler await options.onResponse?.(response); // check if the response is json (`application/json; charset=utf-8`) const isJson = response.headers.get('content-type').startsWith('application/json'); // censor access token in url to not leak it in error messages const erroredUrl = hasAccessToken(options) ? url.toString().replace(options.accessToken, '***') : url.toString(); // check if the response is an error if(!response.ok) { // if the response is JSON, it might have more details in the `text` prop if(isJson) { const error: unknown = await response.json(); if(typeof error === 'object' && 'text' in error && typeof error.text === 'string') { throw new Gw2ApiError(`The GW2 API call to '${erroredUrl}' returned ${response.status} ${response.statusText}: ${error.text}.`, response); } } // otherwise just throw error with the status code throw new Gw2ApiError(`The GW2 API call to '${erroredUrl}' returned ${response.status} ${response.statusText}.`, response); } // if the response is not JSON, throw an error if(!isJson) { throw new Gw2ApiError(`The GW2 API call to '${erroredUrl}' did not respond with a JSON response`, response); } // parse json const json = await response.json(); // check that json is not `["v1", "v2"]` which sometimes happens for authenticated endpoints if(url.toString() !== 'https://api.guildwars2.com/' && Array.isArray(json) && json.length === 2 && json[0] === 'v1' && json[1] === 'v2') { throw new Gw2ApiError(`The GW2 API call to '${erroredUrl}' did returned an invalid response (["v1", "v2"])`, response); } // TODO: catch more errors return json; } export type FetchGw2ApiOptions<Schema extends SchemaVersion> = { /** The schema to use when making the API request */ schema?: Schema; /** onRequest handler allows to modify the request made to the Guild Wars 2 API. */ onRequest?: (request: Request) => Request | Promise<Request>; /** * onResponse handler. Called for all responses, successful or not. * Make sure to clone the response in case of consuming the body. */ onResponse?: (response: Response) => void | Promise<void>; } export type FetchOptions = { /** @see https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal */ signal?: AbortSignal, /** @see https://developer.mozilla.org/en-US/docs/Web/API/Request/cache */ cache?: RequestCache, } export class Gw2ApiError extends Error { constructor(message: string, public response: Response) { super(message); this.name = 'Gw2ApiError'; } } function hasLanguage(options: OptionsByEndpoint<any>): options is LocalizedOptions { return 'language' in options; } function hasAccessToken(options: OptionsByEndpoint<any>): options is AuthenticatedOptions { return 'accessToken' in options; }