| 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 | */ |
| 13 | import * as Effect from "../Effect.ts" |
| 14 | import { dual } from "../Function.ts" |
| 15 | import * as Inspectable from "../Inspectable.ts" |
| 16 | import * as Option from "../Option.ts" |
| 17 | import { type Pipeable, pipeArguments } from "../Pipeable.ts" |
| 18 | import * as Schema from "../Schema.ts" |
| 19 | import type { ParseOptions } from "../SchemaAST.ts" |
| 20 | import * as Stream from "../Stream.ts" |
| 21 | import type { Unify } from "../Unify.ts" |
| 22 | import * as Cookies from "./Cookies.ts" |
| 23 | import * as Headers from "./Headers.ts" |
| 24 | import * as Error from "./HttpClientError.ts" |
| 25 | import * as HttpClientRequest from "./HttpClientRequest.ts" |
| 26 | import * as HttpIncomingMessage from "./HttpIncomingMessage.ts" |
| 27 | import * as UrlParams from "./UrlParams.ts" |
| 28 | |
| 29 | /** |
| 30 | * @stability unstable |
| 31 | */ |
| 32 | export { |
| 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 | */ |
| 66 | export 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 | */ |
| 75 | export 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 | */ |
| 95 | export 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 | */ |
| 105 | export 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 | */ |
| 137 | export 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 | */ |
| 164 | export 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 | */ |
| 175 | export 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 | */ |
| 228 | export 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 | */ |
| 256 | export 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 | |
| 271 | class 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 | } |