| 1 | /** |
| 2 | * Attaches HTTP API metadata to Effect Schema values. |
| 3 | * |
| 4 | * This module is the schema-side bridge for HttpApi endpoint builders, |
| 5 | * generated clients, and OpenAPI support. It does not define routes or perform |
| 6 | * IO. Instead, the helpers annotate schemas so the surrounding HTTP API tooling |
| 7 | * can choose response status codes, content types, body codecs, multipart |
| 8 | * handling, and no-body response behavior. |
| 9 | * |
| 10 | * @stability unstable |
| 11 | * @since 4.0.0 |
| 12 | */ |
| 13 | import * as Sse from "../encoding/Sse.ts" |
| 14 | import { constVoid, type LazyArg } from "../Function.ts" |
| 15 | import { hasBody, type HttpMethod } from "../http/HttpMethod.ts" |
| 16 | import * as HttpStatus from "../http/HttpStatus.ts" |
| 17 | import type * as Multipart_ from "../http/Multipart.ts" |
| 18 | import * as Predicate from "../Predicate.ts" |
| 19 | import * as Schema from "../Schema.ts" |
| 20 | import * as SchemaAST from "../SchemaAST.ts" |
| 21 | import * as SchemaTransformation from "../SchemaTransformation.ts" |
| 22 | import * as Stream from "../Stream.ts" |
| 23 | |
| 24 | declare module "../Schema.ts" { |
| 25 | namespace Annotations { |
| 26 | interface Augment { |
| 27 | readonly httpApiStatus?: number | undefined |
| 28 | /** |
| 29 | * The encoding of the payload or response. |
| 30 | * This is kept internal because encodings are only exposed through the `as*` functions. |
| 31 | * @internal |
| 32 | */ |
| 33 | readonly "~httpApiEncoding"?: Encoding | undefined |
| 34 | /** |
| 35 | * Marks schemas produced by `encodeToWithHeaders`, carrying the body and |
| 36 | * headers schemas so integrations can split the encoded pair. |
| 37 | * @internal |
| 38 | */ |
| 39 | readonly "~httpApiWithHeaders"?: WithHeadersAnnotation | undefined |
| 40 | } |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | /** |
| 45 | * Annotation payload attached by `encodeToWithHeaders`. |
| 46 | * |
| 47 | * @internal |
| 48 | */ |
| 49 | export interface WithHeadersAnnotation { |
| 50 | readonly body: Schema.Top |
| 51 | readonly headers: Schema.Top |
| 52 | /** |
| 53 | * Codecs from the body and headers types to their response representation. |
| 54 | * `HttpApiEndpoint` replaces them with the response encoding codecs. |
| 55 | */ |
| 56 | readonly bodyCodec: Schema.Top |
| 57 | readonly headersCodec: Schema.Top |
| 58 | /** |
| 59 | * The user mapping between the `{ body, headers }` pair and the schema type. |
| 60 | */ |
| 61 | readonly transformation: { |
| 62 | readonly decode: (pair: { readonly body: unknown; readonly headers: unknown }) => unknown |
| 63 | readonly encode: (value: unknown) => { readonly body: unknown; readonly headers: unknown } |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | /** |
| 68 | * HTTP API body encoding metadata used by payloads and responses. |
| 69 | * |
| 70 | * @stability unstable |
| 71 | * @category models |
| 72 | * @since 4.0.0 |
| 73 | */ |
| 74 | export type Encoding = PayloadEncoding | ResponseEncoding |
| 75 | |
| 76 | /** |
| 77 | * HTTP API request payload encoding metadata. |
| 78 | * |
| 79 | * @stability unstable |
| 80 | * @category models |
| 81 | * @since 4.0.0 |
| 82 | */ |
| 83 | export type PayloadEncoding = |
| 84 | | { |
| 85 | readonly _tag: "Multipart" |
| 86 | readonly mode: "buffered" | "stream" |
| 87 | readonly contentType: string |
| 88 | readonly limits?: Multipart_.withLimits.Options | undefined |
| 89 | } |
| 90 | | { |
| 91 | readonly _tag: "Json" | "FormUrlEncoded" | "Uint8Array" | "Text" |
| 92 | readonly contentType: string |
| 93 | } |
| 94 | |
| 95 | /** |
| 96 | * HTTP API response body encoding metadata. |
| 97 | * |
| 98 | * @stability unstable |
| 99 | * @category models |
| 100 | * @since 4.0.0 |
| 101 | */ |
| 102 | export type ResponseEncoding = { |
| 103 | readonly _tag: "Json" | "FormUrlEncoded" | "Uint8Array" | "Text" |
| 104 | readonly contentType: string |
| 105 | } |
| 106 | |
| 107 | const StreamSchemaTypeId = "~effect/http-api/HttpApiSchema/Stream" |
| 108 | |
| 109 | /** |
| 110 | * Common HTTP status code literals accepted by {@link status}. |
| 111 | * |
| 112 | * @stability unstable |
| 113 | * @category models |
| 114 | * @since 4.0.0 |
| 115 | */ |
| 116 | export type StatusLiteral = HttpStatus.Literal |
| 117 | |
| 118 | /** |
| 119 | * Sets the HTTP status code of a schema. |
| 120 | * |
| 121 | * **Details** |
| 122 | * |
| 123 | * This is equivalent to calling `.annotate({ httpApiStatus: code })` on the |
| 124 | * schema. You can pass either a numeric status code (for example, `201`) or a |
| 125 | * common literal name (for example, `"Created"`). |
| 126 | * |
| 127 | * @stability unstable |
| 128 | * @category schemas |
| 129 | * @since 4.0.0 |
| 130 | */ |
| 131 | export function status(code: number): { |
| 132 | <S extends Schema.Top>(self: S): S["Rebuild"] |
| 133 | } |
| 134 | /** |
| 135 | * @stability unstable |
| 136 | */ |
| 137 | export function status(code: StatusLiteral): { |
| 138 | <S extends Schema.Top>(self: S): S["Rebuild"] |
| 139 | } |
| 140 | /** |
| 141 | * @stability unstable |
| 142 | */ |
| 143 | export function status(code: number | StatusLiteral) { |
| 144 | const statusCode = typeof code === "string" ? HttpStatus.fromLiteral(code) : code |
| 145 | return <S extends Schema.Top>(self: S): S["Rebuild"] => self.annotate({ httpApiStatus: statusCode }) |
| 146 | } |
| 147 | |
| 148 | /** |
| 149 | * Creates a void schema with the given HTTP status code. |
| 150 | * This is used to represent empty responses with a specific status code. |
| 151 | * |
| 152 | * @see {@link NoContent} for the predefined 204 no content schema. |
| 153 | * |
| 154 | * @stability unstable |
| 155 | * @category constructors |
| 156 | * @since 4.0.0 |
| 157 | */ |
| 158 | export const Empty = (code: number): Schema.Void => Schema.Void.pipe(status(code)) |
| 159 | |
| 160 | /** |
| 161 | * Type of the `NoContent` schema, a void schema annotated with HTTP status code 204. |
| 162 | * |
| 163 | * @stability unstable |
| 164 | * @category models |
| 165 | * @since 4.0.0 |
| 166 | */ |
| 167 | export interface NoContent extends Schema.Void {} |
| 168 | |
| 169 | /** |
| 170 | * Schema for empty HTTP responses with status code 204. |
| 171 | * |
| 172 | * @stability unstable |
| 173 | * @category schemas |
| 174 | * @since 4.0.0 |
| 175 | */ |
| 176 | export const NoContent: NoContent = Empty(204) |
| 177 | |
| 178 | /** |
| 179 | * Type of the `Created` schema, a void schema annotated with HTTP status code 201. |
| 180 | * |
| 181 | * @stability unstable |
| 182 | * @category models |
| 183 | * @since 4.0.0 |
| 184 | */ |
| 185 | export interface Created extends Schema.Void {} |
| 186 | |
| 187 | /** |
| 188 | * Schema for empty HTTP responses with status code 201. |
| 189 | * |
| 190 | * @stability unstable |
| 191 | * @category schemas |
| 192 | * @since 4.0.0 |
| 193 | */ |
| 194 | export const Created: Created = Empty(201) |
| 195 | |
| 196 | /** |
| 197 | * Type of the `Accepted` schema, a void schema annotated with HTTP status code 202. |
| 198 | * |
| 199 | * @stability unstable |
| 200 | * @category models |
| 201 | * @since 4.0.0 |
| 202 | */ |
| 203 | export interface Accepted extends Schema.Void {} |
| 204 | |
| 205 | /** |
| 206 | * Schema for empty HTTP responses with status code 202. |
| 207 | * |
| 208 | * @stability unstable |
| 209 | * @category schemas |
| 210 | * @since 4.0.0 |
| 211 | */ |
| 212 | export const Accepted: Accepted = Empty(202) |
| 213 | |
| 214 | /** |
| 215 | * Schema type returned by `asNoContent`, encoding as `void` while decoding to the original schema type. |
| 216 | * |
| 217 | * @stability unstable |
| 218 | * @category schemas |
| 219 | * @since 4.0.0 |
| 220 | */ |
| 221 | export interface asNoContent<S extends Schema.Constraint> extends Schema.decodeTo<Schema.toType<S>, Schema.Void> {} |
| 222 | |
| 223 | /** |
| 224 | * Marks a schema as a no-content response while preserving a decoded client value. |
| 225 | * |
| 226 | * **Details** |
| 227 | * |
| 228 | * The server encodes the response as `void`; generated clients call `decode` to |
| 229 | * produce the schema's decoded value when the response has no body. |
| 230 | * |
| 231 | * @see {@link NoContent} for a void schema with the status code 204. |
| 232 | * @see {@link Empty} for creating a void schema with a specific status code. |
| 233 | * |
| 234 | * @stability unstable |
| 235 | * @category encoding |
| 236 | * @since 4.0.0 |
| 237 | */ |
| 238 | export function asNoContent<S extends Schema.Constraint>(options: { |
| 239 | readonly decode: LazyArg<S["Type"]> |
| 240 | }) { |
| 241 | return (self: S): asNoContent<S> => { |
| 242 | return Schema.Void.pipe( |
| 243 | Schema.decodeTo( |
| 244 | Schema.toType(self), |
| 245 | SchemaTransformation.transform({ |
| 246 | decode: options.decode, |
| 247 | encode: constVoid |
| 248 | }) |
| 249 | ) |
| 250 | ) |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | type StreamMode = "sse" | "uint8array" |
| 255 | |
| 256 | /** |
| 257 | * Mode describing whether an SSE stream emits full events or raw data values. |
| 258 | * |
| 259 | * @stability unstable |
| 260 | * @category models |
| 261 | * @since 4.0.0 |
| 262 | */ |
| 263 | export type StreamSseMode = "events" | "data" |
| 264 | |
| 265 | /** |
| 266 | * Schema for a Server-Sent Events success response. |
| 267 | * |
| 268 | * **Details** |
| 269 | * |
| 270 | * `events` describes successful application events emitted by the stream, and |
| 271 | * `error` describes typed stream failures that will be encoded by later |
| 272 | * endpoint/server/client integrations using the reserved failure event. If |
| 273 | * `error` is omitted, it defaults to `Schema.Never`. When `StreamSse` is |
| 274 | * constructed from `data`, handlers and clients expose raw data values while |
| 275 | * the server and client still use an SSE event schema internally. |
| 276 | * |
| 277 | * **Gotchas** |
| 278 | * |
| 279 | * The client treats `effect/http-api/stream/failure` as a stream failure only |
| 280 | * when its decoded `data` is a `Cause`. If an event schema accepts that name |
| 281 | * dynamically but decodes `data` to another value, the client emits it as an |
| 282 | * application event. Endpoint construction rejects event schemas that declare |
| 283 | * the reserved name statically. |
| 284 | * |
| 285 | * @stability unstable |
| 286 | * @category models |
| 287 | * @since 4.0.0 |
| 288 | */ |
| 289 | export interface StreamSse< |
| 290 | Events extends Sse.EventCodec, |
| 291 | Error extends Schema.Constraint, |
| 292 | Value = Events["Type"] |
| 293 | > extends |
| 294 | Schema.BottomLazy< |
| 295 | SchemaAST.Declaration, |
| 296 | StreamSse<Events, Error, Value> |
| 297 | > |
| 298 | { |
| 299 | readonly "Type": Stream.Stream<Value, Error["Type"], never> |
| 300 | readonly "Encoded": Stream.Stream<Value, Error["Type"], never> |
| 301 | readonly "DecodingServices": Events["DecodingServices"] | Error["DecodingServices"] |
| 302 | readonly "EncodingServices": Events["EncodingServices"] | Error["EncodingServices"] |
| 303 | readonly "Rebuild": StreamSse<Events, Error, Value> |
| 304 | readonly "~type.make.in": Stream.Stream<Value, Error["Type"], never> |
| 305 | readonly "~type.make": Stream.Stream<Value, Error["Type"], never> |
| 306 | readonly "Iso": Stream.Stream<Value, Error["Type"], never> |
| 307 | readonly [StreamSchemaTypeId]: typeof StreamSchemaTypeId |
| 308 | readonly _tag: "StreamSse" |
| 309 | readonly mode: "sse" |
| 310 | readonly sseMode: StreamSseMode |
| 311 | readonly contentType: string |
| 312 | readonly events: Events |
| 313 | readonly error: Error |
| 314 | readonly "~Value"?: Value | undefined |
| 315 | } |
| 316 | |
| 317 | /** |
| 318 | * Event schema produced when {@link StreamSse} is constructed from a JSON data schema. |
| 319 | * |
| 320 | * @stability unstable |
| 321 | * @category models |
| 322 | * @since 4.0.0 |
| 323 | */ |
| 324 | export interface SseEventFromData<Data extends Schema.Constraint> extends |
| 325 | Schema.ConstraintCodec< |
| 326 | { |
| 327 | readonly id?: string | undefined |
| 328 | readonly event: string |
| 329 | readonly data: Data["Type"] |
| 330 | }, |
| 331 | { |
| 332 | readonly id?: string | undefined |
| 333 | readonly event?: string | undefined |
| 334 | readonly data: string |
| 335 | }, |
| 336 | Data["DecodingServices"], |
| 337 | Data["EncodingServices"] |
| 338 | > |
| 339 | {} |
| 340 | |
| 341 | /** |
| 342 | * Schema for a streaming `Uint8Array` success response. |
| 343 | * |
| 344 | * **Details** |
| 345 | * |
| 346 | * This declaration stores the response content type for later endpoint, |
| 347 | * server, client, and OpenAPI integrations. It is intentionally separate from |
| 348 | * the buffered `asUint8Array` response encoding. |
| 349 | * |
| 350 | * @stability unstable |
| 351 | * @category models |
| 352 | * @since 4.0.0 |
| 353 | */ |
| 354 | export interface StreamUint8Array extends |
| 355 | Schema.Bottom< |
| 356 | Stream.Stream<Uint8Array, unknown, never>, |
| 357 | Stream.Stream<Uint8Array, unknown, never>, |
| 358 | never, |
| 359 | never, |
| 360 | SchemaAST.Declaration, |
| 361 | StreamUint8Array |
| 362 | > |
| 363 | { |
| 364 | readonly "Rebuild": StreamUint8Array |
| 365 | readonly [StreamSchemaTypeId]: typeof StreamSchemaTypeId |
| 366 | readonly _tag: "StreamUint8Array" |
| 367 | readonly mode: "uint8array" |
| 368 | readonly contentType: string |
| 369 | } |
| 370 | |
| 371 | /** |
| 372 | * Schema for a streaming HTTP API success response. |
| 373 | * |
| 374 | * @stability unstable |
| 375 | * @category models |
| 376 | * @since 4.0.0 |
| 377 | */ |
| 378 | export type StreamSchema = StreamSse<Sse.EventCodec, Schema.Top, unknown> | StreamUint8Array |
| 379 | |
| 380 | const streamSchema = Schema.declare(Stream.isStream) |
| 381 | |
| 382 | /** |
| 383 | * Creates a Server-Sent Events streaming success response schema. |
| 384 | * |
| 385 | * @stability unstable |
| 386 | * @category constructors |
| 387 | * @since 4.0.0 |
| 388 | */ |
| 389 | export const StreamSse: { |
| 390 | <Events extends Sse.EventCodec, Error extends Schema.Constraint = Schema.Never>(options: { |
| 391 | readonly contentType?: string | undefined |
| 392 | readonly events: Events |
| 393 | readonly error?: Error | undefined |
| 394 | }): StreamSse<Events, Error, Events["Type"]> |
| 395 | <Data extends Schema.Constraint, Error extends Schema.Constraint = Schema.Never>(options: { |
| 396 | readonly contentType?: string | undefined |
| 397 | readonly data: Data |
| 398 | readonly error?: Error | undefined |
| 399 | }): StreamSse<SseEventFromData<Data>, Error, Data["Type"]> |
| 400 | } = (options: { |
| 401 | readonly contentType?: string | undefined |
| 402 | readonly events?: Sse.EventCodec | undefined |
| 403 | readonly data?: Schema.Constraint | undefined |
| 404 | readonly error?: Schema.Constraint | undefined |
| 405 | }): StreamSse<Sse.EventCodec, Schema.Top, unknown> => { |
| 406 | const events = options.events ?? (options.data === undefined ? undefined : Schema.Struct({ |
| 407 | ...Sse.EventEncoded.fields, |
| 408 | data: Schema.fromJsonString(options.data) |
| 409 | })) |
| 410 | if (events === undefined) { |
| 411 | throw new Error("StreamSse requires either an events schema or a data schema") |
| 412 | } |
| 413 | return Schema.make<StreamSse<Sse.EventCodec, Schema.Top, unknown>>(streamSchema.ast, { |
| 414 | [StreamSchemaTypeId]: StreamSchemaTypeId, |
| 415 | _tag: "StreamSse", |
| 416 | mode: "sse", |
| 417 | sseMode: options.events === undefined ? "data" : "events", |
| 418 | contentType: options.contentType ?? defaultStreamContentType("sse"), |
| 419 | events, |
| 420 | error: options.error ?? Schema.Never |
| 421 | }) |
| 422 | } |
| 423 | |
| 424 | /** |
| 425 | * Creates a streaming `Uint8Array` success response schema. |
| 426 | * |
| 427 | * @stability unstable |
| 428 | * @category constructors |
| 429 | * @since 4.0.0 |
| 430 | */ |
| 431 | export const StreamUint8Array = (options?: { |
| 432 | readonly contentType?: string | undefined |
| 433 | }): StreamUint8Array => |
| 434 | Schema.make<StreamUint8Array>(streamSchema.ast, { |
| 435 | [StreamSchemaTypeId]: StreamSchemaTypeId, |
| 436 | _tag: "StreamUint8Array", |
| 437 | mode: "uint8array", |
| 438 | contentType: options?.contentType ?? defaultStreamContentType("uint8array") |
| 439 | }) |
| 440 | |
| 441 | /** |
| 442 | * @internal |
| 443 | */ |
| 444 | export const isStreamSchema = (u: unknown): u is StreamSchema => |
| 445 | Schema.isSchema(u) && Predicate.hasProperty(u, StreamSchemaTypeId) |
| 446 | |
| 447 | /** |
| 448 | * @internal |
| 449 | */ |
| 450 | export const isStreamSse = (u: unknown): u is StreamSse<Sse.EventCodec, Schema.Top, unknown> => |
| 451 | isStreamSchema(u) && u._tag === "StreamSse" |
| 452 | |
| 453 | /** |
| 454 | * @internal |
| 455 | */ |
| 456 | export const isStreamUint8Array = (u: unknown): u is StreamUint8Array => |
| 457 | isStreamSchema(u) && u._tag === "StreamUint8Array" |
| 458 | |
| 459 | function defaultStreamContentType(mode: StreamMode): string { |
| 460 | switch (mode) { |
| 461 | case "sse": |
| 462 | return "text/event-stream" |
| 463 | case "uint8array": |
| 464 | return "application/octet-stream" |
| 465 | } |
| 466 | } |
| 467 | |
| 468 | /** |
| 469 | * Runtime brand key used to mark `WithHeaders` response schemas. |
| 470 | * |
| 471 | * @stability unstable |
| 472 | * @category type IDs |
| 473 | * @since 4.0.0 |
| 474 | */ |
| 475 | export const WithHeadersTypeId = "~effect/http-api/HttpApiSchema/WithHeaders" |
| 476 | |
| 477 | /** |
| 478 | * Type-level brand identifier used by `WithHeaders`. |
| 479 | * |
| 480 | * @stability unstable |
| 481 | * @category type IDs |
| 482 | * @since 4.0.0 |
| 483 | */ |
| 484 | export type WithHeadersTypeId = typeof WithHeadersTypeId |
| 485 | |
| 486 | /** |
| 487 | * Runtime brand key used to mark `WithHeaders` response values. |
| 488 | * |
| 489 | * @stability unstable |
| 490 | * @category type IDs |
| 491 | * @since 4.0.0 |
| 492 | */ |
| 493 | export const WithHeadersValueTypeId = "~effect/http-api/HttpApiSchema/WithHeadersValue" |
| 494 | |
| 495 | /** |
| 496 | * Type-level brand identifier used by `WithHeaders` response values. |
| 497 | * |
| 498 | * @stability unstable |
| 499 | * @category type IDs |
| 500 | * @since 4.0.0 |
| 501 | */ |
| 502 | export type WithHeadersValueTypeId = typeof WithHeadersValueTypeId |
| 503 | |
| 504 | /** |
| 505 | * A response schema wrapping a body schema together with a response headers |
| 506 | * schema. |
| 507 | * |
| 508 | * **Details** |
| 509 | * |
| 510 | * `WithHeaders` is a branded declaration schema: it carries the inner success |
| 511 | * schema and the headers schema as properties, and server, client, and OpenAPI |
| 512 | * integrations detect the brand and handle body and headers separately. It is |
| 513 | * supported for error responses, though {@link encodeToWithHeaders} is usually |
| 514 | * more convenient there because handlers can fail with the domain error value. |
| 515 | * |
| 516 | * - `schema` is the inner response schema. Success responses may wrap |
| 517 | * `StreamSse` and `StreamUint8Array`; error responses remain non-streaming. |
| 518 | * Nesting `WithHeaders` is rejected at construction. |
| 519 | * - `headers` is any schema; endpoint construction applies |
| 520 | * `Schema.toCodecStringTree` unless codecs are disabled, so leaves become |
| 521 | * `string | undefined` on the wire and `undefined` leaves are omitted from the response. |
| 522 | * - Status and response-encoding annotations are resolved from the wrapper |
| 523 | * first, falling through to the inner schema. |
| 524 | * - A header-carrying response cannot share its status and content type with |
| 525 | * another response in the same success or error union. Endpoint construction |
| 526 | * rejects ambiguous declarations, including those made with |
| 527 | * {@link encodeToWithHeaders}. |
| 528 | * - `Rebuild` preserves the brand and both parts, so `.annotate` keeps the |
| 529 | * wrapper intact. |
| 530 | * |
| 531 | * @stability unstable |
| 532 | * @category models |
| 533 | * @since 4.0.0 |
| 534 | */ |
| 535 | export interface WithHeaders<S extends Schema.Top, H extends Schema.Top> extends |
| 536 | Schema.Bottom< |
| 537 | withHeaders<S["Type"], H["Type"]>, |
| 538 | withHeaders<S["Encoded"], Schema.StringTree>, |
| 539 | S["DecodingServices"] | H["DecodingServices"], |
| 540 | S["EncodingServices"] | H["EncodingServices"], |
| 541 | SchemaAST.Declaration, |
| 542 | WithHeaders<S, H> |
| 543 | > |
| 544 | { |
| 545 | readonly "Rebuild": WithHeaders<S, H> |
| 546 | readonly [WithHeadersTypeId]: typeof WithHeadersTypeId |
| 547 | readonly schema: S |
| 548 | readonly headers: H |
| 549 | } |
| 550 | |
| 551 | /** |
| 552 | * The Type of a `WithHeaders` schema: what handlers return and what the |
| 553 | * client resolves to, constructed via {@link withHeaders}. |
| 554 | * |
| 555 | * **Details** |
| 556 | * |
| 557 | * `body` is the inner success value. For stream success schemas it is the |
| 558 | * `Stream` itself, so headers are decided before the body starts streaming. |
| 559 | * |
| 560 | * @stability unstable |
| 561 | * @category models |
| 562 | * @since 4.0.0 |
| 563 | */ |
| 564 | export interface withHeaders<A, H> { |
| 565 | readonly [WithHeadersValueTypeId]: WithHeadersValueTypeId |
| 566 | readonly body: A |
| 567 | readonly headers: H |
| 568 | } |
| 569 | |
| 570 | /** |
| 571 | * @internal |
| 572 | */ |
| 573 | export const isWithHeadersValue = (u: unknown): u is withHeaders<unknown, unknown> => |
| 574 | Predicate.hasProperty(u, WithHeadersValueTypeId) |
| 575 | |
| 576 | const withHeadersValueSchema = Schema.declare(isWithHeadersValue) |
| 577 | |
| 578 | /** |
| 579 | * Wraps a success schema with a response headers schema. |
| 580 | * |
| 581 | * **Details** |
| 582 | * |
| 583 | * Headers accept either a schema or a fields shorthand, mirroring the |
| 584 | * request-side headers option. |
| 585 | * |
| 586 | * **Example** (Adding response headers to a success schema) |
| 587 | * |
| 588 | * ```ts import.meta.vitest |
| 589 | * import { Schema } from "effect" |
| 590 | * import { HttpApiSchema } from "effect/http-api" |
| 591 | * |
| 592 | * const schema = HttpApiSchema.WithHeaders(Schema.String, { |
| 593 | * "x-total-count": Schema.FiniteFromString |
| 594 | * }) |
| 595 | * const response: typeof schema.Type = HttpApiSchema.withHeaders({ |
| 596 | * body: "created", |
| 597 | * headers: { "x-total-count": 1 } |
| 598 | * }) |
| 599 | * |
| 600 | * HttpApiSchema.isWithHeaders(schema) // => true |
| 601 | * response.body // => "created" |
| 602 | * response.headers // => { "x-total-count": 1 } |
| 603 | * ``` |
| 604 | * |
| 605 | * @stability unstable |
| 606 | * @category constructors |
| 607 | * @since 4.0.0 |
| 608 | */ |
| 609 | export function WithHeaders<S extends Schema.Top, H extends Schema.Struct.Fields>( |
| 610 | schema: S, |
| 611 | headers: H |
| 612 | ): WithHeaders<S, Schema.Struct<H>> |
| 613 | /** |
| 614 | * @stability unstable |
| 615 | */ |
| 616 | export function WithHeaders<S extends Schema.Top, H extends Schema.Top>( |
| 617 | schema: S, |
| 618 | headers: H |
| 619 | ): WithHeaders<S, H> |
| 620 | /** |
| 621 | * @stability unstable |
| 622 | */ |
| 623 | export function WithHeaders( |
| 624 | schema: Schema.Top, |
| 625 | headers: Schema.Top | Schema.Struct.Fields |
| 626 | ): WithHeaders<Schema.Top, Schema.Top> { |
| 627 | if (isWithHeaders(schema)) { |
| 628 | throw new Error("WithHeaders schemas cannot be nested") |
| 629 | } |
| 630 | return Schema.make<WithHeaders<Schema.Top, Schema.Top>>(withHeadersValueSchema.ast, { |
| 631 | [WithHeadersTypeId]: WithHeadersTypeId, |
| 632 | schema, |
| 633 | headers: Schema.isSchema(headers) ? headers : Schema.Struct(headers) |
| 634 | }) |
| 635 | } |
| 636 | |
| 637 | /** |
| 638 | * Constructs a `WithHeaders` response value from a body and headers. |
| 639 | * |
| 640 | * **Details** |
| 641 | * |
| 642 | * The returned value is branded so servers and clients can detect it exactly, |
| 643 | * including in mixed success unions. The same shape is used on both sides: a |
| 644 | * value received from a client can be returned from another handler unchanged. |
| 645 | * |
| 646 | * See {@link WithHeaders} for an example that constructs a schema and its |
| 647 | * corresponding response value. |
| 648 | * |
| 649 | * @stability unstable |
| 650 | * @category constructors |
| 651 | * @since 4.0.0 |
| 652 | */ |
| 653 | export const withHeaders = <A, H>(options: { |
| 654 | readonly body: A |
| 655 | readonly headers: H |
| 656 | }): withHeaders<A, H> => ({ |
| 657 | [WithHeadersValueTypeId]: WithHeadersValueTypeId, |
| 658 | body: options.body, |
| 659 | headers: options.headers |
| 660 | }) |
| 661 | |
| 662 | /** |
| 663 | * Returns `true` when a schema is a `WithHeaders` response schema. |
| 664 | * |
| 665 | * **Example** (Detecting a response headers schema) |
| 666 | * |
| 667 | * ```ts import.meta.vitest |
| 668 | * import { Schema } from "effect" |
| 669 | * import { HttpApiSchema } from "effect/http-api" |
| 670 | * |
| 671 | * const schema = HttpApiSchema.WithHeaders(Schema.String, { |
| 672 | * "x-request-id": Schema.String |
| 673 | * }) |
| 674 | * |
| 675 | * HttpApiSchema.isWithHeaders(schema) // => true |
| 676 | * HttpApiSchema.isWithHeaders(Schema.String) // => false |
| 677 | * ``` |
| 678 | * |
| 679 | * @stability unstable |
| 680 | * @category predicates |
| 681 | * @since 4.0.0 |
| 682 | */ |
| 683 | export const isWithHeaders = (u: unknown): u is WithHeaders<Schema.Top, Schema.Top> => |
| 684 | Schema.isSchema(u) && Predicate.hasProperty(u, WithHeadersTypeId) |
| 685 | |
| 686 | /** |
| 687 | * @internal |
| 688 | */ |
| 689 | export function rebuildWithHeaders( |
| 690 | self: WithHeaders<Schema.Top, Schema.Top>, |
| 691 | schema: Schema.Top, |
| 692 | headers: Schema.Top |
| 693 | ): WithHeaders<Schema.Top, Schema.Top> { |
| 694 | return Schema.make<WithHeaders<Schema.Top, Schema.Top>>(self.ast, { |
| 695 | [WithHeadersTypeId]: WithHeadersTypeId, |
| 696 | schema, |
| 697 | headers |
| 698 | }) |
| 699 | } |
| 700 | |
| 701 | /** |
| 702 | * Schema type returned by `encodeToWithHeaders`, encoding as a `{ body, headers }` |
| 703 | * pair while decoding to the source schema type. |
| 704 | * |
| 705 | * @stability unstable |
| 706 | * @category schemas |
| 707 | * @since 4.0.0 |
| 708 | */ |
| 709 | export interface encodeToWithHeaders< |
| 710 | S extends Schema.Top, |
| 711 | Body extends Schema.Top, |
| 712 | Headers extends Schema.Struct.Fields |
| 713 | > extends |
| 714 | Schema.decodeTo< |
| 715 | Schema.toType<S>, |
| 716 | Schema.Struct<{ |
| 717 | readonly body: Body |
| 718 | readonly headers: Schema.Struct<Headers> |
| 719 | }> |
| 720 | > |
| 721 | {} |
| 722 | |
| 723 | /** |
| 724 | * Encodes a schema as a `{ body, headers }` pair, folding response headers into |
| 725 | * an opaque domain type such as an error class. |
| 726 | * |
| 727 | * **Details** |
| 728 | * |
| 729 | * The encoded side is the pair of the body schema and the headers fields; the |
| 730 | * Type stays the source schema's Type. The body schema is authoritative for |
| 731 | * everything wire-level: status, content type, and response encoding resolve |
| 732 | * from the body schema's annotations. |
| 733 | * |
| 734 | * The mappings are pure total functions: validation lives in the body and |
| 735 | * header schemas, the mappings only reshape valid data. |
| 736 | * |
| 737 | * Streams used as the body schema turn mid-stream transport errors into |
| 738 | * defects on the client. Stream responses should use {@link WithHeaders}, |
| 739 | * which preserves the body stream's error channel in the generated client. |
| 740 | * |
| 741 | * **Example** (Encoding an error with headers) |
| 742 | * |
| 743 | * ```ts import.meta.vitest |
| 744 | * import { Schema } from "effect" |
| 745 | * import { HttpApiSchema } from "effect/http-api" |
| 746 | * |
| 747 | * class UserNotFound extends Schema.TaggedError<UserNotFound>()("UserNotFound", { |
| 748 | * userId: Schema.Int |
| 749 | * }) {} |
| 750 | * |
| 751 | * const UserNotFoundWithHeaders = UserNotFound.pipe( |
| 752 | * HttpApiSchema.encodeToWithHeaders({ |
| 753 | * body: HttpApiSchema.Empty(404), |
| 754 | * headers: { |
| 755 | * "x-user-id": Schema.Int |
| 756 | * } |
| 757 | * }, { |
| 758 | * decode: ({ headers }) => new UserNotFound({ userId: headers["x-user-id"] }), |
| 759 | * encode: (error) => ({ |
| 760 | * headers: { "x-user-id": error.userId }, |
| 761 | * body: undefined |
| 762 | * }) |
| 763 | * }) |
| 764 | * ) |
| 765 | * |
| 766 | * const encoded = Schema.encodeSync(UserNotFoundWithHeaders)(new UserNotFound({ userId: 123 })) |
| 767 | * encoded // => { body: undefined, headers: { "x-user-id": 123 } } |
| 768 | * ``` |
| 769 | * |
| 770 | * @see {@link WithHeaders} for the structural wrapper recommended for success |
| 771 | * responses, including streams. |
| 772 | * |
| 773 | * @stability unstable |
| 774 | * @category encoding |
| 775 | * @since 4.0.0 |
| 776 | */ |
| 777 | export function encodeToWithHeaders< |
| 778 | S extends Schema.Top, |
| 779 | Body extends Schema.Top, |
| 780 | Headers extends Schema.Struct.Fields |
| 781 | >(options: { |
| 782 | readonly body: Body |
| 783 | readonly headers: Headers |
| 784 | }, transformation: { |
| 785 | readonly decode: ( |
| 786 | value: Schema.Struct.Type<{ readonly body: Body; readonly headers: Schema.Struct<Headers> }> |
| 787 | ) => S["Type"] |
| 788 | readonly encode: ( |
| 789 | value: S["Type"] |
| 790 | ) => Schema.Struct.Type<{ readonly body: Body; readonly headers: Schema.Struct<Headers> }> |
| 791 | }) { |
| 792 | return (self: S): encodeToWithHeaders<S, Body, Headers> => { |
| 793 | const body = options.body |
| 794 | const headers = Schema.Struct(options.headers) |
| 795 | const status = resolveHttpApiStatus(body.ast) |
| 796 | const encoding = resolveHttpApiEncoding(body.ast) |
| 797 | return Schema.Struct({ body, headers }).pipe( |
| 798 | Schema.decodeTo( |
| 799 | Schema.toType(self), |
| 800 | SchemaTransformation.transform(transformation) |
| 801 | ) |
| 802 | ).annotate({ |
| 803 | "~httpApiWithHeaders": { |
| 804 | body, |
| 805 | headers, |
| 806 | bodyCodec: body, |
| 807 | headersCodec: headers, |
| 808 | transformation |
| 809 | } as WithHeadersAnnotation, |
| 810 | httpApiStatus: status, |
| 811 | "~httpApiEncoding": encoding |
| 812 | }) |
| 813 | } |
| 814 | } |
| 815 | |
| 816 | /** |
| 817 | * Runtime brand key used to mark schemas as buffered multipart payloads. |
| 818 | * |
| 819 | * @stability unstable |
| 820 | * @category type IDs |
| 821 | * @since 4.0.0 |
| 822 | */ |
| 823 | export const MultipartTypeId = "~effect/http-api/HttpApiSchema/Multipart" |
| 824 | |
| 825 | /** |
| 826 | * Type-level brand identifier used by `asMultipart`. |
| 827 | * |
| 828 | * @stability unstable |
| 829 | * @category type IDs |
| 830 | * @since 4.0.0 |
| 831 | */ |
| 832 | export type MultipartTypeId = typeof MultipartTypeId |
| 833 | |
| 834 | /** |
| 835 | * Schema type returned by `asMultipart` for buffered multipart payloads. |
| 836 | * |
| 837 | * @stability unstable |
| 838 | * @category schemas |
| 839 | * @since 4.0.0 |
| 840 | */ |
| 841 | export interface asMultipart<S extends Schema.Top> extends Schema.brand<S["Rebuild"], MultipartTypeId> {} |
| 842 | |
| 843 | /** |
| 844 | * Marks a schema as a multipart payload. |
| 845 | * |
| 846 | * @see {@link asMultipartStream} for a multipart stream payload. |
| 847 | * |
| 848 | * @stability unstable |
| 849 | * @category encoding |
| 850 | * @since 4.0.0 |
| 851 | */ |
| 852 | export function asMultipart(options?: Multipart_.withLimits.Options) { |
| 853 | return <S extends Schema.Top>(self: S): asMultipart<S> => |
| 854 | self.pipe(Schema.brand(MultipartTypeId)).annotate({ |
| 855 | "~httpApiEncoding": { |
| 856 | _tag: "Multipart", |
| 857 | mode: "buffered", |
| 858 | contentType: defaultContentType("Multipart"), |
| 859 | limits: options |
| 860 | } |
| 861 | }) |
| 862 | } |
| 863 | |
| 864 | /** |
| 865 | * Runtime brand key used to mark schemas as streaming multipart payloads. |
| 866 | * |
| 867 | * @stability unstable |
| 868 | * @category type IDs |
| 869 | * @since 4.0.0 |
| 870 | */ |
| 871 | export const MultipartStreamTypeId = "~effect/http-api/HttpApiSchema/MultipartStream" |
| 872 | |
| 873 | /** |
| 874 | * Type-level brand identifier used by `asMultipartStream`. |
| 875 | * |
| 876 | * @stability unstable |
| 877 | * @category type IDs |
| 878 | * @since 4.0.0 |
| 879 | */ |
| 880 | export type MultipartStreamTypeId = typeof MultipartStreamTypeId |
| 881 | |
| 882 | /** |
| 883 | * Schema type returned by `asMultipartStream` for streaming multipart payloads. |
| 884 | * |
| 885 | * @stability unstable |
| 886 | * @category schemas |
| 887 | * @since 4.0.0 |
| 888 | */ |
| 889 | export interface asMultipartStream<S extends Schema.Top> extends Schema.brand<S["Rebuild"], MultipartStreamTypeId> {} |
| 890 | |
| 891 | /** |
| 892 | * Marks a schema as a multipart stream payload. |
| 893 | * |
| 894 | * @see {@link asMultipart} for a buffered multipart payload. |
| 895 | * |
| 896 | * @stability unstable |
| 897 | * @category encoding |
| 898 | * @since 4.0.0 |
| 899 | */ |
| 900 | export function asMultipartStream(options?: Multipart_.withLimits.Options) { |
| 901 | return <S extends Schema.Top>(self: S): asMultipartStream<S> => |
| 902 | self.pipe(Schema.brand(MultipartStreamTypeId)).annotate({ |
| 903 | "~httpApiEncoding": { |
| 904 | _tag: "Multipart", |
| 905 | mode: "stream", |
| 906 | contentType: defaultContentType("Multipart"), |
| 907 | limits: options |
| 908 | } |
| 909 | }) |
| 910 | } |
| 911 | |
| 912 | function asNonMultipartEncoding<S extends Schema.Top>(self: S, options: { |
| 913 | readonly _tag: "Json" | "FormUrlEncoded" | "Uint8Array" | "Text" |
| 914 | readonly contentType?: string | undefined |
| 915 | }): S["Rebuild"] { |
| 916 | return self.annotate({ |
| 917 | "~httpApiEncoding": { |
| 918 | _tag: options._tag, |
| 919 | contentType: options.contentType ?? defaultContentType(options._tag) |
| 920 | } |
| 921 | }) |
| 922 | } |
| 923 | |
| 924 | function defaultContentType(_tag: Encoding["_tag"]): string { |
| 925 | switch (_tag) { |
| 926 | case "Multipart": |
| 927 | return "multipart/form-data" |
| 928 | case "Json": |
| 929 | return "application/json" |
| 930 | case "FormUrlEncoded": |
| 931 | return "application/x-www-form-urlencoded" |
| 932 | case "Uint8Array": |
| 933 | return "application/octet-stream" |
| 934 | case "Text": |
| 935 | return "text/plain" |
| 936 | } |
| 937 | } |
| 938 | |
| 939 | /** |
| 940 | * Marks a schema as a JSON payload / response. |
| 941 | * |
| 942 | * @stability unstable |
| 943 | * @category encoding |
| 944 | * @since 4.0.0 |
| 945 | */ |
| 946 | export function asJson(options?: { |
| 947 | readonly contentType?: string |
| 948 | }) { |
| 949 | return <S extends Schema.Top>(self: S) => asNonMultipartEncoding(self, { _tag: "Json", ...options }) |
| 950 | } |
| 951 | |
| 952 | /** |
| 953 | * Marks a schema as an `application/x-www-form-urlencoded` payload or response. |
| 954 | * |
| 955 | * **Details** |
| 956 | * |
| 957 | * The schema's encoded side must be a record of strings. |
| 958 | * |
| 959 | * @stability unstable |
| 960 | * @category encoding |
| 961 | * @since 4.0.0 |
| 962 | */ |
| 963 | export function asFormUrlEncoded(options?: { |
| 964 | readonly contentType?: string |
| 965 | }) { |
| 966 | return <S extends Schema.Top>( |
| 967 | self: S |
| 968 | ) => asNonMultipartEncoding(self, { _tag: "FormUrlEncoded", ...options }) |
| 969 | } |
| 970 | |
| 971 | /** |
| 972 | * Marks a schema as a text payload / response. |
| 973 | * |
| 974 | * **Details** |
| 975 | * |
| 976 | * The schema encoded side must be a string. |
| 977 | * |
| 978 | * @stability unstable |
| 979 | * @category encoding |
| 980 | * @since 4.0.0 |
| 981 | */ |
| 982 | export function asText(options?: { |
| 983 | readonly contentType?: string |
| 984 | }) { |
| 985 | return <S extends Schema.Top & { readonly Encoded: string }>(self: S) => |
| 986 | asNonMultipartEncoding(self, { _tag: "Text", ...options }) |
| 987 | } |
| 988 | |
| 989 | /** |
| 990 | * Marks a schema as a binary payload / response. |
| 991 | * |
| 992 | * **Details** |
| 993 | * |
| 994 | * The schema encoded side must be a `Uint8Array`. |
| 995 | * |
| 996 | * @stability unstable |
| 997 | * @category encoding |
| 998 | * @since 4.0.0 |
| 999 | */ |
| 1000 | export function asUint8Array(options?: { |
| 1001 | readonly contentType?: string |
| 1002 | }) { |
| 1003 | return <S extends Schema.Top & { readonly Encoded: Uint8Array }>(self: S) => |
| 1004 | asNonMultipartEncoding(self, { _tag: "Uint8Array", ...options }) |
| 1005 | } |
| 1006 | /** |
| 1007 | * Returns `true` when a schema AST represents a no-content response. |
| 1008 | * |
| 1009 | * **Details** |
| 1010 | * |
| 1011 | * The check succeeds for direct `void` schemas and schemas whose encoded or |
| 1012 | * transformation target is `void`. |
| 1013 | * |
| 1014 | * @stability unstable |
| 1015 | * @category predicates |
| 1016 | * @since 4.0.0 |
| 1017 | */ |
| 1018 | export const isNoContent = (ast: SchemaAST.AST): boolean => { |
| 1019 | if (SchemaAST.isVoid(ast)) return true |
| 1020 | const encoded = SchemaAST.toEncoded(ast) |
| 1021 | if (SchemaAST.isVoid(encoded)) return true |
| 1022 | const target = ast.encoding?.[0].to |
| 1023 | if (target === undefined) return false |
| 1024 | return SchemaAST.isVoid(target) |
| 1025 | } |
| 1026 | |
| 1027 | const resolveHttpApiEncoding = SchemaAST.resolveAt<Encoding>("~httpApiEncoding") |
| 1028 | |
| 1029 | /** |
| 1030 | * @internal |
| 1031 | */ |
| 1032 | export const getWithHeadersAnnotation = SchemaAST.resolveAt<WithHeadersAnnotation>("~httpApiWithHeaders") |
| 1033 | |
| 1034 | const resolveHttpApiStatus = SchemaAST.resolveAt<number>("httpApiStatus") |
| 1035 | |
| 1036 | const defaultJsonEncoding: Encoding = { |
| 1037 | _tag: "Json", |
| 1038 | contentType: "application/json" |
| 1039 | } |
| 1040 | const defaultUrlEncodedEncoding: Encoding = { |
| 1041 | _tag: "FormUrlEncoded", |
| 1042 | contentType: "application/x-www-form-urlencoded" |
| 1043 | } |
| 1044 | |
| 1045 | function getEncoding(ast: SchemaAST.AST): Encoding { |
| 1046 | return resolveHttpApiEncoding(ast) ?? defaultJsonEncoding |
| 1047 | } |
| 1048 | |
| 1049 | /** |
| 1050 | * @internal |
| 1051 | */ |
| 1052 | export function getPayloadEncoding(ast: SchemaAST.AST, method: HttpMethod): PayloadEncoding { |
| 1053 | const encoding = resolveHttpApiEncoding(ast) |
| 1054 | if (encoding) return encoding |
| 1055 | return hasBody(method) ? defaultJsonEncoding : defaultUrlEncodedEncoding |
| 1056 | } |
| 1057 | |
| 1058 | /** |
| 1059 | * @internal |
| 1060 | */ |
| 1061 | export function getResponseEncoding(ast: SchemaAST.AST): ResponseEncoding { |
| 1062 | const out = getEncoding(ast) |
| 1063 | if (out._tag === "Multipart") { |
| 1064 | throw new Error("Multipart is not supported in response") |
| 1065 | } |
| 1066 | return out |
| 1067 | } |
| 1068 | |
| 1069 | /** |
| 1070 | * @internal |
| 1071 | */ |
| 1072 | export function getStatusSuccess(self: SchemaAST.AST): number { |
| 1073 | return resolveHttpApiStatus(self) ?? 200 |
| 1074 | } |
| 1075 | |
| 1076 | /** |
| 1077 | * @internal |
| 1078 | */ |
| 1079 | export function getStatusSuccessSchema(schema: Schema.Constraint): number { |
| 1080 | if (isWithHeaders(schema)) { |
| 1081 | return resolveHttpApiStatus(schema.ast) ?? getStatusSuccess(schema.schema.ast) |
| 1082 | } |
| 1083 | return getStatusSuccess(schema.ast) |
| 1084 | } |
| 1085 | |
| 1086 | /** |
| 1087 | * @internal |
| 1088 | */ |
| 1089 | export function getResponseEncodingSchema(schema: Schema.Constraint): ResponseEncoding { |
| 1090 | if (isWithHeaders(schema) && resolveHttpApiEncoding(schema.ast) === undefined) { |
| 1091 | return getResponseEncoding(schema.schema.ast) |
| 1092 | } |
| 1093 | return getResponseEncoding(schema.ast) |
| 1094 | } |
| 1095 | |
| 1096 | /** |
| 1097 | * @internal |
| 1098 | */ |
| 1099 | export function getStatusError(self: SchemaAST.AST): number { |
| 1100 | return resolveHttpApiStatus(self) ?? 500 |
| 1101 | } |
| 1102 | |
| 1103 | /** |
| 1104 | * @internal |
| 1105 | */ |
| 1106 | export function getStatusErrorSchema(schema: Schema.Constraint): number { |
| 1107 | if (isWithHeaders(schema)) { |
| 1108 | return resolveHttpApiStatus(schema.ast) ?? getStatusError(schema.schema.ast) |
| 1109 | } |
| 1110 | return getStatusError(schema.ast) |
| 1111 | } |