1 | import { createPrivateKey } from "node:crypto" |
2 | import { METHODS } from "node:http" |
3 | import { rootCertificates } from "node:tls" |
4 |
|
5 | |
6 | * HTTP request |
7 | * Call any URL, or an HTTP API through an optional http resource that holds the base URL, default headers, auth (basic, bearer, OAuth 2.0 client credentials) and TLS settings (private CA, mTLS client certificate). Pick a method or type another one Bun supports (e.g. PROPFIND, PURGE). Sends a JSON, text or form body, and can follow pagination (Link header, a next URL in the body, page or offset parameters) up to max_pages. Returns status, headers and the parsed body (the collected items when paginating). A non-2xx status throws unless allow_error_status is on, which returns it instead. |
8 | */ |
9 | export async function main( |
10 | auth: RT.Http | undefined, |
11 | url: string, |
12 | method: |
13 | | "GET" |
14 | | "POST" |
15 | | "PUT" |
16 | | "PATCH" |
17 | | "DELETE" |
18 | | "HEAD" |
19 | | "OPTIONS" |
20 | | undefined, |
21 | headers: { [key: string]: string } | undefined, |
22 | query: { [key: string]: string } | undefined, |
23 | body: any = null, |
24 | body_format: "json" | "text" | "form" | undefined, |
25 | timeout_seconds: number | undefined, |
26 | allow_error_status: boolean = false, |
27 | pagination: |
28 | | "none" |
29 | | "link_header" |
30 | | "next_url" |
31 | | "page" |
32 | | "offset" |
33 | | undefined, |
34 | next_url_path: string | undefined, |
35 | items_path: string | undefined, |
36 | page_param: string | undefined, |
37 | page_start: number | undefined, |
38 | page_size_param: string | undefined, |
39 | page_size: number | undefined, |
40 | max_pages: number | undefined |
41 | ) { |
42 | |
43 | |
44 | const verb = (method || "GET").toUpperCase() |
45 | if (!METHODS.includes(verb)) { |
46 | throw new Error( |
47 | `Bun's fetch cannot send ${verb} (it would send GET instead); it supports ${METHODS.join(", ")}` |
48 | ) |
49 | } |
50 | const mode = pagination || "none" |
51 | const timeout = (timeout_seconds || 30) * 1000 |
52 | const tls = tlsOptions(auth) |
53 |
|
54 | |
55 | const absolute = /^[a-z][a-z0-9+.-]*:\/\//i.test(url) |
56 | if (!absolute && !auth?.base_url) { |
57 | throw new Error( |
58 | `${url} is not an absolute URL: add the scheme (https://...) or pick a resource with a base_url` |
59 | ) |
60 | } |
61 | const first = !absolute |
62 | ? new URL( |
63 | "./" + url.replace(/^\/+/, ""), |
64 | auth!.base_url!.replace(/\/*$/, "/") |
65 | ) |
66 | : new URL(url) |
67 | for (const [k, v] of Object.entries(query ?? {})) { |
68 | if (v != null && v !== "") first.searchParams.append(k, String(v)) |
69 | } |
70 |
|
71 | |
72 | const lower = (h: { [key: string]: any } | undefined) => |
73 | Object.fromEntries( |
74 | Object.entries(h ?? {}) |
75 | .filter(([, v]) => v != null && v !== "") |
76 | .map(([k, v]) => [k.toLowerCase(), String(v)]) |
77 | ) |
78 | const callerHeaders = lower(headers) |
79 | const requestHeaders = { ...lower(auth?.headers), ...callerHeaders } |
80 | if (!requestHeaders.authorization) { |
81 | const authorization = await authHeader(auth, tls, timeout) |
82 | if (authorization) requestHeaders.authorization = authorization |
83 | } |
84 |
|
85 | let payload: string | undefined |
86 | if (body != null && body !== "") { |
87 | const format = body_format || "json" |
88 | |
89 | |
90 | payload = |
91 | typeof body === "string" |
92 | ? body |
93 | : format === "form" |
94 | ? new URLSearchParams( |
95 | Object.entries(body).map(([k, v]) => [ |
96 | k, |
97 | typeof v === "string" ? v : JSON.stringify(v), |
98 | ]) |
99 | ).toString() |
100 | : JSON.stringify(body) |
101 | |
102 | if ( |
103 | !callerHeaders["content-type"] && |
104 | (body_format || !requestHeaders["content-type"]) |
105 | ) { |
106 | requestHeaders["content-type"] = { |
107 | json: "application/json", |
108 | text: "text/plain", |
109 | form: "application/x-www-form-urlencoded", |
110 | }[format] |
111 | } |
112 | } |
113 |
|
114 | const send = async (target: URL) => { |
115 | |
116 | |
117 | |
118 | let current = target |
119 | let verbNow = verb |
120 | let bodyNow = payload |
121 | let response: Response |
122 | for (let hop = 0; ; hop++) { |
123 | const sameOrigin = current.origin === first.origin |
124 | const sent: Record<string, string> = sameOrigin |
125 | ? { ...requestHeaders } |
126 | : { ...callerHeaders } |
127 | if (!sameOrigin) { |
128 | delete sent.authorization |
129 | |
130 | if (requestHeaders["content-type"] && !sent["content-type"]) { |
131 | sent["content-type"] = requestHeaders["content-type"] |
132 | } |
133 | } |
134 | if (bodyNow === undefined) delete sent["content-type"] |
135 | response = await fetch(current, { |
136 | method: verbNow, |
137 | headers: sent, |
138 | body: bodyNow, |
139 | redirect: "manual", |
140 | signal: AbortSignal.timeout(timeout), |
141 | |
142 | tls: sameOrigin ? tls : tls && { ca: tls.ca, rejectUnauthorized: true }, |
143 | } as RequestInit) |
144 | const location = response.headers.get("location") |
145 | if (response.status < 300 || response.status > 399 || !location) break |
146 | if (hop === 10) throw new Error(`Too many redirects from ${target.href}`) |
147 | current = new URL(location, current) |
148 | if ( |
149 | response.status === 303 || |
150 | ((response.status === 301 || response.status === 302) && |
151 | verbNow !== "GET" && |
152 | verbNow !== "HEAD") |
153 | ) { |
154 | verbNow = verbNow === "HEAD" ? "HEAD" : "GET" |
155 | bodyNow = undefined |
156 | } |
157 | } |
158 | const text = await response.text() |
159 | const type = response.headers.get("content-type") ?? "" |
160 | let parsed: any = text === "" ? null : text |
161 | if (parsed !== null && /json/i.test(type)) { |
162 | try { |
163 | parsed = JSON.parse(text) |
164 | } catch {} |
165 | } |
166 | if (!response.ok && !allow_error_status) { |
167 | throw new Error(`${response.status} ${text}`) |
168 | } |
169 | return { |
170 | status: response.status, |
171 | headers: Object.fromEntries(response.headers), |
172 | body: parsed, |
173 | url: current, |
174 | } |
175 | } |
176 |
|
177 | if (mode === "none") { |
178 | const { url: _, ...result } = await send(first) |
179 | return result |
180 | } |
181 | if (page_size && !page_size_param && (mode === "page" || mode === "offset")) { |
182 | throw new Error( |
183 | "page_size needs page_size_param, the query parameter that carries it" |
184 | ) |
185 | } |
186 |
|
187 | if (mode === "next_url" && !next_url_path) { |
188 | throw new Error( |
189 | "next_url pagination needs next_url_path, the path of the next page's URL in the response" |
190 | ) |
191 | } |
192 | |
193 | const limit = max_pages || 10 |
194 | const offsetMode = mode === "offset" |
195 | let position = page_start ?? (offsetMode ? 0 : 1) |
196 | const items: any[] = [] |
197 | let target: URL | undefined = first |
198 | let last: Awaited<ReturnType<typeof send>> | undefined |
199 | let pages = 0 |
200 | while (target && pages < limit) { |
201 | if (mode === "page" || offsetMode) { |
202 | target.searchParams.set( |
203 | page_param || (offsetMode ? "offset" : "page"), |
204 | String(position) |
205 | ) |
206 | if (page_size_param && page_size) { |
207 | target.searchParams.set(page_size_param, String(page_size)) |
208 | } |
209 | } |
210 | last = await send(target) |
211 | pages++ |
212 | |
213 | if (last.status >= 300) { |
214 | const { url: _, ...error } = last |
215 | return { |
216 | ...error, |
217 | body: items, |
218 | error_body: last.body, |
219 | pages, |
220 | has_more: true, |
221 | } |
222 | } |
223 | const pageItems = items_path ? pick(last.body, items_path) : last.body |
224 | |
225 | const list = Array.isArray(pageItems) |
226 | ? pageItems |
227 | : pages > 1 && pageItems == null && (mode === "page" || offsetMode) |
228 | ? [] |
229 | : undefined |
230 | if ((mode === "page" || offsetMode) && !list) { |
231 | throw new Error( |
232 | `Page ${pages} has no item list${items_path ? ` at ${items_path}` : ""}: set items_path to the array of items in the response` |
233 | ) |
234 | } |
235 | if (list) items.push(...list) |
236 | else items.push(last.body) |
237 |
|
238 | let next: string | undefined |
239 | if (mode === "link_header") next = nextLink(last.headers.link) |
240 | else if (mode === "next_url") { |
241 | const value = pick(last.body, next_url_path!) |
242 | next = typeof value === "string" && value !== "" ? value : undefined |
243 | } else if (list!.length > 0) { |
244 | |
245 | position += offsetMode ? list!.length : 1 |
246 | next = target.href |
247 | } |
248 | if (!next) { |
249 | target = undefined |
250 | break |
251 | } |
252 | |
253 | const following: URL = new URL(next, last.url) |
254 | |
255 | if (following.origin !== first.origin) { |
256 | throw new Error( |
257 | `Next page ${following.href} is on another origin than ${first.origin}` |
258 | ) |
259 | } |
260 | target = following |
261 | } |
262 | return { |
263 | status: last!.status, |
264 | headers: last!.headers, |
265 | body: items, |
266 | pages, |
267 | has_more: target != null, |
268 | } |
269 | } |
270 |
|
271 | function pick(value: any, path: string) { |
272 | return path |
273 | .split(".") |
274 | .reduce((v, key) => (v == null ? undefined : v[key]), value) |
275 | } |
276 |
|
277 | function nextLink(link: string | undefined) { |
278 | |
279 | for (const part of link?.split(/,(?=\s*<)/) ?? []) { |
280 | const rel = part.match(/;\s*rel="?([^";]*)/i)?.[1] |
281 | if (rel?.toLowerCase().split(/\s+/).includes("next")) { |
282 | return part.match(/<([^>]*)>/)?.[1] |
283 | } |
284 | } |
285 | return undefined |
286 | } |
287 |
|
288 | function tlsOptions(auth: RT.Http | undefined) { |
289 | const t = auth?.tls |
290 | if (!t) return undefined |
291 | return { |
292 | |
293 | ca: t.ca_certificate ? [...rootCertificates, t.ca_certificate] : undefined, |
294 | cert: t.client_certificate || undefined, |
295 | |
296 | key: t.client_key |
297 | ? t.client_key_password |
298 | ? createPrivateKey({ |
299 | key: t.client_key, |
300 | passphrase: t.client_key_password, |
301 | }) |
302 | .export({ type: "pkcs8", format: "pem" }) |
303 | .toString() |
304 | : t.client_key |
305 | : undefined, |
306 | rejectUnauthorized: t.verify !== false, |
307 | } |
308 | } |
309 |
|
310 | async function authHeader( |
311 | auth: RT.Http | undefined, |
312 | tls: ReturnType<typeof tlsOptions>, |
313 | timeout: number |
314 | ) { |
315 | const a = auth?.auth |
316 | if (!a || a.label === "NONE") return undefined |
317 | if (a.label === "BASIC") |
318 | return `Basic ${base64(`${a.username}:${a.password}`)}` |
319 | if (a.label === "BEARER") return `Bearer ${a.token}` |
320 | |
321 | const form = new URLSearchParams({ grant_type: "client_credentials" }) |
322 | if (a.scope) form.set("scope", a.scope) |
323 | if (a.audience) form.set("audience", a.audience) |
324 | const tokenHeaders: Record<string, string> = { |
325 | "Content-Type": "application/x-www-form-urlencoded", |
326 | Accept: "application/json", |
327 | } |
328 | if (a.client_auth === "body") { |
329 | form.set("client_id", a.client_id) |
330 | form.set("client_secret", a.client_secret) |
331 | } else { |
332 | const enc = (s: string) => encodeURIComponent(s) |
333 | tokenHeaders.Authorization = `Basic ${base64(`${enc(a.client_id)}:${enc(a.client_secret)}`)}` |
334 | } |
335 | const response = await fetch(a.token_url, { |
336 | method: "POST", |
337 | headers: tokenHeaders, |
338 | body: form, |
339 | signal: AbortSignal.timeout(timeout), |
340 | |
341 | tls: tls && { ...tls, rejectUnauthorized: true }, |
342 | } as RequestInit) |
343 | if (!response.ok) { |
344 | throw new Error( |
345 | `Token request: ${response.status} ${await response.text()}` |
346 | ) |
347 | } |
348 | const text = await response.text() |
349 | let access_token: unknown |
350 | try { |
351 | access_token = JSON.parse(text).access_token |
352 | } catch {} |
353 | if (typeof access_token !== "string" || !access_token) { |
354 | throw new Error(`Token request: no access_token in ${text}`) |
355 | } |
356 | return `Bearer ${access_token}` |
357 | } |
358 |
|
359 | function base64(s: string) { |
360 | return Buffer.from(s).toString("base64") |
361 | } |
362 |
|