repos / starfx

supercharged async flow control library.
git clone https://github.com/neurosnap/starfx.git

starfx / mdw
Eric Bower · 26 Aug 24

fetch.ts

  1import { sleep } from "../deps.ts";
  2import { safe } from "../fx/mod.ts";
  3import type { FetchCtx, FetchJsonCtx } from "../query/mod.ts";
  4import { isObject, noop } from "../query/util.ts";
  5import type { Next } from "../types.ts";
  6
  7/**
  8 * This middleware converts the name provided to {@link createApi}
  9 * into `url` and `method` for the fetch request.
 10 */
 11export function* nameParser<Ctx extends FetchJsonCtx = FetchJsonCtx>(
 12  ctx: Ctx,
 13  next: Next,
 14) {
 15  const httpMethods = [
 16    "get",
 17    "head",
 18    "post",
 19    "put",
 20    "delete",
 21    "connect",
 22    "options",
 23    "trace",
 24    "patch",
 25  ];
 26
 27  const options = ctx.payload || {};
 28  if (!isObject(options)) {
 29    yield* next();
 30    return;
 31  }
 32
 33  let url = Object.keys(options).reduce((acc, key) => {
 34    return acc.replace(`:${key}`, options[key]);
 35  }, ctx.name);
 36
 37  let method = "";
 38  httpMethods.forEach((curMethod) => {
 39    const pattern = new RegExp(`\\s*\\[` + curMethod + `\\]\\s*\\w*`, "i");
 40    const tmpUrl = url.replace(pattern, "");
 41    if (tmpUrl.length !== url.length) {
 42      method = curMethod.toLocaleUpperCase();
 43    }
 44    url = tmpUrl;
 45  }, url);
 46
 47  if (ctx.req().url === "") {
 48    ctx.request = ctx.req({ url });
 49  }
 50
 51  if (method) {
 52    ctx.request = ctx.req({ method });
 53  }
 54
 55  yield* next();
 56}
 57
 58/**
 59 * Automatically sets `content-type` to `application/json` when
 60 * that header is not already present.
 61 */
 62export function* headers<CurCtx extends FetchCtx = FetchCtx>(
 63  ctx: CurCtx,
 64  next: Next,
 65) {
 66  if (!ctx.request) {
 67    yield* next();
 68    return;
 69  }
 70
 71  const cur = ctx.req();
 72  if (!Object.hasOwn(cur.headers, "Content-Type")) {
 73    ctx.request = ctx.req({
 74      headers: { "Content-Type": "application/json" },
 75    });
 76  }
 77
 78  yield* next();
 79}
 80
 81/**
 82 * This middleware takes the `ctx.response` and sets `ctx.json` to the body representation
 83 * requested.  It uses the `ctx.bodyType` property to determine how to represent the body.
 84 * The default is set to `json` which calls `Response.json()`.
 85 *
 86 * @example
 87 * ```ts
 88 * const fetchUsers = api.get('/users', function*(ctx, next) {
 89 *  ctx.bodyType = 'text'; // calls Response.text();
 90 *  yield next();
 91 * })
 92 * ```
 93 */
 94export function* json<CurCtx extends FetchJsonCtx = FetchJsonCtx>(
 95  ctx: CurCtx,
 96  next: Next,
 97) {
 98  if (!ctx.response) {
 99    yield* next();
100    return;
101  }
102
103  if (ctx.response.status === 204) {
104    ctx.json = {
105      ok: true,
106      value: {},
107    };
108    yield* next();
109    return;
110  }
111
112  const data = yield* safe(() => {
113    const resp = ctx.response;
114    if (!resp) throw new Error("response is falsy");
115    return resp[ctx.bodyType]();
116  });
117
118  if (data.ok) {
119    if (ctx.response.ok) {
120      ctx.json = {
121        ok: true,
122        value: data.value,
123      };
124    } else {
125      ctx.json = {
126        ok: false,
127        error: data.value,
128      };
129    }
130  } else {
131    const dta = { message: data.error.message };
132    ctx.json = {
133      ok: false,
134      error: dta,
135    };
136  }
137
138  yield* next();
139}
140
141/*
142 * This middleware takes the `baseUrl` provided to {@link mdw.fetch} and combines it
143 * with the url from `ctx.request.url`.
144 */
145export function composeUrl<CurCtx extends FetchJsonCtx = FetchJsonCtx>(
146  baseUrl = "",
147) {
148  return function* (ctx: CurCtx, next: Next) {
149    const req = ctx.req();
150    ctx.request = ctx.req({ url: `${baseUrl}${req.url}` });
151    yield* next();
152  };
153}
154
155/**
156 * If there's a slug inside the ctx.name (which is the URL segement in this case)
157 * and there is *not* a corresponding truthy value in the payload, then that means
158 * the user has an empty value (e.g. empty string) which means we want to abort the
159 * fetch request.
160 *
161 * e.g. `ctx.name = "/apps/:id"` with `payload = { id: '' }`
162 *
163 * Ideally the action wouldn't have been dispatched at all but that is *not* a
164 * gaurantee we can make here.
165 */
166export function* payload<CurCtx extends FetchJsonCtx = FetchJsonCtx>(
167  ctx: CurCtx,
168  next: Next,
169) {
170  const payload = ctx.payload;
171  if (!payload) {
172    yield* next();
173    return;
174  }
175
176  const keys = Object.keys(payload);
177  for (let i = 0; i < keys.length; i += 1) {
178    const key = keys[i];
179    if (!ctx.name.includes(`:${key}`)) {
180      continue;
181    }
182
183    const val = payload[key];
184    if (!val) {
185      const data =
186        `found :${key} in endpoint name (${ctx.name}) but payload has falsy value (${val})`;
187      ctx.json = {
188        ok: false,
189        error: data,
190      };
191      return;
192    }
193  }
194
195  yield* next();
196}
197
198/*
199 * This middleware simply checks if `ctx.response` already contains a
200 * truthy value, and if it does, bail out of the middleware stack.
201 */
202export function response<CurCtx extends FetchCtx = FetchCtx>(
203  response?: Response,
204) {
205  return function* responseMdw(
206    ctx: CurCtx,
207    next: Next,
208  ) {
209    if (response) {
210      ctx.response = response;
211    }
212    yield* next();
213  };
214}
215
216/*
217 * This middleware makes the `fetch` http request using `ctx.request` and
218 * assigns the response to `ctx.response`.
219 */
220export function* request<CurCtx extends FetchCtx = FetchCtx>(
221  ctx: CurCtx,
222  next: Next,
223) {
224  // if there is already a response then we want to bail so we don't
225  // override it.
226  if (ctx.response) {
227    yield* next();
228    return;
229  }
230
231  const { url, ...req } = ctx.req();
232  const request = new Request(url, req);
233  const result = yield* safe(fetch(request));
234  if (result.ok) {
235    ctx.response = result.value;
236  } else {
237    throw result.error;
238  }
239  yield* next();
240}
241
242function backoffExp(attempt: number): number {
243  if (attempt > 5) return -1;
244  // 1s, 1s, 1s, 2s, 4s
245  return Math.max(2 ** attempt * 125, 1000);
246}
247
248/**
249 * This middleware will retry failed `Fetch` request if `response.ok` is `false`.
250 * It accepts a backoff function to determine how long to continue retrying.
251 * The default is an exponential backoff {@link backoffExp} where the minimum is
252 * 1sec between attempts and it'll reach 4s between attempts at the end with a
253 * max of 5 attempts.
254 *
255 * An example backoff:
256 * @example
257 * ```ts
258 *  // Any value less than 0 will stop the retry middleware.
259 *  // Each attempt will wait 1s
260 *  const backoff = (attempt: number) => {
261 *    if (attempt > 5) return -1;
262 *    return 1000;
263 *  }
264 *
265 * const api = createApi();
266 * api.use(mdw.api());
267 * api.use(api.routes());
268 * api.use(mdw.fetch());
269 *
270 * const fetchUsers = api.get('/users', [
271 *  function*(ctx, next) {
272 *    // ...
273 *    yield next();
274 *  },
275 *  // fetchRetry should be after your endpoint function because
276 *  // the retry middleware will update `ctx.json` before it reaches
277 *  // your middleware
278 *  fetchRetry(backoff),
279 * ])
280 * ```
281 */
282export function fetchRetry<CurCtx extends FetchJsonCtx = FetchJsonCtx>(
283  backoff: (attempt: number) => number = backoffExp,
284) {
285  return function* (ctx: CurCtx, next: Next) {
286    yield* next();
287
288    if (!ctx.response) {
289      return;
290    }
291
292    if (ctx.response.ok) {
293      return;
294    }
295
296    let attempt = 1;
297    let waitFor = backoff(attempt);
298    while (waitFor >= 1) {
299      yield* sleep(waitFor);
300      // reset response so `request` mdw actually runs
301      ctx.response = null;
302      yield* safe(() => request(ctx, noop));
303      yield* safe(() => json(ctx, noop));
304
305      if (ctx.response && (ctx.response as Response).ok) {
306        return;
307      }
308
309      attempt += 1;
310      waitFor = backoff(attempt);
311    }
312  };
313}