import { joinUrls } from './utils' import { isPlainObject } from './core/rtkImports' import type { BaseQueryApi, BaseQueryFn } from './baseQueryTypes' import type { MaybePromise, Override } from './tsHelpers' export type ResponseHandler = | 'content-type' | 'json' | 'text' | ((response: Response) => Promise) type CustomRequestInit = Override< RequestInit, { headers?: | Headers | string[][] | Record | undefined } > export interface FetchArgs extends CustomRequestInit { url: string params?: Record body?: any responseHandler?: ResponseHandler validateStatus?: (response: Response, body: any) => boolean /** * A number in milliseconds that represents that maximum time a request can take before timing out. */ timeout?: number } /** * A mini-wrapper that passes arguments straight through to * {@link [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API)}. * Avoids storing `fetch` in a closure, in order to permit mocking/monkey-patching. */ const defaultFetchFn: typeof fetch = (...args) => fetch(...args) const defaultValidateStatus = (response: Response) => response.status >= 200 && response.status <= 299 const defaultIsJsonContentType = (headers: Headers) => /*applicat*/ /ion\/(vnd\.api\+)?json/.test(headers.get('content-type') || '') export type FetchBaseQueryError = | { /** * * `number`: * HTTP status code */ status: number data: unknown } | { /** * * `"FETCH_ERROR"`: * An error that occurred during execution of `fetch` or the `fetchFn` callback option **/ status: 'FETCH_ERROR' data?: undefined error: string } | { /** * * `"PARSING_ERROR"`: * An error happened during parsing. * Most likely a non-JSON-response was returned with the default `responseHandler` "JSON", * or an error occurred while executing a custom `responseHandler`. **/ status: 'PARSING_ERROR' originalStatus: number data: string error: string } | { /** * * `"TIMEOUT_ERROR"`: * Request timed out **/ status: 'TIMEOUT_ERROR' data?: undefined error: string } | { /** * * `"CUSTOM_ERROR"`: * A custom error type that you can return from your `queryFn` where another error might not make sense. **/ status: 'CUSTOM_ERROR' data?: unknown error: string } function stripUndefined(obj: any) { if (!isPlainObject(obj)) { return obj } const copy: Record = { ...obj } for (const [k, v] of Object.entries(copy)) { if (v === undefined) delete copy[k] } return copy } export type FetchBaseQueryArgs = { baseUrl?: string prepareHeaders?: ( headers: Headers, api: Pick< BaseQueryApi, 'getState' | 'extra' | 'endpoint' | 'type' | 'forced' >, ) => MaybePromise fetchFn?: ( input: RequestInfo, init?: RequestInit | undefined, ) => Promise paramsSerializer?: (params: Record) => string /** * By default, we only check for 'application/json' and 'application/vnd.api+json' as the content-types for json. If you need to support another format, you can pass * in a predicate function for your given api to get the same automatic stringifying behavior * @example * ```ts * const isJsonContentType = (headers: Headers) => ["application/vnd.api+json", "application/json", "application/vnd.hal+json"].includes(headers.get("content-type")?.trim()); * ``` */ isJsonContentType?: (headers: Headers) => boolean /** * Defaults to `application/json`; */ jsonContentType?: string /** * Custom replacer function used when calling `JSON.stringify()`; */ jsonReplacer?: (this: any, key: string, value: any) => any } & RequestInit & Pick export type FetchBaseQueryMeta = { request: Request; response?: Response } /** * This is a very small wrapper around fetch that aims to simplify requests. * * @example * ```ts * const baseQuery = fetchBaseQuery({ * baseUrl: 'https://api.your-really-great-app.com/v1/', * prepareHeaders: (headers, { getState }) => { * const token = (getState() as RootState).auth.token; * // If we have a token set in state, let's assume that we should be passing it. * if (token) { * headers.set('authorization', `Bearer ${token}`); * } * return headers; * }, * }) * ``` * * @param {string} baseUrl * The base URL for an API service. * Typically in the format of https://example.com/ * * @param {(headers: Headers, api: { getState: () => unknown; extra: unknown; endpoint: string; type: 'query' | 'mutation'; forced: boolean; }) => Headers} prepareHeaders * An optional function that can be used to inject headers on requests. * Provides a Headers object, as well as most of the `BaseQueryApi` (`dispatch` is not available). * Useful for setting authentication or headers that need to be set conditionally. * * @link https://developer.mozilla.org/en-US/docs/Web/API/Headers * * @param {(input: RequestInfo, init?: RequestInit | undefined) => Promise} fetchFn * Accepts a custom `fetch` function if you do not want to use the default on the window. * Useful in SSR environments if you need to use a library such as `isomorphic-fetch` or `cross-fetch` * * @param {(params: Record) => string} paramsSerializer * An optional function that can be used to stringify querystring parameters. * * @param {(headers: Headers) => boolean} isJsonContentType * An optional predicate function to determine if `JSON.stringify()` should be called on the `body` arg of `FetchArgs` * * @param {string} jsonContentType Used when automatically setting the content-type header for a request with a jsonifiable body that does not have an explicit content-type header. Defaults to `application/json`. * * @param {(this: any, key: string, value: any) => any} jsonReplacer Custom replacer function used when calling `JSON.stringify()`. * * @param {number} timeout * A number in milliseconds that represents the maximum time a request can take before timing out. */ export function fetchBaseQuery({ baseUrl, prepareHeaders = (x) => x, fetchFn = defaultFetchFn, paramsSerializer, isJsonContentType = defaultIsJsonContentType, jsonContentType = 'application/json', jsonReplacer, timeout: defaultTimeout, responseHandler: globalResponseHandler, validateStatus: globalValidateStatus, ...baseFetchOptions }: FetchBaseQueryArgs = {}): BaseQueryFn< string | FetchArgs, unknown, FetchBaseQueryError, {}, FetchBaseQueryMeta > { if (typeof fetch === 'undefined' && fetchFn === defaultFetchFn) { console.warn( 'Warning: `fetch` is not available. Please supply a custom `fetchFn` property to use `fetchBaseQuery` on SSR environments.', ) } return async (arg, api) => { const { signal, getState, extra, endpoint, forced, type } = api let meta: FetchBaseQueryMeta | undefined let { url, headers = new Headers(baseFetchOptions.headers), params = undefined, responseHandler = globalResponseHandler ?? ('json' as const), validateStatus = globalValidateStatus ?? defaultValidateStatus, timeout = defaultTimeout, ...rest } = typeof arg == 'string' ? { url: arg } : arg let config: RequestInit = { ...baseFetchOptions, signal, ...rest, } headers = new Headers(stripUndefined(headers)) config.headers = (await prepareHeaders(headers, { getState, extra, endpoint, forced, type, })) || headers // Only set the content-type to json if appropriate. Will not be true for FormData, ArrayBuffer, Blob, etc. const isJsonifiable = (body: any) => typeof body === 'object' && (isPlainObject(body) || Array.isArray(body) || typeof body.toJSON === 'function') if (!config.headers.has('content-type') && isJsonifiable(config.body)) { config.headers.set('content-type', jsonContentType) } if (isJsonifiable(config.body) && isJsonContentType(config.headers)) { config.body = JSON.stringify(config.body, jsonReplacer) } if (params) { const divider = ~url.indexOf('?') ? '&' : '?' const query = paramsSerializer ? paramsSerializer(params) : new URLSearchParams(stripUndefined(params)) url += divider + query } url = joinUrls(baseUrl, url) const request = new Request(url, config) const requestClone = new Request(url, config) meta = { request: requestClone } let response, timedOut = false, timeoutId = timeout && setTimeout(() => { timedOut = true api.abort() }, timeout) try { response = await fetchFn(request) } catch (e) { return { error: { status: timedOut ? 'TIMEOUT_ERROR' : 'FETCH_ERROR', error: String(e), }, meta, } } finally { if (timeoutId) clearTimeout(timeoutId) } const responseClone = response.clone() meta.response = responseClone let resultData: any let responseText: string = '' try { let handleResponseError await Promise.all([ handleResponse(response, responseHandler).then( (r) => (resultData = r), (e) => (handleResponseError = e), ), // see https://github.com/node-fetch/node-fetch/issues/665#issuecomment-538995182 // we *have* to "use up" both streams at the same time or they will stop running in node-fetch scenarios responseClone.text().then( (r) => (responseText = r), () => {}, ), ]) if (handleResponseError) throw handleResponseError } catch (e) { return { error: { status: 'PARSING_ERROR', originalStatus: response.status, data: responseText, error: String(e), }, meta, } } return validateStatus(response, resultData) ? { data: resultData, meta, } : { error: { status: response.status, data: resultData, }, meta, } } async function handleResponse( response: Response, responseHandler: ResponseHandler, ) { if (typeof responseHandler === 'function') { return responseHandler(response) } if (responseHandler === 'content-type') { responseHandler = isJsonContentType(response.headers) ? 'json' : 'text' } if (responseHandler === 'json') { const text = await response.text() return text.length ? JSON.parse(text) : null } return response.text() } }