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}