1import { describe, it } from "@effect/vitest"
2import { assertNone, assertSome, assertTrue, deepStrictEqual, strictEqual } from "@effect/vitest/utils"
3import { ByteSize, Effect, FileSystem, Stream } from "effect"
4import { Headers, HttpBody, HttpClientRequest } from "effect/http"
5import * as Option from "effect/Option"
6
7describe("HttpClientRequest", () => {
8 describe("bodyFile", () => {
9 const oversized = 9007199254740993n
10 const fileSystem = (size: bigint) =>
11 FileSystem.makeNoop({
12 stat: () => Effect.succeed({ size: ByteSize.bytes(size) } as FileSystem.File.Info),
13 stream: () => Stream.empty
14 })
15
16 it.each([
17 { name: "unsafe bigint offset", size: oversized + 3n, options: { offset: oversized }, expected: 3 },
18 { name: "range past EOF", size: 6n, options: { offset: 7 }, expected: 0 }
19 ])("sets the exact outgoing Content-Length for $name", async ({ expected, options, size }) => {
20 const request = await Effect.runPromise(
21 HttpClientRequest.post("https://example.com").pipe(
22 HttpClientRequest.bodyText("stale"),
23 HttpClientRequest.bodyFile("x", { ...options, contentType: "application/octet-stream" }),
24 Effect.provideService(FileSystem.FileSystem, fileSystem(size))
25 )
26 )
27 strictEqual(request.body._tag, "Stream")
28 if (request.body._tag === "Stream") {
29 strictEqual(request.body.contentLength, expected)
30 }
31 strictEqual(request.headers["content-length"], String(expected))
32 strictEqual(request.headers["content-type"], "application/octet-stream")
33 const web = await Effect.runPromise(HttpClientRequest.toWeb(request))
34 strictEqual(web.headers.get("content-length"), String(expected))
35 })
36
37 it("propagates typed BadArgument from file range validation", async () => {
38 const request = HttpClientRequest.bodyFile(HttpClientRequest.post("https://example.com"), "x", {
39 // @ts-expect-error Exercise runtime validation of malformed input.
40 bytesToRead: "garbage"
41 })
42 const error = await Effect.runPromise(request.pipe(
43 Effect.flip,
44 Effect.provideService(FileSystem.FileSystem, fileSystem(6n))
45 ))
46 strictEqual(error._tag, "PlatformError")
47 strictEqual(error.reason._tag, "BadArgument")
48 })
49 })
50
51 describe("appendUrl", () => {
52 it("joins segments without slashes", () => {
53 const request = HttpClientRequest.get("base").pipe(
54 HttpClientRequest.appendUrl("users")
55 )
56
57 strictEqual(request.url, "base/users")
58 })
59
60 it("avoids double slashes", () => {
61 const request = HttpClientRequest.get("base/").pipe(
62 HttpClientRequest.appendUrl("/users")
63 )
64
65 strictEqual(request.url, "base/users")
66 })
67
68 it("preserves existing slash", () => {
69 const request = HttpClientRequest.get("base/").pipe(
70 HttpClientRequest.appendUrl("users")
71 )
72
73 strictEqual(request.url, "base/users")
74 })
75
76 it("preserves leading slash", () => {
77 const request = HttpClientRequest.get("base").pipe(
78 HttpClientRequest.appendUrl("/users")
79 )
80
81 strictEqual(request.url, "base/users")
82 })
83
84 it("no-ops on empty path", () => {
85 const request = HttpClientRequest.get("base").pipe(
86 HttpClientRequest.appendUrl("")
87 )
88
89 strictEqual(request.url, "base")
90 })
91 })
92
93 describe("bodyFormDataRecord", () => {
94 it("creates a form data body from a record", () => {
95 const request = HttpClientRequest.post("/").pipe(
96 HttpClientRequest.bodyFormDataRecord({
97 a: "a",
98 b: 1,
99 c: true,
100 d: null,
101 e: undefined,
102 f: ["x", 2, false, null, undefined]
103 })
104 )
105
106 strictEqual(request.body._tag, "FormData")
107 if (request.body._tag === "FormData") {
108 strictEqual(request.body.formData.get("a"), "a")
109 strictEqual(request.body.formData.get("b"), "1")
110 strictEqual(request.body.formData.get("c"), "true")
111 strictEqual(request.body.formData.has("d"), false)
112 strictEqual(request.body.formData.has("e"), false)
113 strictEqual(request.body.formData.getAll("f").join(","), "x,2,false")
114 }
115 })
116
117 it("removes content headers when switching to form data", () => {
118 const request = HttpClientRequest.post("/").pipe(
119 HttpClientRequest.bodyText("hello"),
120 HttpClientRequest.bodyFormDataRecord({ a: "a" })
121 )
122
123 strictEqual(request.headers["content-type"], undefined)
124 strictEqual(request.headers["content-length"], undefined)
125 })
126 })
127
128 describe("setBody", () => {
129 it("removes stale content length when the replacement body has no known length", () => {
130 const request = HttpClientRequest.bodyText(HttpClientRequest.post("https://example.com"), "abc").pipe(
131 HttpClientRequest.setBody(HttpBody.stream(Stream.empty))
132 )
133
134 strictEqual(request.headers["content-length"], undefined)
135 })
136 })
137
138 describe("removeHeader", () => {
139 it("removes an existing header", () => {
140 const request = HttpClientRequest.get("/").pipe(
141 HttpClientRequest.setHeader("X-Test", "ok"),
142 HttpClientRequest.removeHeader("X-Test")
143 )
144
145 strictEqual(request.headers["x-test"], undefined)
146 })
147
148 it("no-ops on a missing header", () => {
149 const request = HttpClientRequest.get("/").pipe(
150 HttpClientRequest.setHeader("X-Test", "ok")
151 )
152 const removed = HttpClientRequest.removeHeader(request, "X-Missing")
153
154 deepStrictEqual(removed.headers, request.headers)
155 })
156
157 it("preserves the request prototype", () => {
158 const request = HttpClientRequest.get("/").pipe(
159 HttpClientRequest.removeHeader("X-Test")
160 )
161
162 assertTrue(HttpClientRequest.isHttpClientRequest(request))
163 })
164 })
165
166 describe("updateHeaders", () => {
167 it("transforms the header collection", () => {
168 const request = HttpClientRequest.get("/").pipe(
169 HttpClientRequest.setHeaders({ "X-A": "a", "X-B": "b" }),
170 HttpClientRequest.updateHeaders((headers) => Headers.set(Headers.remove(headers, "X-A"), "X-C", "c"))
171 )
172
173 deepStrictEqual(request.headers, Headers.fromInput({ "x-b": "b", "x-c": "c" }))
174 })
175
176 it("preserves the request prototype", () => {
177 const request = HttpClientRequest.get("/").pipe(
178 HttpClientRequest.updateHeaders(Headers.set("X-Test", "ok"))
179 )
180
181 assertTrue(HttpClientRequest.isHttpClientRequest(request))
182 strictEqual(request.headers["x-test"], "ok")
183 })
184
185 it("supports the data-first overload", () => {
186 const request = HttpClientRequest.get("/")
187 const updated = HttpClientRequest.updateHeaders(request, Headers.set("X-Test", "ok"))
188
189 strictEqual(updated.headers["x-test"], "ok")
190 })
191 })
192
193 describe("hash", () => {
194 it("stores hash as Option", () => {
195 const request = HttpClientRequest.get(new URL("http://example.com/path?x=1#section"))
196 assertSome(request.hash, "section")
197 const url = HttpClientRequest.toUrl(request)
198 if (Option.isNone(url)) {
199 throw new Error("Expected toUrl to return Some")
200 }
201 strictEqual(url.value.toString(), "http://example.com/path?x=1#section")
202 })
203
204 it("removes hash", () => {
205 const request = HttpClientRequest.get("http://example.com").pipe(
206 HttpClientRequest.setHash("section"),
207 HttpClientRequest.removeHash
208 )
209 assertNone(request.hash)
210 })
211
212 it("returns none for invalid url", () => {
213 const request = HttpClientRequest.get("http://example.com").pipe(
214 HttpClientRequest.setUrl("http://[::1")
215 )
216 assertNone(HttpClientRequest.toUrl(request))
217 })
218 })
219
220 describe("web conversions", () => {
221 it.effect("fromWeb", () =>
222 Effect.gen(function*() {
223 const webRequest = new Request("http://localhost:3000/todos/1?a=1&a=2#top", {
224 method: "POST",
225 headers: {
226 "content-type": "application/json",
227 "content-length": "13",
228 "x-test": "ok"
229 },
230 body: "{\"foo\":\"bar\"}"
231 })
232 const request = HttpClientRequest.fromWeb(webRequest)
233
234 strictEqual(request.method, "POST")
235 strictEqual(request.url, "http://localhost:3000/todos/1")
236 assertSome(request.hash, "top")
237 strictEqual(request.headers["content-type"], "application/json")
238 strictEqual(request.headers["content-length"], "13")
239 strictEqual(request.headers["x-test"], "ok")
240 deepStrictEqual([...request.urlParams], [["a", "1"], ["a", "2"]])
241 strictEqual(request.body._tag, "Raw")
242
243 const webRequest2 = yield* HttpClientRequest.toWeb(request)
244 strictEqual(yield* Effect.promise(() => webRequest2.text()), "{\"foo\":\"bar\"}")
245 }))
246
247 it("fromWeb ignores malformed or unsafe content lengths", () => {
248 for (const contentLength of ["2junk", "1.5", "1e3", "9007199254740992"]) {
249 const request = HttpClientRequest.fromWeb(
250 new Request("http://localhost:3000", {
251 method: "POST",
252 headers: { "content-length": contentLength },
253 body: "hello"
254 })
255 )
256
257 strictEqual(request.headers["content-length"], undefined)
258 strictEqual(request.body._tag, "Raw")
259 if (request.body._tag === "Raw") {
260 strictEqual(request.body.contentLength, undefined)
261 }
262 }
263 })
264
265 it.effect("toWeb stream body", () =>
266 Effect.gen(function*() {
267 const body = new Uint8Array([104, 101, 108, 108, 111])
268 const request = HttpClientRequest.post("http://localhost:3000/stream").pipe(
269 HttpClientRequest.bodyStream(Stream.succeed(body), {
270 contentType: "text/plain",
271 contentLength: body.length
272 })
273 )
274 const webRequest = yield* HttpClientRequest.toWeb(request)
275
276 strictEqual(webRequest.method, "POST")
277 strictEqual(webRequest.url, "http://localhost:3000/stream")
278 strictEqual(yield* Effect.promise(() => webRequest.text()), "hello")
279 }))
280
281 it("toWebResult returns failure for invalid url", () => {
282 const request = HttpClientRequest.get("http://localhost").pipe(
283 HttpClientRequest.setUrl("http://[::1")
284 )
285 const result = HttpClientRequest.toWebResult(request)
286
287 strictEqual(result._tag, "Failure")
288 })
289 })
290})