| 42 | } |
| 43 | |
| 44 | async request<T = unknown>(method: string, path: string, opts: RequestOpts = {}): Promise<RawResponse<T>> { |
| 45 | await this.limiter.tick(); |
| 46 | const url = new URL(path, this.env.apiUrl); |
| 47 | if (opts.query) { |
| 48 | for (const [k, v] of Object.entries(opts.query)) { |
| 49 | if (v !== undefined) url.searchParams.set(k, String(v)); |
| 50 | } |
| 51 | } |
| 52 | const headers: Record<string, string> = { |
| 53 | Accept: "application/json", |
| 54 | ...(opts.headers ?? {}), |
| 55 | }; |
| 56 | const key = opts.apiKey === null ? null : opts.apiKey ?? this.env.apiKey; |
| 57 | if (key) headers.Authorization = `Bearer ${key}`; |
| 58 | let body: string | undefined; |
| 59 | if (opts.body !== undefined) { |
| 60 | body = typeof opts.body === "string" ? opts.body : JSON.stringify(opts.body); |
| 61 | headers["Content-Type"] = headers["Content-Type"] ?? "application/json"; |
| 62 | } |
| 63 | const t0 = performance.now(); |
| 64 | // redirect: "manual" stops fetch from transparently following 3xx |
| 65 | // responses. The default `"follow"` makes tests assert against |
| 66 | // whatever the redirect target replies — which silently defeats |
| 67 | // CSRF-discipline checks like "/api/billing/checkout via GET must |
| 68 | // be rejected" if the endpoint ever started returning 302 → |
| 69 | // Stripe (fetch would follow to Stripe and the test would assert |
| 70 | // against Stripe's response, not ours). We test API endpoints, so |
| 71 | // a 3xx from any of our routes is a real signal that callers must |
| 72 | // see, not transparently swallow. |
| 73 | const res = await fetch(url, { method, headers, body, redirect: "manual" }); |
| 74 | const raw = await res.text(); |
| 75 | const latencyMs = performance.now() - t0; |
| 76 | let parsed: T | null = null; |
| 77 | if (raw.length > 0) { |
| 78 | try { |
| 79 | parsed = JSON.parse(raw) as T; |
| 80 | } catch { |
| 81 | parsed = null; |
| 82 | } |
| 83 | } |
| 84 | const out: RawResponse<T> = { |
| 85 | status: res.status, |
| 86 | ok: res.ok, |
| 87 | headers: Object.fromEntries(res.headers.entries()), |
| 88 | body: parsed, |
| 89 | raw, |
| 90 | latencyMs, |
| 91 | }; |
| 92 | if (opts.expect !== undefined) { |
| 93 | const expected = Array.isArray(opts.expect) ? opts.expect : [opts.expect]; |
| 94 | if (!expected.includes(res.status)) { |
| 95 | throw new Error( |
| 96 | `${method} ${url.pathname}: expected ${expected.join("|")}, got ${res.status}. Body: ${raw.slice(0, 400)}`, |
| 97 | ); |
| 98 | } |
| 99 | } |
| 100 | return out; |
| 101 | } |