1/**
2 * Generates OpenAPI 3.1 documents from declarative `HttpApi` contracts.
3 *
4 * The generator reads API groups, endpoints, schemas, security definitions, and
5 * annotations, then produces an OpenAPI document. This module also provides the
6 * annotations used to shape that output and the TypeScript model for the
7 * OpenAPI objects it generates.
8 *
9 * @stability unstable
10 * @since 4.0.0
11 */
12import * as Arr from "../Array.ts"
13import type { NonEmptyArray } from "../Array.ts"
14import * as Context from "../Context.ts"
15import * as Equal from "../Equal.ts"
16import { constFalse } from "../Function.ts"
17import * as HttpMethod from "../http/HttpMethod.ts"
18import * as InternalRecord from "../internal/record.ts"
19import * as InternalToCodec from "../internal/schema/toCodec.ts"
20import * as InternalToJsonSchemaDocument from "../internal/schema/toJsonSchemaDocument.ts"
21import * as InternalToRepresentation from "../internal/schema/toRepresentation.ts"
22import * as JsonSchema from "../JsonSchema.ts"
23import * as Option from "../Option.ts"
24import * as Schema from "../Schema.ts"
25import * as SchemaAST from "../SchemaAST.ts"
26import type * as SchemaRepresentation from "../SchemaRepresentation.ts"
27import * as HttpApi from "./HttpApi.ts"
28import * as HttpApiEndpoint from "./HttpApiEndpoint.ts"
29import type * as HttpApiGroup from "./HttpApiGroup.ts"
30import * as HttpApiMiddleware from "./HttpApiMiddleware.ts"
31import * as HttpApiSchema from "./HttpApiSchema.ts"
32import type { HttpApiSecurity } from "./HttpApiSecurity.ts"
33import * as HttpApiPath from "./internal/path.ts"
34
35/**
36 * OpenAPI annotation for overriding generated identifiers, including operation ids.
37 *
38 * @stability unstable
39 * @category services
40 * @since 4.0.0
41 */
42export class Identifier extends Context.Service<Identifier, string>()("effect/http-api/OpenApi/Identifier") {}
43
44/**
45 * OpenAPI annotation for setting the API title or group tag name.
46 *
47 * @stability unstable
48 * @category services
49 * @since 4.0.0
50 */
51export class Title extends Context.Service<Title, string>()("effect/http-api/OpenApi/Title") {}
52
53/**
54 * OpenAPI annotation for setting the generated API version.
55 *
56 * @stability unstable
57 * @category services
58 * @since 4.0.0
59 */
60export class Version extends Context.Service<Version, string>()("effect/http-api/OpenApi/Version") {}
61
62/**
63 * OpenAPI annotation for setting generated descriptions on APIs, groups, endpoints, or security schemes.
64 *
65 * @stability unstable
66 * @category services
67 * @since 4.0.0
68 */
69export class Description extends Context.Service<Description, string>()("effect/http-api/OpenApi/Description") {}
70
71/**
72 * OpenAPI annotation for setting the generated API license metadata.
73 *
74 * @stability unstable
75 * @category services
76 * @since 4.0.0
77 */
78export class License extends Context.Service<License, OpenAPISpecLicense>()("effect/http-api/OpenApi/License") {}
79
80/**
81 * OpenAPI annotation for adding external documentation metadata to groups or endpoints.
82 *
83 * @stability unstable
84 * @category services
85 * @since 4.0.0
86 */
87export class ExternalDocs
88 extends Context.Service<ExternalDocs, OpenAPISpecExternalDocs>()("effect/http-api/OpenApi/ExternalDocs")
89{}
90
91/**
92 * OpenAPI annotation for setting the generated API server list.
93 *
94 * @stability unstable
95 * @category services
96 * @since 4.0.0
97 */
98export class Servers
99 extends Context.Service<Servers, ReadonlyArray<OpenAPISpecServer>>()("effect/http-api/OpenApi/Servers")
100{}
101
102/**
103 * OpenAPI annotation for setting the format metadata, such as a bearer token format on security schemes.
104 *
105 * @stability unstable
106 * @category services
107 * @since 4.0.0
108 */
109export class Format extends Context.Service<Format, string>()("effect/http-api/OpenApi/Format") {}
110
111/**
112 * OpenAPI annotation for setting generated summary text.
113 *
114 * @stability unstable
115 * @category services
116 * @since 4.0.0
117 */
118export class Summary extends Context.Service<Summary, string>()("effect/http-api/OpenApi/Summary") {}
119
120/**
121 * OpenAPI annotation for marking a generated endpoint operation as deprecated.
122 *
123 * @stability unstable
124 * @category services
125 * @since 4.0.0
126 */
127export class Deprecated extends Context.Service<Deprecated, boolean>()("effect/http-api/OpenApi/Deprecated") {}
128
129/**
130 * OpenAPI annotation for shallowly merging additional fields into a generated OpenAPI object.
131 *
132 * @stability unstable
133 * @category services
134 * @since 4.0.0
135 */
136export class Override
137 extends Context.Service<Override, Record<string, unknown>>()("effect/http-api/OpenApi/Override")
138{}
139
140/**
141 * Annotation that excludes an annotated group or endpoint from the generated
142 * OpenAPI specification.
143 *
144 * **When to use**
145 *
146 * Use to hide internal, experimental, or otherwise undocumented HTTP API groups
147 * and endpoints from generated OpenAPI output.
148 *
149 * @stability unstable
150 * @category services
151 * @since 4.0.0
152 */
153export const Exclude = Context.Reference<boolean>("effect/http-api/OpenApi/Exclude", {
154 defaultValue: constFalse
155})
156
157/**
158 * OpenAPI annotation for transforming a generated OpenAPI object.
159 *
160 * **Details**
161 *
162 * The function is applied during generation to the annotated API, group tag, or
163 * endpoint operation.
164 *
165 * @stability unstable
166 * @category services
167 * @since 4.0.0
168 */
169export class Transform extends Context.Service<
170 Transform,
171 (openApiSpec: Record<string, any>) => Record<string, any>
172>()("effect/http-api/OpenApi/Transform") {}
173
174const servicesPartial = <Tags extends Record<string, Context.Key<any, any> | Context.Key<never, any>>>(
175 tags: Tags
176): (
177 options: {
178 readonly [K in keyof Tags]?: Context.Service.Shape<Tags[K]> | undefined
179 }
180) => Context.Context<never> => {
181 const entries = Object.entries(tags)
182 return (options) => {
183 let context = Context.empty()
184 for (const [key, tag] of entries) {
185 if (options[key] !== undefined) {
186 context = Context.add(context, tag as any, options[key]!)
187 }
188 }
189 return context
190 }
191}
192
193/**
194 * Builds a `Context` containing OpenAPI annotations from the supplied options.
195 *
196 * @stability unstable
197 * @category annotations
198 * @since 4.0.0
199 */
200export const annotations: (
201 options: {
202 readonly identifier?: string | undefined
203 readonly title?: string | undefined
204 readonly version?: string | undefined
205 readonly description?: string | undefined
206 readonly license?: OpenAPISpecLicense | undefined
207 readonly summary?: string | undefined
208 readonly deprecated?: boolean | undefined
209 readonly externalDocs?: OpenAPISpecExternalDocs | undefined
210 readonly servers?: ReadonlyArray<OpenAPISpecServer> | undefined
211 readonly format?: string | undefined
212 readonly override?: Record<string, unknown> | undefined
213 readonly exclude?: boolean | undefined
214 readonly transform?: ((openApiSpec: Record<string, any>) => Record<string, any>) | undefined
215 }
216) => Context.Context<never> = servicesPartial({
217 identifier: Identifier,
218 title: Title,
219 version: Version,
220 description: Description,
221 license: License,
222 summary: Summary,
223 deprecated: Deprecated,
224 externalDocs: ExternalDocs,
225 servers: Servers,
226 format: Format,
227 override: Override,
228 exclude: Exclude,
229 transform: Transform
230})
231
232const defaultOptions: SchemaRepresentation.ToRepresentationOptions = {}
233const apiCache = new WeakMap<
234 SchemaRepresentation.ToRepresentationOptions,
235 WeakMap<HttpApi.Constraint, OpenAPISpec>
236>()
237
238const cloneOpenAPISpec = <A>(value: A): A => {
239 if (Array.isArray(value)) {
240 return value.map(cloneOpenAPISpec) as A
241 }
242 if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
243 const out: Record<string, unknown> = {}
244 for (const key of Object.keys(value)) {
245 InternalRecord.assignProperty(out, key, cloneOpenAPISpec((value as Record<string, unknown>)[key]))
246 }
247 return out as A
248 }
249 return value
250}
251
252/**
253 * This function checks if a given tag exists within the provided context. If
254 * the tag is present, it retrieves the associated value and applies the given
255 * callback function to it. If the tag is not found, the function does nothing.
256 */
257function processAnnotation<Services, S, I>(
258 ctx: Context.Context<Services>,
259 annotation: Context.Key<I, S>,
260 f: (s: S) => void
261) {
262 const o = Context.getOption(ctx, annotation)
263 if (Option.isSome(o)) {
264 f(o.value)
265 }
266}
267
268/**
269 * Generates an OpenAPI 3.1 specification from an `HttpApi`.
270 *
271 * **When to use**
272 *
273 * Use when you need a programmatic OpenAPI document for an API definition.
274 *
275 * **Details**
276 *
277 * This function takes an `HttpApi` instance, which defines a structured API,
278 * and generates an OpenAPI Specification (`OpenAPISpec`). The resulting spec
279 * adheres to the OpenAPI 3.1.0 standard and includes detailed metadata such as
280 * paths, operations, security schemes, and components. The function processes
281 * the API's annotations, middleware, groups, and endpoints to build a complete
282 * and accurate representation of the API in OpenAPI format.
283 *
284 * The function also deduplicates schemas, applies transformations, and
285 * integrates annotations like descriptions, summaries, external documentation,
286 * and overrides. The optional reference policy receives canonical JSON encoded
287 * ASTs and controls which schemas are extracted into components. By default, only candidates with resolved identifiers
288 * become references; anonymous non-recursive schemas remain inline.
289 *
290 * **Gotchas**
291 *
292 * Cached results are keyed by both the `HttpApi` instance and the identity of the options object. Reuse the same immutable
293 * options object to reuse a cached result; mutating an options object after its first use does not invalidate the cached
294 * specification. Each call returns a copy of the cached specification.
295 *
296 * @stability unstable
297 * @category constructors
298 * @since 4.0.0
299 */
300export function fromApi<Id extends string, Groups extends HttpApiGroup.Constraint>(
301 api: HttpApi.HttpApi<Id, Groups>,
302 options?: SchemaRepresentation.ToRepresentationOptions
303): OpenAPISpec {
304 const resolvedOptions = options ?? defaultOptions
305 let cache = apiCache.get(resolvedOptions)
306 if (cache === undefined) {
307 cache = new WeakMap()
308 apiCache.set(resolvedOptions, cache)
309 }
310 const cached = cache.get(api)
311 if (cached !== undefined) return cloneOpenAPISpec(cached)
312 const spec = makeOpenApi(api, resolvedOptions)
313 cache.set(api, cloneOpenAPISpec(spec))
314 return spec
315}
316
317type SchemaOp =
318 | {
319 readonly _tag: "schema"
320 readonly ast: SchemaAST.AST
321 readonly target: JsonSchema.JsonSchema
322 }
323 | {
324 readonly _tag: "properties"
325 readonly ast: SchemaAST.AST
326 readonly select?: ((property: SchemaRepresentation.PropertySignature) => boolean) | undefined
327 readonly apply: (property: SchemaRepresentation.PropertySignature, schema: JsonSchema.JsonSchema) => void
328 }
329
330function makeOpenApi<Id extends string, Groups extends HttpApiGroup.Constraint>(
331 api: HttpApi.HttpApi<Id, Groups>,
332 options: SchemaRepresentation.ToRepresentationOptions
333): OpenAPISpec {
334 let spec: OpenAPISpec = {
335 openapi: "3.1.0",
336 info: {
337 title: "Api",
338 version: "0.0.1"
339 },
340 paths: {},
341 components: {
342 schemas: {},
343 securitySchemes: {}
344 },
345 security: [],
346 tags: []
347 }
348
349 const schemaOps: Array<SchemaOp> = []
350 const pathOperations = new Set<string>()
351 const operationIds = new Set<string>()
352 const finalizeOperations: Array<() => void> = []
353
354 function registerSchema(
355 ast: SchemaAST.AST,
356 encoding: HttpApiSchema.Encoding["_tag"] = "Json"
357 ): JsonSchema.JsonSchema {
358 // Text bodies remain strings even when an opaque schema compiles to {}.
359 const target: JsonSchema.JsonSchema = encoding === "Text" ? { type: "string" } : {}
360 schemaOps.push({ _tag: "schema", ast: toEncodingAST(ast, encoding), target })
361 return target
362 }
363
364 processAnnotation(api.annotations, Title, (title) => {
365 spec.info.title = title
366 })
367 processAnnotation(api.annotations, Version, (version) => {
368 spec.info.version = version
369 })
370 processAnnotation(api.annotations, Description, (description) => {
371 spec.info.description = description
372 })
373 processAnnotation(api.annotations, License, (license) => {
374 spec.info.license = license
375 })
376 processAnnotation(api.annotations, Summary, (summary) => {
377 spec.info.summary = summary
378 })
379 processAnnotation(api.annotations, Servers, (servers) => {
380 spec.servers = [...servers]
381 })
382
383 HttpApi.reflect(api, {
384 onGroup({ group }) {
385 if (Context.get(group.annotations, Exclude)) {
386 return
387 }
388 let tag: OpenAPISpecTag = {
389 name: Context.getOrElse(group.annotations, Title, () => group.identifier)
390 }
391 processAnnotation(group.annotations, Description, (description) => {
392 tag.description = description
393 })
394 processAnnotation(group.annotations, ExternalDocs, (externalDocs) => {
395 tag.externalDocs = externalDocs
396 })
397 processAnnotation(group.annotations, Override, (override) => {
398 // OpenAPI documents are JSON, so symbol keys are intentionally ignored.
399 for (const [key, value] of Object.entries(override)) {
400 InternalRecord.assignProperty(tag as any, key, value)
401 }
402 })
403 processAnnotation(group.annotations, Transform, (transformFn) => {
404 tag = transformFn(tag) as OpenAPISpecTag
405 })
406
407 spec.tags.push(tag)
408 },
409 onEndpoint({ endpoint, group, mergedAnnotations, middleware }) {
410 if (Context.get(mergedAnnotations, Exclude)) {
411 return
412 }
413 const op: OpenAPISpecOperation = {
414 tags: [Context.getOrElse(group.annotations, Title, () => group.identifier)],
415 operationId: Context.getOrElse(
416 endpoint.annotations,
417 Identifier,
418 () => group.topLevel ? endpoint.identifier : `${group.identifier}.${endpoint.identifier}`
419 ),
420 parameters: [],
421 security: [],
422 responses: {}
423 }
424
425 const paramNames = HttpApiPath.getParamNames(endpoint.params)
426 const path = endpoint.path.replace(
427 /:(\w+)\??/g,
428 (match, key: string) => paramNames === undefined || paramNames.has(key) ? `{${key}}` : match
429 )
430 const method = endpoint.method.toLowerCase() as Lowercase<HttpMethod.HttpMethod>
431 const isQuery = method === "query"
432 const operationKey = isQuery ? "QUERY" : method
433
434 function processResponseBodies(bodies: ResponseBodies, defaultDescription: () => string) {
435 for (const [status, { content, descriptions, headers, streamContent }] of bodies) {
436 const description = descriptions.size > 0 ? Array.from(descriptions).join(" | ") : defaultDescription()
437 InternalRecord.assignProperty(op.responses, status, {
438 description
439 })
440 for (const schema of headers) {
441 schemaOps.push({
442 _tag: "properties",
443 ast: schema.ast,
444 select: (property) => String(property.name).toLowerCase() !== "content-type",
445 apply(property, schema) {
446 const name = String(property.name).toLowerCase()
447 op.responses[status].headers ??= {}
448 InternalRecord.assignProperty(op.responses[status].headers, name, {
449 schema,
450 required: !property.isOptional
451 })
452 }
453 })
454 }
455 if (content !== undefined) {
456 content.forEach((map, encoding) => {
457 map.forEach((schemas, contentType) => {
458 const asts = Array.from(schemas, SchemaAST.getAST)
459 const ast = asts.length === 1 ? asts[0] : new SchemaAST.Union(asts)
460 op.responses[status].content ??= {}
461 InternalRecord.assignProperty(op.responses[status].content, contentType, {
462 schema: registerSchema(ast, encoding)
463 })
464 })
465 })
466 }
467 if (streamContent !== undefined) {
468 streamContent.forEach((stream, contentType) => {
469 op.responses[status].content ??= {}
470 if (HttpApiSchema.isStreamSse(stream)) {
471 InternalRecord.assignProperty(op.responses[status].content, contentType, {
472 schema: registerSchema(stream.events.ast),
473 "x-effect-stream": {
474 encoding: "sse",
475 causeSchema: registerSchema(Schema.Cause(stream.error, Schema.Defect()).ast),
476 errorSchema: registerSchema(stream.error.ast),
477 failureEvent: reservedStreamFailureEvent
478 }
479 })
480 } else {
481 InternalRecord.assignProperty(op.responses[status].content, contentType, {
482 schema: {
483 type: "string",
484 format: "binary"
485 },
486 "x-effect-stream": {
487 encoding: "uint8array"
488 }
489 })
490 }
491 })
492 }
493 }
494 }
495
496 function processParameters(schema: Schema.Constraint | undefined, i: OpenAPISpecParameter["in"]) {
497 if (schema) {
498 schemaOps.push({
499 _tag: "properties",
500 ast: schema.ast,
501 apply(property, schema) {
502 op.parameters.push({
503 name: String(property.name),
504 in: i,
505 schema,
506 required: i === "path" || !property.isOptional
507 })
508 }
509 })
510 }
511 }
512
513 processAnnotation(endpoint.annotations, Description, (description) => {
514 op.description = description
515 })
516 processAnnotation(endpoint.annotations, Summary, (summary) => {
517 op.summary = summary
518 })
519 processAnnotation(endpoint.annotations, Deprecated, (deprecated) => {
520 op.deprecated = deprecated
521 })
522 processAnnotation(endpoint.annotations, ExternalDocs, (externalDocs) => {
523 op.externalDocs = externalDocs
524 })
525
526 middleware.forEach((middleware) => {
527 if (!HttpApiMiddleware.isSecurity(middleware)) {
528 return
529 }
530 for (const [name, security] of Object.entries(middleware.security)) {
531 processHttpApiSecurity(name, security)
532 op.security.push({ [name]: [] })
533 }
534 })
535
536 function processHttpApiSecurity(
537 name: string,
538 security: HttpApiSecurity
539 ) {
540 const scheme = makeSecurityScheme(security)
541 if (!Object.hasOwn(spec.components.securitySchemes, name)) {
542 InternalRecord.assignProperty(spec.components.securitySchemes, name, scheme)
543 return
544 }
545 if (
546 !Equal.equals(
547 securitySchemeForComparison(spec.components.securitySchemes[name]),
548 securitySchemeForComparison(scheme)
549 )
550 ) {
551 throw new globalThis.Error(`Conflicting OpenAPI security scheme: ${name}`)
552 }
553 }
554
555 const hasBody = HttpMethod.hasBody(endpoint.method)
556 if (hasBody) {
557 const schemasByContentType = new Map<string, {
558 readonly encoding: HttpApiSchema.PayloadEncoding
559 readonly schemas: Array<Schema.Top>
560 }>()
561 for (const schema of HttpApiEndpoint.getPayloadSchemas(endpoint)) {
562 if (HttpApiSchema.isNoContent(schema.ast)) continue
563 const encoding = HttpApiSchema.getPayloadEncoding(schema.ast, endpoint.method)
564 const existing = schemasByContentType.get(encoding.contentType)
565 if (existing === undefined) {
566 schemasByContentType.set(encoding.contentType, { encoding, schemas: [schema] })
567 } else {
568 existing.schemas.push(schema)
569 }
570 }
571 if (schemasByContentType.size > 0) {
572 const content: OpenApiSpecContent = {}
573 for (const [contentType, { encoding, schemas }] of schemasByContentType) {
574 const asts = schemas.map(SchemaAST.getAST)
575 const ast = asts.length === 1 ? asts[0] : new SchemaAST.Union(asts)
576 InternalRecord.assignProperty(content, contentType, {
577 schema: registerSchema(ast, encoding._tag)
578 })
579 }
580 op.requestBody = { content, required: true }
581 }
582 }
583
584 processParameters(endpoint.params, "path")
585 if (!hasBody && endpoint.payload.size === 1) {
586 const entry = endpoint.payload.values().next().value!
587 processParameters(entry.schemas[0], "query")
588 }
589 processParameters(endpoint.headers, "header")
590 processParameters(endpoint.query, "query")
591
592 processResponseBodies(
593 extractSuccessResponseBodies(endpoint),
594 () => "Success"
595 )
596 processResponseBodies(
597 extractResponseBodies(
598 HttpApiEndpoint.getErrorSchemas(endpoint),
599 HttpApiSchema.getStatusErrorSchema,
600 resolveDescriptionOrIdentifier
601 ),
602 () => "Error"
603 )
604
605 const pathOperation = `${method} ${path.replace(/\{[^}]+\}/g, "{}")}`
606 if (pathOperations.has(pathOperation)) {
607 throw new globalThis.Error(`Duplicate OpenAPI operation for ${endpoint.method} ${path}`)
608 }
609 pathOperations.add(pathOperation)
610 if (!Object.hasOwn(spec.paths, path)) {
611 InternalRecord.assignProperty(spec.paths, path, {})
612 }
613 const getOperations = (): Partial<Record<OpenAPISpecMethodName | "QUERY", OpenAPISpecOperation>> => {
614 const pathItem = spec.paths[path]
615 return isQuery ? (pathItem["x-oai-additionalOperations"] ??= {}) : pathItem
616 }
617 getOperations()[operationKey] = op
618 finalizeOperations.push(() => {
619 const operations = getOperations()
620 let op = operations[operationKey]!
621 processAnnotation(endpoint.annotations, Override, (override) => {
622 // OpenAPI documents are JSON, so symbol keys are intentionally ignored.
623 for (const [key, value] of Object.entries(override)) {
624 InternalRecord.assignProperty(op as any, key, value)
625 }
626 })
627 processAnnotation(endpoint.annotations, Transform, (transformFn) => {
628 op = transformFn(op) as OpenAPISpecOperation
629 })
630 const operationId = op.operationId
631 if (operationId !== undefined) {
632 if (operationIds.has(operationId)) {
633 throw new globalThis.Error(`Duplicate OpenAPI operationId: ${operationId}`)
634 }
635 operationIds.add(operationId)
636 }
637 operations[operationKey] = op
638 })
639 }
640 })
641
642 processAnnotation(api.annotations, HttpApi.AdditionalSchemas, (componentSchemas) => {
643 componentSchemas.forEach((componentSchema) => {
644 const identifier = SchemaAST.resolveIdentifier(componentSchema.ast)
645 if (identifier !== undefined) {
646 if (Object.hasOwn(spec.components.schemas, identifier)) {
647 throw new globalThis.Error(`Duplicate component schema identifier: ${identifier}`)
648 }
649 InternalRecord.assignProperty(spec.components.schemas, identifier, registerSchema(componentSchema.ast))
650 }
651 })
652 })
653
654 compileSchemaOps(schemaOps, options, spec.components.schemas)
655
656 for (const finalize of finalizeOperations) {
657 finalize()
658 }
659
660 Object.keys(spec.components.schemas).forEach((key) => {
661 if (!JsonSchema.VALID_OPEN_API_COMPONENTS_SCHEMAS_KEY_REGEXP.test(key)) {
662 throw new globalThis.Error(`Invalid component schema key: ${key}`)
663 }
664 })
665
666 processAnnotation(api.annotations, Override, (override) => {
667 // OpenAPI documents are JSON, so symbol keys are intentionally ignored.
668 for (const [key, value] of Object.entries(override)) {
669 InternalRecord.assignProperty(spec as any, key, value)
670 }
671 })
672 processAnnotation(api.annotations, Transform, (transformFn) => {
673 spec = transformFn(spec) as OpenAPISpec
674 })
675
676 return spec
677}
678
679function compileSchemaOps(
680 schemaOps: Array<SchemaOp>,
681 options: SchemaRepresentation.ToRepresentationOptions,
682 components: JsonSchema.Definitions
683): void {
684 if (!Arr.isArrayNonEmpty(schemaOps)) return
685
686 const document = InternalToRepresentation.toRepresentations(
687 Arr.map(schemaOps, (op) => op.ast),
688 options
689 )
690 const representations = Arr.map(document.representations, (representation, index) => {
691 const op = schemaOps[index]
692 return op._tag === "schema"
693 ? representation
694 : makePropertiesRepresentation(representation, document.references, op.select)
695 })
696 const references = selectReferences(representations, document.references)
697 const jsonSchemaMultiDocument = JsonSchema.toMultiDocumentOpenApi3_1(
698 InternalToJsonSchemaDocument.toJsonSchemaMultiDocument(
699 { representations, references },
700 { onExcessProperty: "error" }
701 )
702 )
703
704 for (let index = 0; index < schemaOps.length; index++) {
705 const op = schemaOps[index]
706 const schema = jsonSchemaMultiDocument.schemas[index]
707 if (op._tag === "schema") {
708 InternalRecord.assignProperties(op.target, schema)
709 continue
710 }
711 const representation = representations[index]
712 if (representation._tag !== "Objects") {
713 throw new globalThis.Error("Expected an object representation for HTTP properties")
714 }
715 // These schemas come from the synthetic Objects below, without root checks or annotations.
716 const properties = schema.properties as Record<string, JsonSchema.JsonSchema>
717 for (const property of representation.propertySignatures) {
718 op.apply(property, properties[String(property.name)])
719 }
720 }
721
722 for (const [name, definition] of Object.entries(jsonSchemaMultiDocument.definitions)) {
723 InternalRecord.assignProperty(components, name, definition)
724 }
725}
726
727function makePropertiesRepresentation(
728 representation: SchemaRepresentation.Representation,
729 references: SchemaRepresentation.References,
730 select: ((property: SchemaRepresentation.PropertySignature) => boolean) | undefined
731): SchemaRepresentation.Objects {
732 const resolved = resolveRepresentation(representation, references)
733 return {
734 _tag: "Objects",
735 propertySignatures: resolved._tag === "Objects"
736 ? resolved.propertySignatures
737 .filter((property) => select?.(property) ?? true)
738 .map((property) => ({
739 ...property,
740 name: String(property.name)
741 }))
742 : [],
743 indexSignatures: [],
744 checks: []
745 }
746}
747
748function resolveRepresentation(
749 representation: SchemaRepresentation.Representation,
750 references: SchemaRepresentation.References,
751 seen = new Set<string>()
752): SchemaRepresentation.Representation {
753 if (representation._tag === "Suspend") {
754 return resolveRepresentation(representation.thunk, references, seen)
755 }
756 if (representation._tag !== "Reference" || seen.has(representation.$ref)) return representation
757 const target = references[representation.$ref]
758 return target === undefined
759 ? representation
760 : resolveRepresentation(target, references, new Set(seen).add(representation.$ref))
761}
762
763function selectReferences(
764 representations: ReadonlyArray<SchemaRepresentation.Representation>,
765 references: SchemaRepresentation.References
766): SchemaRepresentation.References {
767 // The JSON Schema compiler emits every supplied reference. Keep only references reachable from the projected
768 // roots so named parameter containers and excluded properties do not leak into OpenAPI components.
769 const selected: Record<string, SchemaRepresentation.Representation> = {}
770 const visited = new WeakSet<object>()
771 const stack = [...representations]
772
773 function pushChecks(checks: ReadonlyArray<SchemaRepresentation.Check>): void {
774 for (const check of checks) {
775 if (check.representation?.schemas !== undefined) stack.push(...check.representation.schemas)
776 if (check._tag === "FilterGroup") pushChecks(check.checks)
777 }
778 }
779
780 while (stack.length > 0) {
781 const representation = stack.pop()!
782 if (visited.has(representation)) continue
783 visited.add(representation)
784 if (representation._tag === "Reference") {
785 if (Object.hasOwn(selected, representation.$ref)) continue
786 const target = references[representation.$ref]
787 if (target === undefined) continue
788 InternalRecord.assignProperty(selected, representation.$ref, target)
789 stack.push(target)
790 continue
791 }
792
793 pushChecks(representation.checks)
794 switch (representation._tag) {
795 case "Declaration":
796 stack.push(...representation.typeParameters)
797 break
798 case "Suspend":
799 stack.push(representation.thunk)
800 break
801 case "TemplateLiteral":
802 stack.push(...representation.parts)
803 break
804 case "Arrays":
805 for (const element of representation.elements) stack.push(element.type)
806 stack.push(...representation.rest)
807 break
808 case "Objects":
809 for (const property of representation.propertySignatures) stack.push(property.type)
810 for (const signature of representation.indexSignatures) {
811 stack.push(signature.parameter, signature.type)
812 }
813 break
814 case "Union":
815 stack.push(...representation.types)
816 break
817 }
818 }
819 return selected
820}
821
822type ResponseBodies = Map<
823 number, // status
824 {
825 descriptions: Set<string>
826 content: Content | undefined // undefined means no content
827 headers: Array<Schema.Constraint>
828 streamContent: StreamContent | undefined
829 }
830>
831
832const reservedStreamFailureEvent = "effect/http-api/stream/failure"
833
834function extractSuccessResponseBodies(endpoint: HttpApiEndpoint.Top): ResponseBodies {
835 return extractResponseBodies(
836 HttpApiEndpoint.getSuccessSchemas(endpoint),
837 HttpApiSchema.getStatusSuccessSchema,
838 resolveDescriptionOrIdentifier
839 )
840}
841
842function extractResponseBodies(
843 schemas: Array<Schema.Constraint>,
844 getStatus: (schema: Schema.Constraint) => number,
845 getDescription: (ast: SchemaAST.AST) => string | undefined
846): ResponseBodies {
847 const map = new Map<number, {
848 descriptions: Set<string>
849 content: Content | undefined
850 headers: Array<Schema.Constraint>
851 streamContent: StreamContent | undefined
852 }>()
853
854 schemas.forEach(process)
855
856 return map
857
858 function process(schema: Schema.Constraint) {
859 const annotation = HttpApiSchema.getWithHeadersAnnotation(schema.ast)
860 const body = HttpApiSchema.isWithHeaders(schema) ? schema.schema : annotation?.body ?? schema
861 const headers = HttpApiSchema.isWithHeaders(schema) ? schema.headers : annotation?.headersCodec
862 const status = getStatus(schema)
863 const ast = body.ast
864 if (HttpApiSchema.isStreamSchema(body)) {
865 addStreamContent(body, status)
866 } else if (HttpApiSchema.isNoContent(ast)) {
867 addNoContent(status, getDescription(schema.ast) ?? getDescription(ast) ?? "<No Content>")
868 } else {
869 addContent(
870 body,
871 status,
872 HttpApiSchema.getResponseEncodingSchema(schema),
873 getDescription(schema.ast) ?? getDescription(ast)
874 )
875 }
876 if (headers !== undefined) {
877 map.get(status)!.headers.push(headers)
878 }
879 }
880
881 function addNoContent(status: number, description: string) {
882 const statusMap = map.get(status)
883 if (statusMap === undefined) {
884 map.set(status, {
885 descriptions: new Set([description]),
886 content: undefined,
887 headers: [],
888 streamContent: undefined
889 })
890 } else {
891 if (description !== undefined) {
892 statusMap.descriptions.add(description)
893 }
894 }
895 }
896
897 function addContent(
898 schema: Schema.Constraint,
899 status: number,
900 encoding: HttpApiSchema.Encoding,
901 description: string | undefined
902 ) {
903 const statusMap = map.get(status)
904 const { _tag, contentType } = encoding
905 if (statusMap === undefined) {
906 map.set(status, {
907 descriptions: new Set(description !== undefined ? [description] : []),
908 content: new Map([[_tag, new Map([[contentType, new Set([schema])]])]]),
909 headers: [],
910 streamContent: undefined
911 })
912 } else {
913 // concat descriptions
914 if (description !== undefined) {
915 statusMap.descriptions.add(description)
916 }
917
918 if (statusMap.content === undefined) {
919 statusMap.content = new Map([[_tag, new Map([[contentType, new Set([schema])]])]])
920 } else {
921 const schemasByContentType = statusMap.content.get(_tag)
922 if (schemasByContentType === undefined) {
923 statusMap.content.set(_tag, new Map([[contentType, new Set([schema])]]))
924 } else {
925 const set = schemasByContentType.get(contentType)
926 if (set === undefined) {
927 schemasByContentType.set(contentType, new Set([schema]))
928 } else {
929 set.add(schema)
930 }
931 }
932 }
933 }
934 }
935
936 function addStreamContent(
937 stream: HttpApiSchema.StreamSchema,
938 status: number
939 ) {
940 const statusMap = map.get(status)
941 if (statusMap === undefined) {
942 map.set(status, {
943 descriptions: new Set(),
944 content: undefined,
945 headers: [],
946 streamContent: new Map([[stream.contentType, stream]])
947 })
948 } else {
949 if (statusMap.streamContent === undefined) {
950 statusMap.streamContent = new Map([[stream.contentType, stream]])
951 } else {
952 statusMap.streamContent.set(stream.contentType, stream)
953 }
954 }
955 }
956}
957
958function resolveDescriptionOrIdentifier(ast: SchemaAST.AST): string | undefined {
959 return SchemaAST.resolveDescription(ast) ?? SchemaAST.resolveIdentifier(ast)
960}
961
962type Content = Map<
963 HttpApiSchema.Encoding["_tag"],
964 Map<
965 string, // contentType
966 Set<Schema.Constraint>
967 >
968>
969
970type StreamContent = Map<string, HttpApiSchema.StreamSchema>
971
972const Uint8ArrayEncoding = Schema.String.annotate({
973 format: "binary"
974})
975
976function toEncodingAST(ast: SchemaAST.AST, _tag: HttpApiSchema.Encoding["_tag"]): SchemaAST.AST {
977 switch (_tag) {
978 case "Uint8Array":
979 return Uint8ArrayEncoding.ast
980 case "Text":
981 return SchemaAST.isDeclaration(ast) && ast.encoding === undefined ? Schema.String.ast : ast
982 case "Multipart":
983 ast = persistedFileToBinaryEncoding(ast)
984 }
985 return InternalToCodec.toCodecJsonAST(ast)
986}
987
988function persistedFileToBinaryEncoding(ast: SchemaAST.AST): SchemaAST.AST {
989 if (
990 SchemaAST.isDeclaration(ast) &&
991 ((ast.annotations as (Schema.Annotations.Declaration<unknown, readonly []> | undefined))?.representation?.id ===
992 "effect/http/PersistedFile")
993 ) {
994 return Uint8ArrayEncoding.ast
995 }
996
997 if (typeof (ast as any)?.recur === "function") {
998 return (ast as any).recur(persistedFileToBinaryEncoding)
999 }
1000
1001 return ast
1002}
1003
1004const makeSecurityScheme = (security: HttpApiSecurity): OpenAPISecurityScheme => {
1005 const meta: Partial<OpenAPISecurityScheme> = {}
1006 processAnnotation(security.annotations, Description, (description) => {
1007 meta.description = description
1008 })
1009 switch (security._tag) {
1010 case "Basic": {
1011 return {
1012 ...meta,
1013 type: "http",
1014 scheme: "basic"
1015 }
1016 }
1017 case "Http": {
1018 const format = Context.getOption(security.annotations, Format).pipe(
1019 Option.map((format) => ({ bearerFormat: format })),
1020 Option.getOrUndefined
1021 )
1022 return {
1023 ...meta,
1024 type: "http",
1025 scheme: security.scheme,
1026 ...format
1027 }
1028 }
1029 case "ApiKey": {
1030 return {
1031 ...meta,
1032 type: "apiKey",
1033 name: security.key,
1034 in: security.in
1035 }
1036 }
1037 }
1038}
1039
1040const securitySchemeForComparison = (scheme: OpenAPISecurityScheme): OpenAPISecurityScheme => {
1041 if (scheme.type === "http") {
1042 return { ...scheme, scheme: scheme.scheme.toLowerCase() }
1043 }
1044 if (scheme.in === "header") {
1045 return { ...scheme, name: scheme.name.toLowerCase() }
1046 }
1047 return scheme
1048}
1049
1050/**
1051 * This model describes the OpenAPI specification (version 3.1.0) returned by
1052 * {@link fromApi}. It is not intended to describe the entire OpenAPI
1053 * specification, only the output of `fromApi`.
1054 *
1055 * @stability unstable
1056 * @category models
1057 * @since 4.0.0
1058 */
1059export interface OpenAPISpec {
1060 openapi: "3.1.0"
1061 info: OpenAPISpecInfo
1062 paths: OpenAPISpecPaths
1063 components: OpenAPIComponents
1064 security: Array<OpenAPISecurityRequirement>
1065 tags: Array<OpenAPISpecTag>
1066 servers?: Array<OpenAPISpecServer>
1067}
1068
1069/**
1070 * OpenAPI `info` object generated by `fromApi`.
1071 *
1072 * @stability unstable
1073 * @category models
1074 * @since 4.0.0
1075 */
1076export interface OpenAPISpecInfo {
1077 title: string
1078 version: string
1079 description?: string
1080 license?: OpenAPISpecLicense
1081 summary?: string
1082}
1083
1084/**
1085 * OpenAPI tag object generated for an HTTP API group.
1086 *
1087 * @stability unstable
1088 * @category models
1089 * @since 4.0.0
1090 */
1091export interface OpenAPISpecTag {
1092 name: string
1093 description?: string
1094 externalDocs?: OpenAPISpecExternalDocs
1095}
1096
1097/**
1098 * OpenAPI external documentation metadata.
1099 *
1100 * @stability unstable
1101 * @category models
1102 * @since 4.0.0
1103 */
1104export interface OpenAPISpecExternalDocs {
1105 url: string
1106 description?: string
1107}
1108
1109/**
1110 * OpenAPI license metadata used in the generated `info` object.
1111 *
1112 * @stability unstable
1113 * @category models
1114 * @since 4.0.0
1115 */
1116export interface OpenAPISpecLicense {
1117 name: string
1118 url?: string
1119 [key: string]: unknown
1120}
1121
1122/**
1123 * OpenAPI server object used in the generated `servers` array.
1124 *
1125 * @stability unstable
1126 * @category models
1127 * @since 4.0.0
1128 */
1129export interface OpenAPISpecServer {
1130 url: string
1131 description?: string
1132 variables?: Record<string, OpenAPISpecServerVariable>
1133}
1134
1135/**
1136 * OpenAPI variable definition for templated server URLs.
1137 *
1138 * @stability unstable
1139 * @category models
1140 * @since 4.0.0
1141 */
1142export interface OpenAPISpecServerVariable {
1143 default: string
1144 enum?: NonEmptyArray<string>
1145 description?: string
1146}
1147
1148/**
1149 * Generated OpenAPI `paths` object, keyed by route path.
1150 *
1151 * @stability unstable
1152 * @category models
1153 * @since 4.0.0
1154 */
1155export type OpenAPISpecPaths = Record<string, OpenAPISpecPathItem>
1156
1157/**
1158 * Lowercase HTTP method names used as keys in generated OpenAPI path items.
1159 *
1160 * @stability unstable
1161 * @category models
1162 * @since 4.0.0
1163 */
1164export type OpenAPISpecMethodName =
1165 | "get"
1166 | "put"
1167 | "post"
1168 | "delete"
1169 | "options"
1170 | "head"
1171 | "patch"
1172 | "trace"
1173
1174/**
1175 * Generated OpenAPI path item mapping HTTP methods to operations for a single route path.
1176 * Parameters declared here are shared by every operation on the path.
1177 * `QUERY` operations are emitted under `x-oai-additionalOperations`.
1178 *
1179 * @stability unstable
1180 * @category models
1181 * @since 4.0.0
1182 */
1183export type OpenAPISpecPathItem =
1184 & {
1185 [K in OpenAPISpecMethodName]?: OpenAPISpecOperation
1186 }
1187 & {
1188 parameters?: Array<OpenAPISpecParameter>
1189 "x-oai-additionalOperations"?: Partial<Record<HttpMethod.HttpMethod, OpenAPISpecOperation>>
1190 }
1191
1192/**
1193 * Generated OpenAPI parameter object for path, query, header, or cookie parameters.
1194 *
1195 * @stability unstable
1196 * @category models
1197 * @since 4.0.0
1198 */
1199export interface OpenAPISpecParameter {
1200 name: string
1201 in: "query" | "header" | "path" | "cookie"
1202 schema: object
1203 required: boolean
1204 description?: string
1205}
1206
1207/**
1208 * Generated OpenAPI responses object, keyed by HTTP status code.
1209 *
1210 * @stability unstable
1211 * @category models
1212 * @since 4.0.0
1213 */
1214export type OpenAPISpecResponses = Record<number, OpenApiSpecResponse>
1215
1216/**
1217 * Generated OpenAPI content object, keyed by media type.
1218 *
1219 * @stability unstable
1220 * @category models
1221 * @since 4.0.0
1222 */
1223export type OpenApiSpecContent = {
1224 [K in string]: OpenApiSpecMediaType
1225}
1226
1227/**
1228 * Generated OpenAPI response object for an endpoint success or error schema.
1229 *
1230 * @stability unstable
1231 * @category models
1232 * @since 4.0.0
1233 */
1234export interface OpenApiSpecResponse {
1235 description: string
1236 content?: OpenApiSpecContent
1237 headers?: Record<string, OpenAPISpecHeader>
1238}
1239
1240/**
1241 * Generated OpenAPI response header object.
1242 *
1243 * @stability unstable
1244 * @category models
1245 * @since 4.0.0
1246 */
1247export type OpenAPISpecHeader = Omit<OpenAPISpecParameter, "name" | "in">
1248
1249/**
1250 * Generated OpenAPI media type object containing the JSON Schema for a request or response body.
1251 *
1252 * @stability unstable
1253 * @category models
1254 * @since 4.0.0
1255 */
1256export interface OpenApiSpecMediaType {
1257 schema: JsonSchema.JsonSchema
1258 "x-effect-stream"?: OpenApiSpecEffectStream
1259}
1260
1261/**
1262 * Effect-specific metadata for generated streaming response media types.
1263 *
1264 * @stability unstable
1265 * @category models
1266 * @since 4.0.0
1267 */
1268export type OpenApiSpecEffectStream =
1269 | {
1270 encoding: "sse"
1271 causeSchema: JsonSchema.JsonSchema
1272 errorSchema: JsonSchema.JsonSchema
1273 failureEvent: "effect/http-api/stream/failure"
1274 }
1275 | {
1276 encoding: "uint8array"
1277 }
1278
1279/**
1280 * Generated OpenAPI request body object for endpoint payloads.
1281 *
1282 * @stability unstable
1283 * @category models
1284 * @since 4.0.0
1285 */
1286export interface OpenAPISpecRequestBody {
1287 content: OpenApiSpecContent
1288 required: true
1289}
1290
1291/**
1292 * Generated OpenAPI components containing shared schemas and security schemes.
1293 *
1294 * @stability unstable
1295 * @category models
1296 * @since 4.0.0
1297 */
1298export interface OpenAPIComponents {
1299 schemas: JsonSchema.Definitions
1300 securitySchemes: Record<string, OpenAPISecurityScheme>
1301}
1302
1303/**
1304 * Generated OpenAPI HTTP security scheme, such as bearer or basic authentication.
1305 *
1306 * @stability unstable
1307 * @category models
1308 * @since 4.0.0
1309 */
1310export interface OpenAPIHTTPSecurityScheme {
1311 readonly type: "http"
1312 scheme: "bearer" | "basic" | string
1313 description?: string
1314 /* only for scheme: 'bearer' */
1315 bearerFormat?: string
1316}
1317
1318/**
1319 * Generated OpenAPI API key security scheme.
1320 *
1321 * @stability unstable
1322 * @category models
1323 * @since 4.0.0
1324 */
1325export interface OpenAPIApiKeySecurityScheme {
1326 readonly type: "apiKey"
1327 name: string
1328 in: "query" | "header" | "cookie"
1329 description?: string
1330}
1331
1332/**
1333 * Union of security scheme objects emitted in generated OpenAPI components.
1334 *
1335 * @stability unstable
1336 * @category models
1337 * @since 4.0.0
1338 */
1339export type OpenAPISecurityScheme =
1340 | OpenAPIHTTPSecurityScheme
1341 | OpenAPIApiKeySecurityScheme
1342
1343/**
1344 * Generated OpenAPI security requirement, keyed by security scheme name.
1345 *
1346 * @stability unstable
1347 * @category models
1348 * @since 4.0.0
1349 */
1350export type OpenAPISecurityRequirement = Record<string, Array<string>>
1351
1352/**
1353 * Generated OpenAPI operation object for an HTTP API endpoint.
1354 *
1355 * @stability unstable
1356 * @category models
1357 * @since 4.0.0
1358 */
1359export interface OpenAPISpecOperation {
1360 operationId: string
1361 parameters: Array<OpenAPISpecParameter>
1362 responses: OpenAPISpecResponses
1363 /** Always contains at least the title annotation or the group identifier */
1364 tags: NonEmptyArray<string>
1365 security: Array<OpenAPISecurityRequirement>
1366 requestBody?: OpenAPISpecRequestBody
1367 description?: string
1368 summary?: string
1369 deprecated?: boolean
1370 externalDocs?: OpenAPISpecExternalDocs
1371}