| 1 | import { joinUrls } from './utils'
|
|---|
| 2 | import { isPlainObject } from './core/rtkImports'
|
|---|
| 3 | import type { BaseQueryApi, BaseQueryFn } from './baseQueryTypes'
|
|---|
| 4 | import type { MaybePromise, Override } from './tsHelpers'
|
|---|
| 5 | import { anySignal, timeoutSignal } from './utils/signals'
|
|---|
| 6 |
|
|---|
| 7 | export type ResponseHandler =
|
|---|
| 8 | | 'content-type'
|
|---|
| 9 | | 'json'
|
|---|
| 10 | | 'text'
|
|---|
| 11 | | ((response: Response) => Promise<any>)
|
|---|
| 12 |
|
|---|
| 13 | type CustomRequestInit = Override<
|
|---|
| 14 | RequestInit,
|
|---|
| 15 | {
|
|---|
| 16 | headers?:
|
|---|
| 17 | | Headers
|
|---|
| 18 | | string[][]
|
|---|
| 19 | | Record<string, string | undefined>
|
|---|
| 20 | | undefined
|
|---|
| 21 | }
|
|---|
| 22 | >
|
|---|
| 23 |
|
|---|
| 24 | export interface FetchArgs extends CustomRequestInit {
|
|---|
| 25 | url: string
|
|---|
| 26 | params?: Record<string, any>
|
|---|
| 27 | body?: any
|
|---|
| 28 | responseHandler?: ResponseHandler
|
|---|
| 29 | validateStatus?: (response: Response, body: any) => boolean
|
|---|
| 30 | /**
|
|---|
| 31 | * A number in milliseconds that represents that maximum time a request can take before timing out.
|
|---|
| 32 | */
|
|---|
| 33 | timeout?: number
|
|---|
| 34 | }
|
|---|
| 35 |
|
|---|
| 36 | /**
|
|---|
| 37 | * A mini-wrapper that passes arguments straight through to
|
|---|
| 38 | * {@link [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API)}.
|
|---|
| 39 | * Avoids storing `fetch` in a closure, in order to permit mocking/monkey-patching.
|
|---|
| 40 | */
|
|---|
| 41 | const defaultFetchFn: typeof fetch = (...args) => fetch(...args)
|
|---|
| 42 |
|
|---|
| 43 | const defaultValidateStatus = (response: Response) =>
|
|---|
| 44 | response.status >= 200 && response.status <= 299
|
|---|
| 45 |
|
|---|
| 46 | const defaultIsJsonContentType = (headers: Headers) =>
|
|---|
| 47 | /*applicat*/ /ion\/(vnd\.api\+)?json/.test(headers.get('content-type') || '')
|
|---|
| 48 |
|
|---|
| 49 | export type FetchBaseQueryError =
|
|---|
| 50 | | {
|
|---|
| 51 | /**
|
|---|
| 52 | * * `number`:
|
|---|
| 53 | * HTTP status code
|
|---|
| 54 | */
|
|---|
| 55 | status: number
|
|---|
| 56 | data: unknown
|
|---|
| 57 | }
|
|---|
| 58 | | {
|
|---|
| 59 | /**
|
|---|
| 60 | * * `"FETCH_ERROR"`:
|
|---|
| 61 | * An error that occurred during execution of `fetch` or the `fetchFn` callback option
|
|---|
| 62 | **/
|
|---|
| 63 | status: 'FETCH_ERROR'
|
|---|
| 64 | data?: undefined
|
|---|
| 65 | error: string
|
|---|
| 66 | }
|
|---|
| 67 | | {
|
|---|
| 68 | /**
|
|---|
| 69 | * * `"PARSING_ERROR"`:
|
|---|
| 70 | * An error happened during parsing.
|
|---|
| 71 | * Most likely a non-JSON-response was returned with the default `responseHandler` "JSON",
|
|---|
| 72 | * or an error occurred while executing a custom `responseHandler`.
|
|---|
| 73 | **/
|
|---|
| 74 | status: 'PARSING_ERROR'
|
|---|
| 75 | originalStatus: number
|
|---|
| 76 | data: string
|
|---|
| 77 | error: string
|
|---|
| 78 | }
|
|---|
| 79 | | {
|
|---|
| 80 | /**
|
|---|
| 81 | * * `"TIMEOUT_ERROR"`:
|
|---|
| 82 | * Request timed out
|
|---|
| 83 | **/
|
|---|
| 84 | status: 'TIMEOUT_ERROR'
|
|---|
| 85 | data?: undefined
|
|---|
| 86 | error: string
|
|---|
| 87 | }
|
|---|
| 88 | | {
|
|---|
| 89 | /**
|
|---|
| 90 | * * `"CUSTOM_ERROR"`:
|
|---|
| 91 | * A custom error type that you can return from your `queryFn` where another error might not make sense.
|
|---|
| 92 | **/
|
|---|
| 93 | status: 'CUSTOM_ERROR'
|
|---|
| 94 | data?: unknown
|
|---|
| 95 | error: string
|
|---|
| 96 | }
|
|---|
| 97 |
|
|---|
| 98 | function stripUndefined(obj: any) {
|
|---|
| 99 | if (!isPlainObject(obj)) {
|
|---|
| 100 | return obj
|
|---|
| 101 | }
|
|---|
| 102 | const copy: Record<string, any> = { ...obj }
|
|---|
| 103 | for (const [k, v] of Object.entries(copy)) {
|
|---|
| 104 | if (v === undefined) delete copy[k]
|
|---|
| 105 | }
|
|---|
| 106 | return copy
|
|---|
| 107 | }
|
|---|
| 108 |
|
|---|
| 109 | // Only set the content-type to json if appropriate. Will not be true for FormData, ArrayBuffer, Blob, etc.
|
|---|
| 110 | const isJsonifiable = (body: any) =>
|
|---|
| 111 | typeof body === 'object' &&
|
|---|
| 112 | (isPlainObject(body) ||
|
|---|
| 113 | Array.isArray(body) ||
|
|---|
| 114 | typeof body.toJSON === 'function')
|
|---|
| 115 |
|
|---|
| 116 | export type FetchBaseQueryArgs = {
|
|---|
| 117 | baseUrl?: string
|
|---|
| 118 | prepareHeaders?: (
|
|---|
| 119 | headers: Headers,
|
|---|
| 120 | api: Pick<
|
|---|
| 121 | BaseQueryApi,
|
|---|
| 122 | 'getState' | 'extra' | 'endpoint' | 'type' | 'forced'
|
|---|
| 123 | > & { arg: string | FetchArgs; extraOptions: unknown },
|
|---|
| 124 | ) => MaybePromise<Headers | void>
|
|---|
| 125 | fetchFn?: (
|
|---|
| 126 | input: RequestInfo,
|
|---|
| 127 | init?: RequestInit | undefined,
|
|---|
| 128 | ) => Promise<Response>
|
|---|
| 129 | paramsSerializer?: (params: Record<string, any>) => string
|
|---|
| 130 | /**
|
|---|
| 131 | * 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
|
|---|
| 132 | * in a predicate function for your given api to get the same automatic stringifying behavior
|
|---|
| 133 | * @example
|
|---|
| 134 | * ```ts
|
|---|
| 135 | * const isJsonContentType = (headers: Headers) => ["application/vnd.api+json", "application/json", "application/vnd.hal+json"].includes(headers.get("content-type")?.trim());
|
|---|
| 136 | * ```
|
|---|
| 137 | */
|
|---|
| 138 | isJsonContentType?: (headers: Headers) => boolean
|
|---|
| 139 | /**
|
|---|
| 140 | * Defaults to `application/json`;
|
|---|
| 141 | */
|
|---|
| 142 | jsonContentType?: string
|
|---|
| 143 |
|
|---|
| 144 | /**
|
|---|
| 145 | * Custom replacer function used when calling `JSON.stringify()`;
|
|---|
| 146 | */
|
|---|
| 147 | jsonReplacer?: (this: any, key: string, value: any) => any
|
|---|
| 148 | } & RequestInit &
|
|---|
| 149 | Pick<FetchArgs, 'responseHandler' | 'validateStatus' | 'timeout'>
|
|---|
| 150 |
|
|---|
| 151 | export type FetchBaseQueryMeta = { request: Request; response?: Response }
|
|---|
| 152 |
|
|---|
| 153 | /**
|
|---|
| 154 | * This is a very small wrapper around fetch that aims to simplify requests.
|
|---|
| 155 | *
|
|---|
| 156 | * @example
|
|---|
| 157 | * ```ts
|
|---|
| 158 | * const baseQuery = fetchBaseQuery({
|
|---|
| 159 | * baseUrl: 'https://api.your-really-great-app.com/v1/',
|
|---|
| 160 | * prepareHeaders: (headers, { getState }) => {
|
|---|
| 161 | * const token = (getState() as RootState).auth.token;
|
|---|
| 162 | * // If we have a token set in state, let's assume that we should be passing it.
|
|---|
| 163 | * if (token) {
|
|---|
| 164 | * headers.set('authorization', `Bearer ${token}`);
|
|---|
| 165 | * }
|
|---|
| 166 | * return headers;
|
|---|
| 167 | * },
|
|---|
| 168 | * })
|
|---|
| 169 | * ```
|
|---|
| 170 | *
|
|---|
| 171 | * @param {string} baseUrl
|
|---|
| 172 | * The base URL for an API service.
|
|---|
| 173 | * Typically in the format of https://example.com/
|
|---|
| 174 | *
|
|---|
| 175 | * @param {(headers: Headers, api: { getState: () => unknown; arg: string | FetchArgs; extra: unknown; endpoint: string; type: 'query' | 'mutation'; forced: boolean; }) => Headers} prepareHeaders
|
|---|
| 176 | * An optional function that can be used to inject headers on requests.
|
|---|
| 177 | * Provides a Headers object, most of the `BaseQueryApi` (`dispatch` is not available), and the arg passed into the query function.
|
|---|
| 178 | * Useful for setting authentication or headers that need to be set conditionally.
|
|---|
| 179 | *
|
|---|
| 180 | * @link https://developer.mozilla.org/en-US/docs/Web/API/Headers
|
|---|
| 181 | *
|
|---|
| 182 | * @param {(input: RequestInfo, init?: RequestInit | undefined) => Promise<Response>} fetchFn
|
|---|
| 183 | * Accepts a custom `fetch` function if you do not want to use the default on the window.
|
|---|
| 184 | * Useful in SSR environments if you need to use a library such as `isomorphic-fetch` or `cross-fetch`
|
|---|
| 185 | *
|
|---|
| 186 | * @param {(params: Record<string, unknown>) => string} paramsSerializer
|
|---|
| 187 | * An optional function that can be used to stringify querystring parameters.
|
|---|
| 188 | *
|
|---|
| 189 | * @param {(headers: Headers) => boolean} isJsonContentType
|
|---|
| 190 | * An optional predicate function to determine if `JSON.stringify()` should be called on the `body` arg of `FetchArgs`
|
|---|
| 191 | *
|
|---|
| 192 | * @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`.
|
|---|
| 193 | *
|
|---|
| 194 | * @param {(this: any, key: string, value: any) => any} jsonReplacer Custom replacer function used when calling `JSON.stringify()`.
|
|---|
| 195 | *
|
|---|
| 196 | * @param {number} timeout
|
|---|
| 197 | * A number in milliseconds that represents the maximum time a request can take before timing out.
|
|---|
| 198 | */
|
|---|
| 199 |
|
|---|
| 200 | export function fetchBaseQuery({
|
|---|
| 201 | baseUrl,
|
|---|
| 202 | prepareHeaders = (x) => x,
|
|---|
| 203 | fetchFn = defaultFetchFn,
|
|---|
| 204 | paramsSerializer,
|
|---|
| 205 | isJsonContentType = defaultIsJsonContentType,
|
|---|
| 206 | jsonContentType = 'application/json',
|
|---|
| 207 | jsonReplacer,
|
|---|
| 208 | timeout: defaultTimeout,
|
|---|
| 209 | responseHandler: globalResponseHandler,
|
|---|
| 210 | validateStatus: globalValidateStatus,
|
|---|
| 211 | ...baseFetchOptions
|
|---|
| 212 | }: FetchBaseQueryArgs = {}): BaseQueryFn<
|
|---|
| 213 | string | FetchArgs,
|
|---|
| 214 | unknown,
|
|---|
| 215 | FetchBaseQueryError,
|
|---|
| 216 | {},
|
|---|
| 217 | FetchBaseQueryMeta
|
|---|
| 218 | > {
|
|---|
| 219 | if (typeof fetch === 'undefined' && fetchFn === defaultFetchFn) {
|
|---|
| 220 | console.warn(
|
|---|
| 221 | 'Warning: `fetch` is not available. Please supply a custom `fetchFn` property to use `fetchBaseQuery` on SSR environments.',
|
|---|
| 222 | )
|
|---|
| 223 | }
|
|---|
| 224 | return async (arg, api, extraOptions) => {
|
|---|
| 225 | const { getState, extra, endpoint, forced, type } = api
|
|---|
| 226 | let meta: FetchBaseQueryMeta | undefined
|
|---|
| 227 | let {
|
|---|
| 228 | url,
|
|---|
| 229 | headers = new Headers(baseFetchOptions.headers),
|
|---|
| 230 | params = undefined,
|
|---|
| 231 | responseHandler = globalResponseHandler ?? ('json' as const),
|
|---|
| 232 | validateStatus = globalValidateStatus ?? defaultValidateStatus,
|
|---|
| 233 | timeout = defaultTimeout,
|
|---|
| 234 | ...rest
|
|---|
| 235 | } = typeof arg == 'string' ? { url: arg } : arg
|
|---|
| 236 |
|
|---|
| 237 | let config: RequestInit = {
|
|---|
| 238 | ...baseFetchOptions,
|
|---|
| 239 | signal: timeout
|
|---|
| 240 | ? anySignal(api.signal, timeoutSignal(timeout))
|
|---|
| 241 | : api.signal,
|
|---|
| 242 | ...rest,
|
|---|
| 243 | }
|
|---|
| 244 |
|
|---|
| 245 | headers = new Headers(stripUndefined(headers))
|
|---|
| 246 | config.headers =
|
|---|
| 247 | (await prepareHeaders(headers, {
|
|---|
| 248 | getState,
|
|---|
| 249 | arg,
|
|---|
| 250 | extra,
|
|---|
| 251 | endpoint,
|
|---|
| 252 | forced,
|
|---|
| 253 | type,
|
|---|
| 254 | extraOptions,
|
|---|
| 255 | })) || headers
|
|---|
| 256 |
|
|---|
| 257 | const bodyIsJsonifiable = isJsonifiable(config.body)
|
|---|
| 258 |
|
|---|
| 259 | // Remove content-type for non-jsonifiable bodies to let the browser set it automatically
|
|---|
| 260 | // Exception: keep content-type for string bodies as they might be intentional (text/plain, text/html, etc.)
|
|---|
| 261 | if (
|
|---|
| 262 | config.body != null &&
|
|---|
| 263 | !bodyIsJsonifiable &&
|
|---|
| 264 | typeof config.body !== 'string'
|
|---|
| 265 | ) {
|
|---|
| 266 | config.headers.delete('content-type')
|
|---|
| 267 | }
|
|---|
| 268 |
|
|---|
| 269 | if (!config.headers.has('content-type') && bodyIsJsonifiable) {
|
|---|
| 270 | config.headers.set('content-type', jsonContentType)
|
|---|
| 271 | }
|
|---|
| 272 |
|
|---|
| 273 | if (bodyIsJsonifiable && isJsonContentType(config.headers)) {
|
|---|
| 274 | config.body = JSON.stringify(config.body, jsonReplacer)
|
|---|
| 275 | }
|
|---|
| 276 |
|
|---|
| 277 | // Set Accept header based on responseHandler if not already set
|
|---|
| 278 | if (!config.headers.has('accept')) {
|
|---|
| 279 | if (responseHandler === 'json') {
|
|---|
| 280 | config.headers.set('accept', 'application/json')
|
|---|
| 281 | } else if (responseHandler === 'text') {
|
|---|
| 282 | config.headers.set('accept', 'text/plain, text/html, */*')
|
|---|
| 283 | }
|
|---|
| 284 | // For 'content-type' responseHandler, don't set Accept (let server decide)
|
|---|
| 285 | }
|
|---|
| 286 |
|
|---|
| 287 | if (params) {
|
|---|
| 288 | const divider = ~url.indexOf('?') ? '&' : '?'
|
|---|
| 289 | const query = paramsSerializer
|
|---|
| 290 | ? paramsSerializer(params)
|
|---|
| 291 | : new URLSearchParams(stripUndefined(params))
|
|---|
| 292 | url += divider + query
|
|---|
| 293 | }
|
|---|
| 294 |
|
|---|
| 295 | url = joinUrls(baseUrl, url)
|
|---|
| 296 |
|
|---|
| 297 | const request = new Request(url, config)
|
|---|
| 298 | const requestClone = new Request(url, config)
|
|---|
| 299 | meta = { request: requestClone }
|
|---|
| 300 |
|
|---|
| 301 | let response
|
|---|
| 302 | try {
|
|---|
| 303 | response = await fetchFn(request)
|
|---|
| 304 | } catch (e) {
|
|---|
| 305 | return {
|
|---|
| 306 | error: {
|
|---|
| 307 | status:
|
|---|
| 308 | (e instanceof Error ||
|
|---|
| 309 | (typeof DOMException !== 'undefined' &&
|
|---|
| 310 | e instanceof DOMException)) &&
|
|---|
| 311 | e.name === 'TimeoutError'
|
|---|
| 312 | ? 'TIMEOUT_ERROR'
|
|---|
| 313 | : 'FETCH_ERROR',
|
|---|
| 314 | error: String(e),
|
|---|
| 315 | },
|
|---|
| 316 | meta,
|
|---|
| 317 | }
|
|---|
| 318 | }
|
|---|
| 319 | const responseClone = response.clone()
|
|---|
| 320 |
|
|---|
| 321 | meta.response = responseClone
|
|---|
| 322 |
|
|---|
| 323 | let resultData: any
|
|---|
| 324 | let responseText: string = ''
|
|---|
| 325 | try {
|
|---|
| 326 | let handleResponseError
|
|---|
| 327 | await Promise.all([
|
|---|
| 328 | handleResponse(response, responseHandler).then(
|
|---|
| 329 | (r) => (resultData = r),
|
|---|
| 330 | (e) => (handleResponseError = e),
|
|---|
| 331 | ),
|
|---|
| 332 | // see https://github.com/node-fetch/node-fetch/issues/665#issuecomment-538995182
|
|---|
| 333 | // we *have* to "use up" both streams at the same time or they will stop running in node-fetch scenarios
|
|---|
| 334 | responseClone.text().then(
|
|---|
| 335 | (r) => (responseText = r),
|
|---|
| 336 | () => {},
|
|---|
| 337 | ),
|
|---|
| 338 | ])
|
|---|
| 339 | if (handleResponseError) throw handleResponseError
|
|---|
| 340 | } catch (e) {
|
|---|
| 341 | return {
|
|---|
| 342 | error: {
|
|---|
| 343 | status: 'PARSING_ERROR',
|
|---|
| 344 | originalStatus: response.status,
|
|---|
| 345 | data: responseText,
|
|---|
| 346 | error: String(e),
|
|---|
| 347 | },
|
|---|
| 348 | meta,
|
|---|
| 349 | }
|
|---|
| 350 | }
|
|---|
| 351 |
|
|---|
| 352 | return validateStatus(response, resultData)
|
|---|
| 353 | ? {
|
|---|
| 354 | data: resultData,
|
|---|
| 355 | meta,
|
|---|
| 356 | }
|
|---|
| 357 | : {
|
|---|
| 358 | error: {
|
|---|
| 359 | status: response.status,
|
|---|
| 360 | data: resultData,
|
|---|
| 361 | },
|
|---|
| 362 | meta,
|
|---|
| 363 | }
|
|---|
| 364 | }
|
|---|
| 365 |
|
|---|
| 366 | async function handleResponse(
|
|---|
| 367 | response: Response,
|
|---|
| 368 | responseHandler: ResponseHandler,
|
|---|
| 369 | ) {
|
|---|
| 370 | if (typeof responseHandler === 'function') {
|
|---|
| 371 | return responseHandler(response)
|
|---|
| 372 | }
|
|---|
| 373 |
|
|---|
| 374 | if (responseHandler === 'content-type') {
|
|---|
| 375 | responseHandler = isJsonContentType(response.headers) ? 'json' : 'text'
|
|---|
| 376 | }
|
|---|
| 377 |
|
|---|
| 378 | if (responseHandler === 'json') {
|
|---|
| 379 | const text = await response.text()
|
|---|
| 380 | return text.length ? JSON.parse(text) : null
|
|---|
| 381 | }
|
|---|
| 382 |
|
|---|
| 383 | return response.text()
|
|---|
| 384 | }
|
|---|
| 385 | }
|
|---|