1import { NodeHttpServer } from "@effect/platform-node"
2import { assert, describe, expect, it } from "@effect/vitest"
3import {
4 Array,
5 ByteSize,
6 Cause,
7 Context,
8 DateTime,
9 Effect,
10 Equal,
11 FileSystem,
12 Layer,
13 Option,
14 Redacted,
15 Ref,
16 Schema,
17 SchemaGetter,
18 SchemaTransformation,
19 Stream,
20 Struct
21} from "effect"
22import {
23 Cookies,
24 HttpBody,
25 HttpClient,
26 HttpClientRequest,
27 type HttpClientResponse,
28 HttpRouter,
29 HttpServer,
30 HttpServerRequest,
31 HttpServerResponse,
32 Multipart
33} from "effect/http"
34import {
35 HttpApi,
36 HttpApiBuilder,
37 HttpApiClient,
38 HttpApiEndpoint,
39 HttpApiError,
40 HttpApiGroup,
41 HttpApiMiddleware,
42 HttpApiSchema,
43 HttpApiSecurity,
44 HttpApiTest,
45 OpenApi
46} from "effect/http-api"
47
48function* assertServerText(res: HttpClientResponse.HttpClientResponse, status: number, text: string) {
49 assert.strictEqual(res.status, status)
50 assert.strictEqual(yield* res.text, text)
51}
52
53function* assertServerJson(res: HttpClientResponse.HttpClientResponse, status: number, json: unknown) {
54 assert.strictEqual(res.status, status)
55 assert.deepStrictEqual(yield* res.json, json)
56}
57
58function* assertClientText<E, R>(res: Effect.Effect<string, E, R>, text: string) {
59 assert.strictEqual(yield* res, text)
60}
61
62function* assertClientJson<E, R>(res: Effect.Effect<unknown, E, R>, json: unknown) {
63 assert.deepStrictEqual(yield* res, json)
64}
65
66function* assertClientError<A, E, R>(res: Effect.Effect<A, E, R>, error: E) {
67 assert.ok(Equal.equals(yield* Effect.flip(res), error))
68}
69
70describe("HttpApi", () => {
71 it.effect("catch all path", () => {
72 const Api = HttpApi.make("api")
73 .add(
74 HttpApiGroup.make("group")
75 .add(
76 HttpApiEndpoint.get("catchAll", "*", {
77 success: Schema.String
78 })
79 )
80 )
81 const GroupLayer = HttpApiBuilder.group(
82 Api,
83 "group",
84 (handlers) => handlers.handle("catchAll", (ctx) => Effect.succeed(ctx.request.url))
85 )
86 const ApiLayer = HttpRouter.serve(
87 HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLayer)),
88 { disableListenLog: true, disableLogger: true }
89 ).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
90
91 return Effect.gen(function*() {
92 // server side
93 yield* assertServerText(yield* HttpClient.get(""), 200, `"/"`)
94 yield* assertServerText(yield* HttpClient.get("/"), 200, `"/"`)
95 yield* assertServerText(yield* HttpClient.get("/a/b/c"), 200, `"/a/b/c"`)
96
97 // client side
98 const client = yield* HttpApiClient.make(Api)
99 yield* assertClientText(client.group.catchAll(), "/*")
100 }).pipe(Effect.provide(ApiLayer))
101 })
102
103 describe("middleware", () => {
104 it.effect("error", () => {
105 class M extends HttpApiMiddleware.Service<M>()("Http/Logger", {
106 error: Schema.String
107 .pipe(
108 HttpApiSchema.status("MethodNotAllowed"),
109 HttpApiSchema.asText()
110 )
111 }) {}
112
113 const Api = HttpApi.make("api")
114 .add(
115 HttpApiGroup.make("group")
116 .add(
117 HttpApiEndpoint.get("a", "/a", {
118 success: Schema.Finite
119 })
120 ).middleware(M)
121 )
122 const GroupLayer = HttpApiBuilder.group(
123 Api,
124 "group",
125 (handlers) => handlers.handle("a", () => Effect.succeed(1))
126 )
127 const MLayer = Layer.succeed(
128 M,
129 () => Effect.fail("error")
130 )
131
132 const ApiLayer = HttpRouter.serve(
133 HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLayer), Layer.provide(MLayer)),
134 { disableListenLog: true, disableLogger: true }
135 ).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
136
137 return Effect.gen(function*() {
138 // server side
139 yield* assertServerText(yield* HttpClient.get("/a"), 405, "error")
140
141 // client side
142 const client = yield* HttpApiClient.make(Api)
143 yield* assertClientError(client.group.a(), "error")
144 }).pipe(Effect.provide(ApiLayer))
145 })
146
147 it.effect("error array", () => {
148 class M extends HttpApiMiddleware.Service<M>()("Http/Auth", {
149 error: [HttpApiError.UnauthorizedNoContent, HttpApiError.ForbiddenNoContent]
150 }) {}
151
152 const Api = HttpApi.make("api").add(
153 HttpApiGroup.make("group")
154 .add(
155 HttpApiEndpoint.get("unauthorized", "/unauthorized", {
156 success: Schema.String
157 }),
158 HttpApiEndpoint.get("forbidden", "/forbidden", {
159 success: Schema.String
160 })
161 )
162 .middleware(M)
163 )
164 const GroupLayer = HttpApiBuilder.group(
165 Api,
166 "group",
167 (handlers) =>
168 handlers
169 .handle("unauthorized", () => Effect.succeed("ok"))
170 .handle("forbidden", () => Effect.succeed("ok"))
171 )
172 const MLayer = Layer.succeed(
173 M,
174 (_, { endpoint }) =>
175 endpoint.identifier === "unauthorized"
176 ? Effect.fail(new HttpApiError.Unauthorized({}))
177 : Effect.fail(new HttpApiError.Forbidden({}))
178 )
179
180 const ApiLayer = HttpRouter.serve(
181 HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLayer), Layer.provide(MLayer)),
182 { disableListenLog: true, disableLogger: true }
183 ).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
184
185 return Effect.gen(function*() {
186 yield* assertServerText(yield* HttpClient.get("/unauthorized"), 401, "")
187 yield* assertServerText(yield* HttpClient.get("/forbidden"), 403, "")
188
189 const client = yield* HttpApiClient.make(Api)
190 yield* assertClientError(client.group.unauthorized(), new HttpApiError.Unauthorized({}))
191 yield* assertClientError(client.group.forbidden(), new HttpApiError.Forbidden({}))
192 }).pipe(Effect.provide(ApiLayer))
193 })
194
195 it.effect("client middleware modifies request and receives metadata", () => {
196 class M extends HttpApiMiddleware.Service<M>()("Client/Metadata", {
197 requiredForClient: true
198 }) {}
199
200 const Api = HttpApi.make("api").add(
201 HttpApiGroup.make("group")
202 .add(
203 HttpApiEndpoint.get("a", "/a", {
204 success: Schema.String
205 })
206 )
207 .middleware(M)
208 )
209 const GroupLayer = HttpApiBuilder.group(
210 Api,
211 "group",
212 (handlers) => handlers.handle("a", (ctx) => Effect.succeed(ctx.request.headers["x-client"] ?? "missing"))
213 )
214 const MLayer = Layer.succeed(M, (effect) => effect)
215
216 const ApiLayer = HttpRouter.serve(
217 HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLayer), Layer.provide(MLayer)),
218 { disableListenLog: true, disableLogger: true }
219 ).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
220
221 return Effect.gen(function*() {
222 const metadata = yield* Ref.make<{ readonly group: string; readonly endpoint: string } | undefined>(undefined)
223
224 const MClient = HttpApiMiddleware.layerClient(
225 M,
226 Effect.fnUntraced(function*({ endpoint, group, next, request }) {
227 yield* Ref.set(metadata, { group: group.identifier, endpoint: endpoint.identifier })
228 return yield* next(HttpClientRequest.setHeader(request, "x-client", "from-client"))
229 })
230 )
231
232 const client = yield* HttpApiClient.make(Api).pipe(
233 Effect.provide(MClient)
234 )
235 assert.strictEqual(yield* client.group.a(), "from-client")
236 assert.deepStrictEqual(yield* Ref.get(metadata), {
237 group: "group",
238 endpoint: "a"
239 })
240 }).pipe(Effect.provide(ApiLayer))
241 })
242
243 it.effect("client middleware chain order is LIFO", () => {
244 class M1 extends HttpApiMiddleware.Service<M1>()("Client/Chain1", {
245 requiredForClient: true
246 }) {}
247
248 class M2 extends HttpApiMiddleware.Service<M2>()("Client/Chain2", {
249 requiredForClient: true
250 }) {}
251
252 const Api = HttpApi.make("api").add(
253 HttpApiGroup.make("group")
254 .add(
255 HttpApiEndpoint.get("a", "/a", {
256 success: Schema.String
257 })
258 .middleware(M1)
259 .middleware(M2)
260 )
261 )
262 const GroupLayer = HttpApiBuilder.group(
263 Api,
264 "group",
265 (handlers) =>
266 handlers.handle(
267 "a",
268 (ctx) => Effect.succeed(`${ctx.request.headers["x-m1"] ?? "0"}${ctx.request.headers["x-m2"] ?? "0"}`)
269 )
270 )
271 const M1Layer = Layer.succeed(M1, (effect) => effect)
272 const M2Layer = Layer.succeed(M2, (effect) => effect)
273
274 const ApiLayer = HttpRouter.serve(
275 HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLayer), Layer.provide(M1Layer), Layer.provide(M2Layer)),
276 { disableListenLog: true, disableLogger: true }
277 ).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
278
279 return Effect.gen(function*() {
280 const order: Array<string> = []
281
282 const M1Client = HttpApiMiddleware.layerClient(
283 M1,
284 Effect.fnUntraced(function*({ next, request }) {
285 order.push("m1-before")
286 const response = yield* next(HttpClientRequest.setHeader(request, "x-m1", "1"))
287 order.push("m1-after")
288 return response
289 })
290 )
291
292 const M2Client = HttpApiMiddleware.layerClient(
293 M2,
294 Effect.fnUntraced(function*({ next, request }) {
295 order.push("m2-before")
296 const response = yield* next(HttpClientRequest.setHeader(request, "x-m2", "1"))
297 order.push("m2-after")
298 return response
299 })
300 )
301
302 const client = yield* HttpApiClient.make(Api).pipe(
303 Effect.provide([M1Client, M2Client])
304 )
305
306 assert.strictEqual(yield* client.group.a(), "11")
307 assert.deepStrictEqual(order, ["m2-before", "m1-before", "m1-after", "m2-after"])
308 }).pipe(Effect.provide(ApiLayer))
309 })
310
311 it.effect("required client middleware can fail with typed clientError", () => {
312 class ClientFailure extends Schema.Error<ClientFailure>("ClientFailure")({
313 _tag: Schema.tag("ClientFailure")
314 }) {}
315
316 class M extends HttpApiMiddleware.Service<M, {
317 clientError: ClientFailure
318 }>()("Client/Failure", {
319 requiredForClient: true
320 }) {}
321
322 const Api = HttpApi.make("api").add(
323 HttpApiGroup.make("group")
324 .add(
325 HttpApiEndpoint.get("a", "/a", {
326 success: Schema.String
327 })
328 )
329 .middleware(M)
330 )
331 let handled = false
332 const GroupLayer = HttpApiBuilder.group(
333 Api,
334 "group",
335 (handlers) =>
336 handlers.handle("a", () =>
337 Effect.sync(() => {
338 handled = true
339 return "ok"
340 }))
341 )
342 const MLayer = Layer.succeed(M, (effect) => effect)
343
344 const ApiLayer = HttpRouter.serve(
345 HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLayer), Layer.provide(MLayer)),
346 { disableListenLog: true, disableLogger: true }
347 ).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
348
349 return Effect.gen(function*() {
350 const MClient = HttpApiMiddleware.layerClient(
351 M,
352 () => Effect.fail(new ClientFailure({}))
353 )
354
355 const client = yield* HttpApiClient.make(Api).pipe(
356 Effect.provide(MClient)
357 )
358
359 yield* assertClientError(client.group.a(), new ClientFailure({}))
360 assert.isFalse(handled)
361 }).pipe(Effect.provide(ApiLayer))
362 })
363
364 it.effect("optional client middleware is skipped when not provided", () => {
365 class M extends HttpApiMiddleware.Service<M>()("Client/Optional") {}
366
367 const Api = HttpApi.make("api").add(
368 HttpApiGroup.make("group")
369 .add(
370 HttpApiEndpoint.get("a", "/a", {
371 success: Schema.String
372 })
373 )
374 .middleware(M)
375 )
376 const GroupLayer = HttpApiBuilder.group(
377 Api,
378 "group",
379 (handlers) => handlers.handle("a", (ctx) => Effect.succeed(ctx.request.headers["x-optional"] ?? "missing"))
380 )
381 const MLayer = Layer.succeed(M, (effect) => effect)
382
383 const ApiLayer = HttpRouter.serve(
384 HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLayer), Layer.provide(MLayer)),
385 { disableListenLog: true, disableLogger: true }
386 ).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
387
388 return Effect.gen(function*() {
389 const client = yield* HttpApiClient.make(Api)
390 assert.strictEqual(yield* client.group.a(), "missing")
391 }).pipe(Effect.provide(ApiLayer))
392 })
393
394 it.effect("layerClient supports effectful construction", () => {
395 class HeaderValue extends Context.Service<HeaderValue, string>()("HeaderValue") {}
396
397 class M extends HttpApiMiddleware.Service<M>()("Client/Effectful", {
398 requiredForClient: true
399 }) {}
400
401 const Api = HttpApi.make("api").add(
402 HttpApiGroup.make("group")
403 .add(
404 HttpApiEndpoint.get("a", "/a", {
405 success: Schema.String
406 })
407 )
408 .middleware(M)
409 )
410 const GroupLayer = HttpApiBuilder.group(
411 Api,
412 "group",
413 (handlers) => handlers.handle("a", (ctx) => Effect.succeed(ctx.request.headers["x-effect"] ?? "missing"))
414 )
415 const MLayer = Layer.succeed(M, (effect) => effect)
416
417 const ApiLayer = HttpRouter.serve(
418 HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLayer), Layer.provide(MLayer)),
419 { disableListenLog: true, disableLogger: true }
420 ).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
421
422 return Effect.gen(function*() {
423 const MClient = HttpApiMiddleware.layerClient(
424 M,
425 Effect.gen(function*() {
426 const header = yield* HeaderValue
427 return ({ next, request }) => next(HttpClientRequest.setHeader(request, "x-effect", header))
428 })
429 )
430
431 const client = yield* HttpApiClient.make(Api).pipe(
432 Effect.provide(MClient),
433 Effect.provideService(HeaderValue, "from-effect")
434 )
435
436 assert.strictEqual(yield* client.group.a(), "from-effect")
437 }).pipe(Effect.provide(ApiLayer))
438 })
439
440 it.effect("security middleware can be implemented with layerClient", () => {
441 class CurrentToken extends Context.Service<CurrentToken, string>()("CurrentToken") {}
442
443 class M extends HttpApiMiddleware.Service<M, {
444 provides: CurrentToken
445 }>()("Client/Security", {
446 security: {
447 apiKey: HttpApiSecurity.apiKey({
448 in: "header",
449 key: "authorization"
450 })
451 }
452 }) {}
453
454 const Api = HttpApi.make("api").add(
455 HttpApiGroup.make("group")
456 .add(HttpApiEndpoint.get("a", "/a", {
457 success: Schema.String
458 }))
459 .middleware(M)
460 )
461 const GroupLayer = HttpApiBuilder.group(
462 Api,
463 "group",
464 (handlers) => handlers.handle("a", () => CurrentToken)
465 )
466 const MLayer = Layer.succeed(M)({
467 apiKey: (effect, options) => Effect.provideService(effect, CurrentToken, Redacted.value(options.credential))
468 })
469
470 const ApiLayer = HttpRouter.serve(
471 HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLayer), Layer.provide(MLayer)),
472 { disableListenLog: true, disableLogger: true }
473 ).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
474
475 return Effect.gen(function*() {
476 const client = yield* HttpApiClient.make(Api)
477 assert.strictEqual(yield* client.group.a(), "")
478
479 const MClient = HttpApiMiddleware.layerClient(
480 M,
481 ({ next, request }) => next(HttpClientRequest.setHeader(request, "authorization", "token"))
482 )
483 const authedClient = yield* HttpApiClient.make(Api).pipe(Effect.provide(MClient))
484
485 assert.strictEqual(yield* authedClient.group.a(), "token")
486 }).pipe(Effect.provide(ApiLayer))
487 })
488
489 it("security middleware cache does not reuse the first service impl", async () => {
490 class CurrentMarker extends Context.Service<CurrentMarker, string>()("CurrentMarker") {}
491
492 class M extends HttpApiMiddleware.Service<M, {
493 provides: CurrentMarker
494 }>()("Server/Security/Repro", {
495 security: {
496 apiKey: HttpApiSecurity.apiKey({
497 in: "header",
498 key: "authorization"
499 })
500 }
501 }) {}
502
503 const Api = HttpApi.make("api").add(
504 HttpApiGroup.make("group")
505 .add(HttpApiEndpoint.get("a", "/a", {
506 success: Schema.Struct({
507 marker: Schema.String
508 })
509 }))
510 .middleware(M)
511 )
512
513 const makeWebHandler = (marker: string) => {
514 const GroupLayer = HttpApiBuilder.group(
515 Api,
516 "group",
517 (handlers) => handlers.handle("a", () => Effect.map(CurrentMarker, (marker) => ({ marker })))
518 )
519 const MLayer = Layer.succeed(M)({
520 apiKey: (effect) => Effect.provideService(effect, CurrentMarker, marker)
521 })
522
523 return HttpRouter.toWebHandler(
524 Layer.mergeAll(
525 HttpApiBuilder.layer(Api).pipe(
526 Layer.provide(GroupLayer),
527 Layer.provide(MLayer),
528 Layer.provide(HttpServer.layerServices)
529 )
530 ),
531 { disableLogger: true }
532 )
533 }
534
535 const first = makeWebHandler("first")
536 const second = makeWebHandler("second")
537
538 try {
539 const firstResponse = await first.handler(
540 new Request("http://localhost/a", {
541 headers: {
542 authorization: "token"
543 }
544 })
545 )
546 assert.strictEqual(firstResponse.status, 200)
547 assert.deepStrictEqual(await firstResponse.json(), { marker: "first" })
548
549 const secondResponse = await second.handler(
550 new Request("http://localhost/a", {
551 headers: {
552 authorization: "token"
553 }
554 })
555 )
556 assert.strictEqual(secondResponse.status, 200)
557 assert.deepStrictEqual(await secondResponse.json(), { marker: "second" })
558 } finally {
559 await first.dispose()
560 await second.dispose()
561 }
562 })
563
564 it.effect("addHttpApi + middleware works across merged groups", () => {
565 class M1 extends HttpApiMiddleware.Service<M1>()("Http/M1") {}
566 class M2 extends HttpApiMiddleware.Service<M2>()("Http/M2") {}
567
568 const calls: Array<string> = []
569
570 const V0 = HttpApi.make("v0").add(
571 HttpApiGroup.make("users").add(
572 HttpApiEndpoint.get("list", "/users", {
573 success: Schema.String
574 })
575 )
576 )
577 const Api = HttpApi.make("api")
578 .add(
579 HttpApiGroup.make("health").add(
580 HttpApiEndpoint.get("health", "/health", {
581 success: Schema.String
582 })
583 )
584 )
585 .addHttpApi(V0)
586 .middleware(M1)
587 .middleware(M2)
588
589 const HealthLayer = HttpApiBuilder.group(
590 Api,
591 "health",
592 (handlers) => handlers.handle("health", () => Effect.succeed("ok"))
593 )
594 const UsersLayer = HttpApiBuilder.group(
595 Api,
596 "users",
597 (handlers) => handlers.handle("list", () => Effect.succeed("ok"))
598 )
599 const M1Layer = Layer.succeed(
600 M1,
601 (effect, { endpoint, group }) =>
602 Effect.sync(() => calls.push(`m1:${group.identifier}.${endpoint.identifier}`)).pipe(
603 Effect.flatMap(() => effect)
604 )
605 )
606 const M2Layer = Layer.succeed(
607 M2,
608 (effect, { endpoint, group }) =>
609 Effect.sync(() => calls.push(`m2:${group.identifier}.${endpoint.identifier}`)).pipe(
610 Effect.flatMap(() => effect)
611 )
612 )
613
614 const ApiLayer = HttpRouter.serve(
615 HttpApiBuilder.layer(Api).pipe(
616 Layer.provide(HealthLayer),
617 Layer.provide(UsersLayer),
618 Layer.provide(M1Layer),
619 Layer.provide(M2Layer)
620 ),
621 { disableListenLog: true, disableLogger: true }
622 ).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
623
624 return Effect.gen(function*() {
625 yield* assertServerJson(yield* HttpClient.get("/health"), 200, "ok")
626 yield* assertServerJson(yield* HttpClient.get("/users"), 200, "ok")
627 assert.deepStrictEqual(calls, [
628 "m2:health.health",
629 "m1:health.health",
630 "m2:users.list",
631 "m1:users.list"
632 ])
633 }).pipe(Effect.provide(ApiLayer))
634 })
635
636 it.effect("missing middleware layer fails with service not found error", () => {
637 class M extends HttpApiMiddleware.Service<M>()("Server/MissingMiddleware") {}
638
639 const Api = HttpApi.make("api").add(
640 HttpApiGroup.make("group")
641 .add(
642 HttpApiEndpoint.get("a", "/a", {
643 success: Schema.String
644 })
645 )
646 .middleware(M)
647 )
648 const GroupLayer = HttpApiBuilder.group(
649 Api,
650 "group",
651 (handlers) => handlers.handle("a", () => Effect.succeed("ok"))
652 )
653 const ApiLayer = HttpRouter.serve(
654 HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLayer)),
655 { disableListenLog: true, disableLogger: true }
656 ).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
657
658 return HttpClient.get("/a").pipe(
659 Effect.provide(ApiLayer),
660 Effect.sandbox,
661 Effect.flip,
662 Effect.flatMap((cause) =>
663 Effect.sync(() => {
664 const defect = Cause.squash(cause)
665 assert.instanceOf(defect, Error)
666 assert.include(defect.message, "Service not found: Server/MissingMiddleware")
667 assert.isFalse(defect.message.includes("is not a function"))
668 })
669 )
670 ) as Effect.Effect<void, HttpClientResponse.HttpClientResponse>
671 })
672 })
673
674 it.effect("missing addHttpApi group layer has actionable error", () => {
675 const HealthApi = HttpApiGroup.make("health").add(
676 HttpApiEndpoint.get("health", "/health", {
677 success: Schema.String
678 })
679 )
680 const V0 = HttpApi.make("v0").add(
681 HttpApiGroup.make("users").add(
682 HttpApiEndpoint.get("list", "/users", {
683 success: Schema.String
684 })
685 )
686 )
687 const Api = HttpApi.make("api")
688 .add(HealthApi)
689 .addHttpApi(V0)
690
691 const UsersLayer = HttpApiBuilder.group(
692 Api,
693 "users",
694 (handlers) => handlers.handle("list", () => Effect.succeed("ok"))
695 )
696 const ApiLayer = HttpRouter.serve(
697 HttpApiBuilder.layer(Api).pipe(Layer.provide(UsersLayer)),
698 { disableListenLog: true, disableLogger: true }
699 ).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
700
701 return HttpClient.get("/users").pipe(
702 Effect.provide(ApiLayer),
703 Effect.sandbox,
704 Effect.flip,
705 Effect.flatMap((cause) =>
706 Effect.sync(() => {
707 const defect = Cause.squash(cause)
708 assert.strictEqual(typeof defect, "string")
709 assert.include(defect, "HttpApiGroup \"health\" not found")
710 assert.include(defect, "HttpApiBuilder.group(api, \"health\", ...)")
711 assert.include(defect, "Available groups: effect/http-api/HttpApiGroup/users")
712 })
713 )
714 ) as Effect.Effect<void, HttpClientResponse.HttpClientResponse>
715 })
716
717 describe("payload option", () => {
718 describe("GET", () => {
719 it.effect("query parameters", () => {
720 const Api = HttpApi.make("api")
721 .add(
722 HttpApiGroup.make("group")
723 .add(
724 HttpApiEndpoint.get("a", "/a", {
725 payload: {
726 required: Schema.FiniteFromString,
727 optionalKey: Schema.optionalKey(Schema.FiniteFromString),
728 optional: Schema.optional(Schema.FiniteFromString)
729 },
730 success: Schema.Any
731 })
732 )
733 )
734 const GroupLayer = HttpApiBuilder.group(
735 Api,
736 "group",
737 (handlers) => handlers.handle("a", (ctx) => Effect.succeed(ctx.payload))
738 )
739 const ApiLayer = HttpRouter.serve(
740 HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLayer)),
741 { disableListenLog: true, disableLogger: true }
742 ).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
743
744 return Effect.gen(function*() {
745 // server side
746 yield* assertServerText(yield* HttpClient.get("/a"), 400, "")
747 yield* assertServerJson(yield* HttpClient.get("/a?required=1"), 200, { required: 1 })
748 yield* assertServerJson(yield* HttpClient.get("/a?required=1&optionalKey=1"), 200, {
749 required: 1,
750 optionalKey: 1
751 })
752 yield* assertServerJson(yield* HttpClient.get("/a?required=1&optional=1"), 200, { required: 1, optional: 1 })
753 yield* assertServerJson(yield* HttpClient.get("/a?required=1"), 200, { required: 1 })
754 // TODO: fix this test
755 yield* assertServerJson(yield* HttpClient.get("/a?required=1&optional="), 200, { required: 1, optional: 0 })
756
757 // client side
758 const client = yield* HttpApiClient.make(Api)
759 yield* assertClientJson(client.group.a({ payload: { required: 1 } }), { required: 1 })
760 yield* assertClientJson(client.group.a({ payload: { required: 1, optionalKey: 1 } }), {
761 required: 1,
762 optionalKey: 1
763 })
764 yield* assertClientJson(client.group.a({ payload: { required: 1, optional: 1 } }), {
765 required: 1,
766 optional: 1
767 })
768 yield* assertClientJson(client.group.a({ payload: { required: 1, optional: undefined } }), { required: 1 })
769 }).pipe(Effect.provide(ApiLayer))
770 })
771 })
772
773 it.effect("array of schemas with different encodings", () => {
774 const Api = HttpApi.make("api").add(
775 HttpApiGroup.make("group").add(
776 HttpApiEndpoint.post("a", "/a", {
777 payload: [
778 Schema.Struct({ a: Schema.String }), // application/json
779 Schema.String.pipe(HttpApiSchema.asText()), // text/plain
780 Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()) // application/octet-stream
781 ],
782 success: Schema.Any
783 })
784 )
785 )
786 const GroupLayer = HttpApiBuilder.group(
787 Api,
788 "group",
789 (handlers) => handlers.handle("a", (ctx) => Effect.succeed(ctx.payload))
790 )
791
792 const ApiLayer = HttpRouter.serve(
793 HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLayer)),
794 { disableListenLog: true, disableLogger: true }
795 ).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
796
797 return Effect.gen(function*() {
798 // server side
799 yield* assertServerText(yield* HttpClient.post("/a"), 400, "")
800 yield* assertServerJson(yield* HttpClient.post("/a", { body: HttpBody.jsonUnsafe({ a: "text" }) }), 200, {
801 a: "text"
802 })
803 yield* assertServerJson(yield* HttpClient.post("/a", { body: HttpBody.text("text") }), 200, "text")
804 yield* assertServerJson(
805 yield* HttpClient.post("/a", { body: HttpBody.uint8Array(new Uint8Array([1, 2, 3])) }),
806 200,
807 { 0: 1, 1: 2, 2: 3 }
808 )
809
810 // client side
811 const client = yield* HttpApiClient.make(Api)
812 yield* assertClientJson(client.group.a({ payload: { a: "text" } }), { a: "text" })
813 yield* assertClientJson(client.group.a({ payload: "text" }), "text")
814 yield* assertClientJson(client.group.a({ payload: new Uint8Array([1, 2, 3]) }), { 0: 1, 1: 2, 2: 3 })
815 }).pipe(Effect.provide(ApiLayer))
816 })
817 })
818
819 describe("params option", () => {
820 it.effect("should accept a record of schemas", () => {
821 const Api = HttpApi.make("api").add(
822 HttpApiGroup.make("group").add(
823 HttpApiEndpoint.get("get", "/:id", {
824 params: {
825 id: Schema.FiniteFromString
826 },
827 success: Schema.String
828 })
829 )
830 )
831 const GroupLayer = HttpApiBuilder.group(
832 Api,
833 "group",
834 (handlers) => handlers.handle("get", (ctx) => Effect.succeed(`User ${ctx.params.id}`))
835 )
836
837 const ApiLayer = HttpRouter.serve(
838 HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLayer)),
839 { disableListenLog: true, disableLogger: true }
840 ).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
841
842 return Effect.gen(function*() {
843 // server side
844 yield* assertServerJson(yield* HttpClient.get("/1"), 200, "User 1")
845
846 // client side
847 const client = yield* HttpApiClient.make(Api)
848 yield* assertClientJson(client.group.get({ params: { id: 1 } }), "User 1")
849 }).pipe(Effect.provide(ApiLayer))
850 })
851 })
852
853 describe("query option", () => {
854 it.effect("should accept a record of schemas", () => {
855 const Api = HttpApi.make("api").add(
856 HttpApiGroup.make("group").add(
857 HttpApiEndpoint.get("get", "/", {
858 query: {
859 id: Schema.FiniteFromString
860 },
861 success: Schema.String
862 })
863 )
864 )
865 const GroupLayer = HttpApiBuilder.group(
866 Api,
867 "group",
868 (handlers) => handlers.handle("get", (ctx) => Effect.succeed(`User ${ctx.query.id}`))
869 )
870
871 const ApiLayer = HttpRouter.serve(
872 HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLayer)),
873 { disableListenLog: true, disableLogger: true }
874 ).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
875
876 return Effect.gen(function*() {
877 // server side
878 yield* assertServerJson(yield* HttpClient.get("/?id=1"), 200, "User 1")
879
880 // client side
881 const client = yield* HttpApiClient.make(Api)
882 yield* assertClientJson(client.group.get({ query: { id: 1 } }), "User 1")
883 }).pipe(Effect.provide(ApiLayer))
884 })
885 })
886
887 describe("success option", () => {
888 it.effect("no content", () => {
889 const Api = HttpApi.make("api")
890 .add(
891 HttpApiGroup.make("group")
892 .add(
893 HttpApiEndpoint.get("a", "/a", {
894 success: Schema.Void
895 }),
896 HttpApiEndpoint.get("b", "/b", {
897 success: HttpApiSchema.NoContent
898 }),
899 HttpApiEndpoint.get("c", "/c", {
900 success: Schema.String.pipe(HttpApiSchema.asNoContent({ decode: () => "c" }))
901 })
902 )
903 )
904 const GroupLayer = HttpApiBuilder.group(
905 Api,
906 "group",
907 (handlers) =>
908 handlers
909 .handle("a", () => Effect.void)
910 .handle("b", () => Effect.succeed(HttpApiSchema.NoContent.make()))
911 .handle("c", () => Effect.succeed("-"))
912 )
913
914 const ApiLayer = HttpRouter.serve(
915 HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLayer)),
916 { disableListenLog: true, disableLogger: true }
917 ).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
918
919 return Effect.gen(function*() {
920 // server side
921 yield* assertServerText(yield* HttpClient.get("/a"), 200, "")
922 yield* assertServerText(yield* HttpClient.get("/b"), 204, "")
923 yield* assertServerText(yield* HttpClient.get("/c"), 200, "")
924
925 // client side
926 const client = yield* HttpApiClient.make(Api)
927 yield* assertClientJson(client.group.a(), undefined)
928 yield* assertClientJson(client.group.b(), undefined)
929 yield* assertClientJson(client.group.c(), "c")
930 }).pipe(Effect.provide(ApiLayer))
931 })
932
933 it("no content via toWebHandler", async () => {
934 const Api = HttpApi.make("api")
935 .add(
936 HttpApiGroup.make("group")
937 .add(HttpApiEndpoint.delete("remove", "/items/:id", { params: { id: Schema.String } }))
938 )
939 const GroupLayer = HttpApiBuilder.group(
940 Api,
941 "group",
942 (handlers) => handlers.handle("remove", () => Effect.void)
943 )
944 const ApiLayer = HttpApiBuilder.layer(Api).pipe(
945 Layer.provide(GroupLayer),
946 Layer.provide(HttpServer.layerServices)
947 )
948 const { handler, dispose } = HttpRouter.toWebHandler(
949 Layer.mergeAll(ApiLayer),
950 { disableLogger: true }
951 )
952 try {
953 const response = await handler(new Request("http://localhost/items/123", { method: "DELETE" }))
954 assert.strictEqual(response.status, 204)
955 assert.strictEqual(response.body, null)
956 } finally {
957 await dispose()
958 }
959 })
960
961 describe("encodings", () => {
962 describe("asJson", () => {
963 it.effect("custom contentType", () => {
964 const Api = HttpApi.make("api").add(
965 HttpApiGroup.make("group").add(
966 HttpApiEndpoint.get("a", "/a", {
967 success: Schema.String.pipe(HttpApiSchema.asJson({ contentType: "application/scim+json" }))
968 })
969 )
970 )
971 const GroupLayer = HttpApiBuilder.group(
972 Api,
973 "group",
974 (handlers) => handlers.handle("a", () => Effect.succeed("a"))
975 )
976
977 const ApiLayer = HttpRouter.serve(
978 HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLayer)),
979 { disableListenLog: true, disableLogger: true }
980 ).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
981
982 return Effect.gen(function*() {
983 // server side
984 const res = yield* HttpClient.get("/a")
985 yield* assertServerJson(res, 200, "a")
986 assert.strictEqual(res.headers["content-type"], "application/scim+json")
987
988 // client side
989 const client = yield* HttpApiClient.make(Api)
990 yield* assertClientJson(client.group.a(), "a")
991 }).pipe(Effect.provide(ApiLayer))
992 })
993 })
994 })
995 })
996
997 describe("error option", () => {
998 it.effect("no content", () => {
999 const Api = HttpApi.make("api")
1000 .add(
1001 HttpApiGroup.make("group")
1002 .add(
1003 HttpApiEndpoint.get("a", "/a", {
1004 error: Schema.Void.pipe(HttpApiSchema.status("Forbidden"))
1005 }),
1006 HttpApiEndpoint.get("b", "/b", {
1007 error: HttpApiSchema.NoContent,
1008 success: Schema.String
1009 }),
1010 HttpApiEndpoint.get("c", "/c", {
1011 error: Schema.String.pipe(
1012 HttpApiSchema.asNoContent({ decode: () => "c" }),
1013 HttpApiSchema.status("Forbidden")
1014 )
1015 }),
1016 HttpApiEndpoint.get("d", "/d", {
1017 error: HttpApiSchema.Empty(400)
1018 }),
1019 HttpApiEndpoint.get("e", "/e", {
1020 error: HttpApiError.UnauthorizedNoContent
1021 }),
1022 HttpApiEndpoint.get("f", "/f", {
1023 error: HttpApiError.BadRequestNoContent
1024 })
1025 )
1026 )
1027 const GroupLayer = HttpApiBuilder.group(
1028 Api,
1029 "group",
1030 (handlers) =>
1031 handlers
1032 .handle("a", () => Effect.fail(undefined))
1033 .handle("b", () => Effect.fail(HttpApiSchema.NoContent.make()))
1034 .handle("c", () => Effect.fail(""))
1035 .handle("d", () => Effect.fail(void 0))
1036 .handle("e", () => Effect.fail(new HttpApiError.Unauthorized({})))
1037 .handle("f", () => Effect.fail(new HttpApiError.BadRequest({})))
1038 )
1039
1040 const ApiLayer = HttpRouter.serve(
1041 HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLayer)),
1042 { disableListenLog: true, disableLogger: true }
1043 ).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
1044
1045 return Effect.gen(function*() {
1046 // server side
1047 yield* assertServerText(yield* HttpClient.get("/a"), 403, "")
1048 yield* assertServerText(yield* HttpClient.get("/b"), 204, "")
1049 yield* assertServerText(yield* HttpClient.get("/c"), 403, "")
1050 yield* assertServerText(yield* HttpClient.get("/d"), 400, "")
1051 yield* assertServerText(yield* HttpClient.get("/e"), 401, "")
1052 yield* assertServerText(yield* HttpClient.get("/f"), 400, "")
1053
1054 // client side
1055 const client = yield* HttpApiClient.make(Api)
1056 yield* assertClientError(client.group.a(), undefined)
1057 yield* assertClientError(client.group.b(), undefined)
1058 yield* assertClientError(client.group.c(), "c")
1059 yield* assertClientError(client.group.d(), undefined)
1060 yield* assertClientError(client.group.e(), new HttpApiError.Unauthorized({}))
1061 yield* assertClientError(client.group.f(), new HttpApiError.BadRequest({}))
1062 }).pipe(Effect.provide(ApiLayer))
1063 })
1064 })
1065
1066 describe("original tests", () => {
1067 describe("payload", () => {
1068 it.effect("is decoded / encoded", () =>
1069 Effect.gen(function*() {
1070 const expected = new User({
1071 id: 123,
1072 name: "Joe",
1073 createdAt: DateTime.makeUnsafe(0)
1074 })
1075 const client = yield* HttpApiClient.make(Api)
1076 const clientUsersGroup = yield* HttpApiClient.group(Api, {
1077 httpClient: yield* HttpClient.HttpClient,
1078 group: "users"
1079 })
1080 const clientUsersEndpointCreate = yield* HttpApiClient.endpoint(Api, {
1081 httpClient: yield* HttpClient.HttpClient,
1082 group: "users",
1083 endpoint: "create"
1084 })
1085
1086 const apiClientUser = yield* client.users.create({
1087 query: { id: 123 },
1088 payload: { name: "Joe" }
1089 })
1090 assert.deepStrictEqual(
1091 apiClientUser,
1092 expected
1093 )
1094 const groupClientUser = yield* clientUsersGroup.create({
1095 query: { id: 123 },
1096 payload: { name: "Joe" }
1097 })
1098 assert.deepStrictEqual(
1099 groupClientUser,
1100 expected
1101 )
1102 const endpointClientUser = yield* clientUsersEndpointCreate({
1103 query: { id: 123 },
1104 payload: { name: "Joe" }
1105 })
1106 assert.deepStrictEqual(
1107 endpointClientUser,
1108 expected
1109 )
1110 }).pipe(Effect.provide(HttpLayer)))
1111
1112 it.live("multipart", () =>
1113 Effect.gen(function*() {
1114 const client = yield* HttpApiClient.make(Api)
1115 const data = new FormData()
1116 data.append("file", new Blob(["hello"], { type: "text/plain" }), "hello.txt")
1117 const result = yield* client.users.upload({ payload: data, params: {} })
1118 assert.deepStrictEqual(result, {
1119 contentType: "text/plain",
1120 length: 5
1121 })
1122 }).pipe(Effect.provide(HttpLayer)))
1123
1124 it.live("multipart stream", () =>
1125 Effect.gen(function*() {
1126 const client = yield* HttpApiClient.make(Api)
1127 const data = new FormData()
1128 data.append("file", new Blob(["hello"], { type: "text/plain" }), "hello.txt")
1129 const result = yield* client.users.uploadStream({ payload: data })
1130 assert.deepStrictEqual(result, {
1131 contentType: "text/plain",
1132 length: 5
1133 })
1134 }).pipe(Effect.provide(HttpLayer)))
1135 })
1136
1137 describe("headers", () => {
1138 it.effect("should accept a record of schemas", () => {
1139 const Api = HttpApi.make("api").add(
1140 HttpApiGroup.make("group").add(
1141 HttpApiEndpoint.get("get", "/:id", {
1142 headers: {
1143 id: Schema.FiniteFromString
1144 },
1145 success: Schema.String
1146 })
1147 )
1148 )
1149 const GroupLayer = HttpApiBuilder.group(
1150 Api,
1151 "group",
1152 (handlers) => handlers.handle("get", (ctx) => Effect.succeed(`User ${ctx.headers.id}`))
1153 )
1154
1155 const ApiLayer = HttpRouter.serve(
1156 HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLayer)),
1157 { disableListenLog: true, disableLogger: true }
1158 ).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
1159
1160 return Effect.gen(function*() {
1161 const client = yield* HttpApiClient.make(Api)
1162 const result = yield* client.group.get({ headers: { id: 1 } })
1163 assert.strictEqual(result, "User 1")
1164 }).pipe(Effect.provide(ApiLayer))
1165 })
1166
1167 it.effect("is decoded / encoded", () =>
1168 Effect.gen(function*() {
1169 const client = yield* HttpApiClient.make(Api)
1170 const users = yield* client.users.list({
1171 headers: { page: 1 },
1172 query: {}
1173 })
1174 const user = users[0]
1175 assert.deepStrictEqual(
1176 user,
1177 new User({
1178 id: 1,
1179 name: "page 1",
1180 createdAt: DateTime.makeUnsafe(0)
1181 })
1182 )
1183 }).pipe(Effect.provide(HttpLayer)))
1184 })
1185
1186 describe("errors", () => {
1187 it.effect("empty errors have no body", () =>
1188 Effect.gen(function*() {
1189 const response = yield* HttpClient.get("/groups/0")
1190 assert.strictEqual(response.status, 418)
1191 const text = yield* response.text
1192 assert.strictEqual(text, "")
1193 }).pipe(Effect.provide(HttpLayer)))
1194
1195 it.effect("empty errors decode", () =>
1196 Effect.gen(function*() {
1197 const client = yield* HttpApiClient.make(Api)
1198 const error = yield* client.groups.findById({ params: { id: 0 } }).pipe(
1199 Effect.flip
1200 )
1201 assert.deepStrictEqual(error, new GroupError({}))
1202 }).pipe(Effect.provide(HttpLayer)))
1203
1204 it.effect("default to 500 status code", () =>
1205 Effect.gen(function*() {
1206 const response = yield* HttpClientRequest.get("/users").pipe(
1207 HttpClientRequest.setHeaders({ page: "0" }),
1208 HttpClient.execute
1209 )
1210 assert.strictEqual(response.status, 500)
1211 const body = yield* response.json
1212 assert.deepStrictEqual(body, {
1213 _tag: "NoStatusError"
1214 })
1215 }).pipe(Effect.provide(HttpLayer)))
1216
1217 it.effect("class level annotations", () =>
1218 Effect.gen(function*() {
1219 const response = yield* HttpClientRequest.post("/users").pipe(
1220 HttpClientRequest.setUrlParams({ id: "0" }),
1221 HttpClientRequest.bodyJsonUnsafe({ name: "boom" }),
1222 HttpClient.execute
1223 )
1224 assert.strictEqual(response.status, 400)
1225 }).pipe(Effect.provide(HttpLayer)))
1226
1227 it.effect("BadRequest", () =>
1228 Effect.gen(function*() {
1229 const client = yield* HttpApiClient.make(Api)
1230 const error = yield* client.users.upload({ params: {}, payload: new FormData() }).pipe(
1231 Effect.flip
1232 )
1233 assert(error._tag === "HttpClientError" && error.reason._tag === "DecodeError")
1234 assert.strictEqual(error.reason.response.status, 400)
1235 }).pipe(Effect.provide(HttpLayer)))
1236 })
1237
1238 it.effect("handler level context", () =>
1239 Effect.gen(function*() {
1240 const client = yield* HttpApiClient.make(Api)
1241 const users = yield* client.users.list({ headers: { page: 1 }, query: {} })
1242 const user = users[0]
1243 assert.strictEqual(user.name, "page 1")
1244 assert.deepStrictEqual(user.createdAt, DateTime.makeUnsafe(0))
1245 }).pipe(Effect.provide(HttpLayer)))
1246
1247 it.effect("custom client context", () =>
1248 Effect.gen(function*() {
1249 let tapped = false
1250 const client = yield* HttpApiClient.makeWith(Api, {
1251 httpClient: (yield* HttpClient.HttpClient).pipe(
1252 HttpClient.tapRequest(Effect.fnUntraced(function*(_request) {
1253 tapped = true
1254 yield* CurrentUser
1255 }))
1256 )
1257 })
1258 const users = yield* client.users.list({ headers: { page: 1 }, query: {} }).pipe(
1259 Effect.provideService(
1260 CurrentUser,
1261 new User({
1262 id: 1,
1263 name: "foo",
1264 createdAt: DateTime.makeUnsafe(0)
1265 })
1266 )
1267 )
1268 const user = users[0]
1269 assert.strictEqual(user.name, "page 1")
1270 assert.isTrue(tapped)
1271 }).pipe(Effect.provide(HttpLayer)))
1272
1273 describe("security", () => {
1274 it.effect("security middleware sets current user", () =>
1275 Effect.gen(function*() {
1276 const ref = yield* Ref.make(Cookies.empty.pipe(
1277 Cookies.setUnsafe("token", "foo")
1278 ))
1279 const client = yield* HttpApiClient.makeWith(Api, {
1280 httpClient: HttpClient.withCookiesRef(yield* HttpClient.HttpClient, ref)
1281 })
1282 const user = yield* client.users.findById({ params: { id: -1 } })
1283 assert.strictEqual(user.name, "foo")
1284 }).pipe(Effect.provide(HttpLayer)))
1285
1286 it.effect("apiKey header security", () =>
1287 Effect.gen(function*() {
1288 const decode = HttpApiBuilder.securityDecode(securityHeader).pipe(
1289 Effect.provideService(
1290 HttpServerRequest.HttpServerRequest,
1291 HttpServerRequest.fromWeb(
1292 new Request("http://localhost:3000/", {
1293 headers: {
1294 "x-api-key": "foo"
1295 }
1296 })
1297 )
1298 ),
1299 Effect.provideService(HttpServerRequest.ParsedSearchParams, {})
1300 )
1301 const redacted = yield* decode
1302 assert.strictEqual(Redacted.value(redacted), "foo")
1303 }).pipe(Effect.provide(HttpLayer)))
1304
1305 it.effect("apiKey query security", () =>
1306 Effect.gen(function*() {
1307 const redacted = yield* HttpApiBuilder.securityDecode(securityQuery).pipe(
1308 Effect.provideService(
1309 HttpServerRequest.HttpServerRequest,
1310 HttpServerRequest.fromWeb(new Request("http://localhost:3000/"))
1311 ),
1312 Effect.provideService(HttpServerRequest.ParsedSearchParams, {
1313 api_key: "foo"
1314 })
1315 )
1316 assert.strictEqual(Redacted.value(redacted), "foo")
1317 }).pipe(Effect.provide(HttpLayer)))
1318 })
1319
1320 it.effect("client responseMode decoded-and-response", () =>
1321 Effect.gen(function*() {
1322 const client = yield* HttpApiClient.make(Api)
1323 const [users, response] = yield* client.users.list({
1324 headers: { page: 1 },
1325 query: {},
1326 responseMode: "decoded-and-response"
1327 })
1328 assert.strictEqual(users[0].name, "page 1")
1329 assert.strictEqual(response.status, 200)
1330 }).pipe(Effect.provide(HttpLayer)))
1331
1332 it.effect("client responseMode response-only skips decoding", () => {
1333 const Api = HttpApi.make("api").add(
1334 HttpApiGroup.make("group").add(
1335 HttpApiEndpoint.get("bad", "/bad", {
1336 success: Schema.Struct({
1337 value: Schema.Finite
1338 })
1339 })
1340 )
1341 )
1342
1343 const GroupLayer = HttpApiBuilder.group(
1344 Api,
1345 "group",
1346 (handlers) => handlers.handleRaw("bad", () => Effect.succeed(HttpServerResponse.text("not-json")))
1347 )
1348
1349 const ApiLayer = HttpRouter.serve(
1350 HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLayer)),
1351 { disableListenLog: true, disableLogger: true }
1352 ).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
1353
1354 return Effect.gen(function*() {
1355 const client = yield* HttpApiClient.make(Api)
1356
1357 yield* Effect.flip(client.group.bad())
1358
1359 const response = yield* client.group.bad({
1360 responseMode: "response-only"
1361 })
1362 assert.strictEqual(response.status, 200)
1363 assert.strictEqual(yield* response.text, "not-json")
1364 }).pipe(Effect.provide(ApiLayer))
1365 })
1366
1367 it.effect("multiple payload types", () =>
1368 Effect.gen(function*() {
1369 const client = yield* HttpApiClient.make(Api)
1370 let [group, response] = yield* client.groups.create({
1371 payload: { name: "Some group" },
1372 responseMode: "decoded-and-response"
1373 })
1374 assert.deepStrictEqual(group, new Group({ id: 1, name: "Some group" }))
1375 assert.strictEqual(response.status, 200)
1376
1377 const data = new FormData()
1378 data.set("name", "Some group")
1379 ;[group, response] = yield* client.groups.create({
1380 payload: data,
1381 responseMode: "decoded-and-response"
1382 })
1383 assert.deepStrictEqual(group, new Group({ id: 1, name: "Some group" }))
1384 assert.strictEqual(response.status, 200)
1385
1386 group = yield* client.groups.create({
1387 payload: { foo: "Some group" }
1388 })
1389 assert.deepStrictEqual(group, new Group({ id: 1, name: "Some group" }))
1390 }).pipe(Effect.provide(HttpLayer)))
1391
1392 it.effect(".handle can return HttpServerResponse", () =>
1393 Effect.gen(function*() {
1394 const client = yield* HttpApiClient.make(Api)
1395 const response = yield* client.groups.handle({
1396 params: { id: 1 },
1397 payload: { name: "Some group" }
1398 })
1399 assert.deepStrictEqual(response, {
1400 id: 1,
1401 name: "Some group"
1402 })
1403 }).pipe(Effect.provide(HttpLayer)))
1404
1405 it.effect(".handleRaw can manually process body", () =>
1406 Effect.gen(function*() {
1407 const client = yield* HttpApiClient.make(Api)
1408 const response = yield* client.groups.handleRaw({
1409 params: { id: 1 },
1410 payload: { name: "Some group" }
1411 })
1412 assert.deepStrictEqual(response, {
1413 id: 1,
1414 name: "Some group"
1415 })
1416 }).pipe(Effect.provide(HttpLayer)))
1417
1418 describe("OpenAPI spec", () => {
1419 it("fixture", () => {
1420 const spec = OpenApi.fromApi(Api)
1421 expect(spec).toMatchSnapshot()
1422 })
1423 })
1424
1425 it.effect("error from plain text", () => {
1426 class RateLimitError extends Schema.Error<RateLimitError>("RateLimitError")({
1427 _tag: Schema.tag("RateLimitError"),
1428 message: Schema.String
1429 }) {}
1430
1431 const RateLimitErrorSchema = Schema.String.pipe(
1432 Schema.decodeTo(
1433 RateLimitError,
1434 SchemaTransformation.transform({
1435 encode: ({ message }) => message,
1436 decode: (message) => new RateLimitError({ message })
1437 })
1438 ),
1439 HttpApiSchema.status("TooManyRequests"),
1440 HttpApiSchema.asText()
1441 )
1442
1443 const Api = HttpApi.make("api").add(
1444 HttpApiGroup.make("group").add(
1445 HttpApiEndpoint.get("error", "/error", {
1446 error: RateLimitErrorSchema
1447 })
1448 )
1449 )
1450 const ApiLayer = HttpApiBuilder.layer(Api).pipe(
1451 Layer.provide(
1452 HttpApiBuilder.group(
1453 Api,
1454 "group",
1455 (handlers) => handlers.handle("error", () => new RateLimitError({ message: "Rate limit exceeded" }))
1456 )
1457 ),
1458 HttpRouter.serve,
1459 Layer.provideMerge(NodeHttpServer.layerTest)
1460 )
1461 return Effect.gen(function*() {
1462 const client = yield* HttpApiClient.make(Api)
1463 const response = yield* client.group.error().pipe(Effect.flip)
1464 assert.deepStrictEqual(response, new RateLimitError({ message: "Rate limit exceeded" }))
1465 }).pipe(Effect.provide(ApiLayer))
1466 })
1467 })
1468
1469 describe("HttpApiTest", () => {
1470 it.effect("works", () =>
1471 Effect.gen(function*() {
1472 const client = yield* HttpApiTest.groups(Api, ["groups"])
1473 const result = yield* client.groups.findById({ params: { id: 0 } })
1474 assert.deepStrictEqual(result, new Group({ id: 1, name: "foo" }))
1475 }).pipe(
1476 Effect.provide([
1477 NodeHttpServer.layerHttpServices,
1478 AuthorizationLayer,
1479 HttpApiBuilder.group(Api, "groups", (handlers) =>
1480 handlers
1481 .handle("findById", () => Effect.succeed(new Group({ id: 1, name: "foo" })))
1482 .handle(
1483 "handle",
1484 () => Effect.die("unimplemented")
1485 )
1486 .handle("create", () => Effect.die("unimplemented"))
1487 .handle("handleRaw", () => Effect.die("unimplemented")))
1488 ])
1489 ))
1490 })
1491})
1492
1493class UserError extends Schema.Error<UserError>("UserError")({
1494 _tag: Schema.tag("UserError")
1495}, {
1496 httpApiStatus: 400
1497}) {}
1498class GroupError extends Schema.Error<GroupError>("GroupError")({
1499 _tag: Schema.tag("GroupError")
1500}, {
1501 httpApiStatus: 418
1502}) {}
1503class NoStatusError extends Schema.Error<NoStatusError>("NoStatusError")({
1504 _tag: Schema.tag("NoStatusError")
1505}) {}
1506
1507class User extends Schema.Class<User>("User")({
1508 id: Schema.Int,
1509 uuid: Schema.optional(Schema.String),
1510 name: Schema.String,
1511 createdAt: Schema.DateTimeUtcFromString
1512}, {
1513 description: "Some description for User"
1514}) {}
1515
1516class Group extends Schema.Class<Group>("Group")({
1517 id: Schema.Int,
1518 name: Schema.String
1519}) {}
1520
1521const securityHeader = HttpApiSecurity.apiKey({
1522 in: "header",
1523 key: "x-api-key"
1524})
1525
1526const securityQuery = HttpApiSecurity.apiKey({
1527 in: "query",
1528 key: "api_key"
1529})
1530
1531class CurrentUser extends Context.Service<CurrentUser, User>()("CurrentUser") {}
1532
1533class Authorization extends HttpApiMiddleware.Service<Authorization, {
1534 provides: CurrentUser
1535}>()("Authorization", {
1536 security: {
1537 cookie: HttpApiSecurity.apiKey({
1538 in: "cookie",
1539 key: "token"
1540 })
1541 }
1542}) {}
1543
1544class GroupsApi extends HttpApiGroup.make("groups").add(
1545 HttpApiEndpoint.get("findById", "/:id", {
1546 params: {
1547 id: Schema.FiniteFromString
1548 },
1549 success: Group,
1550 error: GroupError.pipe(HttpApiSchema.asNoContent({
1551 decode: () => new GroupError({})
1552 }))
1553 }),
1554 HttpApiEndpoint.post("create", "/", {
1555 payload: [
1556 Schema.Struct(Struct.pick(Group.fields, ["name"])),
1557 Schema.Struct({ foo: Schema.String }).pipe(
1558 HttpApiSchema.asFormUrlEncoded()
1559 ),
1560 Schema.Struct(Struct.pick(Group.fields, ["name"])).pipe(
1561 HttpApiSchema.asMultipart()
1562 )
1563 ],
1564 success: Group
1565 }),
1566 HttpApiEndpoint.post("handle", "/handle/:id", {
1567 params: {
1568 id: Schema.FiniteFromString
1569 },
1570 payload: Schema.Struct({
1571 name: Schema.String
1572 }),
1573 success: Schema.Struct({
1574 id: Schema.Finite,
1575 name: Schema.String
1576 })
1577 }),
1578 HttpApiEndpoint.post("handleRaw", "/handleraw/:id", {
1579 params: {
1580 id: Schema.FiniteFromString
1581 },
1582 payload: Schema.Struct({
1583 name: Schema.String
1584 }),
1585 success: Schema.Struct({
1586 id: Schema.Finite,
1587 name: Schema.String
1588 })
1589 })
1590).prefix("/groups") {}
1591
1592class UsersApi extends HttpApiGroup.make("users")
1593 .add(
1594 HttpApiEndpoint.get("findById", "/:id", {
1595 params: {
1596 id: Schema.Finite
1597 },
1598 success: User,
1599 error: UserError
1600 }),
1601 HttpApiEndpoint.post("create", "/", {
1602 payload: Schema.Struct(Struct.omit(User.fields, ["id", "createdAt"])),
1603 query: {
1604 id: Schema.Finite
1605 },
1606 success: User,
1607 error: [UserError, UserError]
1608 }),
1609 HttpApiEndpoint.get("list", "/", {
1610 headers: {
1611 page: Schema.Finite.pipe(
1612 Schema.optionalKey,
1613 Schema.decode({
1614 decode: SchemaGetter.withDefault(Effect.succeed(1)),
1615 encode: SchemaGetter.passthrough()
1616 })
1617 )
1618 },
1619 query: {
1620 query: Schema.optional(Schema.String).annotate({ description: "search query" })
1621 },
1622 success: Schema.Array(User),
1623 error: NoStatusError
1624 })
1625 .annotate(OpenApi.Deprecated, true)
1626 .annotate(OpenApi.Summary, "test summary")
1627 .annotateMerge(OpenApi.annotations({ identifier: "listUsers" })),
1628 HttpApiEndpoint.post("upload", "/upload/:0?", {
1629 params: {
1630 0: Schema.optional(Schema.String)
1631 },
1632 payload: Schema.Struct({
1633 file: Multipart.SingleFileSchema
1634 }).pipe(HttpApiSchema.asMultipart()),
1635 success: Schema.Struct({
1636 contentType: Schema.String,
1637 length: Schema.Int
1638 })
1639 }),
1640 HttpApiEndpoint.post("uploadStream", `/uploadstream`, {
1641 payload: Schema.Struct({
1642 file: Multipart.SingleFileSchema
1643 }).pipe(HttpApiSchema.asMultipartStream()),
1644 success: Schema.Struct({
1645 contentType: Schema.String,
1646 length: Schema.Int
1647 })
1648 })
1649 )
1650 .middleware(Authorization)
1651 .annotateMerge(OpenApi.annotations({ title: "Users API" }))
1652{}
1653
1654class TopLevelApi extends HttpApiGroup.make("root", { topLevel: true })
1655 .add(
1656 HttpApiEndpoint.get("healthz", `/healthz`, {
1657 success: HttpApiSchema.NoContent.annotate({ description: "Empty" })
1658 })
1659 )
1660{}
1661
1662class AnotherApi extends HttpApi.make("another").add(GroupsApi) {}
1663
1664class Api extends HttpApi.make("api")
1665 .addHttpApi(AnotherApi)
1666 .add(UsersApi.prefix("/users"))
1667 .add(TopLevelApi)
1668 .annotateMerge(OpenApi.annotations({
1669 title: "API",
1670 summary: "test api summary",
1671 transform: (openApiSpec) => ({
1672 ...openApiSpec,
1673 tags: [...openApiSpec.tags ?? [], {
1674 name: "Tag from OpenApi.Transform annotation"
1675 }]
1676 })
1677 }))
1678 .annotate(
1679 HttpApi.AdditionalSchemas,
1680 [
1681 Schema.Struct({
1682 contentType: Schema.String,
1683 length: Schema.Int
1684 }).annotate({
1685 identifier: "ComponentsSchema"
1686 })
1687 ]
1688 )
1689{}
1690
1691// impl
1692
1693class UserRepo extends Context.Service<UserRepo, {
1694 readonly findById: (id: number) => Effect.Effect<User>
1695}>()("UserRepo") {
1696 static layer = Layer.succeed(this)({
1697 findById: (id) => Effect.map(DateTime.now, (now) => ({ id, name: "foo", createdAt: now }))
1698 })
1699}
1700
1701const AuthorizationLayer = Layer.succeed(
1702 Authorization
1703)({
1704 cookie: (effect, opts) =>
1705 Effect.provideService(
1706 effect,
1707 CurrentUser,
1708 new User({
1709 id: 1,
1710 name: Redacted.value(opts.credential),
1711 createdAt: DateTime.nowUnsafe()
1712 })
1713 )
1714})
1715
1716const HttpUsersLayer = HttpApiBuilder.group(
1717 Api,
1718 "users",
1719 Effect.fnUntraced(function*(handlers) {
1720 const fs = yield* FileSystem.FileSystem
1721 const repo = yield* UserRepo
1722 return handlers
1723 .handle("findById", (_) => _.params.id === -1 ? CurrentUser : repo.findById(_.params.id))
1724 .handle("create", (_) =>
1725 _.payload.name === "boom"
1726 ? Effect.fail(new UserError({}))
1727 : Effect.map(DateTime.now, (now) =>
1728 new User({
1729 id: _.query.id,
1730 name: _.payload.name,
1731 createdAt: now
1732 })))
1733 .handle("list", (_) =>
1734 _.headers.page === 0
1735 ? Effect.fail(new NoStatusError({}))
1736 // test handler level context
1737 : Effect.map(DateTime.nowInCurrentZone, (now) => [
1738 new User({
1739 id: 1,
1740 name: `page ${_.headers.page}`,
1741 createdAt: DateTime.toUtc(now)
1742 })
1743 ]))
1744 .handle("upload", (_) =>
1745 Effect.gen(function*() {
1746 const stat = yield* fs.stat(_.payload.file.path).pipe(Effect.orDie)
1747 return {
1748 contentType: _.payload.file.contentType,
1749 length: Option.getOrThrow(ByteSize.toNumber(stat.size))
1750 }
1751 }))
1752 .handle("uploadStream", (_) =>
1753 Effect.gen(function*() {
1754 const { content, file } = yield* _.payload.pipe(
1755 Stream.filter(Multipart.isFile),
1756 Stream.mapEffect((file) =>
1757 file.contentEffect.pipe(
1758 Effect.map((content) => ({ file, content }))
1759 )
1760 ),
1761 Stream.runCollect,
1762 Effect.flatMap((_) => Effect.fromOption(Array.head(_))),
1763 Effect.orDie
1764 )
1765 return {
1766 contentType: file.contentType,
1767 length: content.length
1768 }
1769 }))
1770 })
1771).pipe(
1772 Layer.provide([
1773 UserRepo.layer,
1774 AuthorizationLayer
1775 ])
1776)
1777
1778const HttpGroupsLayer = HttpApiBuilder.group(
1779 Api,
1780 "groups",
1781 (handlers) =>
1782 handlers
1783 .handle("findById", ({ params }) =>
1784 params.id === 0
1785 ? Effect.fail(new GroupError({}))
1786 : Effect.succeed(new Group({ id: 1, name: "foo" })))
1787 .handle("create", ({ payload }) =>
1788 Effect.succeed(
1789 new Group({
1790 id: 1,
1791 name: "foo" in payload ? payload.foo : payload.name
1792 })
1793 ))
1794 .handle(
1795 "handle",
1796 Effect.fnUntraced(function*({ params, payload }) {
1797 return HttpServerResponse.jsonUnsafe({
1798 id: params.id,
1799 name: payload.name
1800 })
1801 })
1802 )
1803 .handleRaw(
1804 "handleRaw",
1805 Effect.fnUntraced(function*({ params, request }) {
1806 const body = (yield* Effect.orDie(request.json)) as { name: string }
1807 return HttpServerResponse.jsonUnsafe({
1808 id: params.id,
1809 name: body.name
1810 })
1811 })
1812 )
1813)
1814
1815const TopLevelLayer = HttpApiBuilder.group(
1816 Api,
1817 "root",
1818 (handlers) => handlers.handle("healthz", (_) => Effect.void)
1819)
1820
1821const HttpApiLayer = Layer.provide(HttpApiBuilder.layer(Api), [
1822 HttpGroupsLayer,
1823 HttpUsersLayer,
1824 TopLevelLayer
1825])
1826
1827const HttpLayer = HttpRouter.serve(HttpApiLayer, {
1828 disableListenLog: true,
1829 disableLogger: true
1830}).pipe(
1831 Layer.provideMerge(NodeHttpServer.layerTest),
1832 Layer.provide(DateTime.layerCurrentZoneOffset(0))
1833)