import { Config, EnqueuedTaskObject } from './types' import { PACKAGE_VERSION } from './package-version' import { MeiliSearchError, httpResponseErrorHandler, httpErrorHandler, } from './errors' import { addTrailingSlash, addProtocolIfNotPresent } from './utils' type queryParams = { [key in keyof T]: string } function toQueryParams(parameters: T): queryParams { const params = Object.keys(parameters) as Array const queryParams = params.reduce>((acc, key) => { const value = parameters[key] if (value === undefined) { return acc } else if (Array.isArray(value)) { return { ...acc, [key]: value.join(',') } } else if (value instanceof Date) { return { ...acc, [key]: value.toISOString() } } return { ...acc, [key]: value } }, {} as queryParams) return queryParams } function constructHostURL(host: string): string { try { host = addProtocolIfNotPresent(host) host = addTrailingSlash(host) return host } catch (e) { throw new MeiliSearchError('The provided host is not valid.') } } function cloneAndParseHeaders(headers: HeadersInit): Record { if (Array.isArray(headers)) { return headers.reduce((acc, headerPair) => { acc[headerPair[0]] = headerPair[1] return acc }, {} as Record) } else if ('has' in headers) { const clonedHeaders: Record = {} ;(headers as Headers).forEach((value, key) => (clonedHeaders[key] = value)) return clonedHeaders } else { return Object.assign({}, headers) } } function createHeaders(config: Config): Record { const agentHeader = 'X-Meilisearch-Client' const packageAgent = `Meilisearch JavaScript (v${PACKAGE_VERSION})` const contentType = 'Content-Type' const authorization = 'Authorization' const headers = cloneAndParseHeaders(config.requestConfig?.headers ?? {}) // do not override if user provided the header if (config.apiKey && !headers[authorization]) { headers[authorization] = `Bearer ${config.apiKey}` } if (!headers[contentType]) { headers['Content-Type'] = 'application/json' } // Creates the custom user agent with information on the package used. if (config.clientAgents && Array.isArray(config.clientAgents)) { const clients = config.clientAgents.concat(packageAgent) headers[agentHeader] = clients.join(' ; ') } else if (config.clientAgents && !Array.isArray(config.clientAgents)) { // If the header is defined but not an array throw new MeiliSearchError( `Meilisearch: The header "${agentHeader}" should be an array of string(s).\n` ) } else { headers[agentHeader] = packageAgent } return headers } class HttpRequests { headers: Record url: URL requestConfig?: Config['requestConfig'] httpClient?: Required['httpClient'] requestTimeout?: number constructor(config: Config) { this.headers = createHeaders(config) this.requestConfig = config.requestConfig this.httpClient = config.httpClient this.requestTimeout = config.timeout try { const host = constructHostURL(config.host) this.url = new URL(host) } catch (e) { throw new MeiliSearchError('The provided host is not valid.') } } async request({ method, url, params, body, config = {}, }: { method: string url: string params?: { [key: string]: any } body?: any config?: Record }) { if (typeof fetch === 'undefined') { require('cross-fetch/polyfill') } const constructURL = new URL(url, this.url) if (params) { const queryParams = new URLSearchParams() Object.keys(params) .filter((x: string) => params[x] !== null) .map((x: string) => queryParams.set(x, params[x])) constructURL.search = queryParams.toString() } // in case a custom content-type is provided // do not stringify body if (!config.headers?.['Content-Type']) { body = JSON.stringify(body) } const headers = { ...this.headers, ...config.headers } try { const result = this.fetchWithTimeout( constructURL.toString(), { ...config, ...this.requestConfig, method, body, headers, }, this.requestTimeout ) // When using a custom HTTP client, the response is returned to allow the user to parse/handle it as they see fit if (this.httpClient) { return await result } const response = await result.then((res: any) => httpResponseErrorHandler(res) ) const parsedBody = await response.json().catch(() => undefined) return parsedBody } catch (e: any) { const stack = e.stack httpErrorHandler(e, stack, constructURL.toString()) } } async fetchWithTimeout( url: string, options: Record | RequestInit | undefined, timeout: HttpRequests['requestTimeout'] ): Promise { return new Promise((resolve, reject) => { const fetchFn = this.httpClient ? this.httpClient : fetch const fetchPromise = fetchFn(url, options) const promises: Array> = [fetchPromise] // TimeoutPromise will not run if undefined or zero let timeoutId: ReturnType if (timeout) { const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { reject(new Error('Error: Request Timed Out')) }, timeout) }) promises.push(timeoutPromise) } Promise.race(promises) .then(resolve) .catch(reject) .finally(() => { clearTimeout(timeoutId) }) }) } async get( url: string, params?: { [key: string]: any }, config?: Record ): Promise async get( url: string, params?: { [key: string]: any }, config?: Record ): Promise async get( url: string, params?: { [key: string]: any }, config?: Record ): Promise { return await this.request({ method: 'GET', url, params, config, }) } async post( url: string, data?: T, params?: { [key: string]: any }, config?: Record ): Promise async post( url: string, data?: any, params?: { [key: string]: any }, config?: Record ): Promise { return await this.request({ method: 'POST', url, body: data, params, config, }) } async put( url: string, data?: T, params?: { [key: string]: any }, config?: Record ): Promise async put( url: string, data?: any, params?: { [key: string]: any }, config?: Record ): Promise { return await this.request({ method: 'PUT', url, body: data, params, config, }) } async patch( url: string, data?: any, params?: { [key: string]: any }, config?: Record ): Promise { return await this.request({ method: 'PATCH', url, body: data, params, config, }) } async delete( url: string, data?: any, params?: { [key: string]: any }, config?: Record ): Promise async delete( url: string, data?: any, params?: { [key: string]: any }, config?: Record ): Promise async delete( url: string, data?: any, params?: { [key: string]: any }, config?: Record ): Promise { return await this.request({ method: 'DELETE', url, body: data, params, config, }) } } export { HttpRequests, toQueryParams }