1import { assert, describe, it } from "@effect/vitest"
2import { assertNone, assertSome, deepStrictEqual, strictEqual } from "@effect/vitest/utils"
3import { Effect, Schema, Stream } from "effect"
4import { HttpBody, HttpClientRequest, HttpServerRequest } from "effect/http"
5import * as Option from "effect/Option"
6
7describe("HttpServerRequest", () => {
8 it.effect("preserves FormData through client-server-client conversion", () =>
9 Effect.gen(function*() {
10 const formData = new FormData()
11 formData.set("name", "alice")
12 const clientRequest = HttpClientRequest.post("https://example.com/upload").pipe(
13 HttpClientRequest.bodyFormData(formData)
14 )
15
16 const roundTrip = HttpServerRequest.toClientRequest(HttpServerRequest.fromClientRequest(clientRequest))
17 const webRequest = yield* HttpClientRequest.toWeb(roundTrip)
18 const parsed = yield* Effect.tryPromise({
19 try: () => webRequest.formData(),
20 catch: () => undefined
21 }).pipe(Effect.option)
22
23 deepStrictEqual(
24 {
25 multipartMime: webRequest.headers.get("content-type")?.startsWith("multipart/form-data; boundary=") ?? false,
26 name: Option.isSome(parsed) ? parsed.value.get("name") : undefined
27 },
28 { multipartMime: true, name: "alice" }
29 )
30 }))
31
32 it("toClientRequest", async () => {
33 const serverRequest = HttpServerRequest.fromWeb(
34 new Request("http://localhost:3000/todos/1?a=1&a=2#top", {
35 method: "POST",
36 headers: {
37 "host": "localhost:3000",
38 "content-type": "application/json",
39 "content-length": "13",
40 "x-test": "ok"
41 },
42 body: "{\"foo\":\"bar\"}"
43 })
44 )
45 const clientRequest = HttpServerRequest.toClientRequest(serverRequest)
46
47 strictEqual(HttpClientRequest.isHttpClientRequest(clientRequest), true)
48 strictEqual(clientRequest.method, "POST")
49 strictEqual(clientRequest.url, "http://localhost:3000/todos/1")
50 assertSome(clientRequest.hash, "top")
51 strictEqual(clientRequest.headers["content-type"], "application/json")
52 strictEqual(clientRequest.headers["content-length"], "13")
53 strictEqual(clientRequest.headers["x-test"], "ok")
54 deepStrictEqual([...clientRequest.urlParams], [["a", "1"], ["a", "2"]])
55 strictEqual(clientRequest.body._tag, "Stream")
56
57 if (clientRequest.body._tag === "Stream") {
58 strictEqual(
59 await Effect.runPromise(clientRequest.body.stream.pipe(
60 Stream.decodeText(),
61 Stream.mkString
62 )),
63 "{\"foo\":\"bar\"}"
64 )
65 }
66 })
67
68 it("toClientRequest ignores malformed or unsafe content lengths", () => {
69 for (const contentLength of ["2junk", "1.5", "1e3", "9007199254740992"]) {
70 const clientRequest = HttpServerRequest.toClientRequest(
71 HttpServerRequest.fromWeb(
72 new Request("http://localhost:3000", {
73 method: "POST",
74 headers: { "content-length": contentLength },
75 body: "hello"
76 })
77 )
78 )
79
80 strictEqual(clientRequest.headers["content-length"], undefined)
81 strictEqual(clientRequest.body._tag, "Stream")
82 if (clientRequest.body._tag === "Stream") {
83 strictEqual(clientRequest.body.contentLength, undefined)
84 }
85 }
86 })
87
88 it("toClientRequest keeps empty bodies empty", () => {
89 const clientRequest = HttpServerRequest.toClientRequest(
90 HttpServerRequest.fromWeb(
91 new Request("http://localhost:3000/todos/1", {
92 method: "GET",
93 headers: {
94 "content-type": "application/json",
95 "x-test": "ok"
96 }
97 })
98 )
99 )
100
101 strictEqual(clientRequest.body._tag, "Empty")
102 strictEqual(clientRequest.headers["content-type"], "application/json")
103 strictEqual(clientRequest.headers["x-test"], "ok")
104 })
105
106 it.effect("fromClientRequest preserves request metadata and shares concurrent stream body accessors", () =>
107 Effect.gen(function*() {
108 const clientRequest = HttpClientRequest.post(new URL("http://localhost:3000/items?existing=1#top")).pipe(
109 HttpClientRequest.appendUrlParam("page", "1"),
110 HttpClientRequest.setHeader("cookie", "session=123"),
111 HttpClientRequest.bodyJsonUnsafe({ foo: "bar" })
112 )
113
114 const request = HttpServerRequest.fromClientRequest(clientRequest)
115 const arrayBuffer = yield* request.arrayBuffer
116
117 assert.strictEqual(request.source, clientRequest)
118 assert.strictEqual(request.method, "POST")
119 assert.strictEqual(request.originalUrl, "http://localhost:3000/items?existing=1&page=1#top")
120 assert.strictEqual(request.url, "/items?existing=1&page=1#top")
121 assert.strictEqual(request.headers["content-type"], "application/json")
122 assert.strictEqual(request.cookies.session, "123")
123 assert.deepStrictEqual(yield* request.json, { foo: "bar" })
124 assert.strictEqual(yield* request.text, "{\"foo\":\"bar\"}")
125 assert.strictEqual(new TextDecoder().decode(new Uint8Array(arrayBuffer)), "{\"foo\":\"bar\"}")
126 }))
127
128 it.effect("fromClientRequest exposes FormData bodies as multipart parts", () =>
129 Effect.gen(function*() {
130 const formData = new FormData()
131 formData.append("name", "alice")
132 formData.append("file", new Blob(["hello"], { type: "text/plain" }), "hello.txt")
133
134 const request = HttpServerRequest.fromClientRequest(
135 HttpClientRequest.post("/upload").pipe(
136 HttpClientRequest.bodyFormData(formData)
137 )
138 )
139 const parts = yield* Stream.runCollect(request.multipartStream)
140
141 assert.strictEqual(parts.length, 2)
142 assert.strictEqual(parts[0]?._tag, "Field")
143 if (parts[0]?._tag === "Field") {
144 assert.strictEqual(parts[0].key, "name")
145 assert.strictEqual(parts[0].value, "alice")
146 }
147 assert.strictEqual(parts[1]?._tag, "File")
148 if (parts[1]?._tag === "File") {
149 assert.strictEqual(parts[1].key, "file")
150 assert.strictEqual(parts[1].name, "hello.txt")
151 assert.strictEqual(parts[1].contentType, "text/plain")
152 assert.strictEqual(new TextDecoder().decode(yield* parts[1].contentEffect), "hello")
153 }
154 }))
155
156 it.effect("reads a raw BodyInit after conversion from a client request", () =>
157 Effect.gen(function*() {
158 const client = HttpClientRequest.setBody(HttpClientRequest.post("https://example.com"), HttpBody.raw("abc"))
159 const server = HttpServerRequest.fromClientRequest(client)
160 assert.strictEqual(yield* server.text, "abc")
161 }))
162
163 it.effect("reads raw BodyInit bytes after conversion from a client request", () =>
164 Effect.gen(function*() {
165 const client = HttpClientRequest.setBody(HttpClientRequest.post("https://example.com"), HttpBody.raw("abc"))
166 const server = HttpServerRequest.fromClientRequest(client)
167 assert.deepStrictEqual(new Uint8Array(yield* server.arrayBuffer), new Uint8Array([97, 98, 99]))
168 }))
169
170 it.effect("streams a raw URLSearchParams after conversion from a client request", () =>
171 Effect.gen(function*() {
172 const client = HttpClientRequest.setBody(
173 HttpClientRequest.post("https://example.com"),
174 HttpBody.raw(new URLSearchParams({ a: "1", b: "two" }))
175 )
176 const server = HttpServerRequest.fromClientRequest(client)
177 assert.strictEqual(yield* server.stream.pipe(Stream.decodeText(), Stream.mkString), "a=1&b=two")
178 }))
179
180 it.effect("schemaBodyJson applies parse options", () =>
181 Effect.gen(function*() {
182 const request = HttpServerRequest.fromWeb(
183 new Request("http://localhost:3000", {
184 method: "POST",
185 headers: {
186 "content-type": "application/json"
187 },
188 body: JSON.stringify({
189 status: "ok",
190 name: "svc",
191 sha: "abc",
192 version: "1.0.0"
193 })
194 })
195 )
196 const schema = Schema.Struct({
197 status: Schema.String,
198 name: Schema.String
199 })
200
201 const decoded = yield* HttpServerRequest.schemaBodyJson(schema, {
202 onExcessProperty: "ignore",
203 reviver: (key, value) => key === "status" ? "revived" : value
204 }).pipe(
205 Effect.provideService(HttpServerRequest.HttpServerRequest, request)
206 )
207 assert.strictEqual(decoded.status, "revived")
208 assert.strictEqual(decoded.name, "svc")
209 assert.isFalse("sha" in decoded)
210 assert.isFalse("version" in decoded)
211 }))
212
213 it("remoteAddress defaults to none for web requests", () => {
214 const request = HttpServerRequest.fromWeb(new Request("http://example.com"))
215 assertNone(request.remoteAddress)
216 })
217
218 it("modify distinguishes missing and explicit none remoteAddress", () => {
219 const request = HttpServerRequest.fromWeb(new Request("http://example.com"))
220 const withRemoteAddress = request.modify({ remoteAddress: Option.some("127.0.0.1") })
221
222 assertSome(withRemoteAddress.remoteAddress, "127.0.0.1")
223 assertSome(withRemoteAddress.modify({}).remoteAddress, "127.0.0.1")
224 assertNone(withRemoteAddress.modify({ remoteAddress: Option.none() }).remoteAddress)
225 })
226})