UNPKG

meilisearch

Version:

The Meilisearch JS client for Node.js and the browser.

290 lines (244 loc) 7.97 kB
import type { Config, HttpRequestsRequestInit, RequestOptions, MainRequestOptions, URLSearchParamsRecord, MeiliSearchErrorResponse, } from "./types/index.js"; import { PACKAGE_VERSION } from "./package-version.js"; import { MeiliSearchError, MeiliSearchApiError, MeiliSearchRequestError, MeiliSearchRequestTimeOutError, } from "./errors/index.js"; import { addProtocolIfNotPresent, addTrailingSlash } from "./utils.js"; /** Append a set of key value pairs to a {@link URLSearchParams} object. */ function appendRecordToURLSearchParams( searchParams: URLSearchParams, recordToAppend: URLSearchParamsRecord, ): void { for (const [key, val] of Object.entries(recordToAppend)) { if (val != null) { searchParams.set( key, Array.isArray(val) ? val.join() : val instanceof Date ? val.toISOString() : String(val), ); } } } /** * Creates a new Headers object from a {@link HeadersInit} and adds various * properties to it, some from {@link Config}. * * @returns A new Headers object */ function getHeaders(config: Config, headersInit?: HeadersInit): Headers { const agentHeader = "X-Meilisearch-Client"; const packageAgent = `Meilisearch JavaScript (v${PACKAGE_VERSION})`; const contentType = "Content-Type"; const authorization = "Authorization"; const headers = new Headers(headersInit); // do not override if user provided the header if (config.apiKey && !headers.has(authorization)) { headers.set(authorization, `Bearer ${config.apiKey}`); } if (!headers.has(contentType)) { headers.set(contentType, "application/json"); } // Creates the custom user agent with information on the package used. if (config.clientAgents !== undefined) { const clients = config.clientAgents.concat(packageAgent); headers.set(agentHeader, clients.join(" ; ")); } else { headers.set(agentHeader, packageAgent); } return headers; } /** Used to identify whether an error is a timeout error after fetch request. */ const TIMEOUT_ID = Symbol("<timeout>"); /** * Attach a timeout signal to a {@link RequestInit}, while preserving original * signal functionality, if there is one. * * @remarks * This could be a short few straight forward lines using {@link AbortSignal.any} * and {@link AbortSignal.timeout}, but these aren't yet widely supported enough, * nor polyfill -able, at the time of writing. * @returns A new function which starts the timeout, which then returns another * function that clears the timeout */ function getTimeoutFn( requestInit: RequestInit, ms: number, ): () => (() => void) | void { const { signal } = requestInit; const ac = new AbortController(); if (signal != null) { let acSignalFn: (() => void) | null = null; if (signal.aborted) { ac.abort(signal.reason); } else { const fn = () => ac.abort(signal.reason); signal.addEventListener("abort", fn, { once: true }); acSignalFn = () => signal.removeEventListener("abort", fn); ac.signal.addEventListener("abort", acSignalFn, { once: true }); } return () => { if (signal.aborted) { return; } const to = setTimeout(() => ac.abort(TIMEOUT_ID), ms); const fn = () => { clearTimeout(to); if (acSignalFn !== null) { ac.signal.removeEventListener("abort", acSignalFn); } }; signal.addEventListener("abort", fn, { once: true }); return () => { signal.removeEventListener("abort", fn); fn(); }; }; } requestInit.signal = ac.signal; return () => { const to = setTimeout(() => ac.abort(TIMEOUT_ID), ms); return () => clearTimeout(to); }; } /** Class used to perform HTTP requests. */ export class HttpRequests { #url: URL; #requestInit: HttpRequestsRequestInit; #customRequestFn?: Config["httpClient"]; #requestTimeout?: Config["timeout"]; constructor(config: Config) { const host = addTrailingSlash(addProtocolIfNotPresent(config.host)); try { this.#url = new URL(host); } catch (error) { throw new MeiliSearchError("The provided host is not valid", { cause: error, }); } this.#requestInit = { ...config.requestInit, headers: getHeaders(config, config.requestInit?.headers), }; this.#customRequestFn = config.httpClient; this.#requestTimeout = config.timeout; } /** * Combines provided extra {@link RequestInit} headers, provided content type * and class instance RequestInit headers, prioritizing them in this order. * * @returns A new Headers object or the main headers of this class if no * headers are provided */ #getHeaders(extraHeaders?: HeadersInit, contentType?: string): Headers { if (extraHeaders === undefined && contentType === undefined) { return this.#requestInit.headers; } const headers = new Headers(extraHeaders); if (contentType !== undefined && !headers.has("Content-Type")) { headers.set("Content-Type", contentType); } for (const [key, val] of this.#requestInit.headers) { if (!headers.has(key)) { headers.set(key, val); } } return headers; } /** * Sends a request with {@link fetch} or a custom HTTP client, combining * parameters and class properties. * * @returns A promise containing the response */ async #request<T = unknown>({ path, method, params, contentType, body, extraRequestInit, }: MainRequestOptions): Promise<T> { const url = new URL(path, this.#url); if (params !== undefined) { appendRecordToURLSearchParams(url.searchParams, params); } const init: RequestInit = { method, body: contentType === undefined || typeof body !== "string" ? JSON.stringify(body) : body, ...extraRequestInit, ...this.#requestInit, headers: this.#getHeaders(extraRequestInit?.headers, contentType), }; const startTimeout = this.#requestTimeout !== undefined ? getTimeoutFn(init, this.#requestTimeout) : null; const stopTimeout = startTimeout?.(); let response: Response; let responseBody: string; try { if (this.#customRequestFn !== undefined) { // When using a custom HTTP client, the response should already be handled and ready to be returned return (await this.#customRequestFn(url, init)) as T; } response = await fetch(url, init); responseBody = await response.text(); } catch (error) { throw new MeiliSearchRequestError( url.toString(), Object.is(error, TIMEOUT_ID) ? new MeiliSearchRequestTimeOutError(this.#requestTimeout!, init) : error, ); } finally { stopTimeout?.(); } const parsedResponse = responseBody === "" ? undefined : (JSON.parse(responseBody) as T | MeiliSearchErrorResponse); if (!response.ok) { throw new MeiliSearchApiError( response, parsedResponse as MeiliSearchErrorResponse | undefined, ); } return parsedResponse as T; } /** Request with GET. */ get<T = unknown>(options: RequestOptions): Promise<T> { return this.#request<T>(options); } /** Request with POST. */ post<T = unknown>(options: RequestOptions): Promise<T> { return this.#request<T>({ ...options, method: "POST" }); } /** Request with PUT. */ put<T = unknown>(options: RequestOptions): Promise<T> { return this.#request<T>({ ...options, method: "PUT" }); } /** Request with PATCH. */ patch<T = unknown>(options: RequestOptions): Promise<T> { return this.#request<T>({ ...options, method: "PATCH" }); } /** Request with DELETE. */ delete<T = unknown>(options: RequestOptions): Promise<T> { return this.#request<T>({ ...options, method: "DELETE" }); } }