| 1 | /** |
| 2 | * Builds server routes from declarative `HttpApi` contracts. |
| 3 | * |
| 4 | * This module turns an `HttpApi` description plus group handlers into |
| 5 | * `HttpRouter` routes. At runtime it decodes request parts with schemas, runs |
| 6 | * middleware and security handlers, invokes the registered endpoint handler, and |
| 7 | * encodes successes or declared errors into `HttpServerResponse` values. |
| 8 | * |
| 9 | * @stability unstable |
| 10 | * @since 4.0.0 |
| 11 | */ |
| 12 | import type { NonEmptyReadonlyArray } from "../Array.ts" |
| 13 | import type * as Cause from "../Cause.ts" |
| 14 | import * as Context from "../Context.ts" |
| 15 | import * as Effect from "../Effect.ts" |
| 16 | import * as Base64 from "../encoding/Base64.ts" |
| 17 | import * as Sse from "../encoding/Sse.ts" |
| 18 | import * as Fiber from "../Fiber.ts" |
| 19 | import type { FileSystem } from "../FileSystem.ts" |
| 20 | import * as Function from "../Function.ts" |
| 21 | import type { Cookie } from "../http/Cookies.ts" |
| 22 | import type * as Etag from "../http/Etag.ts" |
| 23 | import * as HttpEffect from "../http/HttpEffect.ts" |
| 24 | import * as HttpMethod from "../http/HttpMethod.ts" |
| 25 | import type { HttpPlatform } from "../http/HttpPlatform.ts" |
| 26 | import * as HttpRouter from "../http/HttpRouter.ts" |
| 27 | import * as Request from "../http/HttpServerRequest.ts" |
| 28 | import { HttpServerRequest } from "../http/HttpServerRequest.ts" |
| 29 | import * as Response from "../http/HttpServerResponse.ts" |
| 30 | import type { HttpServerResponse } from "../http/HttpServerResponse.ts" |
| 31 | import * as Multipart from "../http/Multipart.ts" |
| 32 | import * as UrlParams from "../http/UrlParams.ts" |
| 33 | import { stringOrRedacted } from "../internal/redacted.ts" |
| 34 | import * as Layer from "../Layer.ts" |
| 35 | import type { Path } from "../Path.ts" |
| 36 | import { type Pipeable, pipeArguments } from "../Pipeable.ts" |
| 37 | import { hasProperty } from "../Predicate.ts" |
| 38 | import * as Redacted from "../Redacted.ts" |
| 39 | import * as Result from "../Result.ts" |
| 40 | import * as Schema from "../Schema.ts" |
| 41 | import * as SchemaAST from "../SchemaAST.ts" |
| 42 | import * as SchemaIssue from "../SchemaIssue.ts" |
| 43 | import * as SchemaTransformation from "../SchemaTransformation.ts" |
| 44 | import * as Scope from "../Scope.ts" |
| 45 | import * as Stream from "../Stream.ts" |
| 46 | import type { NoInfer } from "../Types.ts" |
| 47 | import * as UndefinedOr from "../UndefinedOr.ts" |
| 48 | import * as HttpApi from "./HttpApi.ts" |
| 49 | import * as HttpApiEndpoint from "./HttpApiEndpoint.ts" |
| 50 | import { HttpApiSchemaError } from "./HttpApiError.ts" |
| 51 | import type * as HttpApiGroup from "./HttpApiGroup.ts" |
| 52 | import * as HttpApiMiddleware from "./HttpApiMiddleware.ts" |
| 53 | import * as HttpApiSchema from "./HttpApiSchema.ts" |
| 54 | import type * as HttpApiSecurity from "./HttpApiSecurity.ts" |
| 55 | import * as MediaType from "./internal/mediaType.ts" |
| 56 | import * as HttpApiPath from "./internal/path.ts" |
| 57 | import * as OpenApi from "./OpenApi.ts" |
| 58 | |
| 59 | /** |
| 60 | * Registers an `HttpApi` with a `HttpRouter`. |
| 61 | * |
| 62 | * @stability unstable |
| 63 | * @category layers |
| 64 | * @since 4.0.0 |
| 65 | */ |
| 66 | export const layer = <Id extends string, Groups extends HttpApiGroup.Constraint>( |
| 67 | api: HttpApi.HttpApi<Id, Groups>, |
| 68 | options?: { |
| 69 | readonly openapiPath?: `/${string}` | undefined |
| 70 | } |
| 71 | ): Layer.Layer< |
| 72 | never, |
| 73 | never, |
| 74 | | Etag.Generator |
| 75 | | HttpRouter.HttpRouter |
| 76 | | FileSystem |
| 77 | | HttpPlatform |
| 78 | | Path |
| 79 | | HttpApiGroup.ToService<Id, Groups> |
| 80 | > => |
| 81 | HttpRouter.use(Effect.fnUntraced(function*(router) { |
| 82 | const services = yield* Effect.context< |
| 83 | | Etag.Generator |
| 84 | | HttpRouter.HttpRouter |
| 85 | | FileSystem |
| 86 | | HttpPlatform |
| 87 | | Path |
| 88 | >() |
| 89 | const routes: Array<HttpRouter.Route<any, any>> = [] |
| 90 | const availableGroups = Array.from(services.mapUnsafe.keys()).filter((key) => |
| 91 | key.startsWith("effect/http-api/HttpApiGroup/") |
| 92 | ) |
| 93 | const groups = Object.values(api.groups) as ReadonlyArray<HttpApiGroup.Top> |
| 94 | for (const group of groups) { |
| 95 | const groupRoutes = services.mapUnsafe.get(group.key)?.routes as Array<HttpRouter.Route<any, any>> |
| 96 | if (groupRoutes === undefined) { |
| 97 | const available = availableGroups.length === 0 ? "none" : availableGroups.join(", ") |
| 98 | return yield* Effect.die( |
| 99 | `HttpApiGroup "${group.identifier}" not found (key: "${group.key}"). Did you forget to provide HttpApiBuilder.group(api, "${group.identifier}", ...)? Available groups: ${available}` |
| 100 | ) |
| 101 | } |
| 102 | routes.push(...groupRoutes) |
| 103 | } |
| 104 | yield* (router.addAll(routes) as Effect.Effect<void>) |
| 105 | if (options?.openapiPath) { |
| 106 | const makeResponse = Function.memoize((api: HttpApi.HttpApi<Id, Groups>): HttpServerResponse => |
| 107 | Response.jsonUnsafe(OpenApi.fromApi(api)) |
| 108 | ) |
| 109 | yield* router.add( |
| 110 | "GET", |
| 111 | options.openapiPath, |
| 112 | Effect.sync(() => makeResponse(api)) |
| 113 | ) |
| 114 | } |
| 115 | })) |
| 116 | |
| 117 | /** |
| 118 | * Create a `Layer` that implements all endpoints in an `HttpApi` group. |
| 119 | * |
| 120 | * **Details** |
| 121 | * |
| 122 | * The `build` function receives an unimplemented `Handlers` instance that can |
| 123 | * be used to add handlers to the group. Implement endpoints with |
| 124 | * `handlers.handle`. |
| 125 | * |
| 126 | * @stability unstable |
| 127 | * @category handlers |
| 128 | * @since 4.0.0 |
| 129 | */ |
| 130 | export const group = < |
| 131 | ApiId extends string, |
| 132 | Groups extends HttpApiGroup.Constraint, |
| 133 | const Identifier extends HttpApiGroup.Identifier<Groups>, |
| 134 | Return |
| 135 | >( |
| 136 | api: HttpApi.HttpApi<ApiId, Groups>, |
| 137 | groupIdentifier: Identifier, |
| 138 | build: ( |
| 139 | handlers: Handlers.FromGroup<HttpApiGroup.WithIdentifier<Groups, Identifier>> |
| 140 | ) => Handlers.ValidateReturn<Return> |
| 141 | ): Layer.Layer< |
| 142 | HttpApiGroup.Service<ApiId, Identifier>, |
| 143 | Handlers.Error<Return>, |
| 144 | Exclude<Handlers.Context<Return>, Scope.Scope> |
| 145 | > => |
| 146 | Layer.effectContext(Effect.gen(function*() { |
| 147 | const services = (yield* Effect.context<any>()).pipe( |
| 148 | Context.omit(Scope.Scope) |
| 149 | ) |
| 150 | const group = api.groups[groupIdentifier] as HttpApiGroup.Constraint |
| 151 | const result = build(makeHandlers(group)) |
| 152 | const handlers: Handlers<any, any, any> = Effect.isEffect(result) |
| 153 | ? (yield* result as Effect.Effect<any, any, any>) |
| 154 | : result |
| 155 | const routes: Array<HttpRouter.Route<any, any>> = [] |
| 156 | for (const item of handlers.handlers.values()) { |
| 157 | routes.push(handlerToRoute(api, group as any, item, services)) |
| 158 | } |
| 159 | return Context.makeUnsafe( |
| 160 | new Map([[group.key, { |
| 161 | routes, |
| 162 | handlers: handlers.handlers |
| 163 | }]]) |
| 164 | ) |
| 165 | })) as any |
| 166 | |
| 167 | /** |
| 168 | * Creates a reusable handler for a single endpoint in an API group. |
| 169 | * |
| 170 | * **Details** |
| 171 | * |
| 172 | * Returns the supplied callback unchanged, with its request, success, and error |
| 173 | * types inferred from the endpoint, preserving the callback's service |
| 174 | * requirements. Pass the result to `handlers.handle` when implementing the group. |
| 175 | * |
| 176 | * @stability unstable |
| 177 | * @category handlers |
| 178 | * @since 4.0.0 |
| 179 | */ |
| 180 | export const handler = < |
| 181 | ApiId extends string, |
| 182 | Groups extends HttpApiGroup.Constraint, |
| 183 | const GroupIdentifier extends HttpApiGroup.Identifier<Groups>, |
| 184 | const EndpointIdentifier extends HttpApiGroup.EndpointsWithIdentifier<Groups, GroupIdentifier>["identifier"], |
| 185 | R |
| 186 | >( |
| 187 | _api: HttpApi.HttpApi<ApiId, Groups>, |
| 188 | _groupIdentifier: GroupIdentifier, |
| 189 | _endpointIdentifier: EndpointIdentifier, |
| 190 | f: HttpApiEndpoint.HandlerWithIdentifier< |
| 191 | HttpApiGroup.EndpointsWithIdentifier<Groups, GroupIdentifier>, |
| 192 | EndpointIdentifier, |
| 193 | HttpApiEndpoint.MiddlewareError< |
| 194 | HttpApiEndpoint.WithIdentifier< |
| 195 | HttpApiGroup.EndpointsWithIdentifier<Groups, GroupIdentifier>, |
| 196 | EndpointIdentifier |
| 197 | > |
| 198 | >, |
| 199 | R |
| 200 | > |
| 201 | ): HttpApiEndpoint.HandlerWithIdentifier< |
| 202 | HttpApiGroup.EndpointsWithIdentifier<Groups, GroupIdentifier>, |
| 203 | EndpointIdentifier, |
| 204 | HttpApiEndpoint.MiddlewareError< |
| 205 | HttpApiEndpoint.WithIdentifier< |
| 206 | HttpApiGroup.EndpointsWithIdentifier<Groups, GroupIdentifier>, |
| 207 | EndpointIdentifier |
| 208 | > |
| 209 | >, |
| 210 | R |
| 211 | > => f |
| 212 | |
| 213 | const HandlersTypeId = "~effect/http-api/HttpApiBuilder/Handlers" |
| 214 | |
| 215 | type EndpointMap<Endpoints extends HttpApiEndpoint.Constraint> = { |
| 216 | readonly [Endpoint in Endpoints as HttpApiEndpoint.Identifier<Endpoint>]: Endpoint |
| 217 | } |
| 218 | |
| 219 | type HandlerRequirements< |
| 220 | Endpoint extends HttpApiEndpoint.Constraint, |
| 221 | R1, |
| 222 | R = HttpApiEndpoint.ExcludeProvided< |
| 223 | Endpoint, |
| 224 | R1 | HttpApiEndpoint.ServerServices<Endpoint> |
| 225 | > |
| 226 | > = |
| 227 | | HttpApiEndpoint.Middleware<Endpoint> |
| 228 | | HttpApiEndpoint.MiddlewareServices<Endpoint> |
| 229 | | ([R] extends [never] ? never : HttpRouter.Request.From<"Requires", R>) |
| 230 | |
| 231 | interface HandlerOptions { |
| 232 | readonly uninterruptible?: boolean | undefined |
| 233 | } |
| 234 | |
| 235 | /** |
| 236 | * @internal |
| 237 | */ |
| 238 | export interface HandlerRuntime { |
| 239 | readonly endpoint: HttpApiEndpoint.Top |
| 240 | readonly handler: HttpApiEndpoint.Handler<HttpApiEndpoint.Constraint, unknown, unknown> |
| 241 | readonly isRaw: boolean |
| 242 | readonly uninterruptible: boolean |
| 243 | } |
| 244 | |
| 245 | type HandleAllEntry<Endpoint extends HttpApiEndpoint.Constraint> = |
| 246 | | HttpApiEndpoint.Handler< |
| 247 | Endpoint, |
| 248 | HttpApiEndpoint.MiddlewareError<Endpoint>, |
| 249 | unknown |
| 250 | > |
| 251 | | { |
| 252 | readonly handler: HttpApiEndpoint.Handler< |
| 253 | Endpoint, |
| 254 | HttpApiEndpoint.MiddlewareError<Endpoint>, |
| 255 | unknown |
| 256 | > |
| 257 | readonly options?: HandlerOptions | undefined |
| 258 | } |
| 259 | |
| 260 | type HandleAllHandlers<EndpointsByIdentifier extends Record<string, HttpApiEndpoint.Constraint>> = { |
| 261 | readonly [Identifier in keyof EndpointsByIdentifier]?: HandleAllEntry<EndpointsByIdentifier[Identifier]> |
| 262 | } |
| 263 | |
| 264 | type HandleAllExtraKeys< |
| 265 | EndpointsByIdentifier extends Record<string, HttpApiEndpoint.Constraint>, |
| 266 | HandlersByIdentifier |
| 267 | > = { |
| 268 | readonly [Identifier in Exclude<keyof HandlersByIdentifier, keyof EndpointsByIdentifier>]: never |
| 269 | } |
| 270 | |
| 271 | type NotHandledIdentifier<Identifier extends PropertyKey, HandledIdentifiers extends PropertyKey> = Identifier extends |
| 272 | HandledIdentifiers ? never : |
| 273 | unknown |
| 274 | |
| 275 | type HandleAllEntryHandler<Entry> = Entry extends { readonly handler: infer Handler } ? Handler : Entry |
| 276 | |
| 277 | type HandleAllRequirements< |
| 278 | EndpointsByIdentifier extends Record<string, HttpApiEndpoint.Constraint>, |
| 279 | HandlersByIdentifier extends HandleAllHandlers<EndpointsByIdentifier> |
| 280 | > = { |
| 281 | readonly [Identifier in keyof HandlersByIdentifier & keyof EndpointsByIdentifier]: HandleAllEntryHandler< |
| 282 | HandlersByIdentifier[Identifier] |
| 283 | > extends HttpApiEndpoint.Handler< |
| 284 | EndpointsByIdentifier[Identifier], |
| 285 | HttpApiEndpoint.MiddlewareError<EndpointsByIdentifier[Identifier]>, |
| 286 | infer R1 |
| 287 | > ? HandlerRequirements<EndpointsByIdentifier[Identifier], R1> : |
| 288 | never |
| 289 | }[keyof HandlersByIdentifier & keyof EndpointsByIdentifier] |
| 290 | |
| 291 | type HandlersResult<A> = A extends Effect.Effect<infer H, any, any> ? H : A |
| 292 | |
| 293 | type MissingHandlerNames<H extends Handlers<any, any, any>> = Exclude< |
| 294 | keyof H["~EndpointsByIdentifier"], |
| 295 | H["~HandledIdentifiers"] |
| 296 | > |
| 297 | |
| 298 | type ValidateHandlersReturn< |
| 299 | A, |
| 300 | H = HandlersResult<A>, |
| 301 | Missing = H extends Handlers<any, any, any> ? MissingHandlerNames<H> : never |
| 302 | > = H extends Handlers<any, any, any> ? [Missing] extends [never] ? A |
| 303 | : `Endpoint not handled: ${Missing & string}` |
| 304 | : `Must return the implemented handlers` |
| 305 | |
| 306 | /** |
| 307 | * Mutable handler collection for one `HttpApi` group. |
| 308 | * |
| 309 | * **Details** |
| 310 | * |
| 311 | * Each call to `handle` or `handleRaw` registers an endpoint implementation and |
| 312 | * adds that endpoint identifier to the type-level set of implemented endpoints. |
| 313 | * Endpoint identifiers that were already handled are rejected at the type level. |
| 314 | * |
| 315 | * @stability unstable |
| 316 | * @category handlers |
| 317 | * @since 4.0.0 |
| 318 | */ |
| 319 | export interface Handlers< |
| 320 | R, |
| 321 | EndpointsByIdentifier extends Record<string, HttpApiEndpoint.Constraint> = {}, |
| 322 | HandledIdentifiers extends keyof EndpointsByIdentifier = never |
| 323 | > extends Pipeable { |
| 324 | readonly [HandlersTypeId]: typeof HandlersTypeId |
| 325 | readonly "~EndpointsByIdentifier": EndpointsByIdentifier |
| 326 | readonly "~HandledIdentifiers": HandledIdentifiers |
| 327 | /** |
| 328 | * @internal |
| 329 | */ |
| 330 | readonly group: HttpApiGroup.Top |
| 331 | /** |
| 332 | * @internal |
| 333 | */ |
| 334 | readonly handlers: Map<string, HandlerRuntime> |
| 335 | |
| 336 | /** |
| 337 | * Add the implementation for an unhandled `HttpApiEndpoint` to a `Handlers` group. |
| 338 | */ |
| 339 | handle< |
| 340 | Identifier extends keyof EndpointsByIdentifier, |
| 341 | R1 |
| 342 | >( |
| 343 | identifier: Identifier & NotHandledIdentifier<Identifier, HandledIdentifiers>, |
| 344 | handler: HttpApiEndpoint.Handler< |
| 345 | EndpointsByIdentifier[Identifier], |
| 346 | HttpApiEndpoint.MiddlewareError<EndpointsByIdentifier[Identifier]>, |
| 347 | R1 |
| 348 | >, |
| 349 | options?: { readonly uninterruptible?: boolean | undefined } | undefined |
| 350 | ): Handlers< |
| 351 | R | HandlerRequirements<EndpointsByIdentifier[Identifier], R1>, |
| 352 | EndpointsByIdentifier, |
| 353 | HandledIdentifiers | Identifier |
| 354 | > |
| 355 | |
| 356 | /** |
| 357 | * Add implementations for unhandled `HttpApiEndpoint`s in a `Handlers` group. |
| 358 | */ |
| 359 | handleAll<const HandlersByIdentifier extends HandleAllHandlers<Omit<EndpointsByIdentifier, HandledIdentifiers>>>( |
| 360 | handlers: |
| 361 | & HandlersByIdentifier |
| 362 | & HandleAllExtraKeys<Omit<EndpointsByIdentifier, HandledIdentifiers>, HandlersByIdentifier> |
| 363 | ): Handlers< |
| 364 | R | HandleAllRequirements<EndpointsByIdentifier, HandlersByIdentifier>, |
| 365 | EndpointsByIdentifier, |
| 366 | HandledIdentifiers | keyof HandlersByIdentifier & keyof EndpointsByIdentifier |
| 367 | > |
| 368 | |
| 369 | /** |
| 370 | * Add the implementation for an unhandled `HttpApiEndpoint` to a `Handlers` group. |
| 371 | * This version opts out of automatic payload decoding and provides the raw request. |
| 372 | */ |
| 373 | handleRaw< |
| 374 | Identifier extends keyof EndpointsByIdentifier, |
| 375 | R1 |
| 376 | >( |
| 377 | identifier: Identifier & NotHandledIdentifier<Identifier, HandledIdentifiers>, |
| 378 | handler: HttpApiEndpoint.HandlerRaw< |
| 379 | EndpointsByIdentifier[Identifier], |
| 380 | HttpApiEndpoint.MiddlewareError<EndpointsByIdentifier[Identifier]>, |
| 381 | R1 |
| 382 | >, |
| 383 | options?: { readonly uninterruptible?: boolean | undefined } | undefined |
| 384 | ): Handlers< |
| 385 | R | HandlerRequirements<EndpointsByIdentifier[Identifier], R1>, |
| 386 | EndpointsByIdentifier, |
| 387 | HandledIdentifiers | Identifier |
| 388 | > |
| 389 | } |
| 390 | |
| 391 | /** |
| 392 | * Namespace containing helper types for `HttpApiBuilder` handler collections. |
| 393 | * |
| 394 | * @stability unstable |
| 395 | * @since 4.0.0 |
| 396 | */ |
| 397 | export declare namespace Handlers { |
| 398 | /** |
| 399 | * Creates a handler collection for a group where every endpoint in the group is |
| 400 | * still awaiting an implementation. |
| 401 | * |
| 402 | * @stability unstable |
| 403 | * @category handlers |
| 404 | * @since 4.0.0 |
| 405 | */ |
| 406 | export type FromGroup<Group extends HttpApiGroup.Constraint> = Handlers< |
| 407 | never, |
| 408 | Group["endpoints"] |
| 409 | > |
| 410 | |
| 411 | /** |
| 412 | * Validates the return value of a group handler builder, preserving successful |
| 413 | * handler collections and producing a descriptive type error when endpoints remain |
| 414 | * unhandled. |
| 415 | * |
| 416 | * @stability unstable |
| 417 | * @category handlers |
| 418 | * @since 4.0.0 |
| 419 | */ |
| 420 | export type ValidateReturn<A> = ValidateHandlersReturn<A> |
| 421 | |
| 422 | /** |
| 423 | * Extracts the error channel from an effect that produces a `Handlers` |
| 424 | * collection, returning `never` for non-effectful handler collections. |
| 425 | * |
| 426 | * @stability unstable |
| 427 | * @category handlers |
| 428 | * @since 4.0.0 |
| 429 | */ |
| 430 | export type Error<A> = A extends Effect.Effect< |
| 431 | Handlers< |
| 432 | infer _R, |
| 433 | infer _EndpointsByIdentifier, |
| 434 | infer _HandledIdentifiers |
| 435 | >, |
| 436 | infer _EX, |
| 437 | infer _RX |
| 438 | > ? _EX : |
| 439 | never |
| 440 | |
| 441 | /** |
| 442 | * Extracts the services required by a handler collection, including both handler |
| 443 | * requirements and the environment required to construct the handlers. |
| 444 | * |
| 445 | * @stability unstable |
| 446 | * @category handlers |
| 447 | * @since 4.0.0 |
| 448 | */ |
| 449 | export type Context<A> = A extends Handlers< |
| 450 | infer _R, |
| 451 | infer _EndpointsByIdentifier, |
| 452 | infer _HandledIdentifiers |
| 453 | > ? _R : |
| 454 | A extends Effect.Effect< |
| 455 | Handlers< |
| 456 | infer _R, |
| 457 | infer _EndpointsByIdentifier, |
| 458 | infer _HandledIdentifiers |
| 459 | >, |
| 460 | infer _EX, |
| 461 | infer _RX |
| 462 | > ? _R | _RX : |
| 463 | never |
| 464 | } |
| 465 | |
| 466 | type EndpointReturn< |
| 467 | Groups extends HttpApiGroup.Constraint, |
| 468 | GroupIdentifier extends HttpApiGroup.Identifier<Groups>, |
| 469 | EndpointIdentifier extends HttpApiGroup.Endpoints< |
| 470 | HttpApiGroup.WithIdentifier<Groups, GroupIdentifier> |
| 471 | >["identifier"], |
| 472 | R, |
| 473 | Endpoint extends HttpApiEndpoint.Constraint = HttpApiEndpoint.WithIdentifier< |
| 474 | HttpApiGroup.Endpoints<HttpApiGroup.WithIdentifier<Groups, GroupIdentifier>>, |
| 475 | EndpointIdentifier |
| 476 | > |
| 477 | > = Effect.Effect< |
| 478 | Effect.Effect< |
| 479 | HttpServerResponse, |
| 480 | never, |
| 481 | | HttpServerRequest |
| 482 | | HttpRouter.RouteContext |
| 483 | | Request.ParsedSearchParams |
| 484 | | Exclude<R, HttpApiEndpoint.MiddlewareProvides<Endpoint>> |
| 485 | >, |
| 486 | never, |
| 487 | | HttpApiEndpoint.ServerServices<Endpoint> |
| 488 | | HttpApiEndpoint.Middleware<Endpoint> |
| 489 | | HttpApiEndpoint.MiddlewareServices<Endpoint> |
| 490 | | Etag.Generator |
| 491 | | FileSystem |
| 492 | | HttpPlatform |
| 493 | | Path |
| 494 | > |
| 495 | |
| 496 | /** |
| 497 | * Builds the server-side HTTP effect for a single endpoint in an API group using |
| 498 | * the endpoint metadata, middleware, codecs, and supplied handler. |
| 499 | * |
| 500 | * @stability unstable |
| 501 | * @category handlers |
| 502 | * @since 4.0.0 |
| 503 | */ |
| 504 | export const endpoint = < |
| 505 | ApiId extends string, |
| 506 | Groups extends HttpApiGroup.Constraint, |
| 507 | const GroupIdentifier extends HttpApiGroup.Identifier<Groups>, |
| 508 | const EndpointIdentifier extends HttpApiGroup.Endpoints< |
| 509 | HttpApiGroup.WithIdentifier<Groups, GroupIdentifier> |
| 510 | >["identifier"], |
| 511 | R |
| 512 | >( |
| 513 | api: HttpApi.HttpApi<ApiId, Groups>, |
| 514 | groupIdentifier: GroupIdentifier, |
| 515 | endpointIdentifier: EndpointIdentifier, |
| 516 | handler: NoInfer< |
| 517 | HttpApiEndpoint.HandlerWithIdentifier< |
| 518 | HttpApiGroup.Endpoints<HttpApiGroup.WithIdentifier<Groups, GroupIdentifier>>, |
| 519 | EndpointIdentifier, |
| 520 | never, |
| 521 | R |
| 522 | > |
| 523 | > |
| 524 | ): EndpointReturn<Groups, GroupIdentifier, EndpointIdentifier, R> => |
| 525 | Effect.contextWith((context: Context.Context<any>) => { |
| 526 | const group = api.groups[groupIdentifier] as unknown as HttpApiGroup.Top |
| 527 | const endpoint = group.endpoints[endpointIdentifier] |
| 528 | return Effect.succeed(handlerToHttpEffect( |
| 529 | api, |
| 530 | group, |
| 531 | endpoint, |
| 532 | Context.omit(Scope.Scope)(context), |
| 533 | handler as any, |
| 534 | false |
| 535 | )) |
| 536 | }) |
| 537 | |
| 538 | /** |
| 539 | * Decodes credentials for an HTTP API security scheme from the current request, |
| 540 | * supporting bearer, API key, and basic authentication inputs. |
| 541 | * |
| 542 | * @stability unstable |
| 543 | * @category security |
| 544 | * @since 4.0.0 |
| 545 | */ |
| 546 | export const securityDecode = <Security extends HttpApiSecurity.HttpApiSecurity>( |
| 547 | self: Security |
| 548 | ): Effect.Effect< |
| 549 | HttpApiSecurity.HttpApiSecurity.Type<Security>, |
| 550 | never, |
| 551 | HttpServerRequest | Request.ParsedSearchParams |
| 552 | > => { |
| 553 | switch (self._tag) { |
| 554 | case "Http": { |
| 555 | return Effect.map( |
| 556 | HttpServerRequest, |
| 557 | (request) => |
| 558 | Redacted.make( |
| 559 | getAuthorizationCredential(request.headers.authorization, self.scheme) ?? "" |
| 560 | ) as any |
| 561 | ) |
| 562 | } |
| 563 | case "ApiKey": { |
| 564 | const key = self.in === "header" ? self.key.toLowerCase() : self.key |
| 565 | const schema = Schema.Struct({ |
| 566 | [key]: Schema.String |
| 567 | }) |
| 568 | const decode: Effect.Effect< |
| 569 | { readonly [x: string]: string; readonly [x: number]: string }, |
| 570 | Schema.SchemaError, |
| 571 | Request.ParsedSearchParams | HttpServerRequest |
| 572 | > = self.in === "query" |
| 573 | ? Request.schemaSearchParams(schema) |
| 574 | : self.in === "cookie" |
| 575 | ? Request.schemaCookies(schema) |
| 576 | : Request.schemaHeaders(schema) |
| 577 | return Effect.match(decode, { |
| 578 | onFailure: () => Redacted.make("") as any, |
| 579 | onSuccess: (match) => Redacted.make(match[key]) |
| 580 | }) |
| 581 | } |
| 582 | case "Basic": { |
| 583 | const empty: HttpApiSecurity.HttpApiSecurity.Type<Security> = { |
| 584 | username: "", |
| 585 | password: Redacted.make("") |
| 586 | } as any |
| 587 | return Effect.map(HttpServerRequest, (request) => { |
| 588 | const encoded = getAuthorizationCredential(request.headers.authorization, basicScheme) |
| 589 | if (encoded === undefined) return empty |
| 590 | const decoded = Result.getOrUndefined(Base64.decodeString(encoded)) |
| 591 | if (decoded === undefined) return empty |
| 592 | // RFC 7617, Section 2: only the first colon separates the user-id from the password. |
| 593 | // https://www.rfc-editor.org/rfc/rfc7617.html#section-2 |
| 594 | const separator = decoded.indexOf(":") |
| 595 | if (separator === -1) return empty |
| 596 | return { |
| 597 | username: decoded.slice(0, separator), |
| 598 | password: Redacted.make(decoded.slice(separator + 1)) |
| 599 | } as any |
| 600 | }) |
| 601 | } |
| 602 | } |
| 603 | } |
| 604 | |
| 605 | /** |
| 606 | * Registers a pre-response handler that sets an API-key cookie on the outgoing |
| 607 | * response, defaulting the cookie to `secure` and `httpOnly` unless overridden. |
| 608 | * |
| 609 | * @stability unstable |
| 610 | * @category security |
| 611 | * @since 4.0.0 |
| 612 | */ |
| 613 | export const securitySetCookie = ( |
| 614 | self: HttpApiSecurity.ApiKey, |
| 615 | value: string | Redacted.Redacted, |
| 616 | options?: Cookie["options"] |
| 617 | ): Effect.Effect<void, never, HttpServerRequest> => |
| 618 | HttpEffect.appendPreResponseHandler((_req, response) => |
| 619 | Effect.orDie( |
| 620 | Response.setCookie(response, self.key, stringOrRedacted(value), { |
| 621 | secure: true, |
| 622 | httpOnly: true, |
| 623 | ...options |
| 624 | }) |
| 625 | ) |
| 626 | ) |
| 627 | |
| 628 | // ----------------------------------------------------------------------------- |
| 629 | // Internal |
| 630 | // ----------------------------------------------------------------------------- |
| 631 | |
| 632 | const basicScheme = "Basic" |
| 633 | |
| 634 | function getAuthorizationCredential( |
| 635 | authorization: string | undefined, |
| 636 | scheme: string |
| 637 | ): string | undefined { |
| 638 | const schemeLength = scheme.length |
| 639 | // RFC 9110, Section 11.1: schemes are case-insensitive and credentials follow 1*SP. |
| 640 | // https://www.rfc-editor.org/rfc/rfc9110.html#section-11.1 |
| 641 | if ( |
| 642 | authorization === undefined || |
| 643 | authorization.length <= schemeLength || |
| 644 | authorization.charCodeAt(schemeLength) !== 32 || |
| 645 | authorization.slice(0, schemeLength).toLowerCase() !== scheme.toLowerCase() |
| 646 | ) { |
| 647 | return undefined |
| 648 | } |
| 649 | let credentialStart = schemeLength + 1 |
| 650 | while (authorization.charCodeAt(credentialStart) === 32) { |
| 651 | credentialStart++ |
| 652 | } |
| 653 | return credentialStart === authorization.length ? undefined : authorization.slice(credentialStart) |
| 654 | } |
| 655 | |
| 656 | const registerHandler = ( |
| 657 | self: Handlers<any, any, any>, |
| 658 | identifier: string, |
| 659 | handler: HttpApiEndpoint.Handler<HttpApiEndpoint.Constraint, any, any>, |
| 660 | isRaw: boolean, |
| 661 | options?: HandlerOptions | undefined |
| 662 | ) => { |
| 663 | if (!Object.hasOwn(self.group.endpoints, identifier)) { |
| 664 | throw new Error(`HttpApiEndpoint "${identifier}" not found in HttpApiGroup "${self.group.identifier}"`) |
| 665 | } |
| 666 | if (self.handlers.has(identifier)) { |
| 667 | throw new Error( |
| 668 | `Handler for HttpApiEndpoint "${identifier}" is already registered in HttpApiGroup "${self.group.identifier}"` |
| 669 | ) |
| 670 | } |
| 671 | const endpoint = self.group.endpoints[identifier] |
| 672 | self.handlers.set(identifier, { |
| 673 | endpoint, |
| 674 | handler, |
| 675 | isRaw, |
| 676 | uninterruptible: options?.uninterruptible ?? false |
| 677 | }) |
| 678 | return self |
| 679 | } |
| 680 | |
| 681 | const HandlersProto = { |
| 682 | [HandlersTypeId]: HandlersTypeId, |
| 683 | pipe() { |
| 684 | return pipeArguments(this, arguments) |
| 685 | }, |
| 686 | handle( |
| 687 | this: Handlers<any, any, any>, |
| 688 | identifier: string, |
| 689 | handler: HttpApiEndpoint.Handler<HttpApiEndpoint.Constraint, any, any>, |
| 690 | options?: { readonly uninterruptible?: boolean | undefined } | undefined |
| 691 | ) { |
| 692 | return registerHandler(this, identifier, handler, false, options) |
| 693 | }, |
| 694 | handleAll( |
| 695 | this: Handlers<any, any, any>, |
| 696 | handlers: Record< |
| 697 | string, |
| 698 | HttpApiEndpoint.Handler<HttpApiEndpoint.Constraint, any, any> | { |
| 699 | readonly handler: HttpApiEndpoint.Handler<HttpApiEndpoint.Constraint, any, any> |
| 700 | readonly options?: HandlerOptions | undefined |
| 701 | } |
| 702 | > |
| 703 | ) { |
| 704 | for (const [identifier, entry] of Object.entries(handlers)) { |
| 705 | const handler = typeof entry === "function" ? entry : entry.handler |
| 706 | const options = typeof entry === "function" ? undefined : entry.options |
| 707 | registerHandler(this, identifier, handler, false, options) |
| 708 | } |
| 709 | return this |
| 710 | }, |
| 711 | handleRaw( |
| 712 | this: Handlers<any, any, any>, |
| 713 | identifier: string, |
| 714 | handler: HttpApiEndpoint.Handler<HttpApiEndpoint.Constraint, any, any>, |
| 715 | options?: { readonly uninterruptible?: boolean | undefined } | undefined |
| 716 | ) { |
| 717 | return registerHandler(this, identifier, handler, true, options) |
| 718 | } |
| 719 | } |
| 720 | |
| 721 | const makeHandlers = <R, Group extends HttpApiGroup.Constraint>( |
| 722 | group: Group |
| 723 | ): Handlers<R, EndpointMap<HttpApiGroup.Endpoints<Group>>> => { |
| 724 | const self = Object.create(HandlersProto) |
| 725 | self.group = group |
| 726 | self.handlers = new Map<string, HandlerRuntime>() |
| 727 | return self |
| 728 | } |
| 729 | |
| 730 | type PayloadDecoder = |
| 731 | | { |
| 732 | readonly _tag: "Multipart" |
| 733 | readonly mode: "buffered" | "stream" |
| 734 | readonly limits: Multipart.withLimits.Options | undefined |
| 735 | readonly decode: (input: unknown) => Effect.Effect<unknown, Schema.SchemaError, unknown> |
| 736 | } |
| 737 | | { |
| 738 | readonly _tag: "Json" | "FormUrlEncoded" | "Uint8Array" | "Text" |
| 739 | readonly nullOnEmpty: boolean |
| 740 | readonly decode: (input: unknown) => Effect.Effect<unknown, Schema.SchemaError, unknown> |
| 741 | } |
| 742 | |
| 743 | function buildPayloadDecoders( |
| 744 | payloadMap: HttpApiEndpoint.PayloadMap, |
| 745 | options: SchemaAST.ParseOptions | undefined |
| 746 | ): Map<string, PayloadDecoder> { |
| 747 | const result = new Map<string, PayloadDecoder>() |
| 748 | payloadMap.forEach(({ encoding, schemas }, contentType) => { |
| 749 | const decode = Schema.decodeUnknownEffect(Schema.Union(schemas), options) |
| 750 | if (encoding._tag === "Multipart") { |
| 751 | result.set(contentType, { _tag: "Multipart", mode: encoding.mode, limits: encoding.limits, decode }) |
| 752 | } else { |
| 753 | result.set(contentType, { |
| 754 | _tag: encoding._tag, |
| 755 | decode, |
| 756 | nullOnEmpty: schemas.some((s) => SchemaAST.isNull(SchemaAST.toEncoded(s.ast))) |
| 757 | }) |
| 758 | } |
| 759 | }) |
| 760 | return result |
| 761 | } |
| 762 | |
| 763 | function decodePayload( |
| 764 | payloadBy: Map<string, PayloadDecoder>, |
| 765 | httpRequest: HttpServerRequest, |
| 766 | query: Record<string, string | Array<string>> |
| 767 | ): Effect.Effect<unknown, Schema.SchemaError, unknown> | HttpServerResponse | undefined { |
| 768 | const hasBody = HttpMethod.hasBody(httpRequest.method) |
| 769 | const contentType = hasBody |
| 770 | ? MediaType.normalize(httpRequest.headers["content-type"] ?? "application/json") |
| 771 | : "application/x-www-form-urlencoded" |
| 772 | const existing = payloadBy.get(contentType) |
| 773 | if (!existing) { |
| 774 | return Response.text(`Unsupported content-type: ${contentType}`, { status: 415 }) |
| 775 | } |
| 776 | const { _tag, decode } = existing |
| 777 | switch (_tag) { |
| 778 | case "Multipart": { |
| 779 | if (existing.mode === "buffered") { |
| 780 | let eff = Effect.orDie(httpRequest.multipart) |
| 781 | if (existing.limits) { |
| 782 | eff = Effect.provideContext(eff, Multipart.limitsServices(existing.limits)) |
| 783 | } |
| 784 | return Effect.flatMap(eff, decode) |
| 785 | } |
| 786 | return Effect.succeed( |
| 787 | existing.limits |
| 788 | ? Stream.provideContext(httpRequest.multipartStream, Multipart.limitsServices(existing.limits)) |
| 789 | : httpRequest.multipartStream |
| 790 | ) |
| 791 | } |
| 792 | case "Json": |
| 793 | return Effect.flatMap(Effect.orDie(httpRequest.text), (text) => { |
| 794 | if (text === "") { |
| 795 | return decode(existing.nullOnEmpty ? null : undefined) |
| 796 | } |
| 797 | try { |
| 798 | return decode(JSON.parse(text)) |
| 799 | } catch { |
| 800 | return Effect.fail( |
| 801 | new Schema.SchemaError( |
| 802 | new SchemaIssue.InvalidValue({ message: "Expected a valid JSON body" }) |
| 803 | ) |
| 804 | ) |
| 805 | } |
| 806 | }) |
| 807 | case "Text": |
| 808 | return Effect.flatMap(Effect.orDie(httpRequest.text), decode) |
| 809 | case "FormUrlEncoded": { |
| 810 | const source = hasBody |
| 811 | ? Effect.map(Effect.orDie(httpRequest.urlParamsBody), UrlParams.toRecord) |
| 812 | : Effect.succeed(query) |
| 813 | return Effect.flatMap(source, decode) |
| 814 | } |
| 815 | case "Uint8Array": |
| 816 | return Effect.flatMap( |
| 817 | Effect.map(Effect.orDie(httpRequest.arrayBuffer), (buffer) => new Uint8Array(buffer)), |
| 818 | decode |
| 819 | ) |
| 820 | } |
| 821 | } |
| 822 | |
| 823 | function handlerToHttpEffect( |
| 824 | api: Pick<HttpApi.Top, "annotations">, |
| 825 | group: HttpApiGroup.Top, |
| 826 | endpoint: HttpApiEndpoint.Top, |
| 827 | context: Context.Context<any>, |
| 828 | handler: HttpApiEndpoint.Handler<HttpApiEndpoint.Constraint, any, any>, |
| 829 | isRaw: boolean |
| 830 | ) { |
| 831 | const annotations = Context.merge(Context.merge(api.annotations, group.annotations), endpoint.annotations) |
| 832 | const options = HttpApi.getSlotParseOptions(annotations) |
| 833 | const encodeSuccess = Schema.encodeUnknownEffect(makeSuccessSchema(endpoint, options.headers), options.success) |
| 834 | const encodeError = Schema.encodeUnknownEffect(makeErrorSchema(endpoint, options.headers), options.error) |
| 835 | const decodeParams = UndefinedOr.map(endpoint.params, (schema) => Schema.decodeUnknownEffect(schema, options.params)) |
| 836 | const decodeHeaders = UndefinedOr.map( |
| 837 | endpoint.headers, |
| 838 | (schema) => Schema.decodeUnknownEffect(schema, options.headers) |
| 839 | ) |
| 840 | const decodeQuery = UndefinedOr.map( |
| 841 | endpoint.query, |
| 842 | (schema) => Schema.decodeUnknownEffect(Schema.toCodecArrayFromSingle(schema), options.query) |
| 843 | ) |
| 844 | const encodeStream = makeStreamEncoder(endpoint, options.success) |
| 845 | const encodeWithHeaders = makeWithHeadersEncoder(endpoint, options.success, options.headers) |
| 846 | |
| 847 | const shouldParsePayload = endpoint.payload.size > 0 && !isRaw |
| 848 | const payloadBy = shouldParsePayload ? buildPayloadDecoders(endpoint.payload, options.payload) : undefined |
| 849 | |
| 850 | return applyMiddleware( |
| 851 | group, |
| 852 | endpoint, |
| 853 | context, |
| 854 | Effect.gen(function*() { |
| 855 | const fiber = Fiber.getCurrent()! |
| 856 | const context = fiber.context |
| 857 | const httpRequest = Context.getUnsafe(context, HttpServerRequest) |
| 858 | const routeContext = Context.getUnsafe(context, HttpRouter.RouteContext) |
| 859 | const query = Context.getUnsafe(context, Request.ParsedSearchParams) |
| 860 | const request: any = { |
| 861 | request: httpRequest, |
| 862 | endpoint, |
| 863 | group |
| 864 | } |
| 865 | if (decodeParams) { |
| 866 | request.params = yield* HttpApiSchemaError.wrap("Params", decodeParams(routeContext.params)) |
| 867 | } |
| 868 | if (decodeHeaders) { |
| 869 | request.headers = yield* HttpApiSchemaError.wrap("Headers", decodeHeaders(httpRequest.headers)) |
| 870 | } |
| 871 | if (decodeQuery) { |
| 872 | request.query = yield* HttpApiSchemaError.wrap("Query", decodeQuery(query)) |
| 873 | } |
| 874 | if (payloadBy) { |
| 875 | const result = decodePayload(payloadBy, httpRequest, query) |
| 876 | if (Response.isHttpServerResponse(result)) { |
| 877 | return result |
| 878 | } |
| 879 | if (result !== undefined) { |
| 880 | request.payload = yield* HttpApiSchemaError.wrap("Payload", result) |
| 881 | } |
| 882 | } |
| 883 | let response = yield* handler(request) |
| 884 | if (Response.isHttpServerResponse(response)) { |
| 885 | return response |
| 886 | } |
| 887 | let responseHeaders: unknown | undefined |
| 888 | if (encodeWithHeaders !== undefined && HttpApiSchema.isWithHeadersValue(response)) { |
| 889 | responseHeaders = response.headers |
| 890 | response = response.body |
| 891 | } |
| 892 | const encoded = yield* HttpApiSchemaError.wrap( |
| 893 | "Body", |
| 894 | encodeStream?.(response, context) ?? |
| 895 | (responseHeaders !== undefined ? encodeWithHeaders!.encodeBody(response) : encodeSuccess(response)) |
| 896 | ) |
| 897 | if (encodeWithHeaders === undefined || responseHeaders === undefined) return encoded |
| 898 | const encodedHeaders = yield* HttpApiSchemaError.wrap( |
| 899 | "ResponseHeaders", |
| 900 | encodeWithHeaders.encodeHeaders.get(encoded.status)!(responseHeaders) |
| 901 | ) |
| 902 | return Response.setHeaders(encoded, encodedHeaders as any) |
| 903 | }) |
| 904 | ).pipe( |
| 905 | Effect.withErrorReporting, |
| 906 | Effect.catch((error) => { |
| 907 | if (HttpApiSchemaError.is(error)) return Effect.die(error) |
| 908 | return Effect.orDie(encodeError(error)) |
| 909 | }), |
| 910 | Effect.provideContext(context) |
| 911 | ) |
| 912 | } |
| 913 | |
| 914 | /** |
| 915 | * @internal |
| 916 | */ |
| 917 | export function handlerToRoute( |
| 918 | api: Pick<HttpApi.Top, "annotations">, |
| 919 | group: HttpApiGroup.Top, |
| 920 | handler: HandlerRuntime, |
| 921 | context: Context.Context<any> |
| 922 | ): HttpRouter.Route<any, any> { |
| 923 | const endpoint = handler.endpoint |
| 924 | return HttpRouter.route( |
| 925 | endpoint.method, |
| 926 | HttpApiPath.toRouterPath(endpoint.path, endpoint.params) as HttpRouter.PathInput, |
| 927 | handlerToHttpEffect(api, group, endpoint, context, handler.handler, handler.isRaw), |
| 928 | { uninterruptible: handler.uninterruptible } |
| 929 | ) |
| 930 | } |
| 931 | |
| 932 | const applyMiddleware = <Group extends HttpApiGroup.Constraint, A extends Effect.Effect<any, any, any>>( |
| 933 | group: Group, |
| 934 | endpoint: HttpApiEndpoint.Top, |
| 935 | context: Context.Context<any>, |
| 936 | handler: A |
| 937 | ) => { |
| 938 | const options = { group, endpoint } |
| 939 | for (const key_ of endpoint.middlewares) { |
| 940 | const key = key_ as HttpApiMiddleware.AnyService |
| 941 | const service = Context.getUnsafe(context, key) |
| 942 | const apply = HttpApiMiddleware.isSecurity(key) |
| 943 | ? makeSecurityMiddleware(key, service) |
| 944 | : service |
| 945 | handler = apply(handler, options) |
| 946 | } |
| 947 | return handler |
| 948 | } |
| 949 | |
| 950 | const securityMiddlewareCache = new WeakMap< |
| 951 | object, |
| 952 | (effect: Effect.Effect<any, any, any>, options: any) => Effect.Effect<any, any, any> |
| 953 | >() |
| 954 | |
| 955 | const makeSecurityMiddleware = ( |
| 956 | key: HttpApiMiddleware.AnyServiceSecurity, |
| 957 | service: HttpApiMiddleware.HttpApiMiddlewareSecurity<any, any, any, any> |
| 958 | ): (effect: Effect.Effect<any, any, any>, options: any) => Effect.Effect<any, any, any> => { |
| 959 | const cached = securityMiddlewareCache.get(service) |
| 960 | if (cached !== undefined) { |
| 961 | return cached |
| 962 | } |
| 963 | |
| 964 | const entries = Object.entries(key.security).map(([securityKey, security]) => ({ |
| 965 | decode: securityDecode(security), |
| 966 | middleware: service[securityKey] |
| 967 | })) |
| 968 | if (entries.length === 0) { |
| 969 | return Function.identity |
| 970 | } |
| 971 | |
| 972 | const middleware = Effect.fnUntraced(function*(handler: Effect.Effect<any, any, any>, options: { |
| 973 | readonly group: HttpApiGroup.Top |
| 974 | readonly endpoint: HttpApiEndpoint.Top |
| 975 | }) { |
| 976 | handler = Effect.mapError(handler, (error) => new HandlerError(error)) |
| 977 | let lastResult: Result.Result<any, any> | undefined |
| 978 | for (let i = 0; i < entries.length; i++) { |
| 979 | const { decode, middleware } = entries[i] |
| 980 | const result = yield* Effect.result(Effect.flatMap(decode, (credential) => |
| 981 | middleware(handler, { |
| 982 | credential, |
| 983 | endpoint: options.endpoint, |
| 984 | group: options.group |
| 985 | }))) |
| 986 | if (Result.isFailure(result)) { |
| 987 | if (isHandlerError(result.failure)) { |
| 988 | return yield* Effect.fail(result.failure.error) |
| 989 | } |
| 990 | lastResult = result |
| 991 | continue |
| 992 | } |
| 993 | return result.success |
| 994 | } |
| 995 | return yield* Effect.fromResult(lastResult!) |
| 996 | }) |
| 997 | |
| 998 | securityMiddlewareCache.set(service, middleware) |
| 999 | return middleware |
| 1000 | } |
| 1001 | |
| 1002 | const HandlerErrorTypeId = "~effect/http-api/HttpApiBuilder/HandlerError" as const |
| 1003 | class HandlerError { |
| 1004 | readonly [HandlerErrorTypeId] = HandlerErrorTypeId |
| 1005 | readonly error: unknown |
| 1006 | constructor(error: unknown) { |
| 1007 | this.error = error |
| 1008 | } |
| 1009 | } |
| 1010 | const isHandlerError = (value: unknown): value is HandlerError => hasProperty(value, HandlerErrorTypeId) |
| 1011 | |
| 1012 | const $HttpServerResponse = Schema.declare(Response.isHttpServerResponse) |
| 1013 | |
| 1014 | type StreamEncoder = (response: unknown, context: Context.Context<never>) => |
| 1015 | | Effect.Effect<HttpServerResponse, Schema.SchemaError, unknown> |
| 1016 | | undefined |
| 1017 | |
| 1018 | type WithHeadersEncoder = (headers: unknown) => Effect.Effect<unknown, Schema.SchemaError, unknown> |
| 1019 | |
| 1020 | interface WithHeadersEncoders { |
| 1021 | readonly encodeBody: (body: unknown) => Effect.Effect<HttpServerResponse, Schema.SchemaError, unknown> |
| 1022 | readonly encodeHeaders: ReadonlyMap<number, WithHeadersEncoder> |
| 1023 | } |
| 1024 | |
| 1025 | function makeWithHeadersEncoder( |
| 1026 | endpoint: HttpApiEndpoint.Top, |
| 1027 | options: SchemaAST.ParseOptions | undefined, |
| 1028 | headersOptions: SchemaAST.ParseOptions | undefined |
| 1029 | ): WithHeadersEncoders | undefined { |
| 1030 | const encodeHeaders = new Map<number, WithHeadersEncoder>() |
| 1031 | const bodySchemas: Array<Schema.ConstraintEncoder<HttpServerResponse, unknown>> = [] |
| 1032 | for (const schema of endpoint.success) { |
| 1033 | if (!HttpApiSchema.isWithHeaders(schema)) continue |
| 1034 | encodeHeaders.set( |
| 1035 | HttpApiSchema.getStatusSuccessSchema(schema), |
| 1036 | Schema.encodeUnknownEffect(schema.headers, headersOptions) |
| 1037 | ) |
| 1038 | if (!HttpApiSchema.isStreamSchema(schema.schema)) { |
| 1039 | bodySchemas.push(toResponseSuccessSchema(schema, headersOptions)) |
| 1040 | } |
| 1041 | } |
| 1042 | if (encodeHeaders.size === 0) return undefined |
| 1043 | // Branded response bodies encode against the header-carrying members only, so |
| 1044 | // the encoded status always has a matching header schema. |
| 1045 | const bodySchema: Schema.ConstraintEncoder<HttpServerResponse, unknown> = bodySchemas.length === 0 |
| 1046 | ? Schema.Never |
| 1047 | : bodySchemas.length === 1 |
| 1048 | ? bodySchemas[0] |
| 1049 | : Schema.Union(bodySchemas) |
| 1050 | return { |
| 1051 | encodeBody: Schema.encodeUnknownEffect(bodySchema, options), |
| 1052 | encodeHeaders |
| 1053 | } |
| 1054 | } |
| 1055 | |
| 1056 | function makeStreamEncoder( |
| 1057 | endpoint: HttpApiEndpoint.Top, |
| 1058 | options: SchemaAST.ParseOptions | undefined |
| 1059 | ): StreamEncoder | undefined { |
| 1060 | const successSchema = getStreamSuccessSchema(endpoint) |
| 1061 | if (successSchema === undefined) { |
| 1062 | return undefined |
| 1063 | } |
| 1064 | |
| 1065 | const streamSchema = successSchema.body |
| 1066 | const hasBuffered = hasBufferedSuccess(endpoint) |
| 1067 | const status = HttpApiSchema.getStatusSuccessSchema(successSchema.schema) |
| 1068 | const contentType = streamSchema.contentType |
| 1069 | |
| 1070 | if (HttpApiSchema.isStreamUint8Array(streamSchema)) { |
| 1071 | return (response, context) => { |
| 1072 | if (!Stream.isStream(response)) { |
| 1073 | return hasBuffered ? undefined : new Schema.SchemaError( |
| 1074 | new SchemaIssue.InvalidValue({ message: "Expected a streaming response" }) |
| 1075 | ) |
| 1076 | } |
| 1077 | |
| 1078 | return Effect.succeed(Response.stream( |
| 1079 | Stream.provideContext( |
| 1080 | response as Stream.Stream<Uint8Array, unknown, unknown>, |
| 1081 | context as Context.Context<unknown> |
| 1082 | ), |
| 1083 | { status, contentType } |
| 1084 | )) |
| 1085 | } |
| 1086 | } |
| 1087 | |
| 1088 | const sseEncoder = makeSseEncoder(streamSchema, options) |
| 1089 | |
| 1090 | return (response, context) => { |
| 1091 | if (!Stream.isStream(response)) { |
| 1092 | return hasBuffered ? undefined : new Schema.SchemaError( |
| 1093 | new SchemaIssue.InvalidValue({ message: "Expected a streaming response" }) |
| 1094 | ) |
| 1095 | } |
| 1096 | |
| 1097 | return Effect.succeed(Response.stream( |
| 1098 | Stream.provideContext( |
| 1099 | encodeSseStream(response, sseEncoder), |
| 1100 | context as Context.Context<unknown> |
| 1101 | ), |
| 1102 | { status, contentType } |
| 1103 | )) |
| 1104 | } |
| 1105 | } |
| 1106 | |
| 1107 | function getStreamSuccessSchema(endpoint: HttpApiEndpoint.Top) { |
| 1108 | for (const schema of endpoint.success) { |
| 1109 | const body = HttpApiSchema.isWithHeaders(schema) ? schema.schema : schema |
| 1110 | if (HttpApiSchema.isStreamSchema(body)) { |
| 1111 | return { schema, body } |
| 1112 | } |
| 1113 | } |
| 1114 | } |
| 1115 | |
| 1116 | function hasBufferedSuccess(endpoint: HttpApiEndpoint.Top): boolean { |
| 1117 | for (const schema of endpoint.success) { |
| 1118 | const body = HttpApiSchema.isWithHeaders(schema) ? schema.schema : schema |
| 1119 | if (Schema.isSchema(body) && !HttpApiSchema.isStreamSchema(body)) return true |
| 1120 | } |
| 1121 | return endpoint.success.size === 0 |
| 1122 | } |
| 1123 | |
| 1124 | interface SseStreamEncoder { |
| 1125 | readonly sseMode: HttpApiSchema.StreamSseMode |
| 1126 | readonly encodeEvents: ( |
| 1127 | input: NonEmptyReadonlyArray<unknown> |
| 1128 | ) => Effect.Effect<NonEmptyReadonlyArray<Sse.EventEncoded>, Schema.SchemaError, unknown> |
| 1129 | readonly encodeCause: (input: unknown) => Effect.Effect<string, Schema.SchemaError, unknown> |
| 1130 | } |
| 1131 | |
| 1132 | function makeSseEncoder<Events extends Sse.EventCodec, Error extends Schema.Constraint>( |
| 1133 | streamSchema: HttpApiSchema.StreamSse<Events, Error, unknown>, |
| 1134 | options: SchemaAST.ParseOptions | undefined |
| 1135 | ): SseStreamEncoder { |
| 1136 | const CauseSchema = Schema.toCodecJson(Schema.Cause(streamSchema.error, Schema.Defect())) |
| 1137 | return { |
| 1138 | sseMode: streamSchema.sseMode, |
| 1139 | encodeEvents: Schema.encodeUnknownEffect(Schema.Array(streamSchema.events), options) as any, |
| 1140 | encodeCause: Schema.encodeUnknownEffect(Schema.fromJsonString(CauseSchema), options) |
| 1141 | } |
| 1142 | } |
| 1143 | |
| 1144 | function encodeSseStream( |
| 1145 | stream: Stream.Stream<unknown, unknown, unknown>, |
| 1146 | encoder: SseStreamEncoder |
| 1147 | ): Stream.Stream<Uint8Array, unknown, unknown> { |
| 1148 | return stream.pipe( |
| 1149 | encoder.sseMode === "data" ? |
| 1150 | Stream.map((value) => ({ |
| 1151 | id: undefined, |
| 1152 | event: "message", |
| 1153 | data: value |
| 1154 | })) : |
| 1155 | Function.identity, |
| 1156 | Stream.mapArrayEffect((chunk) => Effect.orDie(encoder.encodeEvents(chunk))), |
| 1157 | Stream.catchCause((cause) => Stream.fromEffect(encodeFailureEvent(cause, encoder))), |
| 1158 | Stream.map(renderSseEvent), |
| 1159 | Stream.encodeText |
| 1160 | ) |
| 1161 | } |
| 1162 | |
| 1163 | function encodeFailureEvent(cause: Cause.Cause<unknown>, encoder: SseStreamEncoder) { |
| 1164 | return encoder.encodeCause(cause).pipe( |
| 1165 | Effect.orDie, |
| 1166 | Effect.map((encodedCause) => ({ |
| 1167 | id: undefined, |
| 1168 | event: reservedStreamFailureEvent, |
| 1169 | data: encodedCause |
| 1170 | })) |
| 1171 | ) |
| 1172 | } |
| 1173 | |
| 1174 | const reservedStreamFailureEvent = "effect/http-api/stream/failure" |
| 1175 | |
| 1176 | function renderSseEvent(event: Sse.EventEncoded) { |
| 1177 | return Sse.encoder.write({ |
| 1178 | _tag: "Event", |
| 1179 | event: event.event, |
| 1180 | id: event.id, |
| 1181 | data: event.data |
| 1182 | }) |
| 1183 | } |
| 1184 | |
| 1185 | const toResponseSuccessSchema = toResponseSchema(HttpApiSchema.getStatusSuccessSchema) |
| 1186 | const toResponseErrorSchemaPlain = toResponseSchema(HttpApiSchema.getStatusErrorSchema) |
| 1187 | |
| 1188 | function toResponseErrorSchema( |
| 1189 | schema: Schema.Constraint, |
| 1190 | headersOptions: SchemaAST.ParseOptions | undefined |
| 1191 | ): Schema.ConstraintEncoder<HttpServerResponse, unknown> { |
| 1192 | if (!HttpApiSchema.isWithHeaders(schema)) return toResponseErrorSchemaPlain(schema, headersOptions) |
| 1193 | return toWithHeadersResponseSchema({ |
| 1194 | schema, |
| 1195 | status: HttpApiSchema.getStatusErrorSchema(schema), |
| 1196 | encoding: HttpApiSchema.getResponseEncodingSchema(schema), |
| 1197 | body: schema.schema, |
| 1198 | headers: schema.headers, |
| 1199 | headersOptions, |
| 1200 | toPair: (value) => value as HttpApiSchema.withHeaders<unknown, unknown> |
| 1201 | }) |
| 1202 | } |
| 1203 | |
| 1204 | function makeSuccessSchema( |
| 1205 | endpoint: HttpApiEndpoint.Top, |
| 1206 | headersOptions: SchemaAST.ParseOptions | undefined |
| 1207 | ): Schema.ConstraintEncoder<HttpServerResponse, unknown> { |
| 1208 | const schemas = HttpApiEndpoint.getSuccessSchemas(endpoint).map((schema) => |
| 1209 | toResponseSuccessSchema(schema, headersOptions) |
| 1210 | ) |
| 1211 | return schemas.length === 1 ? schemas[0] : Schema.Union(schemas) |
| 1212 | } |
| 1213 | |
| 1214 | function makeErrorSchema( |
| 1215 | endpoint: HttpApiEndpoint.Top, |
| 1216 | headersOptions: SchemaAST.ParseOptions | undefined |
| 1217 | ): Schema.ConstraintEncoder<HttpServerResponse, unknown> { |
| 1218 | const schemas = HttpApiEndpoint.getErrorSchemas(endpoint).map((schema) => |
| 1219 | toResponseErrorSchema(schema, headersOptions) |
| 1220 | ) |
| 1221 | if (schemas.length === 0) return Schema.Never |
| 1222 | return schemas.length === 1 ? schemas[0] : Schema.Union(schemas) |
| 1223 | } |
| 1224 | |
| 1225 | function toResponseSchema(getStatus: (schema: Schema.Constraint) => number) { |
| 1226 | // WithHeaders wrappers share a single declaration AST, so they are cached by instance |
| 1227 | const cache = new WeakMap<object, Schema.Top>() |
| 1228 | |
| 1229 | return ( |
| 1230 | schema: Schema.Constraint, |
| 1231 | headersOptions: SchemaAST.ParseOptions | undefined |
| 1232 | ): Schema.ConstraintEncoder<HttpServerResponse, unknown> => { |
| 1233 | const withHeaders = HttpApiSchema.getWithHeadersAnnotation(schema.ast) |
| 1234 | if (withHeaders !== undefined) { |
| 1235 | // Depends on the endpoint's header options, so it is not cached |
| 1236 | return toWithHeadersResponseSchema({ |
| 1237 | schema: Schema.toType(schema), |
| 1238 | status: getStatus(withHeaders.body), |
| 1239 | encoding: HttpApiSchema.getResponseEncoding(withHeaders.body.ast), |
| 1240 | body: withHeaders.bodyCodec, |
| 1241 | headers: withHeaders.headersCodec, |
| 1242 | headersOptions, |
| 1243 | toPair: withHeaders.transformation.encode |
| 1244 | }) |
| 1245 | } |
| 1246 | const key = HttpApiSchema.isWithHeaders(schema) ? schema : schema.ast |
| 1247 | const cached = cache.get(key) |
| 1248 | if (cached !== undefined) { |
| 1249 | return cached as any |
| 1250 | } |
| 1251 | const bodySchema = HttpApiSchema.isWithHeaders(schema) ? schema.schema : schema |
| 1252 | const responseSchema = $HttpServerResponse.pipe( |
| 1253 | Schema.decodeTo(bodySchema, getResponseTransformation(getStatus, schema)) |
| 1254 | ) |
| 1255 | cache.set(key, responseSchema) |
| 1256 | return responseSchema |
| 1257 | } |
| 1258 | } |
| 1259 | |
| 1260 | function getResponseTransformation( |
| 1261 | getStatus: (schema: Schema.Constraint) => number, |
| 1262 | schema: Schema.Constraint |
| 1263 | ): SchemaTransformation.Transformation<unknown, Response.HttpServerResponse, never, unknown> { |
| 1264 | const bodySchema = HttpApiSchema.isWithHeaders(schema) ? schema.schema : schema |
| 1265 | const encode = getResponseEncode( |
| 1266 | getStatus(schema), |
| 1267 | HttpApiSchema.getResponseEncodingSchema(schema), |
| 1268 | HttpApiSchema.isNoContent(bodySchema.ast) |
| 1269 | ) |
| 1270 | |
| 1271 | return SchemaTransformation.transformEffect({ |
| 1272 | decode: (input, options) => |
| 1273 | Effect.fail( |
| 1274 | new SchemaIssue.Forbidden({ message: "Encode only schema" }, input, options) |
| 1275 | ), |
| 1276 | encode |
| 1277 | }) |
| 1278 | } |
| 1279 | |
| 1280 | // Encodes the body with the options passed to the schema and the headers with |
| 1281 | // the headers slot options. |
| 1282 | function toWithHeadersResponseSchema(options: { |
| 1283 | readonly schema: Schema.Top |
| 1284 | readonly status: number |
| 1285 | readonly encoding: HttpApiSchema.ResponseEncoding |
| 1286 | readonly body: Schema.Top |
| 1287 | readonly headers: Schema.Top |
| 1288 | readonly headersOptions: SchemaAST.ParseOptions | undefined |
| 1289 | readonly toPair: (value: unknown) => { readonly body: unknown; readonly headers: unknown } |
| 1290 | }): Schema.ConstraintEncoder<HttpServerResponse, unknown> { |
| 1291 | const encodeBody = Schema.encodeUnknownEffect(options.body) |
| 1292 | const encodeHeaders = Schema.encodeUnknownEffect(options.headers, options.headersOptions) |
| 1293 | const encodeResponse = getResponseEncode( |
| 1294 | options.status, |
| 1295 | options.encoding, |
| 1296 | HttpApiSchema.isNoContent(options.body.ast) |
| 1297 | ) |
| 1298 | const toIssue = (error: Schema.SchemaError) => error.issue |
| 1299 | const transformation = SchemaTransformation.transformEffect<unknown, Response.HttpServerResponse, never, unknown>({ |
| 1300 | decode: (input, options) => |
| 1301 | Effect.fail( |
| 1302 | new SchemaIssue.Forbidden({ message: "Encode only schema" }, input, options) |
| 1303 | ), |
| 1304 | encode: (value, parseOptions) => { |
| 1305 | const pair = options.toPair(value) |
| 1306 | return encodeBody(pair.body, parseOptions).pipe( |
| 1307 | Effect.mapError(toIssue), |
| 1308 | Effect.flatMap((body) => encodeResponse(body, parseOptions)), |
| 1309 | Effect.flatMap((response) => |
| 1310 | Effect.map( |
| 1311 | Effect.mapError(encodeHeaders(pair.headers), toIssue), |
| 1312 | (headers) => Response.setHeaders(response, headers as any) |
| 1313 | ) |
| 1314 | ) |
| 1315 | ) |
| 1316 | } |
| 1317 | }) |
| 1318 | return $HttpServerResponse.pipe(Schema.decodeTo(options.schema, transformation)) |
| 1319 | } |
| 1320 | |
| 1321 | function getResponseEncode<E>( |
| 1322 | status: number, |
| 1323 | encoding: HttpApiSchema.ResponseEncoding, |
| 1324 | isNoContent: boolean |
| 1325 | ): ( |
| 1326 | e: E, |
| 1327 | options?: SchemaAST.ParseOptions |
| 1328 | ) => Effect.Effect<Response.HttpServerResponse, SchemaIssue.InvalidValue, never> { |
| 1329 | switch (encoding._tag) { |
| 1330 | case "Json": { |
| 1331 | return ((e, options) => { |
| 1332 | if (e === undefined || isNoContent) { |
| 1333 | return Effect.succeed(Response.empty({ status })) |
| 1334 | } |
| 1335 | try { |
| 1336 | const s = JSON.stringify(e) |
| 1337 | return Effect.succeed(Response.text(s, { status, contentType: encoding.contentType })) |
| 1338 | } catch { |
| 1339 | return Effect.fail( |
| 1340 | new SchemaIssue.InvalidValue( |
| 1341 | { expected: "a JSON-serializable response body" }, |
| 1342 | e, |
| 1343 | options |
| 1344 | ) |
| 1345 | ) |
| 1346 | } |
| 1347 | }) |
| 1348 | } |
| 1349 | case "Text": |
| 1350 | return (e) => |
| 1351 | Effect.succeed(Response.text(e as string, { |
| 1352 | status, |
| 1353 | contentType: encoding.contentType |
| 1354 | })) |
| 1355 | case "Uint8Array": |
| 1356 | return (e) => |
| 1357 | Effect.succeed(Response.uint8Array(e as Uint8Array, { |
| 1358 | status, |
| 1359 | contentType: encoding.contentType |
| 1360 | })) |
| 1361 | case "FormUrlEncoded": |
| 1362 | return (e) => |
| 1363 | Effect.succeed( |
| 1364 | Response.urlParams(e as URLSearchParams, { status }).pipe( |
| 1365 | Response.setHeader("content-type", encoding.contentType) |
| 1366 | ) |
| 1367 | ) |
| 1368 | } |
| 1369 | } |