1import { assert, it, vi } from "@effect/vitest"
2import {
3 Cause,
4 DateTime,
5 Effect,
6 type Exit,
7 FileSystem,
8 Layer,
9 Path,
10 Redacted,
11 Schema,
12 SchemaTransformation,
13 Stream
14} from "effect"
15import { Etag, HttpEffect, HttpPlatform, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http"
16import {
17 HttpApi,
18 HttpApiBuilder,
19 HttpApiEndpoint,
20 HttpApiError,
21 HttpApiGroup,
22 HttpApiMiddleware,
23 HttpApiSchema,
24 HttpApiSecurity,
25 HttpApiTest
26} from "effect/http-api"
27
28const textDecoder = new TextDecoder()
29
30const StreamError = Schema.Struct({ reason: Schema.String })
31
32const TestServices = Layer.mergeAll(
33 Path.layer,
34 Etag.layerWeak,
35 HttpPlatform.layer
36).pipe(Layer.provideMerge(FileSystem.layerNoop({})))
37
38it.layer(TestServices)("HttpApiBuilder ParseOptions", (it) => {
39 const Fields = { firstName: Schema.String, lastName: Schema.String }
40 const Person = Schema.Struct(Fields)
41
42 it.effect("strict SSE encoding rejects excess properties in user event data", () =>
43 Effect.gen(function*() {
44 const api = HttpApi.make("Api").add(
45 HttpApiGroup.make("test").add(HttpApiEndpoint.get("events", "/events", {
46 success: HttpApiSchema.StreamSse({ data: Person, error: StreamError })
47 }))
48 ).annotate(HttpApi.ParseOptions, { onExcessProperty: "error" })
49 const GroupLayer = HttpApiBuilder.group(api, "test", (handlers) =>
50 handlers.handle("events", () =>
51 Effect.succeed(Stream.make({ firstName: "Ada", lastName: "Lovelace", extra: true }))))
52 const client = yield* HttpApiTest.groups(api, ["test"]).pipe(Effect.provide(GroupLayer))
53 const response = yield* client.test.events({ responseMode: "response-only" })
54 const text = yield* response.text
55 assert.isTrue(text.startsWith("event: effect/http-api/stream/failure\ndata: "))
56 const data = text.split("\n")[1]!.slice("data: ".length)
57 const FailureSchema = Schema.fromJsonString(Schema.toCodecJson(Schema.Cause(StreamError, Schema.Defect())))
58 const cause = yield* Schema.decodeUnknownEffect(FailureSchema)(data)
59 const error = Cause.squash(cause)
60 assert.instanceOf(error, Error)
61 assert.include(error.message, "Expected no excess property")
62 assert.include(error.message, "[\"data\"][\"extra\"]")
63 }))
64
65 it.effect("SSE encoding aggregates all user data issues in the reserved failure event", () =>
66 Effect.gen(function*() {
67 const api = HttpApi.make("Api").add(
68 HttpApiGroup.make("test").add(HttpApiEndpoint.get("events", "/events", {
69 success: HttpApiSchema.StreamSse({ data: Person, error: StreamError })
70 }))
71 ).annotate(HttpApi.ParseOptions, { errors: "all" })
72 const GroupLayer = HttpApiBuilder.group(api, "test", (handlers) =>
73 handlers.handle("events", () =>
74 Effect.succeed(Stream.make({} as typeof Person.Type))))
75 const client = yield* HttpApiTest.groups(api, ["test"]).pipe(Effect.provide(GroupLayer))
76 const response = yield* client.test.events({ responseMode: "response-only" })
77 const text = yield* response.text
78 assert.isTrue(text.startsWith("event: effect/http-api/stream/failure\ndata: "))
79 const data = text.split("\n")[1]!.slice("data: ".length)
80 const FailureSchema = Schema.fromJsonString(Schema.toCodecJson(Schema.Cause(StreamError, Schema.Defect())))
81 const cause = yield* Schema.decodeUnknownEffect(FailureSchema)(data)
82 const error = Cause.squash(cause)
83 assert.instanceOf(error, Error)
84 assert.include(error.message, "firstName")
85 assert.include(error.message, "lastName")
86 }))
87
88 const Create = HttpApiEndpoint.post("create", "/users", { payload: Person })
89 const Api = HttpApi.make("Api").add(HttpApiGroup.make("users").add(Create))
90 const payloadError = Effect.fnUntraced(function*(api: typeof Api, payload: unknown = {}) {
91 const handler = yield* HttpRouter.toHttpEffect(
92 HttpApiBuilder.layer(api).pipe(
93 Layer.provide(HttpApiBuilder.group(api, "users", (handlers) => handlers.handle("create", () => Effect.void)))
94 )
95 )
96 const exit = yield* handler.pipe(
97 Effect.provideService(
98 HttpServerRequest.HttpServerRequest,
99 HttpServerRequest.fromWeb(
100 new Request("http://localhost/users", {
101 method: "POST",
102 headers: { "content-type": "application/json" },
103 body: JSON.stringify(payload)
104 })
105 )
106 ),
107 Effect.exit
108 )
109 assert.strictEqual(exit._tag, "Failure")
110 if (exit._tag === "Success") throw new Error("Expected payload decoding to fail")
111 const error = Cause.squash(exit.cause)
112 assert.ok(HttpApiError.HttpApiSchemaError.is(error))
113 assert.strictEqual(error.kind, "Payload")
114 return error.cause.message
115 })
116
117 it.effect("POST payload decoding collects all issues from API options", () =>
118 Effect.gen(function*() {
119 const message = yield* payloadError(Api.annotate(HttpApi.ParseOptions, { errors: "all" }))
120 assert.include(message, "firstName")
121 assert.include(message, "lastName")
122 }))
123
124 it.effect("POST payload decoding defaults to the first issue", () =>
125 Effect.gen(function*() {
126 const message = yield* payloadError(Api)
127 assert.include(message, "firstName")
128 assert.notInclude(message, "lastName")
129 }))
130
131 it.effect("endpoint options replace the whole API value", () =>
132 Effect.gen(function*() {
133 const api = HttpApi.make("Api").add(
134 HttpApiGroup.make("users").add(
135 Create.annotate(HttpApi.ParseOptions, { onExcessProperty: "error" })
136 )
137 ).annotate(HttpApi.ParseOptions, { errors: "all" })
138 const message = yield* payloadError(api)
139 assert.include(message, "firstName")
140 assert.notInclude(message, "lastName")
141 assert.include(yield* payloadError(api, { firstName: "Ada", lastName: "Lovelace", extra: true }), "extra")
142 }))
143
144 it.effect("error responses stay encodable under strict excess options", () =>
145 Effect.gen(function*() {
146 class ReviewNotFound extends Schema.TaggedError<ReviewNotFound>()(
147 "ReviewNotFound",
148 { candidateId: Schema.String },
149 { httpApiStatus: 404 }
150 ) {}
151 const api = HttpApi.make("Api").add(
152 HttpApiGroup.make("test").add(
153 HttpApiEndpoint.post("review", "/review", {
154 payload: Schema.toCodecJson(Schema.Struct({ decision: Schema.Literals(["approve", "reject"]) })),
155 success: Schema.toCodecJson(Schema.Struct({ reviewed: Schema.Boolean })),
156 error: [ReviewNotFound]
157 })
158 )
159 ).annotate(HttpApi.ParseOptions, { onExcessProperty: "error" })
160 const handler = yield* HttpRouter.toHttpEffect(
161 HttpApiBuilder.layer(api).pipe(
162 Layer.provide(
163 HttpApiBuilder.group(api, "test", (handlers) =>
164 handlers.handle("review", () =>
165 Effect.fail(new ReviewNotFound({ candidateId: "candidate-1" }))))
166 )
167 )
168 )
169 const respond = (body: string) =>
170 handler.pipe(
171 Effect.provideService(
172 HttpServerRequest.HttpServerRequest,
173 HttpServerRequest.fromWeb(
174 new Request("http://localhost/review", {
175 method: "POST",
176 headers: { "content-type": "application/json" },
177 body
178 })
179 )
180 ),
181 Effect.exit
182 )
183
184 const failure = yield* respond(`{"decision":"approve"}`)
185 if (failure._tag === "Failure") {
186 return assert.fail("Expected the error response to be encodable")
187 }
188 assert.strictEqual(failure.value.status, 404)
189
190 const rejected = yield* respond(`{"decision":"approve","extra":true}`)
191 assert.strictEqual(rejected._tag, "Failure")
192 if (rejected._tag === "Success") {
193 return assert.fail("Expected payload decoding to fail")
194 }
195 const error = Cause.squash(rejected.cause)
196 assert.ok(HttpApiError.HttpApiSchemaError.is(error))
197 assert.strictEqual(error.kind, "Payload")
198 assert.strictEqual(error.cause.message, `Expected no excess property\n at ["extra"]`)
199 }))
200})
201
202it.layer(TestServices)("HttpApiBuilder slot ParseOptions", (it) => {
203 it.effect("API slot annotations replace endpoint ParseOptions one codec at a time", () =>
204 Effect.gen(function*() {
205 const Person = Schema.Struct({ name: Schema.String })
206 const Group = HttpApiGroup.make("test").add(
207 HttpApiEndpoint.post("create", "/users", {
208 headers: { "x-api-key": Schema.String },
209 query: { q: Schema.String },
210 payload: Person,
211 success: Person
212 }).annotate(HttpApi.ParseOptions, { onExcessProperty: "error", errors: "all" }),
213 HttpApiEndpoint.get("fail", "/fail", { error: Person.pipe(HttpApiSchema.status(400)) })
214 .annotate(HttpApi.ParseOptions, { onExcessProperty: "error" })
215 )
216 const respond = Effect.fnUntraced(function*(api: HttpApi.HttpApi<"Api", typeof Group>, request: Request) {
217 const handler = yield* HttpRouter.toHttpEffect(
218 HttpApiBuilder.layer(api).pipe(
219 Layer.provide(HttpApiBuilder.group(api, "test", (handlers) =>
220 handlers
221 .handle("create", () => Effect.succeed({ name: "Ada", extra: true }))
222 .handle("fail", () => Effect.fail({ name: "Ada", extra: true }))))
223 )
224 )
225 return yield* handler.pipe(
226 Effect.provideService(HttpServerRequest.HttpServerRequest, HttpServerRequest.fromWeb(request)),
227 Effect.exit
228 )
229 })
230 const create = (api: HttpApi.HttpApi<"Api", typeof Group>) =>
231 respond(
232 api,
233 new Request("http://localhost/users?q=x&extra=1", {
234 method: "POST",
235 headers: { "content-type": "application/json", "user-agent": "test", "x-api-key": "key" },
236 body: JSON.stringify({ name: "Ada", extra: true })
237 })
238 )
239 const failedSlot = (exit: Exit.Exit<HttpServerResponse.HttpServerResponse, unknown>) => {
240 if (exit._tag === "Success") throw new Error("Expected the request to fail")
241 const error = Cause.squash(exit.cause)
242 return HttpApiError.HttpApiSchemaError.is(error) ? error.kind : undefined
243 }
244
245 // Unset slots fall back to the strict endpoint ParseOptions, headers included.
246 let api = HttpApi.make("Api").add(Group)
247 assert.strictEqual(failedSlot(yield* create(api)), "Headers")
248 api = api.annotate(HttpApi.HeadersParseOptions, {})
249 assert.strictEqual(failedSlot(yield* create(api)), "Query")
250 api = api.annotate(HttpApi.QueryParseOptions, {})
251 assert.strictEqual(failedSlot(yield* create(api)), "Payload")
252 // Replaces ParseOptions: onExcessProperty is not merged in.
253 api = api.annotate(HttpApi.PayloadParseOptions, { errors: "first" })
254 assert.strictEqual(failedSlot(yield* create(api)), "Body")
255 api = api.annotate(HttpApi.SuccessParseOptions, {})
256 const created = yield* create(api)
257 assert.strictEqual(created._tag, "Success")
258 if (created._tag === "Success") {
259 assert.deepStrictEqual(yield* Effect.promise(() => HttpServerResponse.toWeb(created.value).json()), {
260 name: "Ada"
261 })
262 }
263
264 assert.strictEqual((yield* respond(api, new Request("http://localhost/fail")))._tag, "Failure")
265 const failed = yield* respond(api.annotate(HttpApi.ErrorParseOptions, {}), new Request("http://localhost/fail"))
266 assert.strictEqual(failed._tag === "Success" && failed.value.status, 400)
267 }))
268
269 it.effect("encodeToWithHeaders response headers use HeadersParseOptions", () =>
270 Effect.gen(function*() {
271 class Limited extends Schema.TaggedError<Limited>()("Limited", { id: Schema.String }) {}
272 const LimitedResponse = Limited.pipe(HttpApiSchema.encodeToWithHeaders({
273 body: HttpApiSchema.Empty(429),
274 headers: { "x-id": Schema.String }
275 }, {
276 decode: ({ headers }) => new Limited({ id: headers["x-id"] }),
277 encode: (error) => {
278 const headers = { "x-id": error.id, "x-extra": "extra" }
279 return { body: undefined, headers }
280 }
281 }))
282 const Api = HttpApi.make("Api").add(
283 HttpApiGroup.make("test").add(HttpApiEndpoint.get("limited", "/limited", { error: LimitedResponse }))
284 )
285 const respond = Effect.fnUntraced(function*(api: typeof Api) {
286 const handler = yield* HttpRouter.toHttpEffect(
287 HttpApiBuilder.layer(api).pipe(
288 Layer.provide(HttpApiBuilder.group(api, "test", (handlers) =>
289 handlers.handle("limited", () =>
290 Effect.fail(new Limited({ id: "1" })))))
291 )
292 )
293 return yield* handler.pipe(
294 Effect.provideService(
295 HttpServerRequest.HttpServerRequest,
296 HttpServerRequest.fromWeb(new Request("http://localhost/limited"))
297 ),
298 Effect.exit
299 )
300 })
301
302 // Strict error body options do not apply to the headers.
303 const relaxed = yield* respond(
304 Api.annotate(HttpApi.ParseOptions, { onExcessProperty: "error" }).annotate(HttpApi.HeadersParseOptions, {})
305 )
306 assert.strictEqual(relaxed._tag, "Success")
307 if (relaxed._tag === "Success") {
308 assert.strictEqual(relaxed.value.status, 429)
309 assert.strictEqual(relaxed.value.headers["x-id"], "1")
310 assert.isUndefined(relaxed.value.headers["x-extra"])
311 }
312 // Strict header options apply even with default error body options.
313 const strict = yield* respond(Api.annotate(HttpApi.HeadersParseOptions, { onExcessProperty: "error" }))
314 assert.strictEqual(strict._tag, "Failure")
315 }))
316})
317
318it.layer(TestServices)("HttpApiBuilder.handler", (it) => {
319 it.effect("round trips a path parameter followed by a literal action suffix", () =>
320 Effect.gen(function*() {
321 const Api = HttpApi.make("Api").add(
322 HttpApiGroup.make("operations").add(
323 HttpApiEndpoint.post("wait", "/operations/:id:wait", {
324 params: { id: Schema.String },
325 success: Schema.String
326 })
327 )
328 )
329 const GroupLayer = HttpApiBuilder.group(Api, "operations", (handlers) =>
330 handlers.handle("wait", ({ params }) =>
331 Effect.succeed(params.id)))
332 const client = yield* HttpApiTest.groups(Api, ["operations"]).pipe(Effect.provide(GroupLayer))
333
334 assert.strictEqual(yield* client.operations.wait({ params: { id: "op_1" } }), "op_1")
335 }))
336
337 it.effect("does not bind a non-word schema key in a literal client path", () =>
338 Effect.gen(function*() {
339 const Params = Schema.Struct({ "a$b": Schema.optional(Schema.String) })
340 const Api = HttpApi.make("Api").add(
341 HttpApiGroup.make("test").add(
342 HttpApiEndpoint.get("get", "/x/:a$b", { params: Params, success: Params })
343 )
344 )
345 const GroupLayer = HttpApiBuilder.group(Api, "test", (handlers) =>
346 handlers.handle("get", ({ params }) =>
347 Effect.succeed(params)))
348 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
349
350 assert.deepStrictEqual(yield* client.test.get({ params: { "a$b": "value" } }), {})
351 }))
352
353 it.effect("returns the callback unchanged and supports registration in a group", () =>
354 Effect.gen(function*() {
355 const Api = HttpApi.make("Api").add(
356 HttpApiGroup.make("users").add(
357 HttpApiEndpoint.post("create", "/users/:id", {
358 params: { id: Schema.FiniteFromString },
359 payload: Schema.Struct({ name: Schema.String }),
360 success: Schema.Struct({ id: Schema.Number, name: Schema.String })
361 })
362 )
363 )
364 const callback = vi.fn(({ params, payload }: {
365 readonly params: { readonly id: number }
366 readonly payload: { readonly name: string }
367 }) => Effect.succeed({ id: params.id, name: payload.name }))
368 const handler = HttpApiBuilder.handler(Api, "users", "create", callback)
369
370 assert.strictEqual(handler, callback)
371 assert.strictEqual(callback.mock.calls.length, 0)
372
373 const GroupLayer = HttpApiBuilder.group(Api, "users", (handlers) => handlers.handle("create", handler))
374 const client = yield* HttpApiTest.groups(Api, ["users"]).pipe(Effect.provide(GroupLayer))
375 const result = yield* client.users.create({ params: { id: 42 }, payload: { name: "Ada" } })
376
377 assert.deepStrictEqual(result, { id: 42, name: "Ada" })
378 assert.strictEqual(callback.mock.calls.length, 1)
379 }))
380
381 it.effect("round trips a QUERY request body", () =>
382 Effect.gen(function*() {
383 const Payload = Schema.Struct({ query: Schema.String })
384 const Api = HttpApi.make("Api").add(
385 HttpApiGroup.make("search").add(
386 HttpApiEndpoint.query("run", "/search", {
387 payload: Payload,
388 success: Payload
389 })
390 )
391 )
392 const GroupLayer = HttpApiBuilder.group(Api, "search", (handlers) =>
393 handlers.handle("run", ({ payload }) =>
394 Effect.succeed(payload)))
395 const client = yield* HttpApiTest.groups(Api, ["search"]).pipe(Effect.provide(GroupLayer))
396
397 assert.deepStrictEqual(yield* client.search.run({ payload: { query: "effect" } }), { query: "effect" })
398 }))
399})
400
401it.layer(TestServices)("HttpApiTest pre-response handlers", (it) => {
402 it.effect("runs registered pre-response handlers", () =>
403 Effect.gen(function*() {
404 const Api = HttpApi.make("Api").add(
405 HttpApiGroup.make("test").add(
406 HttpApiEndpoint.get("get", "/fixture", { success: Schema.String.pipe(HttpApiSchema.asText()) })
407 )
408 )
409 const GroupLayer = HttpApiBuilder.group(Api, "test", (handlers) =>
410 handlers.handle("get", () =>
411 Effect.as(
412 HttpEffect.appendPreResponseHandler((_request, response) =>
413 Effect.succeed(HttpServerResponse.setHeader(response, "x-fixture", "present"))
414 ),
415 "ok"
416 )))
417 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
418 const response = yield* client.test.get({ responseMode: "response-only" })
419
420 assert.strictEqual(response.url, "http://localhost:3000/fixture")
421 assert.strictEqual(response.status, 200)
422 assert.strictEqual(response.headers["x-fixture"], "present")
423 assert.strictEqual(yield* response.text, "ok")
424 }))
425})
426
427it.layer(TestServices)("HttpApiBuilder query parameters", (it) => {
428 it.effect("round trips array query parameters with one or more values", () =>
429 Effect.gen(function*() {
430 const Api = HttpApi.make("Api").add(
431 HttpApiGroup.make("test").add(
432 HttpApiEndpoint.get("list", "/list", {
433 query: { ids: Schema.Array(Schema.String) },
434 success: Schema.Struct({ ids: Schema.Array(Schema.String) })
435 })
436 )
437 )
438 const GroupLayer = HttpApiBuilder.group(
439 Api,
440 "test",
441 (handlers) => handlers.handle("list", ({ query }) => Effect.succeed(query))
442 )
443
444 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
445 const multiple = yield* client.test.list({ query: { ids: ["a", "b"] } })
446 const single = yield* client.test.list({ query: { ids: ["a"] } })
447
448 assert.deepStrictEqual(multiple, { ids: ["a", "b"] })
449 assert.deepStrictEqual(single, { ids: ["a"] })
450 }))
451})
452
453it.effect("reuses response schema transformations by source AST", () => {
454 const SharedSuccess = Schema.String.pipe(HttpApiSchema.asText())
455 const DistinctSuccess = Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/custom" }))
456 const Api = HttpApi.make("Api").add(
457 HttpApiGroup.make("test")
458 .add(HttpApiEndpoint.get("first", "/first", { success: SharedSuccess }))
459 .add(HttpApiEndpoint.get("second", "/second", { success: SharedSuccess }))
460 .add(HttpApiEndpoint.get("distinct", "/distinct", { success: DistinctSuccess }))
461 )
462 const GroupLayer = HttpApiBuilder.group(
463 Api,
464 "test",
465 (handlers) =>
466 handlers
467 .handle("first", () => Effect.succeed("first"))
468 .handle("second", () => Effect.succeed("second"))
469 .handle("distinct", () => Effect.succeed("distinct"))
470 )
471
472 return Effect.acquireUseRelease(
473 Effect.sync(() => vi.spyOn(Schema, "decodeTo")),
474 (decodeTo) =>
475 Effect.gen(function*() {
476 yield* Effect.scoped(Layer.build(GroupLayer))
477 const responseSchemaCalls = decodeTo.mock.calls.filter(
478 ([schema]) => schema === SharedSuccess || schema === DistinctSuccess
479 )
480 assert.strictEqual(responseSchemaCalls.length, 2)
481 }),
482 (decodeTo) => Effect.sync(() => decodeTo.mockRestore())
483 )
484})
485
486it.layer(TestServices)("HttpApiBuilder payload content types", (it) => {
487 it.effect("round trips mixed-case media types with declared and received parameters", () =>
488 Effect.gen(function*() {
489 const Payload = Schema.Struct({ name: Schema.String }).pipe(
490 HttpApiSchema.asJson({
491 contentType: "Application/Vnd.Effect+JSON; profile=declared"
492 })
493 )
494 const Api = HttpApi.make("Api").add(
495 HttpApiGroup.make("test").add(
496 HttpApiEndpoint.post("create", "/create", {
497 headers: {
498 "content-type": Schema.optional(Schema.String)
499 },
500 payload: Payload,
501 success: Schema.Struct({ name: Schema.String })
502 })
503 )
504 )
505 const GroupLayer = HttpApiBuilder.group(
506 Api,
507 "test",
508 (handlers) => handlers.handle("create", ({ payload }) => Effect.succeed(payload))
509 )
510
511 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
512 const declared = yield* client.test.create({
513 headers: {},
514 payload: { name: "Ada" }
515 })
516 const received = yield* client.test.create({
517 headers: {
518 "content-type": "application/vnd.effect+json; profile=received"
519 },
520 payload: { name: "Grace" }
521 })
522
523 assert.deepStrictEqual(declared, { name: "Ada" })
524 assert.deepStrictEqual(received, { name: "Grace" })
525 }))
526
527 it.effect("round trips custom form-urlencoded media types", () =>
528 Effect.gen(function*() {
529 const Payload = Schema.Struct({ name: Schema.String }).pipe(
530 HttpApiSchema.asFormUrlEncoded({ contentType: "application/vnd.effect.form" })
531 )
532 const Api = HttpApi.make("Api").add(
533 HttpApiGroup.make("test").add(
534 HttpApiEndpoint.post("create", "/create", {
535 payload: Payload,
536 success: Schema.Struct({ name: Schema.String })
537 })
538 )
539 )
540 const GroupLayer = HttpApiBuilder.group(
541 Api,
542 "test",
543 (handlers) => handlers.handle("create", ({ payload }) => Effect.succeed(payload))
544 )
545
546 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
547 const result = yield* client.test.create({ payload: { name: "Ada" } })
548
549 assert.deepStrictEqual(result, { name: "Ada" })
550 }))
551})
552
553it.layer(TestServices)("HttpApiBuilder WithHeaders responses", (it) => {
554 it.effect("encodes WithHeaders using the schema for the response status", () =>
555 Effect.gen(function*() {
556 const encodedHeader = (prefix: string) =>
557 Schema.String.pipe(
558 Schema.decodeTo(
559 Schema.String,
560 SchemaTransformation.transform({
561 decode: (value) => value,
562 encode: (value) => `${prefix}:${value}`
563 })
564 )
565 )
566 const Ok = HttpApiSchema.WithHeaders(
567 Schema.TaggedStruct("Ok", {}),
568 { "x-source": encodedHeader("ok") }
569 )
570 const Created = HttpApiSchema.WithHeaders(
571 Schema.TaggedStruct("Created", {}).pipe(HttpApiSchema.status(201)),
572 { "x-source": encodedHeader("created") }
573 )
574 const Api = HttpApi.make("Api").add(
575 HttpApiGroup.make("test").add(
576 HttpApiEndpoint.get("result", "/test", { success: [Ok, Created] })
577 )
578 )
579 const GroupLayer = HttpApiBuilder.group(
580 Api,
581 "test",
582 (handlers) =>
583 handlers.handle("result", () =>
584 Effect.succeed(HttpApiSchema.withHeaders({
585 body: { _tag: "Created" as const },
586 headers: { "x-source": "value" }
587 })))
588 )
589
590 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
591 const response = yield* client.test.result({ responseMode: "response-only" })
592
593 assert.strictEqual(response.status, 201)
594 assert.strictEqual(response.headers["x-source"], "created:value")
595 }))
596
597 it.effect("encodes a branded response against the header-carrying member when body shapes overlap", () =>
598 Effect.gen(function*() {
599 const Api = HttpApi.make("Api").add(
600 HttpApiGroup.make("test").add(
601 HttpApiEndpoint.get("result", "/test", {
602 success: [
603 Schema.Struct({ value: Schema.String }).pipe(HttpApiSchema.status(201)),
604 HttpApiSchema.WithHeaders(
605 Schema.Struct({ value: Schema.String }),
606 { "x-source": Schema.String }
607 )
608 ]
609 })
610 )
611 )
612 const GroupLayer = HttpApiBuilder.group(
613 Api,
614 "test",
615 (handlers) =>
616 handlers.handle("result", () =>
617 Effect.succeed(HttpApiSchema.withHeaders({
618 body: { value: "wrapped" },
619 headers: { "x-source": "value" }
620 })))
621 )
622
623 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
624 const response = yield* client.test.result({ responseMode: "response-only" })
625
626 assert.strictEqual(response.status, 200)
627 assert.strictEqual(response.headers["x-source"], "value")
628 }))
629
630 it.effect("fails with a Body schema error when a branded response body matches no header-carrying member", () =>
631 Effect.gen(function*() {
632 const Api = HttpApi.make("Api").add(
633 HttpApiGroup.make("test").add(
634 HttpApiEndpoint.get("result", "/test", {
635 success: [
636 HttpApiSchema.WithHeaders(
637 Schema.TaggedStruct("A", {}),
638 { "x-source": Schema.String }
639 ),
640 Schema.TaggedStruct("B", {}).pipe(HttpApiSchema.status(201))
641 ]
642 })
643 )
644 )
645 const GroupLayer = HttpApiBuilder.group(
646 Api,
647 "test",
648 (handlers) =>
649 handlers.handle("result", () =>
650 Effect.succeed(HttpApiSchema.withHeaders({
651 body: { _tag: "B" as const },
652 headers: { "x-source": "value" }
653 }) as any))
654 )
655
656 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
657 const exit = yield* Effect.exit(client.test.result({ responseMode: "response-only" }))
658
659 assert.strictEqual(exit._tag, "Failure")
660 if (exit._tag === "Failure") {
661 const error = Cause.squash(exit.cause) as any
662 assert.strictEqual(error._tag, "HttpApiSchemaError")
663 assert.strictEqual(error.kind, "Body")
664 }
665 }))
666
667 it.effect("round trips user-managed header codecs through HttpApiTest", () =>
668 Effect.gen(function*() {
669 const Api = HttpApi.make("Api").add(
670 HttpApiGroup.make("test").add(
671 HttpApiEndpoint.get("result", "/test", {
672 disableCodecs: true,
673 success: HttpApiSchema.WithHeaders(
674 Schema.String.pipe(HttpApiSchema.asText()),
675 { "x-count": Schema.FiniteFromString }
676 )
677 })
678 )
679 )
680 const GroupLayer = HttpApiBuilder.group(
681 Api,
682 "test",
683 (handlers) =>
684 handlers.handle("result", () =>
685 Effect.succeed(HttpApiSchema.withHeaders({
686 body: "ok",
687 headers: { "x-count": 2 }
688 })))
689 )
690
691 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
692 const result = yield* client.test.result({})
693
694 assert.deepStrictEqual(
695 result,
696 HttpApiSchema.withHeaders({ body: "ok", headers: { "x-count": 2 } })
697 )
698 }))
699
700 it.effect("passes through string-shaped WithHeaders when endpoint codecs are disabled", () =>
701 Effect.gen(function*() {
702 const Api = HttpApi.make("Api").add(
703 HttpApiGroup.make("test")
704 .add(
705 HttpApiEndpoint.get("success", "/success", {
706 disableCodecs: true,
707 success: HttpApiSchema.WithHeaders(
708 Schema.String.pipe(HttpApiSchema.asText()),
709 { "x-count": Schema.String }
710 )
711 })
712 )
713 .add(
714 HttpApiEndpoint.get("error", "/error", {
715 disableCodecs: true,
716 error: HttpApiSchema.WithHeaders(
717 Schema.String.pipe(HttpApiSchema.status(429), HttpApiSchema.asText()),
718 { "retry-after": Schema.String }
719 )
720 })
721 )
722 )
723 const GroupLayer = HttpApiBuilder.group(
724 Api,
725 "test",
726 (handlers) =>
727 handlers
728 .handle("success", () =>
729 Effect.succeed(HttpApiSchema.withHeaders({
730 body: "ok",
731 headers: { "x-count": "2" }
732 })))
733 .handle("error", () =>
734 Effect.fail(HttpApiSchema.withHeaders({
735 body: "slow down",
736 headers: { "retry-after": "30" }
737 })))
738 )
739
740 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
741 const [success, successResponse] = yield* client.test.success({ responseMode: "decoded-and-response" })
742 const error = yield* Effect.flip(client.test.error({}))
743 const errorResponse = yield* client.test.error({ responseMode: "response-only" })
744 if (!HttpApiSchema.isWithHeadersValue(error)) {
745 throw new Error("Expected WithHeaders error")
746 }
747
748 assert.strictEqual(successResponse.headers["x-count"], "2")
749 assert.deepStrictEqual(success.headers, { "x-count": "2" })
750 assert.strictEqual(errorResponse.headers["retry-after"], "30")
751 assert.deepStrictEqual(error.headers, { "retry-after": "30" })
752 }))
753
754 it.effect("encodes and decodes response headers with codecs enabled", () =>
755 Effect.gen(function*() {
756 const Api = HttpApi.make("Api").add(
757 HttpApiGroup.make("test").add(
758 HttpApiEndpoint.get("created", "/test", {
759 success: HttpApiSchema.WithHeaders(
760 Schema.Struct({ id: Schema.Int }).pipe(HttpApiSchema.status(201)),
761 { "x-count": Schema.Int }
762 )
763 })
764 )
765 )
766 const GroupLayer = HttpApiBuilder.group(
767 Api,
768 "test",
769 (handlers) =>
770 handlers.handle("created", () =>
771 Effect.succeed(HttpApiSchema.withHeaders({
772 body: { id: 1 },
773 headers: { "x-count": 2 }
774 })))
775 )
776
777 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
778 const [value, response] = yield* client.test.created({ responseMode: "decoded-and-response" })
779
780 assert.strictEqual(response.headers["x-count"], "2")
781 assert.deepStrictEqual(
782 value,
783 HttpApiSchema.withHeaders({
784 body: { id: 1 },
785 headers: { "x-count": 2 }
786 })
787 )
788 }))
789
790 it.effect("decodes a transforming buffered success body and its declared headers", () =>
791 Effect.gen(function*() {
792 const Api = HttpApi.make("Api").add(
793 HttpApiGroup.make("test").add(
794 HttpApiEndpoint.get("created", "/test", {
795 success: HttpApiSchema.WithHeaders(Schema.Date, { "x-source": Schema.String })
796 })
797 )
798 )
799 const GroupLayer = HttpApiBuilder.group(
800 Api,
801 "test",
802 (handlers) =>
803 handlers.handle("created", () =>
804 Effect.succeed(HttpApiSchema.withHeaders({
805 body: DateTime.makeUnsafe("2024-01-02T03:04:05.000Z").pipe(DateTime.toDate),
806 headers: { "x-source": "schema" }
807 })))
808 )
809
810 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
811 const value = yield* client.test.created({})
812
813 assert.strictEqual(value.body.toISOString(), "2024-01-02T03:04:05.000Z")
814 assert.deepStrictEqual(value.headers, { "x-source": "schema" })
815 }))
816
817 it.effect("round trips a client-received branded value through another handler", () =>
818 Effect.gen(function*() {
819 const Success = HttpApiSchema.WithHeaders(
820 Schema.Struct({ id: Schema.Int }).pipe(HttpApiSchema.status(201)),
821 { "x-count": Schema.Int }
822 )
823 const Api = HttpApi.make("Api").add(
824 HttpApiGroup.make("test")
825 .add(HttpApiEndpoint.get("created", "/created", { success: Success }))
826 .add(HttpApiEndpoint.get("forwarded", "/forwarded", { success: Success }))
827 )
828 let forwarded: typeof Success.Type | undefined
829 const GroupLayer = HttpApiBuilder.group(
830 Api,
831 "test",
832 (handlers) =>
833 handlers
834 .handle("created", () =>
835 Effect.succeed(HttpApiSchema.withHeaders({
836 body: { id: 1 },
837 headers: { "x-count": 2 }
838 })))
839 .handle("forwarded", () => Effect.succeed(forwarded!))
840 )
841
842 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
843 const [value, response] = yield* client.test.created({ responseMode: "decoded-and-response" })
844 forwarded = value
845 const result = yield* client.test.forwarded({})
846
847 assert.strictEqual(response.status, 201)
848 assert.deepStrictEqual(result, value)
849 }))
850
851 it.effect("encodes a buffered success body and its declared headers", () =>
852 Effect.gen(function*() {
853 const Api = HttpApi.make("Api").add(
854 HttpApiGroup.make("test").add(
855 HttpApiEndpoint.get("created", "/test", {
856 success: HttpApiSchema.WithHeaders(
857 Schema.Struct({ id: Schema.Int }).pipe(HttpApiSchema.status(201)),
858 {
859 "x-count": Schema.Int,
860 "x-optional": Schema.optional(Schema.String)
861 }
862 )
863 })
864 )
865 )
866 const GroupLayer = HttpApiBuilder.group(
867 Api,
868 "test",
869 (handlers) =>
870 handlers.handle("created", () =>
871 Effect.succeed(HttpApiSchema.withHeaders({
872 body: { id: 1 },
873 headers: { "x-count": 2, "x-optional": undefined }
874 })))
875 )
876
877 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
878 const response = yield* client.test.created({ responseMode: "response-only" })
879
880 assert.strictEqual(response.status, 201)
881 assert.strictEqual(response.headers["x-count"], "2")
882 assert.isFalse("x-optional" in response.headers)
883 assert.deepStrictEqual(yield* response.json, { id: 1 })
884 }))
885
886 it.effect("does not unwrap an unbranded value in a mixed success union", () =>
887 Effect.gen(function*() {
888 const Plain = Schema.Struct({
889 body: Schema.String,
890 headers: Schema.Struct({ "x-source": Schema.String })
891 }).pipe(HttpApiSchema.asJson({ contentType: "application/vnd.plain+json" }))
892 const Api = HttpApi.make("Api").add(
893 HttpApiGroup.make("test").add(
894 HttpApiEndpoint.get("mixed", "/test", {
895 success: [
896 HttpApiSchema.WithHeaders(Schema.String, { "x-source": Schema.String }),
897 Plain
898 ]
899 })
900 )
901 )
902 const GroupLayer = HttpApiBuilder.group(
903 Api,
904 "test",
905 (handlers) => handlers.handle("mixed", () => Effect.succeed({ body: "plain", headers: { "x-source": "body" } }))
906 )
907
908 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
909 const response = yield* client.test.mixed({ responseMode: "response-only" })
910
911 assert.strictEqual(response.headers["content-type"], "application/vnd.plain+json")
912 assert.isFalse("x-source" in response.headers)
913 assert.deepStrictEqual(yield* response.json, { body: "plain", headers: { "x-source": "body" } })
914 }))
915
916 it.effect("decodes a plain value in a mixed WithHeaders success union", () =>
917 Effect.gen(function*() {
918 const Wrapped = HttpApiSchema.WithHeaders(
919 Schema.TaggedStruct("Wrapped", { value: Schema.String }),
920 { "x-source": Schema.String }
921 )
922 const Plain = Schema.TaggedStruct("Plain", { value: Schema.String }).pipe(
923 HttpApiSchema.asJson({ contentType: "application/vnd.plain+json" })
924 )
925 const Api = HttpApi.make("Api").add(
926 HttpApiGroup.make("test").add(
927 HttpApiEndpoint.get("mixed", "/test", { success: [Wrapped, Plain] })
928 )
929 )
930 const GroupLayer = HttpApiBuilder.group(
931 Api,
932 "test",
933 (handlers) => handlers.handle("mixed", () => Effect.succeed({ _tag: "Plain" as const, value: "plain" }))
934 )
935
936 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
937 const value = yield* client.test.mixed({})
938
939 assert.deepStrictEqual(value, { _tag: "Plain", value: "plain" })
940 }))
941
942 it.effect("applies declared headers after body encoding", () =>
943 Effect.gen(function*() {
944 const Api = HttpApi.make("Api").add(
945 HttpApiGroup.make("test").add(
946 HttpApiEndpoint.get("override", "/test", {
947 success: HttpApiSchema.WithHeaders(
948 Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/plain" })),
949 { "content-type": Schema.String, "x-source": Schema.String }
950 )
951 })
952 )
953 )
954 const GroupLayer = HttpApiBuilder.group(
955 Api,
956 "test",
957 (handlers) =>
958 handlers.handle("override", () =>
959 Effect.succeed(HttpApiSchema.withHeaders({
960 body: "ok",
961 headers: { "content-type": "application/custom", "x-source": "schema" }
962 })))
963 )
964
965 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
966 const response = yield* client.test.override({ responseMode: "response-only" })
967
968 assert.strictEqual(response.headers["content-type"], "application/custom")
969 assert.strictEqual(response.headers["x-source"], "schema")
970 assert.strictEqual(yield* response.text, "ok")
971 }))
972
973 it.effect("encodes error headers through encodeToWithHeaders", () =>
974 Effect.gen(function*() {
975 class RateLimited extends Schema.TaggedError<RateLimited>()("RateLimited", {
976 retryAfter: Schema.Int
977 }) {}
978 const RateLimitedResponse = RateLimited.pipe(
979 HttpApiSchema.encodeToWithHeaders({
980 body: Schema.String.pipe(HttpApiSchema.status(429), HttpApiSchema.asText()),
981 headers: { "retry-after": Schema.Int }
982 }, {
983 decode: ({ headers }) => new RateLimited({ retryAfter: headers["retry-after"] }),
984 encode: (error) => ({ body: "slow down", headers: { "retry-after": error.retryAfter } })
985 })
986 )
987 const Api = HttpApi.make("Api").add(
988 HttpApiGroup.make("test").add(
989 HttpApiEndpoint.get("limited", "/test", { error: RateLimitedResponse })
990 )
991 )
992 const GroupLayer = HttpApiBuilder.group(
993 Api,
994 "test",
995 (handlers) => handlers.handle("limited", () => Effect.fail(new RateLimited({ retryAfter: 30 })))
996 )
997
998 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
999 const response = yield* client.test.limited({ responseMode: "response-only" })
1000
1001 assert.strictEqual(response.status, 429)
1002 assert.strictEqual(response.headers["content-type"], "text/plain")
1003 assert.strictEqual(response.headers["retry-after"], "30")
1004 assert.strictEqual(yield* response.text, "slow down")
1005 }))
1006
1007 it.effect("encodes annotation headers from their declared encoded shape", () =>
1008 Effect.gen(function*() {
1009 class RateLimited extends Schema.TaggedError<RateLimited>()("RateLimited", {
1010 expiresAt: Schema.Date
1011 }) {}
1012 const RateLimitedResponse = RateLimited.pipe(
1013 HttpApiSchema.encodeToWithHeaders({
1014 body: Schema.String.pipe(HttpApiSchema.status(429)),
1015 headers: { "expires-at": Schema.Date }
1016 }, {
1017 decode: ({ headers }) => new RateLimited({ expiresAt: headers["expires-at"] }),
1018 encode: (error) => ({ body: "slow down", headers: { "expires-at": error.expiresAt } })
1019 })
1020 )
1021 const Api = HttpApi.make("Api").add(
1022 HttpApiGroup.make("test").add(
1023 HttpApiEndpoint.get("limited", "/test", { error: RateLimitedResponse })
1024 )
1025 )
1026 const expiresAt = DateTime.makeUnsafe("2026-08-05T02:00:00.000Z").pipe(DateTime.toDate)
1027 const GroupLayer = HttpApiBuilder.group(
1028 Api,
1029 "test",
1030 (handlers) => handlers.handle("limited", () => Effect.fail(new RateLimited({ expiresAt })))
1031 )
1032
1033 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
1034 const response = yield* client.test.limited({ responseMode: "response-only" })
1035 const error = yield* Effect.flip(client.test.limited({}))
1036
1037 assert.strictEqual(response.headers["expires-at"], "2026-08-05T02:00:00.000Z")
1038 assert.deepStrictEqual(error, new RateLimited({ expiresAt }))
1039 }))
1040
1041 it.effect("encodes and decodes an error wrapped in WithHeaders", () =>
1042 Effect.gen(function*() {
1043 const RateLimited = HttpApiSchema.WithHeaders(
1044 Schema.TaggedStruct("RateLimited", { message: Schema.String }).pipe(HttpApiSchema.status(429)),
1045 { "retry-after": Schema.Int }
1046 )
1047 const Api = HttpApi.make("Api").add(
1048 HttpApiGroup.make("test").add(
1049 HttpApiEndpoint.get("limited", "/test", { error: RateLimited })
1050 )
1051 )
1052 const expected = HttpApiSchema.withHeaders({
1053 body: { _tag: "RateLimited" as const, message: "slow down" },
1054 headers: { "retry-after": 30 }
1055 })
1056 const GroupLayer = HttpApiBuilder.group(
1057 Api,
1058 "test",
1059 (handlers) => handlers.handle("limited", () => Effect.fail(expected))
1060 )
1061
1062 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
1063 const response = yield* client.test.limited({ responseMode: "response-only" })
1064
1065 assert.strictEqual(response.status, 429)
1066 assert.strictEqual(response.headers["retry-after"], "30")
1067 assert.deepStrictEqual(yield* response.json, expected.body)
1068 assert.deepStrictEqual(yield* Effect.flip(client.test.limited({})), expected)
1069 }))
1070
1071 it.effect("passes through string-shaped encodeToWithHeaders values when endpoint codecs are disabled", () =>
1072 Effect.gen(function*() {
1073 class RateLimited extends Schema.TaggedError<RateLimited>()("RateLimited", {
1074 retryAfter: Schema.String
1075 }) {}
1076 const RateLimitedResponse = RateLimited.pipe(
1077 HttpApiSchema.encodeToWithHeaders({
1078 body: Schema.String.pipe(HttpApiSchema.status(429), HttpApiSchema.asText()),
1079 headers: { "retry-after": Schema.String }
1080 }, {
1081 decode: ({ headers }) => new RateLimited({ retryAfter: headers["retry-after"] }),
1082 encode: (error) => ({ body: "slow down", headers: { "retry-after": error.retryAfter } })
1083 })
1084 )
1085 const Api = HttpApi.make("Api").add(
1086 HttpApiGroup.make("test").add(
1087 HttpApiEndpoint.get("limited", "/test", {
1088 disableCodecs: true,
1089 error: RateLimitedResponse
1090 })
1091 )
1092 )
1093 const GroupLayer = HttpApiBuilder.group(
1094 Api,
1095 "test",
1096 (handlers) => handlers.handle("limited", () => Effect.fail(new RateLimited({ retryAfter: "30" })))
1097 )
1098
1099 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
1100 const response = yield* client.test.limited({ responseMode: "response-only" })
1101
1102 assert.strictEqual(response.status, 429)
1103 assert.strictEqual(response.headers["retry-after"], "30")
1104 assert.strictEqual(yield* response.text, "slow down")
1105 assert.deepStrictEqual(
1106 yield* Effect.flip(client.test.limited({})),
1107 new RateLimited({ retryAfter: "30" })
1108 )
1109 }))
1110
1111 it.effect("decodes an error body and headers through encodeToWithHeaders", () =>
1112 Effect.gen(function*() {
1113 class RateLimited extends Schema.TaggedError<RateLimited>()("RateLimited", {
1114 message: Schema.String,
1115 retryAfter: Schema.Int
1116 }) {}
1117 const RateLimitedResponse = RateLimited.pipe(
1118 HttpApiSchema.encodeToWithHeaders({
1119 body: Schema.String.pipe(HttpApiSchema.status(429), HttpApiSchema.asText()),
1120 headers: { "retry-after": Schema.Int }
1121 }, {
1122 decode: ({ body, headers }) =>
1123 new RateLimited({
1124 message: body,
1125 retryAfter: headers["retry-after"]
1126 }),
1127 encode: (error) => ({
1128 body: error.message,
1129 headers: { "retry-after": error.retryAfter }
1130 })
1131 })
1132 )
1133 const Api = HttpApi.make("Api").add(
1134 HttpApiGroup.make("test").add(
1135 HttpApiEndpoint.get("limited", "/test", { error: RateLimitedResponse })
1136 )
1137 )
1138 const GroupLayer = HttpApiBuilder.group(
1139 Api,
1140 "test",
1141 (handlers) =>
1142 handlers.handle("limited", () => Effect.fail(new RateLimited({ message: "slow down", retryAfter: 30 })))
1143 )
1144
1145 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
1146 const error = yield* Effect.flip(client.test.limited({}))
1147
1148 assert.deepStrictEqual(error, new RateLimited({ message: "slow down", retryAfter: 30 }))
1149 }))
1150
1151 it.effect("defects with ResponseHeaders when success header encoding fails", () =>
1152 Effect.gen(function*() {
1153 const Api = HttpApi.make("Api").add(
1154 HttpApiGroup.make("test").add(
1155 HttpApiEndpoint.get("invalid", "/test", {
1156 success: HttpApiSchema.WithHeaders(Schema.String, { "x-count": Schema.Int })
1157 })
1158 )
1159 )
1160 const GroupLayer = HttpApiBuilder.group(
1161 Api,
1162 "test",
1163 (handlers) =>
1164 handlers.handle("invalid", () =>
1165 Effect.succeed(HttpApiSchema.withHeaders({
1166 body: "ok",
1167 headers: { "x-count": "invalid" as any }
1168 })))
1169 )
1170
1171 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
1172 const exit = yield* Effect.exit(client.test.invalid({ responseMode: "response-only" }))
1173
1174 assert.strictEqual(exit._tag, "Failure")
1175 if (exit._tag === "Failure") {
1176 const error = Cause.squash(exit.cause) as any
1177 assert.strictEqual(error._tag, "HttpApiSchemaError")
1178 assert.strictEqual(error.kind, "ResponseHeaders")
1179 }
1180 }))
1181})
1182
1183it.layer(TestServices)("HttpApiBuilder streaming success responses", (it) => {
1184 for (
1185 const [name, innerStatus, wrapperStatus, expectedStatus] of [
1186 ["wrapper status", undefined, 206, 206],
1187 ["wrapper overrides inner status", 201, 206, 206],
1188 ["default status control", undefined, undefined, 200]
1189 ] as const
1190 ) {
1191 it.effect(`preserves WithHeaders stream status: ${name}`, () =>
1192 Effect.gen(function*() {
1193 const stream = HttpApiSchema.StreamUint8Array()
1194 const wrapped = HttpApiSchema.WithHeaders(
1195 innerStatus === undefined ? stream : stream.pipe(HttpApiSchema.status(innerStatus)),
1196 { "x-note": Schema.String }
1197 )
1198 const success = wrapperStatus === undefined ? wrapped : wrapped.pipe(HttpApiSchema.status(wrapperStatus))
1199 const Api = HttpApi.make("Api").add(
1200 HttpApiGroup.make("test").add(HttpApiEndpoint.get("download", "/test", { success }))
1201 )
1202 const GroupLayer = HttpApiBuilder.group(
1203 Api,
1204 "test",
1205 (handlers) =>
1206 handlers.handle("download", () =>
1207 Effect.succeed(HttpApiSchema.withHeaders({
1208 body: Stream.make(new Uint8Array([1, 2])),
1209 headers: { "x-note": "test" }
1210 })))
1211 )
1212
1213 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
1214 const response = yield* client.test.download({ responseMode: "response-only" })
1215 const chunks = yield* Stream.runCollect(response.stream)
1216
1217 assert.strictEqual(response.status, expectedStatus)
1218 assert.strictEqual(response.headers["content-type"], "application/octet-stream")
1219 assert.strictEqual(response.headers["x-note"], "test")
1220 assert.deepStrictEqual(Array.from(chunks, (chunk) => Array.from(chunk)), [[1, 2]])
1221
1222 const decoded = yield* client.test.download({})
1223 const decodedChunks = yield* Stream.runCollect(decoded.body)
1224 assert.deepStrictEqual(decoded.headers, { "x-note": "test" })
1225 assert.deepStrictEqual(Array.from(decodedChunks, (chunk) => Array.from(chunk)), [[1, 2]])
1226 }))
1227 }
1228
1229 it.effect("emits StreamUint8Array handler responses as streamed bytes with the declared content type", () =>
1230 Effect.gen(function*() {
1231 const Api = HttpApi.make("Api").add(
1232 HttpApiGroup.make("test").add(
1233 HttpApiEndpoint.get("download", "/test", {
1234 success: HttpApiSchema.status(206)(
1235 HttpApiSchema.StreamUint8Array({ contentType: "application/custom-bytes" })
1236 )
1237 })
1238 )
1239 )
1240
1241 const GroupLayer = HttpApiBuilder.group(
1242 Api,
1243 "test",
1244 (handlers) =>
1245 handlers.handle("download", () =>
1246 Effect.succeed(
1247 Stream.make(new Uint8Array([1, 2]), new Uint8Array([3]))
1248 ))
1249 )
1250
1251 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
1252 const response = yield* client.test.download({ responseMode: "response-only" })
1253 const chunks = yield* response.stream.pipe(Stream.runCollect)
1254
1255 assert.strictEqual(response.status, 206)
1256 assert.strictEqual(response.headers["content-type"], "application/custom-bytes")
1257 assert.deepStrictEqual(Array.from(chunks, (chunk) => Array.from(chunk)), [[1, 2], [3]])
1258 }))
1259
1260 it.effect("unwraps WithHeaders StreamUint8Array responses before stream dispatch", () =>
1261 Effect.gen(function*() {
1262 const Api = HttpApi.make("Api").add(
1263 HttpApiGroup.make("test").add(
1264 HttpApiEndpoint.get("download", "/test", {
1265 success: HttpApiSchema.WithHeaders(
1266 HttpApiSchema.status(206)(
1267 HttpApiSchema.StreamUint8Array({ contentType: "application/custom-bytes" })
1268 ),
1269 { "content-type": Schema.String, "x-count": Schema.Int }
1270 )
1271 })
1272 )
1273 )
1274 const GroupLayer = HttpApiBuilder.group(
1275 Api,
1276 "test",
1277 (handlers) =>
1278 handlers.handle("download", () =>
1279 Effect.succeed(HttpApiSchema.withHeaders({
1280 body: Stream.make(new Uint8Array([1, 2]), new Uint8Array([3])),
1281 headers: { "content-type": "application/overridden", "x-count": 2 }
1282 })))
1283 )
1284
1285 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
1286 const response = yield* client.test.download({ responseMode: "response-only" })
1287 const chunks = yield* response.stream.pipe(Stream.runCollect)
1288
1289 assert.strictEqual(response.status, 206)
1290 assert.strictEqual(response.headers["content-type"], "application/overridden")
1291 assert.strictEqual(response.headers["x-count"], "2")
1292 assert.deepStrictEqual(Array.from(chunks, (chunk) => Array.from(chunk)), [[1, 2], [3]])
1293 }))
1294
1295 it.effect("renders successful StreamSse events incrementally with the declared content type", () =>
1296 Effect.gen(function*() {
1297 const Events = Schema.Struct({
1298 event: Schema.String,
1299 data: Schema.String
1300 })
1301
1302 const Api = HttpApi.make("Api").add(
1303 HttpApiGroup.make("test").add(
1304 HttpApiEndpoint.get("events", "/test", {
1305 success: HttpApiSchema.status(202)(
1306 HttpApiSchema.StreamSse({
1307 contentType: "text/event-stream; charset=utf-8",
1308 events: Events,
1309 error: StreamError
1310 })
1311 )
1312 })
1313 )
1314 )
1315 const GroupLayer = HttpApiBuilder.group(
1316 Api,
1317 "test",
1318 (handlers) =>
1319 handlers.handle("events", () =>
1320 Effect.succeed(Stream.make(
1321 { event: "first" as const, data: "one" },
1322 { event: "second" as const, data: "two" }
1323 )))
1324 )
1325
1326 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
1327 const response = yield* client.test.events({ responseMode: "response-only" })
1328 const chunks = yield* response.stream.pipe(Stream.runCollect)
1329
1330 assert.strictEqual(response.status, 202)
1331 assert.strictEqual(response.headers["content-type"], "text/event-stream; charset=utf-8")
1332 assert.deepStrictEqual(
1333 Array.from(chunks, (chunk) => textDecoder.decode(chunk)),
1334 ["event: first\ndata: one\n\n", "event: second\ndata: two\n\n"]
1335 )
1336 }))
1337
1338 it.effect("unwraps WithHeaders StreamSse responses before SSE encoding", () =>
1339 Effect.gen(function*() {
1340 const Events = Schema.Struct({
1341 event: Schema.String,
1342 data: Schema.String
1343 })
1344 const Api = HttpApi.make("Api").add(
1345 HttpApiGroup.make("test").add(
1346 HttpApiEndpoint.get("events", "/test", {
1347 success: HttpApiSchema.WithHeaders(
1348 HttpApiSchema.status(202)(
1349 HttpApiSchema.StreamSse({
1350 contentType: "text/event-stream; charset=utf-8",
1351 events: Events,
1352 error: StreamError
1353 })
1354 ),
1355 { "x-count": Schema.Int }
1356 )
1357 })
1358 )
1359 )
1360 const GroupLayer = HttpApiBuilder.group(
1361 Api,
1362 "test",
1363 (handlers) =>
1364 handlers.handle("events", () =>
1365 Effect.succeed(HttpApiSchema.withHeaders({
1366 body: Stream.make({ event: "first", data: "one" }),
1367 headers: { "x-count": 1 }
1368 })))
1369 )
1370
1371 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
1372 const response = yield* client.test.events({ responseMode: "response-only" })
1373 const chunks = yield* response.stream.pipe(Stream.runCollect)
1374
1375 assert.strictEqual(response.status, 202)
1376 assert.strictEqual(response.headers["content-type"], "text/event-stream; charset=utf-8")
1377 assert.strictEqual(response.headers["x-count"], "1")
1378 assert.deepStrictEqual(Array.from(chunks, (chunk) => textDecoder.decode(chunk)), [
1379 "event: first\ndata: one\n\n"
1380 ])
1381 }))
1382
1383 it.effect("fails WithHeaders stream header encoding before returning a response", () =>
1384 Effect.gen(function*() {
1385 const Api = HttpApi.make("Api").add(
1386 HttpApiGroup.make("test").add(
1387 HttpApiEndpoint.get("download", "/test", {
1388 success: HttpApiSchema.WithHeaders(
1389 HttpApiSchema.StreamUint8Array(),
1390 { "x-count": Schema.Int }
1391 )
1392 })
1393 )
1394 )
1395 const GroupLayer = HttpApiBuilder.group(
1396 Api,
1397 "test",
1398 (handlers) =>
1399 handlers.handle("download", () =>
1400 Effect.succeed(HttpApiSchema.withHeaders({
1401 body: Stream.make(new Uint8Array([1])),
1402 headers: { "x-count": "invalid" as any }
1403 })))
1404 )
1405
1406 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
1407 const exit = yield* Effect.exit(client.test.download({ responseMode: "response-only" }))
1408
1409 assert.strictEqual(exit._tag, "Failure")
1410 if (exit._tag === "Failure") {
1411 const error = Cause.squash(exit.cause) as any
1412 assert.strictEqual(error._tag, "HttpApiSchemaError")
1413 assert.strictEqual(error.kind, "ResponseHeaders")
1414 }
1415 }))
1416
1417 it.effect("renders StreamSse failures as one reserved event containing an encoded full cause", () =>
1418 Effect.gen(function*() {
1419 const Events = Schema.Struct({
1420 event: Schema.Literal("message"),
1421 data: Schema.String
1422 })
1423
1424 const Api = HttpApi.make("Api").add(
1425 HttpApiGroup.make("test").add(
1426 HttpApiEndpoint.get("events", "/test", {
1427 success: HttpApiSchema.StreamSse({ events: Events, error: StreamError })
1428 })
1429 )
1430 )
1431
1432 const GroupLayer = HttpApiBuilder.group(
1433 Api,
1434 "test",
1435 (handlers) =>
1436 handlers.handle("events", () =>
1437 Effect.succeed(
1438 Stream.fail({ reason: "boom" })
1439 ))
1440 )
1441
1442 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
1443 const response = yield* client.test.events({ responseMode: "response-only" })
1444 const chunks = yield* response.stream.pipe(Stream.runCollect)
1445 const rendered = Array.from(chunks, (chunk) => textDecoder.decode(chunk))
1446
1447 assert.strictEqual(response.headers["content-type"], "text/event-stream")
1448 assert.strictEqual(rendered.length, 1)
1449 assert.isTrue(rendered[0]!.startsWith("event: effect/http-api/stream/failure\ndata: "))
1450 assert.isTrue(rendered[0]!.endsWith("\n\n"))
1451
1452 const data = rendered[0]!.split("\n")[1]!.slice("data: ".length)
1453 const FailureSchema = Schema.toCodecJson(Schema.Cause(StreamError, Schema.Defect()))
1454 const cause = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(FailureSchema))(data)
1455 assert.deepStrictEqual(cause, Cause.fail({ reason: "boom" }))
1456 }))
1457
1458 it.effect("supports buffered and stream successes with the same status", () =>
1459 Effect.gen(function*() {
1460 const Buffered = Schema.Struct({ message: Schema.String })
1461 const EventData = Schema.Struct({
1462 text: Schema.String
1463 })
1464
1465 const Api = HttpApi.make("Api").add(
1466 HttpApiGroup.make("test").add(
1467 HttpApiEndpoint.get("chat", "/test", {
1468 query: {
1469 stream: Schema.String
1470 },
1471 success: [
1472 Buffered,
1473 HttpApiSchema.StreamSse({ data: EventData, error: StreamError })
1474 ]
1475 })
1476 )
1477 )
1478
1479 const GroupLayer = HttpApiBuilder.group(
1480 Api,
1481 "test",
1482 (handlers) =>
1483 handlers.handle("chat", ({ query }) =>
1484 Effect.succeed(
1485 query.stream === "true" ?
1486 Stream.make({ text: "hello" }) :
1487 { message: "done" }
1488 ))
1489 )
1490
1491 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
1492 const bufferedResponse = yield* client.test.chat({ query: { stream: "false" }, responseMode: "response-only" })
1493 assert.strictEqual(bufferedResponse.status, 200)
1494 assert.strictEqual(bufferedResponse.headers["content-type"], "application/json")
1495
1496 const streamResponse = yield* client.test.chat({ query: { stream: "true" }, responseMode: "response-only" })
1497 const chunks = yield* Stream.runCollect(streamResponse.stream)
1498
1499 assert.strictEqual(streamResponse.status, 200)
1500 assert.strictEqual(streamResponse.headers["content-type"], "text/event-stream")
1501 assert.deepStrictEqual(Array.from(chunks, (chunk) => textDecoder.decode(chunk)), [
1502 `data: {"text":"hello"}\n\n`
1503 ])
1504 }))
1505
1506 it.effect("keeps a plain buffered alternative when the stream is wrapped", () =>
1507 Effect.gen(function*() {
1508 const Buffered = Schema.Struct({ message: Schema.String })
1509 const Api = HttpApi.make("Api").add(
1510 HttpApiGroup.make("test").add(
1511 HttpApiEndpoint.get("result", "/test", {
1512 success: [
1513 HttpApiSchema.WithHeaders(
1514 HttpApiSchema.StreamUint8Array(),
1515 { "x-source": Schema.String }
1516 ),
1517 Buffered
1518 ]
1519 })
1520 )
1521 )
1522 const GroupLayer = HttpApiBuilder.group(
1523 Api,
1524 "test",
1525 (handlers) => handlers.handle("result", () => Effect.succeed({ message: "done" }))
1526 )
1527
1528 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
1529 const response = yield* client.test.result({ responseMode: "response-only" })
1530
1531 assert.strictEqual(response.headers["content-type"], "application/json")
1532 assert.deepStrictEqual(yield* response.json, { message: "done" })
1533 }))
1534
1535 it.effect("detects a wrapped buffered alternative alongside a plain stream", () =>
1536 Effect.gen(function*() {
1537 const Buffered = Schema.Struct({ message: Schema.String })
1538 const Api = HttpApi.make("Api").add(
1539 HttpApiGroup.make("test").add(
1540 HttpApiEndpoint.get("result", "/test", {
1541 success: [
1542 HttpApiSchema.StreamUint8Array(),
1543 HttpApiSchema.WithHeaders(Buffered, { "x-source": Schema.String })
1544 ]
1545 })
1546 )
1547 )
1548 const GroupLayer = HttpApiBuilder.group(
1549 Api,
1550 "test",
1551 (handlers) =>
1552 handlers.handle("result", () =>
1553 Effect.succeed(HttpApiSchema.withHeaders({
1554 body: { message: "done" },
1555 headers: { "x-source": "buffered" }
1556 })))
1557 )
1558
1559 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(Effect.provide(GroupLayer))
1560 const response = yield* client.test.result({ responseMode: "response-only" })
1561
1562 assert.strictEqual(response.headers["content-type"], "application/json")
1563 assert.strictEqual(response.headers["x-source"], "buffered")
1564 assert.deepStrictEqual(yield* response.json, { message: "done" })
1565 }))
1566
1567 it.effect("registers handleAll handlers at runtime", () =>
1568 Effect.gen(function*() {
1569 const User = Schema.Struct({
1570 id: Schema.String,
1571 name: Schema.String
1572 })
1573 const CreateUser = Schema.Struct({
1574 name: Schema.String
1575 })
1576
1577 const Api = HttpApi.make("Api").add(
1578 HttpApiGroup.make("users").add(
1579 HttpApiEndpoint.get("getUser", "/users/:id", {
1580 params: {
1581 id: Schema.String
1582 },
1583 success: User
1584 }),
1585 HttpApiEndpoint.post("createUser", "/users", {
1586 payload: CreateUser,
1587 success: User
1588 })
1589 )
1590 )
1591
1592 const GroupLayer = HttpApiBuilder.group(
1593 Api,
1594 "users",
1595 (handlers) =>
1596 handlers.handleAll({
1597 getUser: ({ params }) =>
1598 Effect.succeed({
1599 id: params.id,
1600 name: "Ada"
1601 }),
1602 createUser: {
1603 handler: ({ payload }) =>
1604 Effect.succeed({
1605 id: "created",
1606 name: payload.name
1607 }),
1608 options: { uninterruptible: true }
1609 }
1610 })
1611 )
1612
1613 const client = yield* HttpApiTest.groups(Api, ["users"]).pipe(Effect.provide(GroupLayer))
1614 const getUser = yield* client.users.getUser({ params: { id: "user-1" } })
1615 const createUser = yield* client.users.createUser({ payload: { name: "Grace" } })
1616
1617 assert.deepStrictEqual(getUser, { id: "user-1", name: "Ada" })
1618 assert.deepStrictEqual(createUser, { id: "created", name: "Grace" })
1619 }))
1620
1621 it.effect("rejects unknown endpoint identifiers", () =>
1622 Effect.gen(function*() {
1623 const Api = HttpApi.make("Api").add(
1624 HttpApiGroup.make("users").add(
1625 HttpApiEndpoint.get("getUser", "/users", { success: Schema.String })
1626 )
1627 )
1628
1629 for (const identifier of ["missing", "toString"] as const) {
1630 const GroupLayer = HttpApiBuilder.group(
1631 Api,
1632 "users",
1633 (handlers) => handlers.handle(identifier as "getUser", () => Effect.succeed("missing"))
1634 )
1635 const exit = yield* Effect.exit(Effect.scoped(Layer.build(GroupLayer)))
1636
1637 assert.strictEqual(exit._tag, "Failure")
1638 if (exit._tag === "Failure") {
1639 const error = Cause.squash(exit.cause)
1640 assert.instanceOf(error, Error)
1641 assert.strictEqual(
1642 error.message,
1643 `HttpApiEndpoint "${identifier}" not found in HttpApiGroup "users"`
1644 )
1645 }
1646 }
1647 }))
1648
1649 it.effect("rejects duplicate handle and handleAll registrations", () =>
1650 Effect.gen(function*() {
1651 const Api = HttpApi.make("Api").add(
1652 HttpApiGroup.make("users").add(
1653 HttpApiEndpoint.get("getUser", "/users", { success: Schema.String })
1654 )
1655 )
1656 const HandleLayer = HttpApiBuilder.group(
1657 Api,
1658 "users",
1659 (handlers) => {
1660 handlers.handle("getUser", () => Effect.succeed("first"))
1661 return handlers.handle("getUser", () => Effect.succeed("second"))
1662 }
1663 )
1664 const HandleAllLayer = HttpApiBuilder.group(
1665 Api,
1666 "users",
1667 (handlers) => {
1668 handlers.handleAll({ getUser: () => Effect.succeed("first") })
1669 return handlers.handleAll({ getUser: () => Effect.succeed("second") })
1670 }
1671 )
1672
1673 for (const GroupLayer of [HandleLayer, HandleAllLayer]) {
1674 const exit = yield* Effect.exit(Effect.scoped(Layer.build(GroupLayer)))
1675 assert.strictEqual(exit._tag, "Failure")
1676 if (exit._tag === "Failure") {
1677 const error = Cause.squash(exit.cause)
1678 assert.instanceOf(error, Error)
1679 assert.strictEqual(
1680 error.message,
1681 "Handler for HttpApiEndpoint \"getUser\" is already registered in HttpApiGroup \"users\""
1682 )
1683 }
1684 }
1685 }))
1686
1687 it.effect("ignores inherited handleAll properties", () =>
1688 Effect.gen(function*() {
1689 const Api = HttpApi.make("Api").add(
1690 HttpApiGroup.make("users").add(
1691 HttpApiEndpoint.get("getUser", "/users", {
1692 success: Schema.String
1693 })
1694 )
1695 )
1696 const implementations: {
1697 readonly getUser: () => Effect.Effect<string>
1698 } = Object.assign(
1699 Object.create({
1700 inherited: () => Effect.succeed("inherited")
1701 }),
1702 {
1703 getUser: () => Effect.succeed("own")
1704 }
1705 )
1706 const GroupLayer = HttpApiBuilder.group(
1707 Api,
1708 "users",
1709 (handlers) => handlers.handleAll(implementations)
1710 )
1711
1712 const client = yield* HttpApiTest.groups(Api, ["users"]).pipe(Effect.provide(GroupLayer))
1713
1714 assert.strictEqual(yield* client.users.getUser(), "own")
1715 }))
1716
1717 it.effect("executes effectful handler builders", () =>
1718 Effect.gen(function*() {
1719 const User = Schema.Struct({
1720 id: Schema.String,
1721 name: Schema.String
1722 })
1723
1724 const Api = HttpApi.make("Api").add(
1725 HttpApiGroup.make("users").add(
1726 HttpApiEndpoint.get("getUser", "/users/:id", {
1727 params: {
1728 id: Schema.String
1729 },
1730 success: User
1731 })
1732 )
1733 )
1734
1735 const GroupLayer = HttpApiBuilder.group(
1736 Api,
1737 "users",
1738 (handlers) =>
1739 Effect.succeed(
1740 handlers.handle("getUser", ({ params }) =>
1741 Effect.succeed({
1742 id: params.id,
1743 name: "Ada"
1744 }))
1745 )
1746 )
1747
1748 const client = yield* HttpApiTest.groups(Api, ["users"]).pipe(Effect.provide(GroupLayer))
1749 const getUser = yield* client.users.getUser({ params: { id: "user-1" } })
1750
1751 assert.deepStrictEqual(getUser, { id: "user-1", name: "Ada" })
1752 }))
1753
1754 it.effect("does not try another security scheme after the handler fails", () =>
1755 Effect.gen(function*() {
1756 class HandlerFailure extends Schema.TaggedError<HandlerFailure>()("HandlerFailure", {
1757 message: Schema.String
1758 }, { httpApiStatus: 418 }) {}
1759
1760 // @effect-diagnostics-next-line leakingRequirements:off
1761 class M extends HttpApiMiddleware.Service<M>()("Security/HandlerFailure", {
1762 error: Schema.String.pipe(
1763 HttpApiSchema.status(401),
1764 HttpApiSchema.asText()
1765 ),
1766 security: {
1767 first: HttpApiSecurity.apiKey({
1768 in: "header",
1769 key: "x-first"
1770 }),
1771 second: HttpApiSecurity.apiKey({
1772 in: "header",
1773 key: "x-second"
1774 })
1775 }
1776 }) {}
1777
1778 const Api = HttpApi.make("Api").add(
1779 HttpApiGroup.make("test").add(
1780 HttpApiEndpoint.get("protected", "/protected", {
1781 headers: {
1782 "x-first": Schema.String
1783 },
1784 success: Schema.String,
1785 error: HandlerFailure
1786 }).middleware(M)
1787 )
1788 )
1789 const GroupLayer = HttpApiBuilder.group(
1790 Api,
1791 "test",
1792 (handlers) => handlers.handle("protected", () => Effect.fail(new HandlerFailure({ message: "handler failed" })))
1793 )
1794 const MLayer = Layer.succeed(M)({
1795 first: (effect, { credential }) =>
1796 Redacted.value(credential) === "ok" ? effect : Effect.fail("first unauthorized"),
1797 second: (effect, { credential }) =>
1798 Redacted.value(credential) === "ok" ? effect : Effect.fail("second unauthorized")
1799 })
1800
1801 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(
1802 Effect.provide(GroupLayer),
1803 Effect.provide(MLayer)
1804 )
1805 const error = yield* Effect.flip(client.test.protected({ headers: { "x-first": "ok" } }))
1806
1807 assert.deepStrictEqual(error, new HandlerFailure({ message: "handler failed" }))
1808 }))
1809})
1810
1811it.layer(TestServices)("HttpApiBuilder middleware error responses", (it) => {
1812 it.effect("encodes a middleware error through the response codec", () =>
1813 Effect.gen(function*() {
1814 const RateLimited = Schema.Struct({ retryAfter: Schema.BigInt }).pipe(HttpApiSchema.status(429))
1815
1816 class RateLimit extends HttpApiMiddleware.Service<RateLimit>()("RateLimit", {
1817 error: RateLimited
1818 }) {}
1819
1820 const Api = HttpApi.make("Api").add(
1821 HttpApiGroup.make("test").add(
1822 HttpApiEndpoint.get("limited", "/limited", { success: Schema.String }).middleware(RateLimit)
1823 )
1824 )
1825 const GroupLayer = HttpApiBuilder.group(
1826 Api,
1827 "test",
1828 (handlers) => handlers.handle("limited", () => Effect.succeed("ok"))
1829 )
1830 const RateLimitLayer = Layer.succeed(RateLimit)(() => Effect.fail({ retryAfter: 30n }))
1831
1832 const client = yield* HttpApiTest.groups(Api, ["test"]).pipe(
1833 Effect.provide(GroupLayer),
1834 Effect.provide(RateLimitLayer)
1835 )
1836 const error = yield* Effect.flip(client.test.limited())
1837
1838 assert.deepStrictEqual(error, { retryAfter: 30n })
1839 }))
1840})