1import { assert, describe, it } from "@effect/vitest"
2import { strictEqual } from "@effect/vitest/utils"
3import { Cause, Effect, Exit, Schema, Stream } from "effect"
4import { Sse } from "effect/encoding"
5import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"
6import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/http-api"
7
8describe("HttpApiClient", () => {
9 describe("ParseOptions", () => {
10 const Fields = { firstName: Schema.String, lastName: Schema.String }
11 const Person = Schema.Struct(Fields)
12
13 const SseApi = HttpApi.make("SseApi").add(
14 HttpApiGroup.make("test").add(HttpApiEndpoint.get("events", "/events", {
15 success: HttpApiSchema.StreamSse({ data: Person, error: Schema.Struct({ reason: Schema.String }) })
16 }))
17 )
18
19 it.effect("strict events-mode decoding omits an absent event ID", () =>
20 Effect.gen(function*() {
21 const decode = (wire: string) =>
22 HttpApiClient.makeWith(
23 StreamingApi.annotate(HttpApi.ParseOptions, { onExcessProperty: "error" }),
24 { baseUrl: "http://test", httpClient: clientFromResponse(() => new Response(textStream([wire]))) }
25 ).pipe(Effect.flatMap((client) => client.test.events({})), Effect.flatMap(Stream.runCollect))
26
27 const error = yield* Effect.flip(decode("id: 1\nevent: person\ndata: hello\n\n"))
28 assert.ok(Schema.isSchemaError(error))
29 assert.include(error.message, "[\"id\"]")
30 assert.deepStrictEqual(yield* decode("event: person\ndata: hello\n\n"), [{ event: "person", data: "hello" }])
31 }))
32
33 it.effect("strict SSE decoding accepts valid user data and framework event metadata", () =>
34 Effect.gen(function*() {
35 const client = yield* HttpApiClient.makeWith(
36 SseApi.annotate(HttpApi.ParseOptions, { onExcessProperty: "error" }),
37 {
38 baseUrl: "http://test",
39 httpClient: clientFromResponse(() =>
40 new Response(
41 textStream([
42 "id: 1\nevent: person\ndata: {\"firstName\":\"Ada\",\"lastName\":\"Lovelace\"}\n\n"
43 ]),
44 { headers: { "content-type": "text/event-stream" } }
45 )
46 )
47 }
48 )
49 const events = yield* client.test.events({}).pipe(Effect.flatMap(Stream.runCollect))
50 assert.deepStrictEqual(events, [{ firstName: "Ada", lastName: "Lovelace" }])
51 }))
52
53 it.effect("strict SSE decoding preserves reserved failure causes", () =>
54 Effect.gen(function*() {
55 const expected = Cause.fail({ reason: "boom" })
56 const FailureSchema = Schema.fromJsonString(Schema.toCodecJson(Schema.Cause(StreamError, Schema.Defect())))
57 const data = yield* Schema.encodeEffect(FailureSchema)(expected)
58 const client = yield* HttpApiClient.makeWith(
59 SseApi.annotate(HttpApi.ParseOptions, { onExcessProperty: "error" }),
60 {
61 baseUrl: "http://test",
62 httpClient: clientFromResponse(() =>
63 new Response(
64 textStream([
65 Sse.encoder.write({ _tag: "Event", event: "effect/http-api/stream/failure", id: undefined, data })
66 ]),
67 { headers: { "content-type": "text/event-stream" } }
68 )
69 )
70 }
71 )
72 const exit = yield* client.test.events({}).pipe(Effect.flatMap(Stream.runCollect), Effect.exit)
73 assert.strictEqual(exit._tag, "Failure")
74 if (exit._tag === "Failure") {
75 const error = Cause.squash(exit.cause)
76 assert.isFalse(Schema.isSchemaError(error), error instanceof Error ? error.message : undefined)
77 assert.deepStrictEqual(exit.cause, expected)
78 }
79 }))
80
81 it.effect("strict SSE decoding rejects excess properties in user event data", () =>
82 Effect.gen(function*() {
83 const client = yield* HttpApiClient.makeWith(
84 SseApi.annotate(HttpApi.ParseOptions, { onExcessProperty: "error" }),
85 {
86 baseUrl: "http://test",
87 httpClient: clientFromResponse(() =>
88 new Response(
89 textStream([
90 "data: {\"firstName\":\"Ada\",\"lastName\":\"Lovelace\",\"extra\":true}\n\n"
91 ]),
92 { headers: { "content-type": "text/event-stream" } }
93 )
94 )
95 }
96 )
97 const exit = yield* client.test.events({}).pipe(Effect.flatMap(Stream.runCollect), Effect.exit)
98 assert.strictEqual(exit._tag, "Failure")
99 if (exit._tag === "Failure") {
100 const error = Cause.squash(exit.cause)
101 assert.ok(Schema.isSchemaError(error))
102 assert.include(error.message, "Expected no excess property")
103 assert.include(error.message, "[\"data\"][\"extra\"]")
104 assert.notInclude(error.message, "[\"_tag\"]")
105 }
106 }))
107
108 it.effect("client payload encoding collects all issues", () =>
109 Effect.gen(function*() {
110 const Api = HttpApi.make("Api").add(
111 HttpApiGroup.make("users").add(HttpApiEndpoint.post("create", "/users", { payload: Person }))
112 ).annotate(HttpApi.ParseOptions, { errors: "all" })
113 let requests = 0
114 const client = yield* HttpApiClient.makeWith(Api, {
115 baseUrl: "http://test",
116 httpClient: clientFromResponse(() => {
117 requests++
118 return new Response(null, { status: 204 })
119 })
120 })
121 const exit = yield* Effect.exit(client.users.create({ payload: {} as typeof Person.Type }))
122 assert.strictEqual(requests, 0)
123 assert.strictEqual(exit._tag, "Failure")
124 if (exit._tag === "Failure") {
125 const error = Cause.squash(exit.cause)
126 assert.ok(Schema.isSchemaError(error))
127 assert.include(error.message, "firstName")
128 assert.include(error.message, "lastName")
129 }
130 }))
131
132 it.effect("client response decoding collects all issues", () =>
133 Effect.gen(function*() {
134 const Api = HttpApi.make("Api").add(
135 HttpApiGroup.make("users").add(HttpApiEndpoint.get("get", "/users", { success: Person }))
136 ).annotate(HttpApi.ParseOptions, { errors: "all" })
137 const client = yield* HttpApiClient.makeWith(Api, {
138 baseUrl: "http://test",
139 httpClient: clientFromResponse(() => new Response("{}", { headers: { "content-type": "application/json" } }))
140 })
141 const exit = yield* Effect.exit(client.users.get({}))
142 assert.strictEqual(exit._tag, "Failure")
143 if (exit._tag === "Failure") {
144 const error = Cause.squash(exit.cause)
145 assert.ok(Schema.isSchemaError(error))
146 assert.include(error.message, "firstName")
147 assert.include(error.message, "lastName")
148 }
149 }))
150
151 it("urlBuilder uses group options over API options", () => {
152 const Api = HttpApi.make("Api").add(
153 HttpApiGroup.make("users").add(HttpApiEndpoint.get("get", "/users", { query: Person }))
154 .annotate(HttpApi.ParseOptions, { errors: "all" })
155 ).annotate(HttpApi.ParseOptions, { errors: "first" })
156 const urls = HttpApiClient.urlBuilder(Api)
157 assert.throws(() => urls.users.get({ query: {} as typeof Person.Type }), /firstName[\s\S]*lastName/)
158 })
159 })
160
161 describe("slot ParseOptions", () => {
162 const Strict = { onExcessProperty: "error" } as const
163 const Person = Schema.Struct({ name: Schema.String })
164 const adaWithExtra = { name: "Ada", extra: true }
165
166 it.effect("API slot annotations replace endpoint ParseOptions for request codecs", () =>
167 Effect.gen(function*() {
168 const Group = HttpApiGroup.make("test").add(
169 HttpApiEndpoint.post("create", "/users/:id", {
170 params: { id: Schema.String },
171 query: { q: Schema.String },
172 headers: { "x-api-key": Schema.String },
173 payload: Person
174 }).annotate(HttpApi.ParseOptions, Strict)
175 )
176 const request = {
177 params: { id: "1", extraParam: "p" },
178 query: { q: "x", extraQuery: "q" },
179 headers: { "x-api-key": "key", "x-extra": "h" },
180 payload: { name: "Ada", extraPayload: true }
181 }
182 const requests: Array<{ readonly url: string; readonly headers: Record<string, string> }> = []
183 const create = (api: HttpApi.HttpApi<"Api", typeof Group>) =>
184 HttpApiClient.makeWith(api, {
185 baseUrl: "http://test",
186 httpClient: HttpClient.make((request, url) =>
187 Effect.sync(() => {
188 requests.push({ url: url.toString(), headers: request.headers })
189 return HttpClientResponse.fromWeb(request, new Response(null, { status: 204 }))
190 })
191 )
192 }).pipe(Effect.flatMap((client) => Effect.exit(client.test.create(request))), Effect.map(String))
193
194 // Encoded in order: params, payload, headers, query.
195 let api = HttpApi.make("Api").add(Group)
196 assert.include(yield* create(api), "extraParam")
197 api = api.annotate(HttpApi.ParamsParseOptions, {})
198 assert.include(yield* create(api), "extraPayload")
199 // Replaces ParseOptions: onExcessProperty is not merged in.
200 api = api.annotate(HttpApi.PayloadParseOptions, { errors: "first" })
201 assert.include(yield* create(api), "x-extra")
202 api = api.annotate(HttpApi.HeadersParseOptions, {})
203 assert.include(yield* create(api), "extraQuery")
204 api = api.annotate(HttpApi.QueryParseOptions, {})
205 yield* create(api)
206 assert.strictEqual(requests.length, 1)
207 assert.strictEqual(requests[0]!.url, "http://test/users/1?q=x")
208 assert.strictEqual(requests[0]!.headers["x-api-key"], "key")
209 assert.isUndefined(requests[0]!.headers["x-extra"])
210 strictEqual(HttpApiClient.urlBuilder(api).test.create(request), "/users/1?q=x")
211 }))
212
213 it.effect("buffered responses decode headers, success and error bodies with their own slots", () =>
214 Effect.gen(function*() {
215 const Group = HttpApiGroup.make("test").add(
216 HttpApiEndpoint.get("get", "/user", {
217 success: HttpApiSchema.WithHeaders(Person, { "x-count": Schema.Int }),
218 error: Person.pipe(HttpApiSchema.status(400))
219 }).annotate(HttpApi.ParseOptions, Strict)
220 )
221 const get = (api: HttpApi.HttpApi<"Api", typeof Group>, status: number) =>
222 HttpApiClient.makeWith(api, {
223 baseUrl: "http://test",
224 httpClient: clientFromResponse(() =>
225 new Response(JSON.stringify(adaWithExtra), {
226 status,
227 headers: { "content-type": "application/json", "server": "test", "x-count": "1" }
228 })
229 )
230 }).pipe(Effect.flatMap((client) => Effect.exit(client.test.get({}))))
231
232 let api = HttpApi.make("Api").add(Group).annotate(HttpApi.SuccessParseOptions, {})
233 const headersError = yield* get(api, 200)
234 assert.strictEqual(headersError._tag, "Failure")
235 assert.include(String(headersError), `["content-type"]`)
236
237 api = api.annotate(HttpApi.HeadersParseOptions, {})
238 const value = yield* get(api, 200)
239 assert.deepStrictEqual(
240 value,
241 Exit.succeed(HttpApiSchema.withHeaders({ body: { name: "Ada" }, headers: { "x-count": 1 } }))
242 )
243
244 const strictError = yield* get(api, 400)
245 assert.ok(strictError._tag === "Failure" && HttpClientError.isHttpClientError(Cause.squash(strictError.cause)))
246 assert.deepStrictEqual(yield* get(api.annotate(HttpApi.ErrorParseOptions, {}), 400), Exit.fail({ name: "Ada" }))
247 }))
248
249 it.effect("streamed WithHeaders responses decode headers and events with their own slots", () =>
250 Effect.gen(function*() {
251 const Group = HttpApiGroup.make("test").add(
252 HttpApiEndpoint.get("events", "/events", {
253 success: HttpApiSchema.WithHeaders(
254 HttpApiSchema.StreamSse({ data: Person, error: StreamError }),
255 { "x-count": Schema.Int }
256 )
257 })
258 )
259 const events = (api: HttpApi.HttpApi<"Api", typeof Group>) =>
260 HttpApiClient.makeWith(api, {
261 baseUrl: "http://test",
262 httpClient: clientFromResponse(() =>
263 new Response(textStream([`data: ${JSON.stringify(adaWithExtra)}\n\n`]), {
264 headers: { "content-type": "text/event-stream", "x-count": "1" }
265 })
266 )
267 }).pipe(
268 Effect.flatMap((client) => client.test.events({})),
269 Effect.flatMap(({ body, headers }) =>
270 Effect.map(Stream.runCollect(body), (events) => ({ events, headers }))
271 ),
272 Effect.exit
273 )
274
275 const api = HttpApi.make("Api").add(Group)
276 .annotate(HttpApi.ParseOptions, Strict)
277 .annotate(HttpApi.HeadersParseOptions, {})
278 const strictEvents = yield* events(api)
279 assert.include(String(strictEvents), `["data"]["extra"]`)
280 assert.deepStrictEqual(
281 yield* events(api.annotate(HttpApi.SuccessParseOptions, {})),
282 Exit.succeed({ events: [{ name: "Ada" }], headers: { "x-count": 1 } })
283 )
284 }))
285
286 it.effect("encodeToWithHeaders decodes header transformations with HeadersParseOptions", () =>
287 Effect.gen(function*() {
288 class Invalid extends Schema.TaggedError<Invalid>()("Invalid", { a: Schema.Finite, b: Schema.Finite }) {}
289 const InvalidResponse = Invalid.pipe(HttpApiSchema.encodeToWithHeaders({
290 body: Schema.String.pipe(HttpApiSchema.status(400), HttpApiSchema.asText()),
291 headers: { "x-a": Schema.FiniteFromString, "x-b": Schema.FiniteFromString }
292 }, {
293 decode: ({ headers }) => new Invalid({ a: headers["x-a"], b: headers["x-b"] }),
294 encode: (error) => ({ body: "invalid", headers: { "x-a": error.a, "x-b": error.b } })
295 }))
296 const Api = HttpApi.make("Api").add(
297 HttpApiGroup.make("test").add(HttpApiEndpoint.get("get", "/invalid", { error: InvalidResponse }))
298 )
299 .annotate(HttpApi.ParseOptions, { errors: "first" })
300 .annotate(HttpApi.HeadersParseOptions, { errors: "all" })
301 const client = yield* HttpApiClient.makeWith(Api, {
302 baseUrl: "http://test",
303 httpClient: clientFromResponse(() =>
304 new Response("invalid", {
305 status: 400,
306 headers: { "content-type": "text/plain", "x-a": "bad", "x-b": "bad" }
307 })
308 )
309 })
310
311 const exit = String(yield* Effect.exit(client.test.get({})))
312 assert.include(exit, `["x-a"]`)
313 assert.include(exit, `["x-b"]`)
314 }))
315
316 it.effect("encodeToWithHeaders validates the mapped response value", () =>
317 Effect.gen(function*() {
318 const Positive = Schema.Number.check(Schema.isGreaterThan(0))
319 const ResponseSchema = Positive.pipe(HttpApiSchema.encodeToWithHeaders({
320 body: Schema.String.pipe(HttpApiSchema.asText()),
321 headers: { "x-value": Schema.FiniteFromString }
322 }, {
323 decode: ({ headers }) => headers["x-value"],
324 encode: (value) => ({ body: "value", headers: { "x-value": value } })
325 }))
326 const Api = HttpApi.make("Api").add(
327 HttpApiGroup.make("test").add(HttpApiEndpoint.get("get", "/value", { success: ResponseSchema }))
328 )
329 const client = yield* HttpApiClient.makeWith(Api, {
330 baseUrl: "http://test",
331 httpClient: clientFromResponse(() =>
332 new Response("value", { headers: { "content-type": "text/plain", "x-value": "-1" } })
333 )
334 })
335
336 const exit = yield* Effect.exit(client.test.get({}))
337 assert.strictEqual(exit._tag, "Failure")
338 assert.include(String(exit), "greater than 0")
339 }))
340 })
341
342 describe("literal action suffixes", () => {
343 const Api = HttpApi.make("Api").add(
344 HttpApiGroup.make("operations").add(
345 HttpApiEndpoint.post("wait", "/operations/:id:wait", {
346 params: Schema.Struct({ id: Schema.String })
347 })
348 )
349 )
350 const expectedUrl = "https://api.example.com/operations/op_1:wait"
351
352 it("urlBuilder preserves the undeclared action suffix", () => {
353 const urls = HttpApiClient.urlBuilder(Api, { baseUrl: "https://api.example.com" })
354
355 strictEqual(urls.operations.wait({ params: { id: "op_1" } }), expectedUrl)
356 })
357
358 it.effect("make sends the request with the literal action suffix", () =>
359 Effect.gen(function*() {
360 let requestUrl: string | undefined
361 const httpClient = HttpClient.make((request, url) =>
362 Effect.sync(() => {
363 requestUrl = url.toString()
364 return HttpClientResponse.fromWeb(request, new Response(null, { status: 204 }))
365 })
366 )
367 const client = yield* HttpApiClient.make(Api, { baseUrl: "https://api.example.com" }).pipe(
368 Effect.provideService(HttpClient.HttpClient, httpClient)
369 )
370
371 yield* client.operations.wait({ params: { id: "op_1" } })
372
373 strictEqual(requestUrl, expectedUrl)
374 }))
375 })
376
377 describe("streaming responses", () => {
378 it.effect("decodes StreamSse events incrementally", () =>
379 Effect.gen(function*() {
380 const client = yield* HttpApiClient.makeWith(StreamingApi, {
381 baseUrl: "http://test",
382 httpClient: clientFromResponse(() =>
383 new Response(
384 textStream([
385 "event: first\ndata: one\n\n",
386 "event: second\ndata: two\n\n"
387 ]),
388 { status: 200 }
389 )
390 )
391 })
392
393 const stream = yield* client.test.events({})
394 const first = yield* stream.pipe(Stream.take(1), Stream.runCollect)
395 assert.deepStrictEqual(first, [{ event: "first", data: "one" }])
396 }))
397
398 it.effect("keeps per-call SSE decode options isolated", () =>
399 Effect.gen(function*() {
400 const client = yield* HttpApiClient.makeWith(StreamingApi, {
401 baseUrl: "http://test",
402 httpClient: clientFromResponse(() => new Response(textStream(["data: ", "hello\n\n"]), { status: 200 }))
403 })
404
405 const limitedStream = yield* client.test.events({ sseOptions: { maxEventSize: 4 } })
406 const defaultStream = yield* client.test.events({})
407 const [error, events] = yield* Effect.all([
408 limitedStream.pipe(Stream.runCollect, Effect.flip),
409 Stream.runCollect(defaultStream)
410 ], { concurrency: "unbounded" })
411
412 assert.instanceOf(error, Sse.SseError)
413 assert.instanceOf(error.reason, Sse.EventTooLarge)
414 assert.strictEqual(error.reason.maxEventSize, 4)
415 assert.deepStrictEqual(events, [{ event: "message", data: "hello" }])
416 }))
417
418 it.effect("keeps StreamSse parser state isolated between responses", () =>
419 Effect.gen(function*() {
420 const bodies = [
421 "event: first\ndata: one\n\n",
422 "event: second\ndata: two\n\n"
423 ]
424 let index = 0
425 const client = yield* HttpApiClient.makeWith(StreamingApi, {
426 baseUrl: "http://test",
427 httpClient: clientFromResponse(() => new Response(textStream([bodies[index++]!]), { status: 200 }))
428 })
429
430 const first = yield* client.test.events({}).pipe(Effect.flatMap(Stream.runCollect))
431 const second = yield* client.test.events({}).pipe(Effect.flatMap(Stream.runCollect))
432
433 assert.deepStrictEqual(first, [{ event: "first", data: "one" }])
434 assert.deepStrictEqual(second, [{ event: "second", data: "two" }])
435 }))
436
437 it.effect("decodes StreamSse reserved failure events as full causes", () =>
438 Effect.gen(function*() {
439 const expectedCause = Cause.fail({ reason: "boom" })
440 const FailureSchema = Schema.toCodecJson(Schema.Cause(StreamError, Schema.Defect()))
441 const encodeCause = Schema.encodeUnknownEffect(Schema.fromJsonString(FailureSchema))
442 const encodedCause = yield* encodeCause(expectedCause)
443 const failureEvent = Sse.encoder.write({
444 _tag: "Event",
445 event: "effect/http-api/stream/failure",
446 id: undefined,
447 data: encodedCause
448 })
449
450 const client = yield* HttpApiClient.makeWith(StreamingApi, {
451 baseUrl: "http://test",
452 httpClient: clientFromResponse(() => new Response(textStream([failureEvent]), { status: 200 }))
453 })
454
455 const stream = yield* client.test.events({})
456 const exit = yield* Effect.exit(Stream.runCollect(stream))
457
458 assert.strictEqual(exit._tag, "Failure")
459 if (exit._tag === "Failure") {
460 assert.deepStrictEqual(exit.cause, expectedCause)
461 }
462 }))
463
464 it.effect("emits StreamSse reserved names with non-Cause data as user events", () =>
465 Effect.gen(function*() {
466 const failureEvent = Sse.encoder.write({
467 _tag: "Event",
468 event: "effect/http-api/stream/failure",
469 id: undefined,
470 data: "not-json"
471 })
472
473 const client = yield* HttpApiClient.makeWith(StreamingApi, {
474 baseUrl: "http://test",
475 httpClient: clientFromResponse(() => new Response(textStream([failureEvent]), { status: 200 }))
476 })
477
478 const stream = yield* client.test.events({})
479 const events = yield* Stream.runCollect(stream)
480
481 assert.deepStrictEqual(events, [{
482 event: "effect/http-api/stream/failure",
483 data: "not-json"
484 }])
485 }))
486
487 it.effect("returns StreamUint8Array response bytes incrementally", () =>
488 Effect.gen(function*() {
489 const client = yield* HttpApiClient.makeWith(StreamingApi, {
490 baseUrl: "http://test",
491 httpClient: clientFromResponse(() =>
492 new Response(byteStream([new Uint8Array([1, 2]), new Uint8Array([3])]), { status: 200 })
493 )
494 })
495
496 const stream = yield* client.test.download({})
497 const first = yield* stream.pipe(Stream.take(1), Stream.runCollect)
498 assert.deepStrictEqual(first.map((chunk) => Array.from(chunk)), [[1, 2]])
499 }))
500
501 it.effect("decodes WithHeaders StreamUint8Array bodies and headers", () =>
502 Effect.gen(function*() {
503 const Api = HttpApi.make("Api").add(
504 HttpApiGroup.make("test").add(
505 HttpApiEndpoint.get("download", "/download", {
506 success: HttpApiSchema.WithHeaders(
507 HttpApiSchema.StreamUint8Array(),
508 { "x-count": Schema.Int }
509 )
510 })
511 )
512 )
513 const client = yield* HttpApiClient.makeWith(Api, {
514 baseUrl: "http://test",
515 httpClient: clientFromResponse(() =>
516 new Response(byteStream([new Uint8Array([1, 2]), new Uint8Array([3])]), {
517 status: 200,
518 headers: { "x-count": "2" }
519 })
520 )
521 })
522
523 const value = yield* client.test.download({})
524 const first = yield* value.body.pipe(Stream.take(1), Stream.runCollect)
525
526 assert.deepStrictEqual(value.headers, { "x-count": 2 })
527 assert.deepStrictEqual(first.map((chunk) => Array.from(chunk)), [[1, 2]])
528 }))
529
530 it.effect("decodes WithHeaders StreamSse bodies and headers", () =>
531 Effect.gen(function*() {
532 const Api = HttpApi.make("Api").add(
533 HttpApiGroup.make("test").add(
534 HttpApiEndpoint.get("events", "/events", {
535 success: HttpApiSchema.WithHeaders(
536 HttpApiSchema.StreamSse({
537 data: Schema.Struct({ text: Schema.String }),
538 error: StreamError
539 }),
540 { "x-count": Schema.Int }
541 )
542 })
543 )
544 )
545 const client = yield* HttpApiClient.makeWith(Api, {
546 baseUrl: "http://test",
547 httpClient: clientFromResponse(() =>
548 new Response(textStream([`data: {"text":"hello"}\n\n`]), {
549 status: 200,
550 headers: {
551 "content-type": "text/event-stream",
552 "x-count": "1"
553 }
554 })
555 )
556 })
557
558 const value = yield* client.test.events({})
559 const events = yield* Stream.runCollect(value.body)
560
561 assert.deepStrictEqual(value.headers, { "x-count": 1 })
562 assert.deepStrictEqual(events, [{ text: "hello" }])
563 }))
564
565 it.effect("fails invalid WithHeaders stream headers before returning the body", () =>
566 Effect.gen(function*() {
567 const Api = HttpApi.make("Api").add(
568 HttpApiGroup.make("test").add(
569 HttpApiEndpoint.get("download", "/download", {
570 success: HttpApiSchema.WithHeaders(
571 HttpApiSchema.StreamUint8Array(),
572 { "x-count": Schema.Int }
573 )
574 })
575 )
576 )
577 const client = yield* HttpApiClient.makeWith(Api, {
578 baseUrl: "http://test",
579 httpClient: clientFromResponse(() =>
580 new Response(byteStream([new Uint8Array([1])]), {
581 status: 200,
582 headers: { "x-count": "invalid" }
583 })
584 )
585 })
586
587 const exit = yield* Effect.exit(client.test.download({}))
588
589 assert.strictEqual(exit._tag, "Failure")
590 if (exit._tag === "Failure") {
591 assert.strictEqual((Cause.squash(exit.cause) as { readonly _tag?: string })._tag, "SchemaError")
592 }
593 }))
594
595 it.effect("forwards SSE options through mixed WithHeaders content-type selection", () =>
596 Effect.gen(function*() {
597 const Api = HttpApi.make("Api").add(
598 HttpApiGroup.make("test").add(
599 HttpApiEndpoint.get("chat", "/chat", {
600 success: [
601 Schema.Struct({ message: Schema.String }),
602 HttpApiSchema.WithHeaders(
603 HttpApiSchema.StreamSse({ data: Schema.Struct({ text: Schema.String }) }),
604 { "x-count": Schema.Int }
605 )
606 ]
607 })
608 )
609 )
610 const client = yield* HttpApiClient.makeWith(Api, {
611 baseUrl: "http://test",
612 httpClient: clientFromResponse(() =>
613 new Response(textStream(["data: ", `{"text":"hello"}\n\n`]), {
614 status: 200,
615 headers: {
616 "content-type": "text/event-stream; charset=utf-8",
617 "x-count": "1"
618 }
619 })
620 )
621 })
622
623 const value = yield* client.test.chat({ sseOptions: { maxEventSize: 4 } })
624 if (!(HttpApiSchema.WithHeadersValueTypeId in value)) {
625 throw new Error("Expected WithHeaders response")
626 }
627 const error = yield* value.body.pipe(Stream.runCollect, Effect.flip)
628
629 assert.deepStrictEqual(value.headers, { "x-count": 1 })
630 assert.instanceOf(error, Sse.SseError)
631 assert.instanceOf(error.reason, Sse.EventTooLarge)
632 assert.strictEqual(error.reason.maxEventSize, 4)
633
634 const defaultValue = yield* client.test.chat({})
635 if (!(HttpApiSchema.WithHeadersValueTypeId in defaultValue)) {
636 throw new Error("Expected WithHeaders response")
637 }
638 const events = yield* Stream.runCollect(defaultValue.body)
639 assert.deepStrictEqual(defaultValue.headers, { "x-count": 1 })
640 assert.deepStrictEqual(events, [{ text: "hello" }])
641 }))
642
643 it.effect("decodes StreamSse successes at the annotated status", () =>
644 Effect.gen(function*() {
645 const client = yield* HttpApiClient.makeWith(AnnotatedStreamingApi, {
646 baseUrl: "http://test",
647 httpClient: clientFromResponse(() =>
648 new Response(textStream(["event: annotated\ndata: ok\n\n"]), { status: 202 })
649 )
650 })
651
652 const stream = yield* client.test.events({})
653 const events = yield* Stream.runCollect(stream)
654 assert.deepStrictEqual(events, [{ event: "annotated", data: "ok" }])
655 }))
656
657 it.effect("decodes StreamUint8Array successes at the annotated status", () =>
658 Effect.gen(function*() {
659 const client = yield* HttpApiClient.makeWith(AnnotatedStreamingApi, {
660 baseUrl: "http://test",
661 httpClient: clientFromResponse(() => new Response(byteStream([new Uint8Array([4, 5])]), { status: 206 }))
662 })
663
664 const stream = yield* client.test.download({})
665 const chunks = yield* Stream.runCollect(stream)
666 assert.deepStrictEqual(chunks.map((chunk) => Array.from(chunk)), [[4, 5]])
667 }))
668
669 it.effect("decodes non-success responses through endpoint error schemas before returning a stream", () =>
670 Effect.gen(function*() {
671 const client = yield* HttpApiClient.makeWith(StreamingApi, {
672 baseUrl: "http://test",
673 httpClient: clientFromResponse(() =>
674 new Response(JSON.stringify({ _tag: "EndpointError", message: "bad request" }), {
675 status: 400,
676 headers: { "content-type": "application/json" }
677 })
678 )
679 })
680
681 const error = yield* Effect.flip(client.test.events({}))
682 assert.deepStrictEqual(error, new EndpointError({ message: "bad request" }))
683 }))
684
685 it.effect("preserves response-only raw response stream access", () =>
686 Effect.gen(function*() {
687 const client = yield* HttpApiClient.makeWith(StreamingApi, {
688 baseUrl: "http://test",
689 httpClient: clientFromResponse(() =>
690 new Response(
691 byteStream([
692 new Uint8Array([1]),
693 new Uint8Array([2, 3])
694 ]),
695 { status: 200 }
696 )
697 )
698 })
699
700 const response = yield* client.test.download({ responseMode: "response-only" })
701 const chunks = yield* Stream.runCollect(response.stream)
702 assert.deepStrictEqual(chunks.map((chunk) => Array.from(chunk)), [[1], [2, 3]])
703 }))
704
705 it.effect("selects a buffered response by content type when a stream uses the same status", () =>
706 Effect.gen(function*() {
707 const client = yield* HttpApiClient.makeWith(MixedSuccessApi, {
708 baseUrl: "http://test",
709 httpClient: clientFromResponse(() =>
710 new Response(JSON.stringify({ message: "done" }), {
711 status: 200,
712 headers: { "content-type": "application/json" }
713 })
714 )
715 })
716
717 const response = yield* client.test.chat({})
718 assert.deepStrictEqual(response, { message: "done" })
719 }))
720
721 it.effect("selects a stream response by content type when buffered success uses the same status", () =>
722 Effect.gen(function*() {
723 const client = yield* HttpApiClient.makeWith(MixedSuccessApi, {
724 baseUrl: "http://test",
725 httpClient: clientFromResponse(() =>
726 new Response(textStream([`event: token\ndata: {"text":"hello"}\n\n`]), {
727 status: 200,
728 headers: { "content-type": "text/event-stream; charset=utf-8" }
729 })
730 )
731 })
732
733 const stream = yield* client.test.chat({})
734 if (!Stream.isStream(stream)) {
735 throw new Error("Expected stream response")
736 }
737 const events = yield* Stream.runCollect(stream)
738 assert.deepStrictEqual(events, [{ text: "hello" }])
739 }))
740 })
741
742 describe("error responses", () => {
743 const makeClient = (response: () => Response) =>
744 HttpApiClient.makeWith(ErrorContentTypeApi, {
745 baseUrl: "http://test",
746 httpClient: clientFromResponse(response)
747 })
748
749 it.effect("selects schemas by normalized content type regardless of declaration order", () =>
750 Effect.gen(function*() {
751 for (const endpoint of ["textFirst", "jsonFirst"] as const) {
752 const jsonClient = yield* makeClient(() =>
753 new Response(JSON.stringify({ _tag: "JsonError", message: "bad request" }), {
754 status: 400,
755 headers: { "content-type": "Application/JSON; charset=utf-8" }
756 })
757 )
758 const jsonError = yield* Effect.flip(jsonClient.test[endpoint]({}))
759 assert.deepStrictEqual(jsonError, { _tag: "JsonError", message: "bad request" })
760
761 const textClient = yield* makeClient(() =>
762 new Response("bad request", {
763 status: 400,
764 headers: { "content-type": "text/plain" }
765 })
766 )
767 const textError = yield* Effect.flip(textClient.test[endpoint]({}))
768 assert.strictEqual(textError, "bad request")
769 }
770 }))
771
772 it.effect("reports unsupported error response content types", () =>
773 Effect.gen(function*() {
774 const client = yield* makeClient(() =>
775 new Response("<error />", {
776 status: 400,
777 headers: { "content-type": "application/xml" }
778 })
779 )
780
781 const exit = yield* Effect.exit(client.test.textFirst({}))
782 assert.strictEqual(exit._tag, "Failure")
783 if (exit._tag === "Failure") {
784 const errors: Array<unknown> = []
785 for (const reason of exit.cause.reasons) {
786 if (Cause.isFailReason(reason)) {
787 errors.push(reason.error)
788 }
789 }
790 assert.ok(
791 errors.some((error) => HttpClientError.isHttpClientError(error) && error.reason._tag === "StatusCodeError")
792 )
793 const decodeError = errors.find((error) =>
794 HttpClientError.isHttpClientError(error) && error.reason._tag === "DecodeError"
795 )
796 assert.ok(HttpClientError.isHttpClientError(decodeError))
797 assert.strictEqual(decodeError.reason._tag, "DecodeError")
798 assert.ok(decodeError.reason.description?.includes("Unsupported response content-type"))
799 }
800 }))
801
802 it.effect("decodes no-content errors without a content-type header", () =>
803 Effect.gen(function*() {
804 const client = yield* makeClient(() => new Response(null, { status: 400 }))
805
806 const error = yield* Effect.flip(client.test.noContent({}))
807 assert.strictEqual(error, "NoContentError")
808 }))
809
810 it.effect("groups schemas by normalized declared content type", () =>
811 Effect.gen(function*() {
812 const client = yield* makeClient(() =>
813 new Response(JSON.stringify({ _tag: "SecondJsonError", message: "bad request" }), {
814 status: 400,
815 headers: { "content-type": "application/problem+json" }
816 })
817 )
818
819 const error = yield* Effect.flip(client.test.equivalentJson({}))
820 assert.deepStrictEqual(error, { _tag: "SecondJsonError", message: "bad request" })
821 }))
822 })
823
824 it.effect("decodes form-urlencoded responses", () =>
825 Effect.gen(function*() {
826 const Api = HttpApi.make("Api").add(
827 HttpApiGroup.make("test").add(
828 HttpApiEndpoint.get("form", "/form", {
829 success: Schema.Struct({ name: Schema.String }).pipe(HttpApiSchema.asFormUrlEncoded())
830 })
831 )
832 )
833 const client = yield* HttpApiClient.makeWith(Api, {
834 baseUrl: "https://example.test",
835 httpClient: clientFromResponse(() =>
836 new Response("name=Ada", {
837 status: 200,
838 headers: { "content-type": "application/x-www-form-urlencoded" }
839 })
840 )
841 })
842
843 const value = yield* client.test.form({})
844
845 assert.deepStrictEqual(value, { name: "Ada" })
846 }))
847
848 describe("response headers", () => {
849 it.effect("fails response decoding when a declared header is invalid", () =>
850 Effect.gen(function*() {
851 const Api = HttpApi.make("Api").add(
852 HttpApiGroup.make("test").add(
853 HttpApiEndpoint.get("created", "/created", {
854 success: HttpApiSchema.WithHeaders(
855 Schema.Struct({ id: Schema.Int }),
856 { "x-count": Schema.Int }
857 )
858 })
859 )
860 )
861 const decodeFailure = Effect.fnUntraced(function*(body: unknown, count: string) {
862 const client = yield* HttpApiClient.makeWith(Api, {
863 baseUrl: "http://test",
864 httpClient: clientFromResponse(() =>
865 new Response(JSON.stringify(body), {
866 status: 200,
867 headers: {
868 "content-type": "application/json",
869 "x-count": count
870 }
871 })
872 )
873 })
874 const exit = yield* Effect.exit(client.test.created({}))
875 assert.strictEqual(exit._tag, "Failure")
876 if (exit._tag === "Success") {
877 throw new Error("Expected response decoding to fail")
878 }
879 return Cause.squash(exit.cause) as { readonly _tag?: string }
880 })
881
882 const bodyError = yield* decodeFailure({ id: "invalid" }, "1")
883 const headerError = yield* decodeFailure({ id: 1 }, "invalid")
884
885 assert.strictEqual(bodyError._tag, "SchemaError")
886 assert.strictEqual(headerError._tag, bodyError._tag)
887 }))
888 })
889
890 describe("urlBuilder", () => {
891 const Api = HttpApi.make("Api")
892 .add(
893 HttpApiGroup.make("users")
894 .add(
895 HttpApiEndpoint.get("getUser", "/users/:id", {
896 params: {
897 id: Schema.Finite
898 },
899 query: {
900 page: Schema.Finite,
901 tags: Schema.Array(Schema.Finite)
902 }
903 }),
904 HttpApiEndpoint.get("health", "/health")
905 )
906 )
907
908 it("builds urls using endpoint schemas", () => {
909 const builder = HttpApiClient.urlBuilder(Api, {
910 baseUrl: "https://api.example.com"
911 })
912
913 strictEqual(
914 builder.users.getUser({
915 params: {
916 id: 123
917 },
918 query: {
919 page: 1,
920 tags: [1, 2]
921 }
922 }),
923 "https://api.example.com/users/123?page=1&tags=1&tags=2"
924 )
925 })
926
927 it("preserves a base URL pathname", () => {
928 const builder = HttpApiClient.urlBuilder(Api, {
929 baseUrl: "https://api.example.com/v1"
930 })
931
932 strictEqual(builder.users.health(), "https://api.example.com/v1/health")
933 })
934
935 it("encodes path parameters", () => {
936 const Api = HttpApi.make("Api")
937 .add(
938 HttpApiGroup.make("stacks")
939 .add(
940 HttpApiEndpoint.get("listResources", "/state/stacks/:stack/stages/:stage/resources", {
941 params: {
942 stack: Schema.String,
943 stage: Schema.String
944 }
945 })
946 )
947 )
948 const builder = HttpApiClient.urlBuilder(Api, {
949 baseUrl: "https://api.example.com"
950 })
951
952 strictEqual(
953 builder.stacks.listResources({
954 params: {
955 stack: "a/b",
956 stage: "prod/blue"
957 }
958 }),
959 "https://api.example.com/state/stacks/a%2Fb/stages/prod%2Fblue/resources"
960 )
961 })
962
963 it("omits missing optional path parameters", () => {
964 const Api = HttpApi.make("Api")
965 .add(
966 HttpApiGroup.make("files")
967 .add(
968 HttpApiEndpoint.get("download", "/files/:path?", {
969 params: {
970 path: Schema.optional(Schema.String)
971 }
972 })
973 )
974 )
975 const builder = HttpApiClient.urlBuilder(Api, {
976 baseUrl: "https://api.example.com"
977 })
978
979 strictEqual(
980 builder.files.download({ params: {} }),
981 "https://api.example.com/files"
982 )
983 strictEqual(
984 builder.files.download({ params: { path: "a/b" } }),
985 "https://api.example.com/files/a%2Fb"
986 )
987 })
988
989 it("returns relative urls when baseUrl is omitted", () => {
990 const builder = HttpApiClient.urlBuilder(Api)
991
992 strictEqual(builder.users.health(), "/health")
993 })
994
995 it("supports top-level endpoints", () => {
996 const TopLevelApi = HttpApi.make("Api")
997 .add(
998 HttpApiGroup.make("top", { topLevel: true })
999 .add(
1000 HttpApiEndpoint.get("health", "/health")
1001 )
1002 )
1003 .prefix("/v1")
1004
1005 const builder = HttpApiClient.urlBuilder(TopLevelApi, {
1006 baseUrl: "https://api.example.com"
1007 })
1008
1009 strictEqual(builder.health(), "https://api.example.com/v1/health")
1010 })
1011
1012 it("stores __proto__ identifiers as own properties", () => {
1013 const Api = HttpApi.make("Api").add(
1014 HttpApiGroup.make("__proto__").add(
1015 HttpApiEndpoint.get("__proto__", "/proto")
1016 )
1017 )
1018 const builder = HttpApiClient.urlBuilder(Api)
1019
1020 assert.isTrue(Object.hasOwn(builder, "__proto__"))
1021 assert.isTrue(Object.hasOwn(builder["__proto__"], "__proto__"))
1022 strictEqual(builder["__proto__"]["__proto__"](), "/proto")
1023 })
1024 })
1025
1026 it.effect("stores __proto__ client identifiers as own properties", () =>
1027 Effect.gen(function*() {
1028 const Api = HttpApi.make("Api").add(
1029 HttpApiGroup.make("__proto__").add(
1030 HttpApiEndpoint.get("__proto__", "/proto")
1031 )
1032 )
1033 const httpClient = clientFromResponse(() => new Response(null, { status: 204 }))
1034 const client = yield* HttpApiClient.makeWith(Api, { httpClient })
1035 const groupClient = yield* HttpApiClient.group(Api, {
1036 group: "__proto__",
1037 httpClient
1038 })
1039
1040 assert.isTrue(Object.hasOwn(client, "__proto__"))
1041 assert.isTrue(Object.hasOwn(client["__proto__"], "__proto__"))
1042 assert.strictEqual(typeof client["__proto__"]["__proto__"], "function")
1043 assert.isTrue(Object.hasOwn(groupClient, "__proto__"))
1044 assert.strictEqual(typeof groupClient["__proto__"], "function")
1045 }))
1046
1047 it.effect("applies transformClient to endpoint clients exactly once", () =>
1048 Effect.gen(function*() {
1049 const Api = HttpApi.make("Api").add(
1050 HttpApiGroup.make("test").add(HttpApiEndpoint.get("health", "/health"))
1051 )
1052 let transformations = 0
1053 const httpClient = HttpClient.make((request, url) =>
1054 Effect.sync(() => {
1055 strictEqual(url.toString(), "https://api.example.com/health")
1056 return HttpClientResponse.fromWeb(request, new Response(null, { status: 204 }))
1057 })
1058 )
1059 const health = yield* HttpApiClient.endpoint(Api, {
1060 group: "test",
1061 endpoint: "health",
1062 httpClient,
1063 transformClient: (client) => {
1064 transformations++
1065 return client.pipe(
1066 HttpClient.mapRequest(HttpClientRequest.prependUrl("https://api.example.com"))
1067 )
1068 }
1069 })
1070
1071 yield* health({ responseMode: "response-only" })
1072 yield* health({ responseMode: "response-only" })
1073
1074 strictEqual(transformations, 1)
1075 }))
1076
1077 it.effect("encodes path parameters when executing requests", () =>
1078 Effect.gen(function*() {
1079 const Api = HttpApi.make("Api")
1080 .add(
1081 HttpApiGroup.make("stacks")
1082 .add(
1083 HttpApiEndpoint.get("listResources", "/state/stacks/:stack/stages/:stage/resources", {
1084 params: {
1085 stack: Schema.String,
1086 stage: Schema.String
1087 }
1088 })
1089 )
1090 )
1091 const httpClient = HttpClient.make((request, url) =>
1092 Effect.sync(() => {
1093 strictEqual(url.toString(), "https://api.example.com/state/stacks/a%2Fb/stages/prod%2Fblue/resources")
1094 return HttpClientResponse.fromWeb(request, new Response(null, { status: 204 }))
1095 })
1096 )
1097 const client = yield* HttpApiClient.makeWith(Api, {
1098 httpClient,
1099 baseUrl: "https://api.example.com"
1100 })
1101
1102 yield* client.stacks.listResources({
1103 params: {
1104 stack: "a/b",
1105 stage: "prod/blue"
1106 },
1107 responseMode: "response-only"
1108 })
1109 }))
1110
1111 it.effect("omits optional path parameters when executing requests", () =>
1112 Effect.gen(function*() {
1113 const Api = HttpApi.make("Api")
1114 .add(
1115 HttpApiGroup.make("files")
1116 .add(
1117 HttpApiEndpoint.get("download", "/files/:path?", {
1118 params: {
1119 path: Schema.optional(Schema.String)
1120 }
1121 })
1122 )
1123 )
1124 const urls: Array<string> = []
1125 const httpClient = HttpClient.make((request, url) =>
1126 Effect.sync(() => {
1127 urls.push(url.toString())
1128 return HttpClientResponse.fromWeb(request, new Response(null, { status: 204 }))
1129 })
1130 )
1131 const client = yield* HttpApiClient.makeWith(Api, {
1132 httpClient,
1133 baseUrl: "https://api.example.com"
1134 })
1135
1136 yield* client.files.download({
1137 params: {},
1138 responseMode: "response-only"
1139 })
1140 yield* client.files.download({
1141 params: { path: "a/b" },
1142 responseMode: "response-only"
1143 })
1144
1145 strictEqual(urls[0], "https://api.example.com/files")
1146 strictEqual(urls[1], "https://api.example.com/files/a%2Fb")
1147 }))
1148})
1149
1150const textEncoder = new TextEncoder()
1151
1152const StreamError = Schema.Struct({ reason: Schema.String })
1153
1154const Events = Schema.Struct({
1155 event: Schema.String,
1156 data: Schema.String
1157})
1158
1159class EndpointError extends Schema.TaggedError<EndpointError>()("EndpointError", {
1160 message: Schema.String
1161}, { httpApiStatus: 400 }) {}
1162
1163const MixedSuccess = Schema.Struct({
1164 message: Schema.String
1165})
1166
1167const MixedEventData = Schema.Struct({
1168 text: Schema.String
1169})
1170
1171const StreamingApi = HttpApi.make("StreamingApi").add(
1172 HttpApiGroup.make("test")
1173 .add(
1174 HttpApiEndpoint.get("events", "/events", {
1175 success: HttpApiSchema.StreamSse({ events: Events, error: StreamError }),
1176 error: EndpointError
1177 }),
1178 HttpApiEndpoint.get("download", "/download", {
1179 success: HttpApiSchema.StreamUint8Array(),
1180 error: EndpointError
1181 })
1182 )
1183)
1184
1185const AnnotatedStreamingApi = HttpApi.make("AnnotatedStreamingApi").add(
1186 HttpApiGroup.make("test")
1187 .add(
1188 HttpApiEndpoint.get("events", "/events", {
1189 success: HttpApiSchema.status(202)(HttpApiSchema.StreamSse({ events: Events, error: StreamError }))
1190 }),
1191 HttpApiEndpoint.get("download", "/download", {
1192 success: HttpApiSchema.status(206)(HttpApiSchema.StreamUint8Array())
1193 })
1194 )
1195)
1196
1197const MixedSuccessApi = HttpApi.make("MixedSuccessApi").add(
1198 HttpApiGroup.make("test")
1199 .add(
1200 HttpApiEndpoint.get("chat", "/chat", {
1201 success: [
1202 MixedSuccess,
1203 HttpApiSchema.StreamSse({ data: MixedEventData, error: StreamError })
1204 ]
1205 })
1206 )
1207)
1208
1209const JsonResponseError = Schema.Struct({
1210 _tag: Schema.Literal("JsonError"),
1211 message: Schema.String
1212}).pipe(HttpApiSchema.status(400))
1213
1214const TextResponseError = Schema.String.pipe(
1215 HttpApiSchema.asText(),
1216 HttpApiSchema.status(400)
1217)
1218
1219const NoContentResponseError = Schema.Literal("NoContentError").pipe(
1220 HttpApiSchema.asNoContent({ decode: () => "NoContentError" as const }),
1221 HttpApiSchema.status(400)
1222)
1223
1224const FirstJsonResponseError = Schema.Struct({
1225 _tag: Schema.Literal("FirstJsonError"),
1226 code: Schema.Number
1227}).pipe(
1228 HttpApiSchema.asJson({ contentType: "Application/Problem+JSON" }),
1229 HttpApiSchema.status(400)
1230)
1231
1232const SecondJsonResponseError = Schema.Struct({
1233 _tag: Schema.Literal("SecondJsonError"),
1234 message: Schema.String
1235}).pipe(
1236 HttpApiSchema.asJson({ contentType: "application/problem+json; charset=utf-8" }),
1237 HttpApiSchema.status(400)
1238)
1239
1240const ErrorContentTypeApi = HttpApi.make("ErrorContentTypeApi").add(
1241 HttpApiGroup.make("test")
1242 .add(
1243 HttpApiEndpoint.get("textFirst", "/text-first", {
1244 error: [TextResponseError, JsonResponseError]
1245 }),
1246 HttpApiEndpoint.get("jsonFirst", "/json-first", {
1247 error: [JsonResponseError, TextResponseError]
1248 }),
1249 HttpApiEndpoint.get("noContent", "/no-content", {
1250 error: [NoContentResponseError, TextResponseError]
1251 }),
1252 HttpApiEndpoint.get("equivalentJson", "/equivalent-json", {
1253 error: [FirstJsonResponseError, SecondJsonResponseError]
1254 })
1255 )
1256)
1257
1258const clientFromResponse = (response: () => Response): HttpClient.HttpClient =>
1259 HttpClient.make((request): Effect.Effect<HttpClientResponse.HttpClientResponse, never, never> =>
1260 Effect.succeed(HttpClientResponse.fromWeb(request, response()))
1261 )
1262
1263const textStream = (chunks: ReadonlyArray<string>): ReadableStream<Uint8Array> => {
1264 let index = 0
1265 return new ReadableStream<Uint8Array>({
1266 pull(controller) {
1267 if (index === chunks.length) {
1268 controller.close()
1269 } else {
1270 controller.enqueue(textEncoder.encode(chunks[index++]!))
1271 }
1272 }
1273 })
1274}
1275
1276const byteStream = (chunks: ReadonlyArray<Uint8Array>): ReadableStream<Uint8Array> => {
1277 let index = 0
1278 return new ReadableStream<Uint8Array>({
1279 pull(controller) {
1280 if (index === chunks.length) {
1281 controller.close()
1282 } else {
1283 controller.enqueue(chunks[index++]!)
1284 }
1285 }
1286 })
1287}