| 1 | /** |
| 2 | * Describes an Effect HTTP API as groups of endpoints. |
| 3 | * |
| 4 | * An `HttpApi` value is data: it has an identifier, annotations, and groups of |
| 5 | * endpoints that describe request inputs, responses, middleware, and route |
| 6 | * metadata. The same description can be used by server builders, generated |
| 7 | * clients, URL builders, OpenAPI generation, and reflection tools. |
| 8 | * |
| 9 | * @stability unstable |
| 10 | * @since 4.0.0 |
| 11 | */ |
| 12 | import type { NonEmptyReadonlyArray } from "../Array.ts" |
| 13 | import * as Context from "../Context.ts" |
| 14 | import type { PathInput } from "../http/HttpRouter.ts" |
| 15 | import * as InternalRecord from "../internal/record.ts" |
| 16 | import { type Pipeable, pipeArguments } from "../Pipeable.ts" |
| 17 | import * as Predicate from "../Predicate.ts" |
| 18 | import * as Record from "../Record.ts" |
| 19 | import type * as Schema from "../Schema.ts" |
| 20 | import type * as SchemaAST from "../SchemaAST.ts" |
| 21 | import * as HttpApiEndpoint from "./HttpApiEndpoint.ts" |
| 22 | import type * as HttpApiGroup from "./HttpApiGroup.ts" |
| 23 | import type * as HttpApiMiddleware from "./HttpApiMiddleware.ts" |
| 24 | import * as HttpApiSchema from "./HttpApiSchema.ts" |
| 25 | |
| 26 | const TypeId = "~effect/http-api/HttpApi" |
| 27 | |
| 28 | /** |
| 29 | * Returns `true` when a value is an `HttpApi`. |
| 30 | * |
| 31 | * @stability unstable |
| 32 | * @category guards |
| 33 | * @since 4.0.0 |
| 34 | */ |
| 35 | export const isHttpApi = (u: unknown): u is Top => Predicate.hasProperty(u, TypeId) |
| 36 | |
| 37 | /** |
| 38 | * Groups indexed by their identifier. |
| 39 | */ |
| 40 | type GroupMap<Groups> = { |
| 41 | readonly [Group in Groups as HttpApiGroup.Identifier<Group>]: Group |
| 42 | } |
| 43 | |
| 44 | /** |
| 45 | * An `HttpApi` is a collection of HTTP API groups and endpoints that represents a |
| 46 | * portion of your domain. |
| 47 | * |
| 48 | * **When to use** |
| 49 | * |
| 50 | * Use when endpoint implementations can be provided with `HttpApiBuilder.group`, and the |
| 51 | * completed API can be registered with `HttpApiBuilder.layer`. |
| 52 | * |
| 53 | * @stability unstable |
| 54 | * @category models |
| 55 | * @since 4.0.0 |
| 56 | */ |
| 57 | export interface HttpApi< |
| 58 | out Id extends string, |
| 59 | in out Groups extends HttpApiGroup.Constraint = never |
| 60 | > extends Pipeable { |
| 61 | new(_: never): {} |
| 62 | readonly [TypeId]: typeof TypeId |
| 63 | readonly identifier: Id |
| 64 | readonly groups: GroupMap<Groups> |
| 65 | readonly annotations: Context.Context<never> |
| 66 | |
| 67 | /** |
| 68 | * Add a `HttpApiGroup` to the `HttpApi`. |
| 69 | */ |
| 70 | add<const A extends NonEmptyReadonlyArray<HttpApiGroup.Constraint>>(...groups: A): HttpApi<Id, Groups | A[number]> |
| 71 | |
| 72 | /** |
| 73 | * Adds every group from another `HttpApi` while preserving its annotation scope. |
| 74 | * |
| 75 | * **When to use** |
| 76 | * |
| 77 | * Use when you want to compose an API from groups declared and annotated under another API. |
| 78 | * |
| 79 | * **Details** |
| 80 | * |
| 81 | * The added API is flattened into this API rather than retained as a nested value. Each added group |
| 82 | * is copied with the added API's annotations, leaving the added API unchanged. Annotation precedence |
| 83 | * from least to most specific is this API, the added API, the group, and then the endpoint. |
| 84 | * |
| 85 | * **Gotchas** |
| 86 | * |
| 87 | * Annotations from the added API do not become top-level annotations of the result and do not affect |
| 88 | * groups already present in this API. They remain scoped to the groups and endpoints being added. |
| 89 | */ |
| 90 | addHttpApi<Id2 extends string, Groups2 extends HttpApiGroup.Constraint>( |
| 91 | api: HttpApi<Id2, Groups2> |
| 92 | ): HttpApi<Id, Groups | Groups2> |
| 93 | |
| 94 | /** |
| 95 | * Prefix all endpoints in the `HttpApi`. |
| 96 | */ |
| 97 | prefix<const Prefix extends PathInput>(prefix: Prefix): HttpApi<Id, HttpApiGroup.AddPrefix<Groups, Prefix>> |
| 98 | |
| 99 | /** |
| 100 | * Adds a middleware to every endpoint currently in the `HttpApi`. |
| 101 | * |
| 102 | * **Gotchas** |
| 103 | * |
| 104 | * Endpoints added after this method is called do not receive the middleware. |
| 105 | */ |
| 106 | middleware<I extends HttpApiMiddleware.AnyId, S>( |
| 107 | middleware: Context.Key<I, S> |
| 108 | ): HttpApi<Id, HttpApiGroup.AddMiddleware<Groups, I>> |
| 109 | |
| 110 | /** |
| 111 | * Annotate the `HttpApi`. |
| 112 | */ |
| 113 | annotate<I, S>(tag: Context.Key<I, S>, value: S): HttpApi<Id, Groups> |
| 114 | |
| 115 | /** |
| 116 | * Annotate the `HttpApi` with a Context. |
| 117 | */ |
| 118 | annotateMerge<I>(context: Context.Context<I>): HttpApi<Id, Groups> |
| 119 | } |
| 120 | |
| 121 | /** |
| 122 | * An `HttpApi` value with its identifier and group types erased. |
| 123 | * |
| 124 | * @stability unstable |
| 125 | * @category models |
| 126 | * @since 4.0.0 |
| 127 | */ |
| 128 | export interface Constraint { |
| 129 | readonly [TypeId]: typeof TypeId |
| 130 | } |
| 131 | |
| 132 | /** |
| 133 | * An `HttpApi` with broad identifier and group types while retaining the concrete |
| 134 | * runtime properties used by implementation helpers. |
| 135 | * |
| 136 | * @stability unstable |
| 137 | * @category models |
| 138 | * @since 4.0.0 |
| 139 | */ |
| 140 | export interface Top extends HttpApi<string, HttpApiGroup.Top> {} |
| 141 | |
| 142 | const Proto = { |
| 143 | [TypeId]: TypeId, |
| 144 | pipe() { |
| 145 | return pipeArguments(this, arguments) |
| 146 | }, |
| 147 | add( |
| 148 | this: Top, |
| 149 | ...toAdd: NonEmptyReadonlyArray<HttpApiGroup.Top> |
| 150 | ) { |
| 151 | const groups = { ...this.groups } |
| 152 | for (const group of toAdd) { |
| 153 | InternalRecord.assignProperty(groups, group.identifier, group) |
| 154 | } |
| 155 | return makeProto({ |
| 156 | ...optionsFromApi(this), |
| 157 | groups |
| 158 | }) |
| 159 | }, |
| 160 | addHttpApi( |
| 161 | this: Top, |
| 162 | api: Top |
| 163 | ) { |
| 164 | const newGroups = { ...this.groups } |
| 165 | for (const key of Object.keys(api.groups)) { |
| 166 | const group = api.groups[key] |
| 167 | InternalRecord.assignProperty( |
| 168 | newGroups, |
| 169 | key, |
| 170 | group.annotateMerge(Context.merge(api.annotations, group.annotations)) |
| 171 | ) |
| 172 | } |
| 173 | return makeProto({ |
| 174 | ...optionsFromApi(this), |
| 175 | groups: newGroups |
| 176 | }) |
| 177 | }, |
| 178 | prefix(this: Top, prefix: PathInput) { |
| 179 | return makeProto({ |
| 180 | ...optionsFromApi(this), |
| 181 | groups: Record.map(this.groups, (group) => group.prefix(prefix)) |
| 182 | }) |
| 183 | }, |
| 184 | middleware(this: Top, tag: HttpApiMiddleware.AnyService) { |
| 185 | return makeProto({ |
| 186 | ...optionsFromApi(this), |
| 187 | groups: Record.map(this.groups, (group) => group.middleware(tag as any)) |
| 188 | }) |
| 189 | }, |
| 190 | annotate(this: Top, key: Context.Key<any, any>, value: any) { |
| 191 | return makeProto({ |
| 192 | ...optionsFromApi(this), |
| 193 | annotations: Context.add(this.annotations, key, value) |
| 194 | }) |
| 195 | }, |
| 196 | annotateMerge(this: Top, annotations: Context.Context<never>) { |
| 197 | return makeProto({ |
| 198 | ...optionsFromApi(this), |
| 199 | annotations: Context.merge(this.annotations, annotations) |
| 200 | }) |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | const optionsFromApi = (api: Top) => ({ |
| 205 | identifier: api.identifier, |
| 206 | groups: api.groups, |
| 207 | annotations: api.annotations |
| 208 | }) |
| 209 | |
| 210 | const makeProto = <Id extends string, Groups extends HttpApiGroup.Constraint>( |
| 211 | options: { |
| 212 | readonly identifier: Id |
| 213 | readonly groups: Record.ReadonlyRecord<string, HttpApiGroup.Constraint> |
| 214 | readonly annotations: Context.Context<never> |
| 215 | } |
| 216 | ): HttpApi<Id, Groups> => { |
| 217 | function HttpApi() {} |
| 218 | Object.setPrototypeOf(HttpApi, Proto) |
| 219 | return Object.assign(HttpApi, options) as any |
| 220 | } |
| 221 | |
| 222 | /** |
| 223 | * Creates an empty `HttpApi` with the supplied identifier. |
| 224 | * |
| 225 | * **When to use** |
| 226 | * |
| 227 | * Use when you need to start defining an HTTP API, add groups with `add` or |
| 228 | * `addHttpApi`, provide endpoint implementations with `HttpApiBuilder.group`, |
| 229 | * and register the API with `HttpApiBuilder.layer`. |
| 230 | * |
| 231 | * @stability unstable |
| 232 | * @category constructors |
| 233 | * @since 4.0.0 |
| 234 | */ |
| 235 | export const make = <const Id extends string>(identifier: Id): HttpApi<Id, never> => |
| 236 | makeProto({ |
| 237 | identifier, |
| 238 | groups: {}, |
| 239 | annotations: Context.empty() |
| 240 | }) |
| 241 | |
| 242 | /** |
| 243 | * Describes the groups and endpoints in an `HttpApi`. |
| 244 | * |
| 245 | * **Details** |
| 246 | * |
| 247 | * The callbacks receive each group or endpoint with merged annotations, endpoint |
| 248 | * middleware, and response schemas grouped by HTTP status. |
| 249 | * |
| 250 | * @stability unstable |
| 251 | * @category reflection |
| 252 | * @since 4.0.0 |
| 253 | */ |
| 254 | export const reflect = <Id extends string, Groups extends HttpApiGroup.Constraint>( |
| 255 | self: HttpApi<Id, Groups>, |
| 256 | options: { |
| 257 | readonly predicate?: |
| 258 | | Predicate.Predicate<{ |
| 259 | readonly endpoint: HttpApiEndpoint.Top |
| 260 | readonly group: HttpApiGroup.Top |
| 261 | }> |
| 262 | | undefined |
| 263 | readonly onGroup: (options: { |
| 264 | readonly group: HttpApiGroup.Top |
| 265 | readonly mergedAnnotations: Context.Context<never> |
| 266 | }) => void |
| 267 | readonly onEndpoint: (options: { |
| 268 | readonly group: HttpApiGroup.Top |
| 269 | readonly endpoint: HttpApiEndpoint.Top |
| 270 | readonly mergedAnnotations: Context.Context<never> |
| 271 | readonly middleware: ReadonlySet<HttpApiMiddleware.AnyService> |
| 272 | readonly successes: ReadonlyMap<number, readonly [Schema.Top, ...Array<Schema.Top>]> |
| 273 | readonly errors: ReadonlyMap<number, readonly [Schema.Top, ...Array<Schema.Top>]> |
| 274 | }) => void |
| 275 | } |
| 276 | ) => { |
| 277 | const groups = Object.values(self.groups) as any as Array<HttpApiGroup.Top> |
| 278 | for (const group of groups) { |
| 279 | const groupAnnotations = Context.merge(self.annotations, group.annotations) |
| 280 | options.onGroup({ |
| 281 | group, |
| 282 | mergedAnnotations: groupAnnotations |
| 283 | }) |
| 284 | const endpoints = Object.values(group.endpoints) as Iterable<HttpApiEndpoint.Top> |
| 285 | for (const endpoint of endpoints) { |
| 286 | if ( |
| 287 | options.predicate && !options.predicate({ |
| 288 | endpoint, |
| 289 | group |
| 290 | } as any) |
| 291 | ) continue |
| 292 | |
| 293 | options.onEndpoint({ |
| 294 | group, |
| 295 | endpoint, |
| 296 | middleware: endpoint.middlewares as any, |
| 297 | mergedAnnotations: Context.merge(groupAnnotations, endpoint.annotations), |
| 298 | successes: extractResponseContent( |
| 299 | HttpApiEndpoint.getSuccessSchemas(endpoint), |
| 300 | HttpApiSchema.getStatusSuccessSchema |
| 301 | ), |
| 302 | errors: extractResponseContent( |
| 303 | HttpApiEndpoint.getErrorSchemas(endpoint), |
| 304 | HttpApiSchema.getStatusErrorSchema |
| 305 | ) |
| 306 | }) |
| 307 | } |
| 308 | } |
| 309 | } |
| 310 | |
| 311 | // ------------------------------------------------------------------------------------- |
| 312 | |
| 313 | const extractResponseContent = ( |
| 314 | schemas: Array<Schema.Top>, |
| 315 | getStatus: (schema: Schema.Constraint) => number |
| 316 | ): ReadonlyMap<number, [Schema.Top, ...Array<Schema.Top>]> => { |
| 317 | const map = new Map<number, [Schema.Top, ...Array<Schema.Top>]>() |
| 318 | |
| 319 | schemas.forEach(add) |
| 320 | |
| 321 | return map |
| 322 | |
| 323 | function add(schema: Schema.Top) { |
| 324 | const body = HttpApiSchema.isWithHeaders(schema) ? schema.schema : schema |
| 325 | if (HttpApiSchema.isStreamSchema(body)) return |
| 326 | const status = getStatus(schema) |
| 327 | const schemas = map.get(status) |
| 328 | if (schemas === undefined) { |
| 329 | map.set(status, [schema]) |
| 330 | } else { |
| 331 | schemas.push(schema) |
| 332 | } |
| 333 | } |
| 334 | } |
| 335 | |
| 336 | /** |
| 337 | * Adds additional schemas to components/schemas. |
| 338 | * The provided schemas must have a `identifier` annotation. |
| 339 | * |
| 340 | * @stability unstable |
| 341 | * @category services |
| 342 | * @since 4.0.0 |
| 343 | */ |
| 344 | export class AdditionalSchemas extends Context.Service< |
| 345 | AdditionalSchemas, |
| 346 | ReadonlyArray<Schema.Constraint> |
| 347 | >()("effect/http-api/HttpApi/AdditionalSchemas") {} |
| 348 | |
| 349 | /** |
| 350 | * Schema parse options for server and client codecs, set on an API, group, or |
| 351 | * endpoint. |
| 352 | * |
| 353 | * **Details** |
| 354 | * |
| 355 | * Each codec slot has its own annotation: |
| 356 | * |
| 357 | * - `ParamsParseOptions` for path params |
| 358 | * - `QueryParseOptions` for the query string |
| 359 | * - `HeadersParseOptions` for request headers and `WithHeaders` response headers |
| 360 | * - `PayloadParseOptions` for request bodies |
| 361 | * - `SuccessParseOptions` for success bodies |
| 362 | * - `ErrorParseOptions` for error bodies |
| 363 | * |
| 364 | * A slot annotation at any level takes precedence over `ParseOptions` at any |
| 365 | * level. If neither is set, Schema defaults apply. Options are replaced, not |
| 366 | * merged. For the same annotation, endpoint overrides group, which overrides |
| 367 | * API. Annotate the API before passing it to `HttpApiBuilder.group` or |
| 368 | * `HttpApiBuilder.endpoint`. |
| 369 | * |
| 370 | * **Gotchas** |
| 371 | * |
| 372 | * Header codecs receive all HTTP headers, including undeclared transport |
| 373 | * headers such as `content-type`, `content-length`, `host`, `user-agent` and |
| 374 | * proxy headers. Without `HeadersParseOptions`, headers use `ParseOptions`: |
| 375 | * |
| 376 | * - `onExcessProperty: "error"` rejects real requests and `WithHeaders` |
| 377 | * responses with transport headers. |
| 378 | * - `onExcessProperty: "preserve"` includes transport headers in the decoded |
| 379 | * value. |
| 380 | * |
| 381 | * Set `HeadersParseOptions` to `{}` at the API level to use Schema defaults for |
| 382 | * headers, even if an endpoint sets `ParseOptions`. |
| 383 | * |
| 384 | * **Example** (Strict bodies with default header parsing) |
| 385 | * |
| 386 | * ```ts import.meta.vitest |
| 387 | * import { Schema } from "effect" |
| 388 | * import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/http-api" |
| 389 | * |
| 390 | * const api = HttpApi.make("Api") |
| 391 | * .add( |
| 392 | * HttpApiGroup.make("users").add( |
| 393 | * HttpApiEndpoint.post("create", "/users", { |
| 394 | * headers: { "x-api-key": Schema.String }, |
| 395 | * payload: { name: Schema.String } |
| 396 | * }) |
| 397 | * ) |
| 398 | * ) |
| 399 | * .annotate(HttpApi.ParseOptions, { onExcessProperty: "error" }) |
| 400 | * .annotate(HttpApi.HeadersParseOptions, {}) |
| 401 | * ``` |
| 402 | * |
| 403 | * @stability unstable |
| 404 | * @category services |
| 405 | * @since 4.0.0 |
| 406 | */ |
| 407 | export class ParseOptions extends Context.Service< |
| 408 | ParseOptions, |
| 409 | SchemaAST.ParseOptions |
| 410 | >()("effect/http-api/HttpApi/ParseOptions") {} |
| 411 | |
| 412 | /** |
| 413 | * Schema parse options for path params: server decoding, client encoding, and |
| 414 | * `HttpApiClient.urlBuilder`. Falls back to `ParseOptions` when unset. |
| 415 | * |
| 416 | * @stability unstable |
| 417 | * @category services |
| 418 | * @since 4.0.0 |
| 419 | */ |
| 420 | export class ParamsParseOptions extends Context.Service< |
| 421 | ParamsParseOptions, |
| 422 | SchemaAST.ParseOptions |
| 423 | >()("effect/http-api/HttpApi/ParamsParseOptions") {} |
| 424 | |
| 425 | /** |
| 426 | * Schema parse options for the query string: server decoding, client encoding, |
| 427 | * and `HttpApiClient.urlBuilder`. Falls back to `ParseOptions` when unset. |
| 428 | * |
| 429 | * @stability unstable |
| 430 | * @category services |
| 431 | * @since 4.0.0 |
| 432 | */ |
| 433 | export class QueryParseOptions extends Context.Service< |
| 434 | QueryParseOptions, |
| 435 | SchemaAST.ParseOptions |
| 436 | >()("effect/http-api/HttpApi/QueryParseOptions") {} |
| 437 | |
| 438 | /** |
| 439 | * Schema parse options for request headers and the headers of `WithHeaders` |
| 440 | * responses: server decoding/encoding and client encoding/decoding, including |
| 441 | * buffered and streamed responses. Falls back to `ParseOptions` when unset. |
| 442 | * |
| 443 | * @stability unstable |
| 444 | * @category services |
| 445 | * @since 4.0.0 |
| 446 | */ |
| 447 | export class HeadersParseOptions extends Context.Service< |
| 448 | HeadersParseOptions, |
| 449 | SchemaAST.ParseOptions |
| 450 | >()("effect/http-api/HttpApi/HeadersParseOptions") {} |
| 451 | |
| 452 | /** |
| 453 | * Schema parse options for request bodies, including multipart payloads: |
| 454 | * server decoding and client encoding. Falls back to `ParseOptions` when unset. |
| 455 | * |
| 456 | * @stability unstable |
| 457 | * @category services |
| 458 | * @since 4.0.0 |
| 459 | */ |
| 460 | export class PayloadParseOptions extends Context.Service< |
| 461 | PayloadParseOptions, |
| 462 | SchemaAST.ParseOptions |
| 463 | >()("effect/http-api/HttpApi/PayloadParseOptions") {} |
| 464 | |
| 465 | /** |
| 466 | * Schema parse options for success bodies, including streams, SSE events, and |
| 467 | * the body of `WithHeaders` responses: server encoding and client decoding. Falls back to `ParseOptions` when unset. |
| 468 | * |
| 469 | * @stability unstable |
| 470 | * @category services |
| 471 | * @since 4.0.0 |
| 472 | */ |
| 473 | export class SuccessParseOptions extends Context.Service< |
| 474 | SuccessParseOptions, |
| 475 | SchemaAST.ParseOptions |
| 476 | >()("effect/http-api/HttpApi/SuccessParseOptions") {} |
| 477 | |
| 478 | /** |
| 479 | * Schema parse options for error bodies: server encoding and client decoding. Falls back to `ParseOptions` when unset. |
| 480 | * |
| 481 | * @stability unstable |
| 482 | * @category services |
| 483 | * @since 4.0.0 |
| 484 | */ |
| 485 | export class ErrorParseOptions extends Context.Service< |
| 486 | ErrorParseOptions, |
| 487 | SchemaAST.ParseOptions |
| 488 | >()("effect/http-api/HttpApi/ErrorParseOptions") {} |
| 489 | |
| 490 | /** |
| 491 | * @internal |
| 492 | */ |
| 493 | export interface SlotParseOptions { |
| 494 | readonly params: SchemaAST.ParseOptions | undefined |
| 495 | readonly query: SchemaAST.ParseOptions | undefined |
| 496 | readonly headers: SchemaAST.ParseOptions | undefined |
| 497 | readonly payload: SchemaAST.ParseOptions | undefined |
| 498 | readonly success: SchemaAST.ParseOptions | undefined |
| 499 | readonly error: SchemaAST.ParseOptions | undefined |
| 500 | } |
| 501 | |
| 502 | /** |
| 503 | * @internal |
| 504 | */ |
| 505 | export const getSlotParseOptions = (annotations: Context.Context<never>): SlotParseOptions => { |
| 506 | const fallback = Context.getOrUndefined(annotations, ParseOptions) |
| 507 | return { |
| 508 | params: Context.getOrUndefined(annotations, ParamsParseOptions) ?? fallback, |
| 509 | query: Context.getOrUndefined(annotations, QueryParseOptions) ?? fallback, |
| 510 | headers: Context.getOrUndefined(annotations, HeadersParseOptions) ?? fallback, |
| 511 | payload: Context.getOrUndefined(annotations, PayloadParseOptions) ?? fallback, |
| 512 | success: Context.getOrUndefined(annotations, SuccessParseOptions) ?? fallback, |
| 513 | error: Context.getOrUndefined(annotations, ErrorParseOptions) ?? fallback |
| 514 | } |
| 515 | } |