/** @effect-diagnostics preferSchemaOverJson:skip-file */ import { NodeHttpServer } from "@effect/platform-node" import { assert, describe, expect, it } from "@effect/vitest" import { Effect } from "effect" import * as Duration from "effect/Duration" import * as Fiber from "effect/Fiber" import * as Layer from "effect/Layer" import * as Schema from "effect/Schema" import * as Stream from "effect/Stream" import * as Tracer from "effect/Tracer" import { Cookies, HttpBody, HttpClient, HttpClientRequest, HttpClientResponse, HttpPlatform, HttpRouter, HttpServer, HttpServerRequest, HttpServerRespondable, HttpServerResponse, Multipart, UrlParams } from "effect/unstable/http" import * as HttpApiError from "effect/unstable/httpapi/HttpApiError" import * as Buffer from "node:buffer" const Todo = Schema.Struct({ id: Schema.Number, title: Schema.String }) const IdParams = Schema.Struct({ id: Schema.FiniteFromString }) const todoResponse = HttpServerResponse.schemaJson(Todo) describe("HttpServer", () => { it.effect("schema", () => Effect.gen(function*() { yield* HttpRouter.add( "GET", "/todos/:id", Effect.flatMap( HttpRouter.schemaParams(IdParams), ({ id }) => todoResponse({ id, title: "test" }) ) ).pipe( HttpRouter.serve, Layer.build ) const todo = yield* HttpClient.get("/todos/1").pipe( Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo)) ) expect(todo).toEqual({ id: 1, title: "test" }) }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.effect("formData", () => Effect.gen(function*() { yield* HttpRouter.add( "POST", "/upload", Effect.gen(function*() { const request = yield* HttpServerRequest.HttpServerRequest const formData = yield* request.multipart const part = formData.file assert(typeof part !== "string") const file = part[0] assert(typeof file !== "string") expect(file.path.endsWith("/test.txt")).toEqual(true) expect(file.contentType).toEqual("text/plain") return yield* HttpServerResponse.json({ ok: "file" in formData }) }) ).pipe( HttpRouter.serve, Layer.build ) const client = yield* HttpClient.HttpClient const formData = new FormData() formData.append("file", new Blob(["test"], { type: "text/plain" }), "test.txt") const result = yield* client.post("/upload", { body: HttpBody.formData(formData) }).pipe( Effect.flatMap((r) => r.json) ) expect(result).toEqual({ ok: true }) }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.effect("schemaBodyForm", () => Effect.gen(function*() { yield* HttpRouter.add( "POST", "/upload", Effect.gen(function*() { const files = yield* HttpServerRequest.schemaBodyForm(Schema.Struct({ file: Multipart.FilesSchema, test: Schema.String })) expect(files).toHaveProperty("file") expect(files).toHaveProperty("test") return HttpServerResponse.empty() }) ).pipe( HttpRouter.serve, Layer.build ) const client = yield* HttpClient.HttpClient const formData = new FormData() formData.append("file", new Blob(["test"], { type: "text/plain" }), "test.txt") formData.append("test", "test") const response = yield* client.post("/upload", { body: HttpBody.formData(formData) }) expect(response.status).toEqual(204) }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.effect("formData withMaxFileSize", () => Effect.gen(function*() { yield* HttpRouter.add( "POST", "/upload", Effect.gen(function*() { const request = yield* HttpServerRequest.HttpServerRequest yield* request.multipart return HttpServerResponse.empty() }).pipe( Effect.catchTag("MultipartError", (error) => error.reason._tag === "FileTooLarge" ? Effect.succeed(HttpServerResponse.empty({ status: 413 })) : Effect.fail(error)) ) ).pipe( HttpRouter.serve, Layer.build, Effect.provideService(Multipart.MaxFileSize, 100) ) const client = yield* HttpClient.HttpClient const formData = new FormData() const data = new Uint8Array(1000) formData.append("file", new Blob([data], { type: "text/plain" }), "test.txt") const response = yield* client.post("/upload", { body: HttpBody.formData(formData) }) expect(response.status).toEqual(413) }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.effect("formData withMaxFieldSize", () => Effect.gen(function*() { yield* HttpRouter.add( "POST", "/upload", Effect.gen(function*() { const request = yield* HttpServerRequest.HttpServerRequest yield* request.multipart return HttpServerResponse.empty() }).pipe( Effect.catchTag("MultipartError", (error) => error.reason._tag === "FieldTooLarge" ? Effect.succeed(HttpServerResponse.empty({ status: 413 })) : Effect.fail(error)) ) ).pipe( HttpRouter.serve, Layer.build, Effect.provideService(Multipart.MaxFieldSize, 100) ) const client = yield* HttpClient.HttpClient const formData = new FormData() const data = new Uint8Array(1000).fill(1) formData.append("file", new TextDecoder().decode(data)) const response = yield* client.post("/upload", { body: HttpBody.formData(formData) }) expect(response.status).toEqual(413) }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.effect("mountApp", () => Effect.gen(function*() { const child = Effect.map(HttpServerRequest.HttpServerRequest, (_) => HttpServerResponse.text(_.url)) yield* HttpRouter.use((router) => router.prefixed("/child").add("*", "*", child)).pipe( HttpRouter.serve, Layer.build ) const client = yield* HttpClient.HttpClient const todo = yield* client.get("/child/1").pipe(Effect.flatMap((_) => _.text)) expect(todo).toEqual("/1") const root = yield* client.get("/child").pipe(Effect.flatMap((_) => _.text)) expect(root).toEqual("/") const rootSearch = yield* client.get("/child?foo=bar").pipe(Effect.flatMap((_) => _.text)) expect(rootSearch).toEqual("?foo=bar") const rootSlash = yield* client.get("/child/").pipe(Effect.flatMap((_) => _.text)) expect(rootSlash).toEqual("/") const invalid = yield* client.get("/child1/", { urlParams: { foo: "bar" } }).pipe(Effect.map((_) => _.status)) expect(invalid).toEqual(404) }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.effect("file", () => Effect.gen(function*() { yield* (yield* HttpServerResponse.file(`${__dirname}/fixtures/text.txt`).pipe( Effect.updateService( HttpPlatform.HttpPlatform, (_) => ({ ..._, fileResponse: (path, options) => Effect.map( _.fileResponse(path, options), (res) => { ;(res as any).headers.etag = "\"etag\"" return res } ) }) ) )).pipe( Effect.succeed, HttpServer.serveEffect() ) const client = yield* HttpClient.HttpClient const res = yield* client.get("/") expect(res.status).toEqual(200) expect(res.headers["content-type"]).toEqual("text/plain") expect(res.headers["content-length"]).toEqual("27") expect(res.headers.etag).toEqual("\"etag\"") const text = yield* res.text expect(text.trim()).toEqual("lorem ipsum dolar sit amet") }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.effect("fileWeb", () => Effect.gen(function*() { const now = new Date() const file = new Buffer.File([new TextEncoder().encode("test")], "test.txt", { type: "text/plain", lastModified: now.getTime() }) yield* HttpServerResponse.fileWeb(file).pipe( Effect.updateService( HttpPlatform.HttpPlatform, (_) => ({ ..._, fileWebResponse: (path, options) => Effect.map( _.fileWebResponse(path, options), (res) => ({ ...res, headers: { ...res.headers, etag: "W/\"etag\"" } }) ) }) ), HttpServer.serveEffect() ) const client = yield* HttpClient.HttpClient const res = yield* client.get("/") expect(res.status).toEqual(200) expect(res.headers["content-type"]).toEqual("text/plain") expect(res.headers["content-length"]).toEqual("4") expect(res.headers["last-modified"]).toEqual(now.toUTCString()) expect(res.headers.etag).toEqual("W/\"etag\"") const text = yield* res.text expect(text.trim()).toEqual("test") }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.effect("schemaBodyUrlParams", () => Effect.gen(function*() { yield* HttpRouter.add( "POST", "/todos", Effect.flatMap( HttpServerRequest.schemaBodyUrlParams(Schema.Struct({ id: Schema.FiniteFromString, title: Schema.String })), ({ id, title }) => todoResponse({ id, title }) ) ).pipe( HttpRouter.serve, Layer.build ) const todo = yield* HttpClientRequest.post("/todos").pipe( HttpClientRequest.bodyUrlParams({ id: "1", title: "test" }), HttpClient.execute, Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo)) ) expect(todo).toEqual({ id: 1, title: "test" }) }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.effect("schemaBodyUrlParams error", () => Effect.gen(function*() { yield* HttpRouter.add( "GET", "/todos", Effect.flatMap( HttpServerRequest.schemaBodyUrlParams(Schema.Struct({ id: Schema.FiniteFromString, title: Schema.String })), ({ id, title }) => todoResponse({ id, title }) ).pipe( Effect.catchTag("SchemaError", (error) => Effect.succeed(HttpServerResponse.jsonUnsafe({ error }, { status: 400 }))) ) ).pipe( HttpRouter.serve, Layer.build ) const client = yield* HttpClient.HttpClient const response = yield* client.get("/todos") expect(response.status).toEqual(400) }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.effect("schemaBodyFormJson", () => Effect.gen(function*() { yield* HttpRouter.add( "POST", "/upload", Effect.gen(function*() { const result = yield* HttpServerRequest.schemaBodyFormJson(Schema.Struct({ test: Schema.String }))("json") expect(result.test).toEqual("content") return HttpServerResponse.empty() }) ).pipe( HttpRouter.serve, Layer.build ) const client = yield* HttpClient.HttpClient const formData = new FormData() formData.append("json", JSON.stringify({ test: "content" })) const response = yield* client.post("/upload", { body: HttpBody.formData(formData) }) expect(response.status).toEqual(204) }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.effect("schemaBodyFormJson file", () => Effect.gen(function*() { yield* HttpRouter.add( "POST", "/upload", Effect.gen(function*() { const result = yield* HttpServerRequest.schemaBodyFormJson(Schema.Struct({ test: Schema.String }))("json") expect(result.test).toEqual("content") return HttpServerResponse.empty() }) ).pipe( HttpRouter.serve, Layer.build ) const client = yield* HttpClient.HttpClient const formData = new FormData() formData.append( "json", new Blob([JSON.stringify({ test: "content" })], { type: "application/json" }), "test.json" ) const response = yield* client.post("/upload", { body: HttpBody.formData(formData) }) expect(response.status).toEqual(204) }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.effect("schemaBodyFormJson url encoded", () => Effect.gen(function*() { yield* HttpRouter.add( "POST", "/upload", Effect.gen(function*() { const result = yield* HttpServerRequest.schemaBodyFormJson(Schema.Struct({ test: Schema.String }))("json") expect(result.test).toEqual("content") return HttpServerResponse.empty() }) ).pipe( HttpRouter.serve, Layer.build ) const client = yield* HttpClient.HttpClient const response = yield* client.post("/upload", { body: HttpBody.urlParams(UrlParams.fromInput({ json: JSON.stringify({ test: "content" }) })) }) expect(response.status).toEqual(204) }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.effect("tracing", () => Effect.gen(function*() { yield* HttpRouter.add( "GET", "/", Effect.flatMap( Effect.currentSpan, (_) => HttpServerResponse.json({ spanId: _.spanId, parent: _.parent }) ) ).pipe( HttpRouter.serve, Layer.build ) const client = yield* HttpClient.HttpClient const requestSpan = yield* Effect.makeSpan("client request") const body = yield* client.get("/").pipe( Effect.flatMap((r) => r.json), Effect.provideService( Tracer.Tracer, Tracer.make({ span(options) { assert.strictEqual(options.name, "http.client GET") assert.strictEqual(options.kind, "client") assert(options.parent._tag === "Some") if (options.parent.value._tag !== "Span") { throw new Error("Expected span parent") } assert.strictEqual(options.parent.value.name, "request parent") return requestSpan } }) ), Effect.withSpan("request parent"), Effect.repeat({ times: 2 }) ) expect((body as any).parent._tag).toEqual("Some") expect((body as any).parent.value.spanId).toEqual(requestSpan.spanId) }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.effect("html", () => Effect.gen(function*() { yield* HttpRouter.addAll([ HttpRouter.route("GET", "/home", HttpServerResponse.html("")), HttpRouter.route( "GET", "/about", HttpServerResponse.html`${Effect.succeed("")}` ), HttpRouter.route( "GET", "/stream", HttpServerResponse.htmlStream`${Stream.make("", 123, "hello")}` ) ]).pipe( HttpRouter.serve, Layer.build ) const client = yield* HttpClient.HttpClient const home = yield* client.get("/home").pipe(Effect.flatMap((r) => r.text)) expect(home).toEqual("") const about = yield* client.get("/about").pipe(Effect.flatMap((r) => r.text)) expect(about).toEqual("") const stream = yield* client.get("/stream").pipe(Effect.flatMap((r) => r.text)) expect(stream).toEqual("123hello") }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.effect("setCookie", () => Effect.gen(function*() { yield* HttpRouter.add( "GET", "/home", HttpServerResponse.empty().pipe( HttpServerResponse.setCookieUnsafe("test", "value"), HttpServerResponse.setCookieUnsafe("test2", "value2", { httpOnly: true, secure: true, sameSite: "lax", partitioned: true, path: "/", domain: "example.com", expires: new Date(2022, 1, 1, 0, 0, 0, 0), maxAge: "5 minutes" }) ) ).pipe( HttpRouter.serve, Layer.build ) const client = yield* HttpClient.HttpClient const res = yield* client.get("/home") assert.deepStrictEqual( res.cookies.toJSON(), Cookies.fromReadonlyRecord({ test: Cookies.makeCookieUnsafe("test", "value"), test2: Cookies.makeCookieUnsafe("test2", "value2", { httpOnly: true, secure: true, sameSite: "lax", partitioned: true, path: "/", domain: "example.com", expires: new Date(2022, 1, 1, 0, 0, 0, 0), maxAge: Duration.minutes(5) }) }).toJSON() ) }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.live("uninterruptible routes", () => Effect.gen(function*() { yield* HttpRouter.add( "GET", "/home", Effect.gen(function*() { const fiber = Fiber.getCurrent()! setTimeout(() => fiber.interruptUnsafe(fiber.id), 10) yield* Effect.sleep(50) return HttpServerResponse.empty() }), { uninterruptible: true } ).pipe( HttpRouter.serve, Layer.build ) const client = yield* HttpClient.HttpClient const res = yield* client.get("/home") assert.strictEqual(res.status, 204) }).pipe(Effect.provide(NodeHttpServer.layerTest))) describe("HttpServerRespondable", () => { it.effect("error/schema", () => Effect.gen(function*() { class CustomError extends Schema.ErrorClass("CustomError")({ _tag: Schema.tag("CustomError"), name: Schema.String }) { [HttpServerRespondable.symbol]() { return HttpServerResponse.schemaJson(CustomError)(this, { status: 599 }) } } yield* HttpRouter.add( "GET", "/home", new CustomError({ name: "test" }) ).pipe( HttpRouter.serve, Layer.build ) const client = yield* HttpClient.HttpClient const res = yield* client.get("/home") assert.strictEqual(res.status, 599) const err = yield* HttpClientResponse.schemaBodyJson(CustomError)(res) assert.deepStrictEqual(err, new CustomError({ name: "test" })) }).pipe(Effect.provide(NodeHttpServer.layerTest))) it.effect("httpapi error", () => Effect.gen(function*() { yield* HttpRouter.add( "GET", "/home", new HttpApiError.BadRequest({}) ).pipe( HttpRouter.serve, Layer.build ) const client = yield* HttpClient.HttpClient const res = yield* client.get("/home") assert.strictEqual(res.status, 400) }).pipe(Effect.provide(NodeHttpServer.layerTest))) }) it.effect("RouterConfig", () => Effect.gen(function*() { yield* HttpRouter.add( "GET", "/:param", Effect.succeed(HttpServerResponse.empty()) ).pipe( HttpRouter.serve, Layer.build ) let res = yield* HttpClient.get("/123456") assert.strictEqual(res.status, 404) res = yield* HttpClient.get("/12345") assert.strictEqual(res.status, 204) }).pipe( Effect.provide([ NodeHttpServer.layerTest, Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 5 }) ]) )) it.effect("HttpRouter prefixed", () => Effect.gen(function*() { const handler = HttpRouter.serve(HttpRouter.use(Effect.fnUntraced(function*(router_) { const router = router_.prefixed("/todos") yield* router.add( "GET", "/:id", Effect.flatMap( HttpRouter.schemaParams(IdParams), ({ id }) => todoResponse({ id, title: "test" }) ) ) yield* router.addAll([ HttpRouter.route("GET", "/", Effect.succeed(HttpServerResponse.text("root"))) ]) }))) yield* Layer.build(handler) const todo = yield* HttpClient.get("/todos/1").pipe( Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo)) ) expect(todo).toEqual({ id: 1, title: "test" }) const root = yield* HttpClient.get("/todos").pipe( Effect.flatMap((r) => r.text) ) expect(root).toEqual("root") }).pipe(Effect.provide(NodeHttpServer.layerTest))) })