| 1 | import { NodeHttpServer } from "@effect/platform-node" |
| 2 | import * as NodeClient from "@effect/platform-node/NodeHttpClient" |
| 3 | import { assert, describe, expect, it } from "@effect/vitest" |
| 4 | import { Struct } from "effect" |
| 5 | import * as Context from "effect/Context" |
| 6 | import * as Effect from "effect/Effect" |
| 7 | import { |
| 8 | HttpBody, |
| 9 | HttpClient, |
| 10 | HttpClientRequest, |
| 11 | HttpClientResponse, |
| 12 | HttpRouter, |
| 13 | HttpServer, |
| 14 | HttpServerRequest, |
| 15 | HttpServerResponse |
| 16 | } from "effect/http" |
| 17 | import * as Layer from "effect/Layer" |
| 18 | import * as Schema from "effect/Schema" |
| 19 | import * as Stream from "effect/Stream" |
| 20 | import * as Http from "node:http" |
| 21 | |
| 22 | const Todo = Schema.Struct({ |
| 23 | userId: Schema.Number, |
| 24 | id: Schema.Number, |
| 25 | title: Schema.String, |
| 26 | completed: Schema.Boolean |
| 27 | }) |
| 28 | const TodoWithoutId = Schema.Struct({ |
| 29 | ...Struct.omit(Todo.fields, ["id"]) |
| 30 | }) |
| 31 | const largeResponseBody = "a".repeat(10 * 1024 * 1024) |
| 32 | const responseBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]) |
| 33 | const 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 | |
| 59 | const 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 | }) |
| 72 | interface LocalServerClient extends Effect.Success<typeof makeLocalServerClient> {} |
| 73 | const LocalServerClient = Context.Service<LocalServerClient>("test/LocalServerClient") |
| 74 | const LocalServerClientLayer = Layer.effect(LocalServerClient)(makeLocalServerClient) |
| 75 | const 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 | |
| 264 | it.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 | )))) |