0

HTTP request

by
Published today

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.

Script http Verified

The script

Submitted by hugo989 Bun
Verified 7 hours ago
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
  // Not limited to the enum: the form also takes a typed-in method. Bun's fetch
43
  // matches its known methods case-insensitively and silently sends GET for any other.
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
  // A relative url is appended to base_url's path: /users on .../v1 is .../v1/users.
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
  // Header names are case-insensitive: the caller's override the resource's.
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
    // A string body is sent as given (already serialized); anything else as JSON,
89
    // whatever the format, except form fields (nested values as JSON).
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
    // A Content-Type from the caller wins, then an explicit body_format, then the resource's.
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
    // Redirects are followed here, not by fetch: fetch would re-send the resource's
116
    // headers to another host. Off the first origin only the caller's headers go,
117
    // without Authorization.
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
        // The body still needs its type: keep the computed one.
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
        // Nor does the client certificate, and the server is always verified there.
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
  // Pagination: collect the items of each page (or each page's body when there is no items list).
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
    // Only with allow_error_status: stop there and return the error response's body.
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
    // Some APIs drop or null the item list past the last page: that ends it too.
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
      // Stop on an empty page, not a short one: APIs cap the page size silently.
245
      position += offsetMode ? list!.length : 1
246
      next = target.href
247
    }
248
    if (!next) {
249
      target = undefined
250
      break
251
    }
252
    // Resolved against where the page actually came from, after redirects.
253
    const following: URL = new URL(next, last.url)
254
    // Credentials only go to the origin of the first request.
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
  // Link: <https://api.example.com/items?page=2>; rel="next", <...>; rel="last"
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
    // A custom ca replaces the default list: keep the system CAs alongside it.
293
    ca: t.ca_certificate ? [...rootCertificates, t.ca_certificate] : undefined,
294
    cert: t.client_certificate || undefined,
295
    // Decrypt here: the key is handed to the TLS client as plain PEM.
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
  // OAUTH2_CLIENT_CREDENTIALS: one token per run (RFC 6749 section 4.4).
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
    // verify off is for the API server only: the IdP receiving the secret is always verified.
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