import { SagaIterator } from 'redux-saga'; import { call, delay } from 'redux-saga/effects'; import { compose } from './compose'; import { noop } from './util'; import type { FetchCtx, FetchJsonCtx, Next } from './types'; /** * Automatically sets `content-type` to `application/json` when * that header is not already present. */ export function* headersMdw( ctx: CurCtx, next: Next, ): SagaIterator { if (!ctx.request) { yield next(); return; } const cur = ctx.req(); if (!cur.headers.hasOwnProperty('Content-Type')) { ctx.request = ctx.req({ headers: { 'Content-Type': 'application/json' }, }); } yield next(); } /** * This middleware takes the `ctx.response` and sets `ctx.json` to the body representation * requested. It uses the `ctx.bodyType` property to determine how to represent the body. * The default is set to `json` which calls `Response.json()`. * * @example * ```ts * const fetchUsers = api.get('/users', function*(ctx, next) { * ctx.bodyType = 'text'; // calls Response.text(); * yield next(); * }) * ``` */ export function* jsonMdw( ctx: CurCtx, next: Next, ): SagaIterator { if (!ctx.response) { yield next(); return; } if (ctx.response.status === 204) { ctx.json = { ok: ctx.response.ok, data: {}, }; yield next(); return; } try { const data = yield call([ctx.response, ctx.bodyType]); ctx.json = { ok: ctx.response.ok, data, }; } catch (err: any) { ctx.json = { ok: false, data: { message: err.message }, }; } yield next(); } /* * This middleware takes the `baseUrl` provided to `fetcher()` and combines it * with the url from `ctx.request.url`. */ export function apiUrlMdw( baseUrl: string = '', ) { return function* (ctx: CurCtx, next: Next): SagaIterator { const req = ctx.req(); ctx.request = ctx.req({ url: `${baseUrl}${req.url}` }); yield next(); }; } /** * If there's a slug inside the ctx.name (which is the URL segement in this case) * and there is *not* a corresponding truthy value in the payload, then that means * the user has an empty value (e.g. empty string) which means we want to abort the * fetch request. * * e.g. `ctx.name = "/apps/:id"` with `payload = { id: '' }` * * Ideally the action wouldn't have been dispatched at all but that is *not* a * gaurantee we can make here. */ export function* payloadMdw( ctx: CurCtx, next: Next, ) { const payload = ctx.payload; if (!payload) { yield next(); return; } const keys = Object.keys(payload); for (let i = 0; i < keys.length; i += 1) { const key = keys[i]; if (!ctx.name.includes(`:${key}`)) { continue; } const val = payload[key]; if (!val) { ctx.json = { ok: false, data: `found :${key} in endpoint name (${ctx.name}) but payload has falsy value (${val})`, }; return; } } yield next(); } /* * This middleware makes the `fetch` http request using `ctx.request` and * assigns the response to `ctx.response`. */ export function* fetchMdw( ctx: CurCtx, next: Next, ): SagaIterator { const { url, ...req } = ctx.req(); const request = new Request(url, req); const response: Response = yield call(fetch, request); ctx.response = response; yield next(); } function backoffExp(attempt: number): number { if (attempt > 5) return -1; // 1s, 1s, 1s, 2s, 4s return Math.max(2 ** attempt * 125, 1000); } /** * This middleware will retry failed `Fetch` request if `response.ok` is `false`. * It accepts a backoff function to determine how long to continue retrying. * The default is an exponential backoff {@link backoffExp} where the minimum is * 1sec between attempts and it'll reach 4s between attempts at the end with a * max of 5 attempts. * * An example backoff: * @example * ```ts * // Any value less than 0 will stop the retry middleware. * // Each attempt will wait 1s * const backoff = (attempt: number) => { * if (attempt > 5) return -1; * return 1000; * } * * const api = createApi(); * api.use(requestMonitor()); * api.use(api.routes()); * api.use(fetcher()); * * const fetchUsers = api.get('/users', [ * function*(ctx, next) { * // ... * yield next(); * }, * // fetchRetry should be after your endpoint function because * // the retry middleware will update `ctx.json` before it reaches your middleware * fetchRetry(backoff), * ]) * ``` */ export function fetchRetry( backoff: (attempt: number) => number = backoffExp, ) { return function* (ctx: CurCtx, next: Next) { yield next(); if (!ctx.response) { return; } if (ctx.response.ok) { return; } let attempt = 1; let waitFor = backoff(attempt); while (waitFor >= 1) { yield delay(waitFor); yield call(fetchMdw, ctx, noop); yield call(jsonMdw, ctx, noop); if (ctx.response.ok) { return; } attempt += 1; waitFor = backoff(attempt); } }; } /** * This middleware is a composition of other middleware required to use `window.fetch` * {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API} with {@link createApi} */ export function fetcher( { baseUrl = '', }: { baseUrl?: string; } = { baseUrl: '' }, ) { return compose([apiUrlMdw(baseUrl), payloadMdw, fetchMdw, jsonMdw]); }