| 1 | /** |
| 2 | * Common model for incoming HTTP messages. |
| 3 | * |
| 4 | * `HttpIncomingMessage` is used by server requests and client responses to |
| 5 | * expose headers, an optional remote address, and body accessors. This module |
| 6 | * provides decoders for JSON bodies, URL-encoded bodies, and headers, plus the |
| 7 | * shared body-size setting and inspection helper used by request and response |
| 8 | * implementations. |
| 9 | * |
| 10 | * @stability unstable |
| 11 | * @since 4.0.0 |
| 12 | */ |
| 13 | import type * as ByteSize from "../ByteSize.ts" |
| 14 | import * as Context from "../Context.ts" |
| 15 | import * as Effect from "../Effect.ts" |
| 16 | import type * as Inspectable from "../Inspectable.ts" |
| 17 | import type * as Option from "../Option.ts" |
| 18 | import { hasProperty } from "../Predicate.ts" |
| 19 | import { redact } from "../Redactable.ts" |
| 20 | import * as Schema from "../Schema.ts" |
| 21 | import type { ParseOptions } from "../SchemaAST.ts" |
| 22 | import type * as Stream from "../Stream.ts" |
| 23 | import type * as Headers from "./Headers.ts" |
| 24 | import type * as UrlParams from "./UrlParams.ts" |
| 25 | |
| 26 | /** |
| 27 | * Type identifier for `HttpIncomingMessage` values. |
| 28 | * |
| 29 | * @stability unstable |
| 30 | * @category type IDs |
| 31 | * @since 4.0.0 |
| 32 | */ |
| 33 | export const TypeId = "~effect/http/HttpIncomingMessage" |
| 34 | |
| 35 | /** |
| 36 | * Returns `true` when a value is an `HttpIncomingMessage`. |
| 37 | * |
| 38 | * @stability unstable |
| 39 | * @category guards |
| 40 | * @since 4.0.0 |
| 41 | */ |
| 42 | export const isHttpIncomingMessage = (u: unknown): u is HttpIncomingMessage => hasProperty(u, TypeId) |
| 43 | |
| 44 | /** |
| 45 | * Options for parsing an incoming HTTP message body as JSON. |
| 46 | * |
| 47 | * @stability unstable |
| 48 | * @category models |
| 49 | * @since 4.0.0 |
| 50 | */ |
| 51 | export interface JsonOptions { |
| 52 | readonly reviver?: Parameters<typeof JSON.parse>[1] | undefined |
| 53 | } |
| 54 | |
| 55 | /** |
| 56 | * Common model for incoming HTTP messages, with headers, remote address, and effectful body accessors. |
| 57 | * |
| 58 | * @stability unstable |
| 59 | * @category models |
| 60 | * @since 4.0.0 |
| 61 | */ |
| 62 | export interface HttpIncomingMessage<E = unknown> extends Inspectable.Inspectable { |
| 63 | readonly [TypeId]: typeof TypeId |
| 64 | readonly headers: Headers.Headers |
| 65 | readonly remoteAddress: Option.Option<string> |
| 66 | readonly json: Effect.Effect<Schema.Json, E> |
| 67 | readonly text: Effect.Effect<string, E> |
| 68 | readonly urlParamsBody: Effect.Effect<UrlParams.UrlParams, E> |
| 69 | readonly arrayBuffer: Effect.Effect<ArrayBuffer, E> |
| 70 | readonly stream: Stream.Stream<Uint8Array, E> |
| 71 | } |
| 72 | |
| 73 | /** |
| 74 | * Creates a decoder that reads an incoming message's JSON body and decodes it with the supplied schema. |
| 75 | * |
| 76 | * @stability unstable |
| 77 | * @category schemas |
| 78 | * @since 4.0.0 |
| 79 | */ |
| 80 | export const schemaBodyJson = <S extends Schema.Constraint>( |
| 81 | schema: S, |
| 82 | options?: (ParseOptions & JsonOptions) | undefined |
| 83 | ) => { |
| 84 | const decode = Schema.decodeEffect(Schema.toCodecJson(schema)) |
| 85 | const decodeJson = options?.reviver === undefined |
| 86 | ? undefined |
| 87 | : Schema.decodeEffect(Schema.fromJsonString(Schema.toCodecJson(schema), options)) |
| 88 | return <E>( |
| 89 | self: HttpIncomingMessage<E> |
| 90 | ): Effect.Effect<S["Type"], E | Schema.SchemaError, S["DecodingServices"]> => |
| 91 | decodeJson === undefined |
| 92 | ? Effect.flatMap(self.json, (u) => decode(u, options)) |
| 93 | : Effect.flatMap(self.text, (body) => |
| 94 | body === "" ? Effect.flatMap(self.json, (u) => decode(u, options)) : decodeJson(body, options)) |
| 95 | } |
| 96 | |
| 97 | /** |
| 98 | * Creates a decoder that reads an incoming message's URL-encoded body parameters and decodes them with the supplied schema. |
| 99 | * |
| 100 | * @stability unstable |
| 101 | * @category schemas |
| 102 | * @since 4.0.0 |
| 103 | */ |
| 104 | export const schemaBodyUrlParams = < |
| 105 | A, |
| 106 | I extends Readonly<Record<string, string | ReadonlyArray<string> | undefined>>, |
| 107 | RD |
| 108 | >( |
| 109 | schema: Schema.ConstraintCodec<A, I, RD, unknown>, |
| 110 | options?: ParseOptions | undefined |
| 111 | ) => { |
| 112 | const decode = Schema.RecordFromUrlParams.pipe( |
| 113 | Schema.decodeTo(schema), |
| 114 | Schema.decodeEffect |
| 115 | ) |
| 116 | return <E>(self: HttpIncomingMessage<E>): Effect.Effect<A, E | Schema.SchemaError, RD> => |
| 117 | Effect.flatMap(self.urlParamsBody, (u) => decode(u, options)) |
| 118 | } |
| 119 | |
| 120 | /** |
| 121 | * Creates a decoder that validates and decodes an incoming message's headers with the supplied schema. |
| 122 | * |
| 123 | * @stability unstable |
| 124 | * @category schemas |
| 125 | * @since 4.0.0 |
| 126 | */ |
| 127 | export const schemaHeaders = <A, I extends Readonly<Record<string, string | undefined>>, RD>( |
| 128 | schema: Schema.ConstraintCodec<A, I, RD, unknown>, |
| 129 | options?: ParseOptions | undefined |
| 130 | ) => { |
| 131 | const decode = Schema.decodeUnknownEffect(schema) |
| 132 | return <E>(self: HttpIncomingMessage<E>): Effect.Effect<A, Schema.SchemaError, RD> => decode(self.headers, options) |
| 133 | } |
| 134 | |
| 135 | /** |
| 136 | * Context reference for the optional maximum size allowed when reading an incoming message body. |
| 137 | * |
| 138 | * @stability unstable |
| 139 | * @category references |
| 140 | * @since 4.0.0 |
| 141 | */ |
| 142 | export const MaxBodySize = Context.Reference<ByteSize.ByteSize | undefined>( |
| 143 | "effect/http/HttpIncomingMessage/MaxBodySize", |
| 144 | { defaultValue: () => undefined } |
| 145 | ) |
| 146 | |
| 147 | /** |
| 148 | * Builds an inspectable object for an incoming message, redacting headers and including a synchronously readable JSON or text body when available. |
| 149 | * |
| 150 | * @stability unstable |
| 151 | * @category converting |
| 152 | * @since 4.0.0 |
| 153 | */ |
| 154 | export const inspect = <E>(self: HttpIncomingMessage<E>, that: object): object => { |
| 155 | const contentType = self.headers["content-type"] ?? "" |
| 156 | let body: unknown |
| 157 | if (contentType.includes("application/json")) { |
| 158 | try { |
| 159 | body = Effect.runSync(self.json) |
| 160 | // oxlint-disable-next-line @typescript-eslint/no-unused-vars |
| 161 | } catch (_) { |
| 162 | // |
| 163 | } |
| 164 | } else if (contentType.includes("text/") || contentType.includes("urlencoded")) { |
| 165 | try { |
| 166 | body = Effect.runSync(self.text) |
| 167 | // oxlint-disable-next-line @typescript-eslint/no-unused-vars |
| 168 | } catch (_) { |
| 169 | // |
| 170 | } |
| 171 | } |
| 172 | const obj: any = { |
| 173 | ...that, |
| 174 | headers: redact(self.headers), |
| 175 | remoteAddress: self.remoteAddress |
| 176 | } |
| 177 | if (body !== undefined) { |
| 178 | obj.body = body |
| 179 | } |
| 180 | return obj |
| 181 | } |