1import { NodeHttpServer } from "@effect/platform-node"
2import * as NodeClient from "@effect/platform-node/NodeHttpClient"
3import { assert, describe, expect, it } from "@effect/vitest"
4import { Struct } from "effect"
5import * as Context from "effect/Context"
6import * as Effect from "effect/Effect"
7import {
8 HttpBody,
9 HttpClient,
10 HttpClientRequest,
11 HttpClientResponse,
12 HttpRouter,
13 HttpServer,
14 HttpServerRequest,
15 HttpServerResponse
16} from "effect/http"
17import * as Layer from "effect/Layer"
18import * as Schema from "effect/Schema"
19import * as Stream from "effect/Stream"
20import * as Http from "node:http"
21
22const Todo = Schema.Struct({
23 userId: Schema.Number,
24 id: Schema.Number,
25 title: Schema.String,
26 completed: Schema.Boolean
27})
28const TodoWithoutId = Schema.Struct({
29 ...Struct.omit(Todo.fields, ["id"])
30})
31const largeResponseBody = "a".repeat(10 * 1024 * 1024)
32const responseBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47])
33const formDataResponses = [
34 {
35 name: "URL-encoded",
36 path: "/form/url-encoded",
37 contentType: "application/x-www-form-urlencoded",
38 body: "tag=one&tag=two"
39 },
40 {
41 name: "multipart",
42 path: "/form/multipart",
43 contentType: "multipart/form-data; boundary=effect-test",
44 body: [
45 "--effect-test",
46 "Content-Disposition: form-data; name=\"tag\"",
47 "",
48 "one",
49 "--effect-test",
50 "Content-Disposition: form-data; name=\"tag\"",
51 "",
52 "two",
53 "--effect-test--",
54 ""
55 ].join("\r\n")
56 }
57] as const
58
59const makeLocalServerClient = Effect.gen(function*() {
60 const client = yield* HttpClient.HttpClient
61 const createTodo = (todo: typeof TodoWithoutId.Type) =>
62 HttpClientRequest.post("/todos").pipe(
63 HttpClientRequest.schemaBodyJson(TodoWithoutId)(todo),
64 Effect.flatMap(client.execute),
65 Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo))
66 )
67 return {
68 client,
69 createTodo
70 } as const
71})
72interface LocalServerClient extends Effect.Success<typeof makeLocalServerClient> {}
73const LocalServerClient = Context.Service<LocalServerClient>("test/LocalServerClient")
74const LocalServerClientLayer = Layer.effect(LocalServerClient)(makeLocalServerClient)
75const LocalServerRoutes = HttpRouter.serve(HttpRouter.addAll([
76 HttpRouter.route(
77 "GET",
78 "/todos/1",
79 Effect.succeed(HttpServerResponse.jsonUnsafe({
80 userId: 1,
81 id: 1,
82 title: "test",
83 completed: false
84 }))
85 ),
86 HttpRouter.route(
87 "POST",
88 "/todos",
89 Effect.gen(function*() {
90 const todo = yield* HttpServerRequest.schemaBodyJson(TodoWithoutId)
91 return HttpServerResponse.jsonUnsafe({ ...todo, id: 201 })
92 })
93 ),
94 HttpRouter.route("GET", "/text", Effect.succeed(HttpServerResponse.text("test"))),
95 HttpRouter.route(
96 "QUERY",
97 "/search",
98 Effect.gen(function*() {
99 const request = yield* HttpServerRequest.HttpServerRequest
100 return HttpServerResponse.jsonUnsafe({
101 method: request.method,
102 contentType: request.headers["content-type"],
103 body: yield* request.text
104 })
105 })
106 ),
107 HttpRouter.route("GET", "/bytes", Effect.succeed(HttpServerResponse.uint8Array(responseBytes))),
108 ...formDataResponses.map(({ body, contentType, path }) =>
109 HttpRouter.route("GET", path, Effect.succeed(HttpServerResponse.text(body, { contentType })))
110 ),
111 HttpRouter.route("GET", "/large", Effect.succeed(HttpServerResponse.text(largeResponseBody))),
112 HttpRouter.route("GET", "/hang", Effect.never),
113 HttpRouter.route("GET", "/redirect", Effect.succeed(HttpServerResponse.redirect("/redirected"))),
114 HttpRouter.route("GET", "/redirected", Effect.succeed(HttpServerResponse.text("redirected"))),
115 HttpRouter.route("HEAD", "/todos", Effect.succeed(HttpServerResponse.empty({ status: 200 })))
116]))
117;[
118 {
119 name: "fetch",
120 layer: NodeClient.layerFetch
121 },
122 {
123 name: "node:http",
124 layer: NodeClient.layerNodeHttp
125 },
126 {
127 name: "undici",
128 layer: NodeClient.layerUndici
129 }
130].forEach(({ layer, name }) => {
131 const layerTest = HttpServer.layerTestClient.pipe(
132 Layer.provide(layer),
133 Layer.provideMerge(NodeHttpServer.layer(Http.createServer, { port: 0 }))
134 )
135 const localServerTestLayer = Layer.merge(LocalServerClientLayer, LocalServerRoutes).pipe(
136 Layer.provideMerge(layerTest)
137 )
138
139 describe(`NodeHttpClient - ${name}`, () => {
140 it.effect("text", () =>
141 Effect.gen(function*() {
142 const response = yield* HttpClient.get("/text?existing=1", {
143 urlParams: { value: "a#b" },
144 hash: "fragment"
145 })
146 const url = new URL(response.url)
147 assert.strictEqual(url.pathname, "/text")
148 assert.strictEqual(url.search, "?existing=1&value=a%23b")
149 assert.strictEqual(url.hash, "")
150 expect(yield* response.text).toBe("test")
151 }).pipe(Effect.provide(localServerTestLayer)))
152
153 it.effect("accessing text preserves response bytes", () =>
154 Effect.gen(function*() {
155 const response = yield* HttpClient.get("/bytes")
156 void response.text
157 assert.deepStrictEqual(new Uint8Array(yield* response.arrayBuffer), responseBytes)
158 }).pipe(Effect.provide(localServerTestLayer)))
159
160 if (name === "undici") {
161 it.effect.each(formDataResponses)("parses $name response form data", ({ path }) =>
162 Effect.gen(function*() {
163 const response = yield* HttpClient.get(path)
164 const form = yield* response.formData
165 assert.deepStrictEqual(form.getAll("tag"), ["one", "two"])
166 }).pipe(Effect.provide(localServerTestLayer)))
167 }
168
169 it.effect("local server followRedirects", () =>
170 Effect.gen(function*() {
171 const client = (yield* HttpClient.HttpClient).pipe(
172 HttpClient.followRedirects()
173 )
174 const response = yield* client.get("/redirect")
175 expect(new URL(response.url).pathname).toBe("/redirected")
176 expect(yield* response.text).toBe("redirected")
177 }).pipe(Effect.provide(localServerTestLayer)))
178
179 it.effect("text stream", () =>
180 Effect.gen(function*() {
181 const client = yield* HttpClient.HttpClient
182 const response = yield* client.get("/text").pipe(
183 Effect.map((_) => _.stream),
184 Stream.unwrap,
185 Stream.decodeText(),
186 Stream.mkString
187 )
188 expect(response).toBe("test")
189 }).pipe(Effect.provide(localServerTestLayer)))
190
191 it.effect("local server", () =>
192 Effect.gen(function*() {
193 const local = yield* LocalServerClient
194 const response = yield* local.client.get("/todos/1").pipe(
195 Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo, {
196 reviver: (key, value) => key === "title" ? "revived" : value
197 }))
198 )
199 expect(response.id).toBe(1)
200 expect(response.title).toBe("revived")
201 }).pipe(
202 Effect.provide(localServerTestLayer)
203 ))
204
205 it.effect("local server schemaBodyJson", () =>
206 Effect.gen(function*() {
207 const local = yield* LocalServerClient
208 const response = yield* local.createTodo({
209 userId: 1,
210 title: "test",
211 completed: false
212 })
213 expect(response.title).toBe("test")
214 }).pipe(
215 Effect.provide(localServerTestLayer)
216 ))
217
218 it.effect("round trips QUERY method, body, and content type through the local server", () =>
219 Effect.gen(function*() {
220 const body = JSON.stringify({ query: "effect" })
221 const response = yield* HttpClient.query("/search", {
222 body: HttpBody.text(body, "application/json")
223 })
224
225 assert.strictEqual(response.status, 200)
226 assert.deepStrictEqual(yield* response.json, {
227 method: "QUERY",
228 contentType: "application/json",
229 body
230 })
231 }).pipe(Effect.provide(localServerTestLayer)))
232
233 it.effect("head request with schemaJson", () =>
234 Effect.gen(function*() {
235 const client = yield* HttpClient.HttpClient
236 const response = yield* client.head("/todos").pipe(
237 Effect.flatMap(
238 HttpClientResponse.schemaJson(Schema.Struct({ status: Schema.Literal(200) }))
239 )
240 )
241 expect(response).toEqual({ status: 200 })
242 }).pipe(Effect.provide(localServerTestLayer)))
243
244 it.live("interrupt", () =>
245 Effect.gen(function*() {
246 const client = yield* HttpClient.HttpClient
247 const response = yield* client.get("/hang").pipe(
248 Effect.flatMap((_) => _.text),
249 Effect.timeout(1),
250 Effect.asSome,
251 Effect.catchTag("TimeoutError", () => Effect.succeedNone)
252 )
253 expect(response._tag).toEqual("None")
254 }).pipe(Effect.provide(localServerTestLayer)))
255
256 it.effect("close early", () =>
257 Effect.gen(function*() {
258 const response = yield* HttpClient.get("/large")
259 expect(response.status).toBe(200)
260 }).pipe(Effect.provide(localServerTestLayer)))
261 })
262})
263
264it.live("returns a stream body encoding failure", () =>
265 Effect.gen(function*() {
266 yield* Effect.gen(function*() {
267 const request = yield* HttpServerRequest.HttpServerRequest
268 yield* request.text
269 return HttpServerResponse.empty()
270 }).pipe(HttpServer.serveEffect())
271 const error = yield* HttpClient.post("/", {
272 body: HttpBody.stream(Stream.fail("encode failure"))
273 }).pipe(Effect.timeout("1 second"), Effect.flip)
274 assert(error._tag === "HttpClientError")
275 assert.strictEqual(error.reason._tag, "EncodeError")
276 assert.strictEqual(error.reason.cause, "encode failure")
277 }).pipe(Effect.provide(HttpServer.layerTestClient.pipe(
278 Layer.provide(NodeClient.layerNodeHttp),
279 Layer.provideMerge(NodeHttpServer.layer(Http.createServer, { port: 0 }))
280 ))))