1/**
2 * Represents responses returned by the Effect HTTP client.
3 *
4 * An `HttpClientResponse` keeps the original request together with the response
5 * status, headers, cookies, and body accessors from the shared incoming-message
6 * model. This module includes constructors, schema-based decoders, helpers for
7 * streaming response bodies, and utilities for matching or filtering by HTTP
8 * status.
9 *
10 * @stability unstable
11 * @since 4.0.0
12 */
13import * as Effect from "../Effect.ts"
14import { dual } from "../Function.ts"
15import * as Inspectable from "../Inspectable.ts"
16import * as Option from "../Option.ts"
17import { type Pipeable, pipeArguments } from "../Pipeable.ts"
18import * as Schema from "../Schema.ts"
19import type { ParseOptions } from "../SchemaAST.ts"
20import * as Stream from "../Stream.ts"
21import type { Unify } from "../Unify.ts"
22import * as Cookies from "./Cookies.ts"
23import * as Headers from "./Headers.ts"
24import * as Error from "./HttpClientError.ts"
25import * as HttpClientRequest from "./HttpClientRequest.ts"
26import * as HttpIncomingMessage from "./HttpIncomingMessage.ts"
27import * as UrlParams from "./UrlParams.ts"
28
29/**
30 * @stability unstable
31 */
32export {
33 /**
34 * Creates a decoder that reads a response JSON body and decodes it with the supplied schema.
35 *
36 * @stability unstable
37 * @category schemas
38 * @since 4.0.0
39 */
40 schemaBodyJson,
41 /**
42 * Creates a decoder that reads response URL-encoded body parameters and decodes them with the supplied schema.
43 *
44 * @stability unstable
45 * @category schemas
46 * @since 4.0.0
47 */
48 schemaBodyUrlParams,
49 /**
50 * Creates a decoder that validates and decodes response headers with the supplied schema.
51 *
52 * @stability unstable
53 * @category schemas
54 * @since 4.0.0
55 */
56 schemaHeaders
57} from "./HttpIncomingMessage.ts"
58
59/**
60 * Type identifier for `HttpClientResponse` values.
61 *
62 * @stability unstable
63 * @category type IDs
64 * @since 4.0.0
65 */
66export const TypeId = "~effect/http/HttpClientResponse"
67
68/**
69 * Model of an HTTP client response, including the original request, status, cookies, headers, and body accessors.
70 *
71 * @stability unstable
72 * @category models
73 * @since 4.0.0
74 */
75export interface HttpClientResponse extends HttpIncomingMessage.HttpIncomingMessage<Error.HttpClientError>, Pipeable {
76 readonly [TypeId]: typeof TypeId
77 readonly request: HttpClientRequest.HttpClientRequest
78 /**
79 * The resolved URL, including query parameters and excluding the hash.
80 * Uses the final URL when redirects are followed. Empty if unknown.
81 */
82 readonly url: string
83 readonly status: number
84 readonly cookies: Cookies.Cookies
85 readonly formData: Effect.Effect<FormData, Error.HttpClientError>
86}
87
88/**
89 * Wraps a Web `Response` and its original `HttpClientRequest` as an `HttpClientResponse`.
90 *
91 * @stability unstable
92 * @category constructors
93 * @since 4.0.0
94 */
95export const fromWeb = (request: HttpClientRequest.HttpClientRequest, source: Response): HttpClientResponse =>
96 new WebHttpClientResponse(request, source)
97
98/**
99 * Creates a decoder for a response's status, headers, and JSON body using the supplied schema.
100 *
101 * @stability unstable
102 * @category schemas
103 * @since 4.0.0
104 */
105export const schemaJson = <
106 A,
107 I extends {
108 readonly status?: number | undefined
109 readonly headers?: Readonly<Record<string, string | undefined>> | undefined
110 readonly body?: unknown
111 },
112 RD
113>(
114 schema: Schema.ConstraintCodec<A, I, RD, unknown>,
115 options?: (ParseOptions & HttpIncomingMessage.JsonOptions) | undefined
116) => {
117 const decode = Schema.decodeEffect(Schema.toCodecJson(schema), options)
118 const decodeBody = HttpIncomingMessage.schemaBodyJson(Schema.Unknown, options)
119 return (
120 self: HttpClientResponse
121 ): Effect.Effect<A, Schema.SchemaError | Error.HttpClientError, RD> =>
122 Effect.flatMap(decodeBody(self), (body) =>
123 decode({
124 status: self.status,
125 headers: self.headers,
126 body: body as Schema.Json
127 }))
128}
129
130/**
131 * Creates a decoder for a response's status and headers without reading a response body.
132 *
133 * @stability unstable
134 * @category schemas
135 * @since 4.0.0
136 */
137export const schemaNoBody = <
138 A,
139 I extends {
140 readonly status?: number | undefined
141 readonly headers?: Readonly<Record<string, string>> | undefined
142 },
143 RD,
144 RE
145>(
146 schema: Schema.Codec<A, I, RD, RE>,
147 options?: ParseOptions | undefined
148) => {
149 const decode = Schema.decodeEffect(schema, options)
150 return (self: HttpClientResponse): Effect.Effect<A, Schema.SchemaError, RD> =>
151 decode({
152 status: self.status,
153 headers: self.headers
154 } as any as I)
155}
156
157/**
158 * Converts an effect producing an `HttpClientResponse` into a stream of response body bytes.
159 *
160 * @stability unstable
161 * @category accessors
162 * @since 4.0.0
163 */
164export const stream = <E, R>(
165 effect: Effect.Effect<HttpClientResponse, E, R>
166): Stream.Stream<Uint8Array, Error.HttpClientError | E, R> => Stream.unwrap(Effect.map(effect, (self) => self.stream))
167
168/**
169 * Pattern matches on a response status, checking exact status handlers before status-class handlers and `orElse`.
170 *
171 * @stability unstable
172 * @category pattern matching
173 * @since 4.0.0
174 */
175export const matchStatus: {
176 <
177 const Cases extends {
178 readonly [status: number]: (_: HttpClientResponse) => any
179 readonly "2xx"?: (_: HttpClientResponse) => any
180 readonly "3xx"?: (_: HttpClientResponse) => any
181 readonly "4xx"?: (_: HttpClientResponse) => any
182 readonly "5xx"?: (_: HttpClientResponse) => any
183 readonly orElse: (_: HttpClientResponse) => any
184 }
185 >(cases: Cases): (self: HttpClientResponse) => Cases[keyof Cases] extends (_: any) => infer R ? Unify<R> : never
186 <
187 const Cases extends {
188 readonly [status: number]: (_: HttpClientResponse) => any
189 readonly "2xx"?: (_: HttpClientResponse) => any
190 readonly "3xx"?: (_: HttpClientResponse) => any
191 readonly "4xx"?: (_: HttpClientResponse) => any
192 readonly "5xx"?: (_: HttpClientResponse) => any
193 readonly orElse: (_: HttpClientResponse) => any
194 }
195 >(self: HttpClientResponse, cases: Cases): Cases[keyof Cases] extends (_: any) => infer R ? Unify<R> : never
196} = dual(2, <
197 const Cases extends {
198 readonly [status: number]: (_: HttpClientResponse) => any
199 readonly "2xx"?: (_: HttpClientResponse) => any
200 readonly "3xx"?: (_: HttpClientResponse) => any
201 readonly "4xx"?: (_: HttpClientResponse) => any
202 readonly "5xx"?: (_: HttpClientResponse) => any
203 readonly orElse: (_: HttpClientResponse) => any
204 }
205>(self: HttpClientResponse, cases: Cases) => {
206 const status = self.status
207 if (cases[status]) {
208 return cases[status](self)
209 } else if (status >= 200 && status < 300 && cases["2xx"]) {
210 return cases["2xx"](self)
211 } else if (status >= 300 && status < 400 && cases["3xx"]) {
212 return cases["3xx"](self)
213 } else if (status >= 400 && status < 500 && cases["4xx"]) {
214 return cases["4xx"](self)
215 } else if (status >= 500 && status < 600 && cases["5xx"]) {
216 return cases["5xx"](self)
217 }
218 return cases.orElse(self)
219})
220
221/**
222 * Succeeds with the response when its status satisfies the predicate, otherwise fails with `HttpClientError`.
223 *
224 * @stability unstable
225 * @category filtering
226 * @since 4.0.0
227 */
228export const filterStatus: {
229 (
230 f: (status: number) => boolean
231 ): (self: HttpClientResponse) => Effect.Effect<HttpClientResponse, Error.HttpClientError>
232 (self: HttpClientResponse, f: (status: number) => boolean): Effect.Effect<HttpClientResponse, Error.HttpClientError>
233} = dual(
234 2,
235 (self: HttpClientResponse, f: (status: number) => boolean) =>
236 Effect.suspend(() =>
237 f(self.status) ? Effect.succeed(self) : Effect.fail(
238 new Error.HttpClientError({
239 reason: new Error.StatusCodeError({
240 response: self,
241 request: self.request,
242 description: "invalid status code"
243 })
244 })
245 )
246 )
247)
248
249/**
250 * Succeeds with the response only when its status is in the 2xx range, otherwise fails with `HttpClientError`.
251 *
252 * @stability unstable
253 * @category filtering
254 * @since 4.0.0
255 */
256export const filterStatusOk = (self: HttpClientResponse): Effect.Effect<HttpClientResponse, Error.HttpClientError> =>
257 self.status >= 200 && self.status < 300 ? Effect.succeed(self) : Effect.fail(
258 new Error.HttpClientError({
259 reason: new Error.StatusCodeError({
260 response: self,
261 request: self.request,
262 description: "non 2xx status code"
263 })
264 })
265 )
266
267// -----------------------------------------------------------------------------
268// internal
269// -----------------------------------------------------------------------------
270
271class WebHttpClientResponse extends Inspectable.Class implements HttpClientResponse, Pipeable {
272 readonly [HttpIncomingMessage.TypeId]: typeof HttpIncomingMessage.TypeId
273 readonly [TypeId]: typeof TypeId
274
275 readonly request: HttpClientRequest.HttpClientRequest
276 private readonly source: globalThis.Response
277
278 constructor(
279 request: HttpClientRequest.HttpClientRequest,
280 source: globalThis.Response
281 ) {
282 super()
283 this.request = request
284 this.source = source
285 this[HttpIncomingMessage.TypeId] = HttpIncomingMessage.TypeId
286 this[TypeId] = TypeId
287 }
288
289 toJSON(): unknown {
290 return HttpIncomingMessage.inspect(this, {
291 _id: "HttpClientResponse",
292 request: this.request.toJSON(),
293 status: this.status
294 })
295 }
296
297 get status(): number {
298 return this.source.status
299 }
300
301 get url(): string {
302 if (this.source.url) return this.source.url.split("#")[0]
303 const url = HttpClientRequest.toUrl(this.request)
304 if (Option.isNone(url)) return ""
305 url.value.hash = ""
306 return url.value.href
307 }
308
309 get headers(): Headers.Headers {
310 return Headers.fromInput(this.source.headers)
311 }
312
313 cachedCookies?: Cookies.Cookies
314 get cookies(): Cookies.Cookies {
315 if (this.cachedCookies) {
316 return this.cachedCookies
317 }
318 return this.cachedCookies = Cookies.fromSetCookie(this.source.headers.getSetCookie())
319 }
320
321 get remoteAddress(): Option.Option<string> {
322 return Option.none()
323 }
324
325 get stream(): Stream.Stream<Uint8Array, Error.HttpClientError> {
326 return this.source.body
327 ? Stream.fromReadableStream({
328 evaluate: () => this.source.body!,
329 onError: (cause) =>
330 new Error.HttpClientError({
331 reason: new Error.DecodeError({
332 request: this.request,
333 response: this,
334 cause
335 })
336 })
337 })
338 : Stream.fail(
339 new Error.HttpClientError({
340 reason: new Error.EmptyBodyError({
341 request: this.request,
342 response: this,
343 description: "can not create stream from empty body"
344 })
345 })
346 )
347 }
348
349 get json(): Effect.Effect<Schema.Json, Error.HttpClientError> {
350 return Effect.flatMap(this.text, (text) =>
351 Effect.try({
352 try: () => text === "" ? null : JSON.parse(text),
353 catch: (cause) =>
354 new Error.HttpClientError({
355 reason: new Error.DecodeError({
356 request: this.request,
357 response: this,
358 cause
359 })
360 })
361 }))
362 }
363
364 private textBody?: Effect.Effect<string, Error.HttpClientError>
365 get text(): Effect.Effect<string, Error.HttpClientError> {
366 return this.textBody ??= Effect.map(this.arrayBuffer, (_) => new TextDecoder().decode(_))
367 }
368
369 get urlParamsBody(): Effect.Effect<UrlParams.UrlParams, Error.HttpClientError> {
370 return Effect.flatMap(this.text, (_) =>
371 Effect.try({
372 try: () => UrlParams.fromInput(new URLSearchParams(_)),
373 catch: (cause) =>
374 new Error.HttpClientError({
375 reason: new Error.DecodeError({
376 request: this.request,
377 response: this,
378 cause
379 })
380 })
381 }))
382 }
383
384 private formDataBody?: Effect.Effect<FormData, Error.HttpClientError>
385 get formData(): Effect.Effect<FormData, Error.HttpClientError> {
386 return this.formDataBody ??= Effect.tryPromise({
387 try: () => this.source.formData(),
388 catch: (cause) =>
389 new Error.HttpClientError({
390 reason: new Error.DecodeError({
391 request: this.request,
392 response: this,
393 cause
394 })
395 })
396 }).pipe(Effect.cached, Effect.runSync)
397 }
398
399 private arrayBufferBody?: Effect.Effect<ArrayBuffer, Error.HttpClientError>
400 get arrayBuffer(): Effect.Effect<ArrayBuffer, Error.HttpClientError> {
401 if (this.arrayBufferBody) {
402 return this.arrayBufferBody
403 }
404 this.arrayBufferBody = Effect.tryPromise({
405 try: () => this.source.arrayBuffer(),
406 catch: (cause) =>
407 new Error.HttpClientError({
408 reason: new Error.DecodeError({
409 request: this.request,
410 response: this,
411 cause
412 })
413 })
414 }).pipe(Effect.cached, Effect.runSync)
415 return this.arrayBufferBody
416 }
417
418 pipe() {
419 return pipeArguments(this, arguments)
420 }
421}