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 */
12import type { NonEmptyReadonlyArray } from "../Array.ts"
13import * as Context from "../Context.ts"
14import type { PathInput } from "../http/HttpRouter.ts"
15import * as InternalRecord from "../internal/record.ts"
16import { type Pipeable, pipeArguments } from "../Pipeable.ts"
17import * as Predicate from "../Predicate.ts"
18import * as Record from "../Record.ts"
19import type * as Schema from "../Schema.ts"
20import type * as SchemaAST from "../SchemaAST.ts"
21import * as HttpApiEndpoint from "./HttpApiEndpoint.ts"
22import type * as HttpApiGroup from "./HttpApiGroup.ts"
23import type * as HttpApiMiddleware from "./HttpApiMiddleware.ts"
24import * as HttpApiSchema from "./HttpApiSchema.ts"
25
26const 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 */
35export const isHttpApi = (u: unknown): u is Top => Predicate.hasProperty(u, TypeId)
36
37/**
38 * Groups indexed by their identifier.
39 */
40type 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 */
57export 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 */
128export 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 */
140export interface Top extends HttpApi<string, HttpApiGroup.Top> {}
141
142const 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
204const optionsFromApi = (api: Top) => ({
205 identifier: api.identifier,
206 groups: api.groups,
207 annotations: api.annotations
208})
209
210const 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 */
235export 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 */
254export 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
313const 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 */
344export 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 */
407export 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 */
420export 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 */
433export 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 */
447export 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 */
460export 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 */
473export 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 */
485export class ErrorParseOptions extends Context.Service<
486 ErrorParseOptions,
487 SchemaAST.ParseOptions
488>()("effect/http-api/HttpApi/ErrorParseOptions") {}
489
490/**
491 * @internal
492 */
493export 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 */
505export 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}