1/**
2 * The `OpenApiGenerator` module orchestrates converting OpenAPI and Swagger
3 * documents into generated Effect source.
4 *
5 * It normalizes Swagger 2.0 input, resolves local references, builds the
6 * parsed operation model, registers request and response schemas, applies
7 * HttpApi-specific adaptations such as multipart helpers and security metadata,
8 * emits warnings for unsupported or lossy OpenAPI features, and then delegates
9 * final rendering to the HttpClient or HttpApi code generators.
10 *
11 * @since 4.0.0
12 */
13import * as Context from "effect/Context"
14import * as Effect from "effect/Effect"
15import type { OpenAPISecurityScheme, OpenAPISpec, OpenAPISpecOperation } from "effect/http-api/OpenApi"
16import type { HttpMethod } from "effect/http/HttpMethod"
17import * as JsonPointer from "effect/JsonPointer"
18import type * as JsonSchema from "effect/JsonSchema"
19import * as Layer from "effect/Layer"
20import * as Predicate from "effect/Predicate"
21import * as Rec from "effect/Record"
22import * as String from "effect/String"
23import SwaggerToOpenApi from "swagger2openapi"
24import * as HttpApiTransformer from "./HttpApiTransformer.ts"
25import * as JsonSchemaGenerator from "./JsonSchemaGenerator.ts"
26import * as OpenApiTransformer from "./OpenApiTransformer.ts"
27import * as ParsedOperation from "./ParsedOperation.ts"
28import * as Utils from "./Utils.ts"
29
30/**
31 * Service for turning OpenAPI or Swagger specifications into generated Effect
32 * HTTP client or HttpApi source code.
33 *
34 * @category services
35 * @since 4.0.0
36 */
37export class OpenApiGenerator extends Context.Service<
38 OpenApiGenerator,
39 { readonly generate: (spec: OpenAPISpec, options: OpenApiGenerateOptions) => Effect.Effect<string> }
40>()("OpenApiGenerator") {}
41
42/**
43 * Output targets supported by the OpenAPI generator.
44 *
45 * @category models
46 * @since 4.0.0
47 */
48export type OpenApiGeneratorFormat = "httpclient" | "httpclient-type-only" | "httpapi"
49
50/**
51 * Stable identifiers for non-fatal OpenAPI generation warnings.
52 *
53 * @category models
54 * @since 4.0.0
55 */
56export type OpenApiGeneratorWarningCode =
57 | "cookie-parameter-dropped"
58 | "additional-tags-dropped"
59 | "sse-operation-skipped"
60 | "response-headers-ignored"
61 | "optional-request-body-approximated"
62 | "default-response-remapped"
63 | "security-and-downgraded"
64 | "no-body-method-request-body-skipped"
65 | "naming-collision"
66
67/**
68 * Describes a non-fatal issue encountered while mapping an OpenAPI operation to
69 * generated Effect source.
70 *
71 * @category models
72 * @since 4.0.0
73 */
74export interface OpenApiGeneratorWarning {
75 readonly code: OpenApiGeneratorWarningCode
76 readonly message: string
77 readonly path?: string | undefined
78 readonly method?: Lowercase<HttpMethod> | undefined
79 readonly operationId?: string | undefined
80}
81
82/**
83 * Options that control one OpenAPI generation run.
84 *
85 * @category options
86 * @since 4.0.0
87 */
88export interface OpenApiGenerateOptions {
89 /**
90 * The name to give to the generated output.
91 */
92 readonly name: string
93 /**
94 * The output format to generate.
95 */
96 readonly format: OpenApiGeneratorFormat
97 /**
98 * Hook to transform each JSON Schema node before processing.
99 */
100 readonly onEnter?: ((js: JsonSchema.JsonSchema) => JsonSchema.JsonSchema) | undefined
101 /**
102 * Callback to receive non-fatal generation warnings.
103 */
104 readonly onWarning?: ((warning: OpenApiGeneratorWarning) => void) | undefined
105}
106
107interface HttpApiMultipartSchemaRefs {
108 readonly kind: "httpapi"
109 readonly singleFile: string
110 readonly files: string
111}
112
113type MultipartSchemaRefs = HttpApiMultipartSchemaRefs | { readonly kind: "client"; readonly singleFile: string }
114
115const methodNames: ReadonlyArray<Lowercase<HttpMethod>> = [
116 "get",
117 "put",
118 "post",
119 "delete",
120 "options",
121 "head",
122 "patch",
123 "trace",
124 "query"
125]
126
127/**
128 * Constructs the OpenAPI generator service implementation.
129 *
130 * @category constructors
131 * @since 4.0.0
132 */
133export const make = Effect.gen(function*() {
134 const generate = Effect.fn(
135 function*(spec: OpenAPISpec, options: OpenApiGenerateOptions) {
136 const generator = JsonSchemaGenerator.make()
137 const openApiTransformer = yield* OpenApiTransformer.OpenApiTransformer
138 const emitWarning = makeWarningEmitter(options)
139
140 if (isSwaggerSpec(spec)) {
141 spec = yield* convertSwaggerSpec(spec)
142 }
143
144 function resolveRef(ref: string) {
145 const parts = ref.split("/").slice(1).map(JsonPointer.unescapeToken)
146 let current: any = spec
147 for (const part of parts) {
148 current = current[part]
149 }
150 return current
151 }
152
153 const multipart = makeMultipartSchemas(spec.components?.schemas ?? {}, options.format, resolveRef)
154
155 const parsed = parseOpenApi(spec, generator, resolveRef, options.format, emitWarning, multipart.transform)
156
157 // TODO: make a CLI option ?
158 const importName = "Schema"
159 const source = getDialect(spec)
160 const schemaOptions = { onEnter: options.onEnter, multipartSchemaRefs: multipart.refs }
161 const generation = options.format === "httpapi"
162 ? generator.generateHttpApi(source, multipart.definitions, schemaOptions)
163 : generator.generate(source, multipart.definitions, options.format === "httpclient-type-only", schemaOptions)
164
165 if (options.format === "httpapi") {
166 const needsMultipartImport = generation.includes("Multipart.")
167 return String.stripMargin(
168 `|${HttpApiTransformer.imports(importName, { multipart: needsMultipartImport })}
169 |${generation}
170 |${HttpApiTransformer.toImplementation(importName, options.name, parsed)}`
171 )
172 }
173
174 return String.stripMargin(
175 `|${openApiTransformer.imports(importName, parsed)}
176 |${generation}
177 |${openApiTransformer.toImplementation(importName, options.name, parsed)}
178 |
179 |${openApiTransformer.toTypes(importName, options.name, parsed)}`
180 )
181 },
182 (effect, _, options) =>
183 Effect.provideServiceEffect(
184 effect,
185 OpenApiTransformer.OpenApiTransformer,
186 options.format === "httpclient-type-only"
187 ? Effect.sync(OpenApiTransformer.makeTransformerTs)
188 : Effect.sync(OpenApiTransformer.makeTransformerSchema)
189 )
190 )
191
192 return { generate } as const
193})
194
195type WarningEmitter = (warning: OpenApiGeneratorWarning) => void
196
197const makeWarningEmitter = (options: OpenApiGenerateOptions): WarningEmitter => (warning) => {
198 options.onWarning?.(warning)
199}
200
201const parseOpenApi = (
202 spec: OpenAPISpec,
203 generator: ReturnType<typeof JsonSchemaGenerator.make>,
204 resolveRef: (ref: string) => unknown,
205 format: OpenApiGeneratorFormat,
206 emitWarning: WarningEmitter,
207 transformMultipart: (schema: JsonSchema.JsonSchema) => JsonSchema.JsonSchema
208): ParsedOperation.ParsedOpenApi => {
209 const operations: Array<ParsedOperation.ParsedOperation> = []
210 const reservedSchemaNames = new Set<string>(Object.keys(spec.components?.schemas ?? {}))
211 const isHttpApi = format === "httpapi"
212 const securitySchemes = isHttpApi ? parseSecuritySchemes(spec, resolveRef) : []
213
214 const addSchema = (
215 baseName: string,
216 schema: JsonSchema.JsonSchema,
217 operation: ParsedOperation.ParsedOperation
218 ): string => {
219 let candidate = baseName
220 let index = 2
221 while (reservedSchemaNames.has(candidate)) {
222 candidate = `${baseName}${index}`
223 index += 1
224 }
225 if (candidate !== baseName) {
226 warnForOperation(emitWarning, operation, {
227 code: "naming-collision",
228 message: `Schema name "${baseName}" collided with an existing name and was renamed to "${candidate}".`
229 })
230 }
231 reservedSchemaNames.add(candidate)
232 return generator.addSchema(candidate, schema)
233 }
234
235 for (const [path, methods] of Object.entries(spec.paths)) {
236 const pathItem: Partial<Record<Lowercase<HttpMethod>, OpenAPISpecOperation>> = methods
237 for (const method of methodNames) {
238 const operation = method === "query"
239 ? pathItem.query ?? methods["x-oai-additionalOperations"]?.QUERY
240 : pathItem[method]
241 if (Predicate.isUndefined(operation)) {
242 continue
243 }
244 const id = operation.operationId
245 ? Utils.camelize(operation.operationId)
246 : `${method.toUpperCase()}${path}`
247
248 const description = Utils.nonEmptyString(operation.description) ?? Utils.nonEmptyString(operation.summary)
249
250 const { pathIds, pathTemplate } = processPath(path)
251
252 const op = ParsedOperation.makeDeepMutable({
253 id,
254 method,
255 description,
256 pathIds,
257 pathTemplate
258 })
259 const httpClientResponses = op.httpClientResponses
260 op.path = path
261 op.operationId = Utils.nonEmptyString(operation.operationId)
262 op.tags = [...(operation.tags ?? [])]
263 if (isHttpApi && op.tags.length > 1) {
264 warnForOperation(emitWarning, op, {
265 code: "additional-tags-dropped",
266 message: `Additional tags (${op.tags.slice(1).join(", ")}) were dropped. Only the first tag ("${
267 op.tags[0]
268 }") is used for grouping.`
269 })
270 }
271 op.metadata = {
272 summary: Utils.nonEmptyString(operation.summary),
273 description: Utils.nonEmptyString(operation.description),
274 deprecated: operation.deprecated === true,
275 externalDocs: operation.externalDocs
276 }
277 op.effectiveSecurity = cloneSecurityRequirements(operation.security ?? spec.security ?? [])
278 if (isHttpApi) {
279 warnForAndSecurityRequirements(emitWarning, op)
280 }
281
282 const schemaId = Utils.identifier(operation.operationId ?? path)
283
284 const pathParameters = Array.isArray(methods.parameters)
285 ? methods.parameters
286 : undefined
287 const parameters = resolveOperationParameters(
288 pathParameters,
289 Array.isArray(operation.parameters) ? operation.parameters : undefined,
290 resolveRef
291 )
292 for (const parameter of parameters) {
293 const parsedParameter: ParsedOperation.ParsedOperationParameter = {
294 name: parameter.name,
295 in: parameter.in,
296 required: parameter.required === true,
297 description: Utils.nonEmptyString(parameter.description),
298 schema: parameter.schema
299 }
300 switch (parameter.in) {
301 case "path": {
302 op.parameters.path.push(parsedParameter)
303 break
304 }
305 case "query": {
306 op.parameters.query.push(parsedParameter)
307 break
308 }
309 case "header": {
310 op.parameters.header.push(parsedParameter)
311 break
312 }
313 case "cookie": {
314 op.parameters.cookie.push(parsedParameter)
315 warnForOperation(emitWarning, op, {
316 code: "cookie-parameter-dropped",
317 message:
318 `Cookie parameter "${parameter.name}" was dropped because non-security cookie parameters are not supported.`
319 })
320 break
321 }
322 }
323 }
324
325 const requestBody = resolveReference(operation.requestBody, resolveRef)
326 if (isHttpApi && !methodSupportsRequestBody(op.method) && Predicate.isObject(requestBody)) {
327 warnForOperation(emitWarning, op, {
328 code: "no-body-method-request-body-skipped",
329 message: `Operation was skipped because ${op.method.toUpperCase()} does not support request bodies.`
330 })
331 continue
332 }
333
334 const resolvedResponses = Object.entries(operation.responses ?? {}).map(
335 ([status, response]) => [status, resolveReference(response, resolveRef)] as const
336 )
337 const hasExplicitSuccessResponse = resolvedResponses.some(([status]) => {
338 if (!/^\d{3}$/.test(status)) {
339 return false
340 }
341 return Number(status) < 400
342 })
343
344 if (isHttpApi && hasUnsupportedSuccessfulSseResponse(resolvedResponses, hasExplicitSuccessResponse)) {
345 warnForOperation(emitWarning, op, {
346 code: "sse-operation-skipped",
347 message:
348 "Operation was skipped because a successful SSE response is missing x-effect-stream metadata required for HttpApi generation."
349 })
350 continue
351 }
352
353 const validParameters = parameters.filter((parameter) => parameter.in !== "path" && parameter.in !== "cookie")
354
355 const combinedParameterSchema = buildParameterSchema(validParameters, (parameter, added) => {
356 if (parameter.in === "query") {
357 Utils.spreadElementsInto(added, op.urlParams)
358 } else if (parameter.in === "header") {
359 Utils.spreadElementsInto(added, op.headers)
360 } else if (parameter.in === "cookie") {
361 Utils.spreadElementsInto(added, op.cookies)
362 }
363 })
364
365 if (combinedParameterSchema !== undefined) {
366 op.params = addSchema(`${schemaId}Params`, combinedParameterSchema.schema, op)
367 op.paramsOptional = combinedParameterSchema.optional
368 }
369
370 if (isHttpApi) {
371 const pathParameterSchema = buildParameterSchema(op.parameters.path)
372 if (pathParameterSchema !== undefined) {
373 op.pathSchema = addSchema(`${schemaId}PathParams`, pathParameterSchema.schema, op)
374 }
375
376 const queryParameterSchema = buildParameterSchema(op.parameters.query)
377 if (queryParameterSchema !== undefined) {
378 op.querySchema = addSchema(`${schemaId}Query`, queryParameterSchema.schema, op)
379 op.querySchemaOptional = queryParameterSchema.optional
380 }
381
382 const headerParameterSchema = buildParameterSchema(op.parameters.header)
383 if (headerParameterSchema !== undefined) {
384 op.headersSchema = addSchema(`${schemaId}Headers`, headerParameterSchema.schema, op)
385 op.headersSchemaOptional = headerParameterSchema.optional
386 }
387 }
388
389 if (Predicate.isNotUndefined(requestBody) && Predicate.isObject(requestBody)) {
390 const content = Predicate.isObject(requestBody.content)
391 ? requestBody.content as Record<string, any>
392 : {}
393 const requestSchemaNames = new Map<string, string>()
394 op.requestBody = {
395 required: requestBody.required === true,
396 contentTypes: Object.keys(content)
397 }
398 if (isHttpApi && requestBody.required === false) {
399 warnForOperation(emitWarning, op, {
400 code: "optional-request-body-approximated",
401 message: "Optional request body was approximated by adding a no-content payload alternative."
402 })
403 }
404
405 if (Predicate.isNotUndefined(content["application/json"]?.schema)) {
406 op.payload = addSchema(`${schemaId}RequestJson`, content["application/json"].schema, op)
407 requestSchemaNames.set("application/json", op.payload)
408 }
409
410 if (Predicate.isNotUndefined(content["multipart/form-data"]?.schema)) {
411 op.payload = addSchema(
412 `${schemaId}RequestFormData`,
413 transformMultipart(content["multipart/form-data"].schema),
414 op
415 )
416 op.payloadFormData = true
417 requestSchemaNames.set("multipart/form-data", op.payload)
418 }
419
420 if (Predicate.isNotUndefined(content["application/x-www-form-urlencoded"]?.schema)) {
421 op.payload = addSchema(
422 `${schemaId}RequestFormUrlEncoded`,
423 content["application/x-www-form-urlencoded"].schema,
424 op
425 )
426 op.payloadFormUrlEncoded = true
427 requestSchemaNames.set("application/x-www-form-urlencoded", op.payload)
428 }
429
430 if (isHttpApi) {
431 const representableRequestBody: Array<ParsedOperation.ParsedOperationMediaTypeSchema> = []
432 for (const [contentType, mediaType] of Object.entries(content)) {
433 if (!Predicate.isObject(mediaType) || Predicate.isUndefined(mediaType.schema)) {
434 continue
435 }
436 const encoding = getRequestMediaTypeEncoding(contentType)
437 if (encoding === undefined) {
438 continue
439 }
440 let schemaName = requestSchemaNames.get(contentType)
441 if (schemaName === undefined) {
442 const schema = encoding === "multipart"
443 ? transformMultipart(mediaType.schema as JsonSchema.JsonSchema)
444 : mediaType.schema as JsonSchema.JsonSchema
445 schemaName = addSchema(
446 `${schemaId}Request${mediaTypeToSuffix(contentType)}`,
447 schema,
448 op
449 )
450 requestSchemaNames.set(contentType, schemaName)
451 }
452 representableRequestBody.push({
453 contentType,
454 encoding,
455 schema: schemaName
456 })
457 }
458 op.requestBodyRepresentable = representableRequestBody
459 }
460 }
461
462 let defaultSchema: string | undefined
463 for (const [status, response] of resolvedResponses) {
464 if (!Predicate.isObject(response)) {
465 continue
466 }
467
468 const parsedStatus = isHttpApi
469 ? remapDefaultResponseStatusForHttpApi(status, hasExplicitSuccessResponse)
470 : status
471 if (isHttpApi && status === "default") {
472 warnForOperation(emitWarning, op, {
473 code: "default-response-remapped",
474 message: `Default response was remapped to status ${parsedStatus} for HttpApi generation.`
475 })
476 }
477
478 const content = Predicate.isObject(response.content)
479 ? response.content as Record<string, any>
480 : undefined
481 if (isHttpApi && Predicate.isNotUndefined(response.headers)) {
482 warnForOperation(emitWarning, op, {
483 code: "response-headers-ignored",
484 message: `Response headers on status ${status} were ignored in HttpApi generation.`
485 })
486 }
487 const representable: Array<ParsedOperation.ParsedOperationMediaTypeSchema> = []
488
489 let jsonSchemaName: string | undefined
490 let jsonResponseContentType: string | undefined
491 if (isHttpApi) {
492 const jsonResponseEntries = Object.entries(content ?? {}).filter(([contentType, mediaType]) =>
493 isJsonMediaType(normalizeMediaType(contentType)) &&
494 Predicate.isObject(mediaType) &&
495 Predicate.isNotUndefined(mediaType.schema)
496 )
497 const jsonResponseEntry = jsonResponseEntries.find(([contentType]) =>
498 normalizeMediaType(contentType) === "application/json"
499 ) ?? jsonResponseEntries[0]
500 if (Predicate.isNotUndefined(jsonResponseEntry)) {
501 const [contentType, mediaType] = jsonResponseEntry
502 jsonResponseContentType = contentType
503 jsonSchemaName = addSchema(`${schemaId}${status}`, mediaType.schema as JsonSchema.JsonSchema, op)
504 representable.push({
505 contentType,
506 encoding: "json",
507 schema: jsonSchemaName
508 })
509 }
510 } else {
511 const jsonSchemas = Object.entries(content ?? {}).flatMap(([contentType, mediaType]) =>
512 isJsonMediaType(normalizeMediaType(contentType)) &&
513 Predicate.isObject(mediaType) &&
514 Predicate.isNotUndefined(mediaType.schema)
515 ? [mediaType.schema as JsonSchema.JsonSchema]
516 : []
517 )
518 if (jsonSchemas.length > 0) {
519 jsonSchemaName = addSchema(
520 `${schemaId}${status}`,
521 jsonSchemas.length === 1 ? jsonSchemas[0] : { anyOf: jsonSchemas },
522 op
523 )
524 }
525 }
526
527 if (isHttpApi) {
528 for (const [contentType, mediaType] of Object.entries(content ?? {})) {
529 if (contentType === jsonResponseContentType) {
530 continue
531 }
532 if (!Predicate.isObject(mediaType)) {
533 continue
534 }
535
536 const streamEncoding = isSuccessStatus(parsedStatus)
537 ? getEffectStreamEncoding(mediaType)
538 : undefined
539 if (streamEncoding === "uint8array") {
540 representable.push({
541 contentType,
542 encoding: "binary",
543 effectStream: "uint8array"
544 })
545 continue
546 }
547 if (streamEncoding === "sse") {
548 const errorSchema = getEffectStreamErrorSchema(mediaType)
549 if (Predicate.isUndefined(mediaType.schema) || Predicate.isUndefined(errorSchema)) {
550 continue
551 }
552 representable.push({
553 contentType,
554 encoding: "text",
555 effectStream: "sse",
556 schema: addSchema(
557 `${schemaId}${status}Sse`,
558 mediaType.schema as JsonSchema.JsonSchema,
559 op
560 ),
561 errorSchema: addSchema(
562 `${schemaId}${status}SseError`,
563 errorSchema,
564 op
565 )
566 })
567 continue
568 }
569
570 if (Predicate.isUndefined(mediaType.schema)) {
571 continue
572 }
573 const encoding = getResponseMediaTypeEncoding(contentType)
574 if (encoding === undefined) {
575 continue
576 }
577 const schemaName = addSchema(
578 `${schemaId}${status}${mediaTypeToSuffix(contentType)}`,
579 mediaType.schema as JsonSchema.JsonSchema,
580 op
581 )
582 representable.push({
583 contentType,
584 encoding,
585 schema: schemaName
586 })
587 }
588 }
589
590 const isEmptyResponse = Predicate.isUndefined(content) || Object.keys(content).length === 0
591 const parsedResponse = {
592 status: parsedStatus,
593 description: Utils.nonEmptyString(response.description),
594 contentTypes: Predicate.isNotUndefined(content) ? Object.keys(content) : [],
595 hasHeaders: Predicate.isNotUndefined(response.headers),
596 isEmpty: isEmptyResponse,
597 representable
598 }
599 op.responses.push(parsedResponse)
600 if (status === "default") {
601 op.defaultResponse = parsedResponse
602 }
603
604 const normalizedStatus = parsedStatus.toLowerCase()
605 const hasBinaryResponse = Object.entries(content ?? {}).some(([contentType, mediaType]) =>
606 Predicate.isObject(mediaType) &&
607 (isBinaryMediaType(normalizeMediaType(contentType)) || isBinarySchema(mediaType.schema))
608 )
609 if (hasBinaryResponse && isSuccessStatus(parsedStatus)) {
610 httpClientResponses.binarySuccessStatuses.add(normalizedStatus)
611 }
612
613 if (Predicate.isNotUndefined(jsonSchemaName)) {
614 if (status === "default" && !isHttpApi) {
615 defaultSchema = jsonSchemaName
616 continue
617 }
618
619 if (Number.isNaN(Number(parsedStatus[0]))) {
620 continue
621 }
622 if (isSuccessStatus(parsedStatus)) {
623 if (!httpClientResponses.binarySuccessStatuses.has(normalizedStatus)) {
624 httpClientResponses.successSchemas.set(normalizedStatus, jsonSchemaName)
625 }
626 } else {
627 httpClientResponses.errorSchemas.set(normalizedStatus, jsonSchemaName)
628 }
629 }
630
631 const sseMediaType = content?.["text/event-stream"]
632 const sseResponseSchema = sseMediaType?.schema
633 if (
634 !isHttpApi && Predicate.isUndefined(httpClientResponses.sseSchema) &&
635 Predicate.isNotUndefined(sseResponseSchema) &&
636 isSuccessStatus(parsedStatus)
637 ) {
638 const effectStream = sseMediaType["x-effect-stream"]
639 httpClientResponses.sseSchemaMode = getEffectStreamEncoding(sseMediaType) === "sse" ? "event" : "data"
640 httpClientResponses.sseSchema = addSchema(
641 `${schemaId}${status}Sse`,
642 httpClientResponses.sseSchemaMode === "event"
643 ? makeSseEventSchema(
644 resolveReference(sseResponseSchema, resolveRef),
645 effectStream?.encoding === "sse" ? effectStream.failureEvent : undefined
646 )
647 : sseResponseSchema,
648 op
649 )
650 }
651
652 if (isEmptyResponse && parsedStatus !== "default") {
653 if (isSuccessStatus(normalizedStatus)) {
654 httpClientResponses.voidSuccessStatuses.add(normalizedStatus)
655 } else {
656 httpClientResponses.voidErrorStatuses.add(normalizedStatus)
657 }
658 }
659 }
660
661 if (
662 !isHttpApi && httpClientResponses.successSchemas.size === 0 && Predicate.isNotUndefined(defaultSchema)
663 ) {
664 httpClientResponses.successSchemas.set("2xx", defaultSchema)
665 warnForOperation(emitWarning, op, {
666 code: "default-response-remapped",
667 message: "Default response was remapped to 2xx for the current HttpClient outputs."
668 })
669 }
670
671 operations.push(op)
672 }
673 }
674
675 return {
676 metadata: {
677 title: spec.info.title,
678 version: spec.info.version,
679 summary: Utils.nonEmptyString(spec.info.summary),
680 description: Utils.nonEmptyString(spec.info.description),
681 license: spec.info.license,
682 servers: spec.servers
683 },
684 tags: (spec.tags ?? []).map((tag) => ({
685 name: tag.name,
686 description: Utils.nonEmptyString(tag.description),
687 externalDocs: tag.externalDocs
688 })),
689 securitySchemes,
690 operations
691 }
692}
693
694interface OpenApiParameter {
695 readonly name: string
696 readonly in: "path" | "query" | "header" | "cookie"
697 readonly required: boolean
698 readonly schema: {}
699 readonly description?: string | undefined
700}
701
702const isOpenApiParameter = (parameter: unknown): parameter is OpenApiParameter => {
703 if (!Predicate.isObject(parameter)) {
704 return false
705 }
706 return (
707 typeof parameter.name === "string" &&
708 (parameter.in === "path" || parameter.in === "query" || parameter.in === "header" || parameter.in === "cookie")
709 )
710}
711
712const resolveOperationParameters = (
713 pathParameters: ReadonlyArray<unknown> | undefined,
714 operationParameters: ReadonlyArray<unknown> | undefined,
715 resolveRef: (ref: string) => unknown
716): Array<OpenApiParameter> => {
717 const resolved = new Map<string, OpenApiParameter>()
718 const add = (parameter: unknown): void => {
719 const current = resolveReference(parameter, resolveRef)
720 if (!isOpenApiParameter(current)) {
721 return
722 }
723
724 const key = `${current.in}:${current.name}`
725 if (resolved.has(key)) {
726 resolved.delete(key)
727 }
728 resolved.set(key, current)
729 }
730
731 for (const parameter of pathParameters ?? []) {
732 add(parameter)
733 }
734
735 for (const parameter of operationParameters ?? []) {
736 add(parameter)
737 }
738
739 return [...resolved.values()]
740}
741
742const buildParameterSchema = <
743 Parameter extends {
744 readonly name: string
745 readonly required: boolean
746 readonly schema: {}
747 readonly in?: "path" | "query" | "header" | "cookie" | undefined
748 }
749>(
750 parameters: ReadonlyArray<Parameter>,
751 onAdded?: ((parameter: Parameter, added: Array<string>) => void) | undefined
752): {
753 readonly schema: JsonSchema.JsonSchema
754 readonly optional: boolean
755} | undefined => {
756 if (parameters.length === 0) {
757 return
758 }
759
760 const schema = {
761 type: "object" as JsonSchema.Type,
762 properties: {} as Record<string, JsonSchema.JsonSchema>,
763 required: [] as Array<string>,
764 additionalProperties: false
765 }
766
767 for (const parameter of parameters) {
768 const paramSchema = parameter.schema as any
769 const added: Array<string> = []
770 if (
771 Predicate.isObject(paramSchema) &&
772 "properties" in paramSchema &&
773 Predicate.isObject(paramSchema.properties)
774 ) {
775 const required = "required" in paramSchema
776 ? paramSchema.required as Array<string>
777 : []
778
779 for (const [name, propertySchema] of Object.entries(paramSchema.properties)) {
780 const adjustedName = `${parameter.name}[${name}]`
781 Rec.assignProperty(schema.properties, adjustedName, propertySchema as JsonSchema.JsonSchema)
782 if (required.includes(name)) {
783 schema.required.push(adjustedName)
784 }
785 added.push(adjustedName)
786 }
787 } else {
788 Rec.assignProperty(schema.properties, parameter.name, parameter.schema as JsonSchema.JsonSchema)
789 if (parameter.required) {
790 schema.required.push(parameter.name)
791 }
792 added.push(parameter.name)
793 }
794
795 onAdded?.(parameter, added)
796 }
797
798 return {
799 schema,
800 optional: schema.required.length === 0
801 }
802}
803
804const mediaTypeToSuffix = (contentType: string): string => {
805 const normalized = contentType.toLowerCase()
806 switch (normalized) {
807 case "application/json":
808 return "Json"
809 case "multipart/form-data":
810 return "FormData"
811 case "application/x-www-form-urlencoded":
812 return "FormUrlEncoded"
813 case "text/plain":
814 return "Text"
815 case "application/octet-stream":
816 return "Binary"
817 }
818 const suffix = Utils.identifier(contentType)
819 return suffix.length > 0 ? suffix : "Body"
820}
821
822const makeMultipartSchemas = (
823 definitions: JsonSchema.Definitions,
824 format: OpenApiGeneratorFormat,
825 resolveRef: (ref: string) => unknown
826) => {
827 const names = new Set(Object.keys(definitions))
828 const allocate = (base: string): string => {
829 let candidate = base
830 let index = 2
831 while (names.has(candidate)) {
832 candidate = `${base}${index}`
833 index += 1
834 }
835 names.add(candidate)
836 return candidate
837 }
838 const output = { ...definitions }
839 if (format === "httpapi") {
840 const refs: HttpApiMultipartSchemaRefs = {
841 kind: "httpapi",
842 singleFile: allocate("__HttpApiMultipartSingleFile"),
843 files: allocate("__HttpApiMultipartFiles")
844 }
845 const transform = (schema: JsonSchema.JsonSchema) => transformMultipartSchema(schema, refs, resolveRef)
846 Rec.assignProperty(output, refs.singleFile, { type: "string", format: "binary" })
847 for (const [name, schema] of Object.entries(definitions)) {
848 Rec.assignProperty(output, name, transform(schema))
849 }
850 Rec.assignProperty(output, refs.files, { type: "array", items: { $ref: toDefinitionRef(refs.singleFile) } })
851 return { refs, definitions: output, transform }
852 }
853 const refs: MultipartSchemaRefs = { kind: "client", singleFile: allocate("__ClientMultipartFile") }
854 Rec.assignProperty(output, refs.singleFile, { type: "string", format: "binary" })
855 // The client transform adds `*Multipart` copies to `output` while parsing, so render schemas after parseOpenApi.
856 return {
857 refs,
858 definitions: output,
859 transform: makeClientMultipartTransformer(output, refs.singleFile, allocate, resolveRef)
860 }
861}
862
863const toDefinitionRef = (name: string): string => `#/$defs/${name.replaceAll("~", "~0").replaceAll("/", "~1")}`
864
865const makeClientMultipartTransformer = (
866 definitions: JsonSchema.Definitions,
867 singleFile: string,
868 allocate: (base: string) => string,
869 resolveRef: (ref: string) => unknown
870) => {
871 const binaryComponents = new Map<string, boolean>()
872 const copies = new Map<string, string>()
873
874 const hasBinary = (value: unknown, seen: Set<string>): boolean => {
875 if (Array.isArray(value)) return value.some((item) => hasBinary(item, seen))
876 if (!Predicate.isObject(value)) return false
877 if (isMultipartBinaryFile(value)) return true
878 if (isComponentRef(value.$ref) && !seen.has(value.$ref)) {
879 seen.add(value.$ref)
880 if (hasBinary(resolveRef(value.$ref), seen)) return true
881 }
882 return Object.values(value).some((item) => hasBinary(item, seen))
883 }
884
885 const isBinaryComponent = ($ref: string): boolean => {
886 let binary = binaryComponents.get($ref)
887 if (binary === undefined) {
888 binary = hasBinary(resolveRef($ref), new Set([$ref]))
889 binaryComponents.set($ref, binary)
890 }
891 return binary
892 }
893
894 // Copies a binary-bearing component to `<Name>Multipart`, leaving the original for JSON payloads.
895 const copyComponent = ($ref: string): string => {
896 let name = copies.get($ref)
897 if (name === undefined) {
898 name = allocate(`${JsonPointer.unescapeToken($ref.slice(componentRefPrefix.length))}Multipart`)
899 copies.set($ref, name)
900 Rec.assignProperty(definitions, name, visit(resolveRef($ref)))
901 }
902 return name
903 }
904
905 const visit = (value: unknown): unknown => {
906 if (Array.isArray(value)) return value.map(visit)
907 if (!Predicate.isObject(value)) return value
908 if (isMultipartBinaryFile(value)) return { $ref: toDefinitionRef(singleFile) }
909 const out = Rec.map(value, visit)
910 if (isComponentRef(value.$ref) && isBinaryComponent(value.$ref)) {
911 out.$ref = toDefinitionRef(copyComponent(value.$ref))
912 }
913 return out
914 }
915
916 return (schema: JsonSchema.JsonSchema) => visit(schema) as JsonSchema.JsonSchema
917}
918
919const componentRefPrefix = "#/components/schemas/"
920
921const isComponentRef = (value: unknown): value is string =>
922 typeof value === "string" && value.startsWith(componentRefPrefix)
923
924const transformMultipartSchema = (
925 schema: JsonSchema.JsonSchema,
926 multipartSchemaRefs: HttpApiMultipartSchemaRefs,
927 resolveRef: (ref: string) => unknown
928): JsonSchema.JsonSchema => {
929 const singleFileRef = toDefinitionRef(multipartSchemaRefs.singleFile)
930 const cache = new Map<string, unknown>()
931 const stack = new Set<string>()
932
933 const visit = (value: unknown): unknown => {
934 if (Array.isArray(value)) {
935 return value.map(visit)
936 }
937 if (!Predicate.isObject(value)) {
938 return value
939 }
940
941 if (isComponentRef(value.$ref)) {
942 const { $ref, ...siblings } = value
943 const withSiblings = (schema: unknown): unknown =>
944 Object.keys(siblings).length === 0 ? schema : { allOf: [schema, visit(siblings)] }
945 const cached = cache.get($ref)
946 if (cached !== undefined) {
947 return withSiblings(cached)
948 }
949 if (stack.has($ref)) {
950 return value
951 }
952 stack.add($ref)
953 const transformed = visit(resolveRef($ref))
954 stack.delete($ref)
955 cache.set($ref, transformed)
956 return withSiblings(transformed)
957 }
958
959 if (isMultipartBinaryFile(value)) {
960 return { $ref: singleFileRef }
961 }
962
963 const out: Record<string, unknown> = {}
964 for (const [key, current] of Object.entries(value)) {
965 Rec.assignProperty(out, key, visit(current))
966 }
967
968 if (isMultipartBinaryFiles(out, singleFileRef)) {
969 return { $ref: toDefinitionRef(multipartSchemaRefs.files) }
970 }
971
972 return out
973 }
974
975 return visit(schema) as JsonSchema.JsonSchema
976}
977
978const isMultipartBinaryFile = (value: unknown): boolean =>
979 Predicate.isObject(value) &&
980 value.type === "string" &&
981 (
982 (typeof value.format === "string" && value.format.toLowerCase() === "binary") ||
983 (typeof value.contentEncoding === "string" && value.contentEncoding.toLowerCase() === "binary")
984 )
985
986const isMultipartBinaryFiles = (value: Record<string, unknown>, singleFileRef: string): boolean => {
987 if (value.type !== "array") {
988 return false
989 }
990 const items = value.items
991 return isMultipartBinaryFile(items) || (Predicate.isObject(items) && items.$ref === singleFileRef)
992}
993
994const isJsonMediaType = (contentType: string): boolean =>
995 contentType === "application/json" ||
996 (contentType.startsWith("application/") && contentType.endsWith("+json"))
997
998const normalizeMediaType = (contentType: string): string => contentType.toLowerCase().split(";", 1)[0].trim()
999
1000const isTextMediaType = (contentType: string): boolean => contentType.startsWith("text/")
1001
1002const binaryMediaTypes = new Set([
1003 "application/octet-stream",
1004 "application/pdf",
1005 "application/zip",
1006 "application/gzip",
1007 "application/x-gzip",
1008 "application/x-tar",
1009 "application/x-zip-compressed"
1010])
1011
1012const binaryMediaTypePrefixes = ["image/", "audio/", "video/", "font/"]
1013
1014const isBinaryMediaType = (contentType: string): boolean =>
1015 binaryMediaTypes.has(contentType) ||
1016 binaryMediaTypePrefixes.some((prefix) => contentType.startsWith(prefix)) ||
1017 (contentType.startsWith("application/") &&
1018 (contentType.includes("binary") ||
1019 contentType.endsWith("+octet-stream") ||
1020 contentType.endsWith("+zip") ||
1021 contentType.endsWith("+gzip")))
1022
1023const isBinarySchema = (schema: unknown): boolean =>
1024 Predicate.isObject(schema) &&
1025 (
1026 (typeof schema.format === "string" && schema.format.toLowerCase() === "binary") ||
1027 (typeof schema.contentEncoding === "string" && schema.contentEncoding.toLowerCase() === "binary")
1028 )
1029
1030const isSuccessStatus = (status: string): boolean => {
1031 const statusMajorNumber = Number(status[0])
1032 return !Number.isNaN(statusMajorNumber) && statusMajorNumber < 4
1033}
1034
1035const getRequestMediaTypeEncoding = (
1036 contentType: string
1037): ParsedOperation.ParsedOperationMediaTypeEncoding | undefined => {
1038 const normalized = normalizeMediaType(contentType)
1039 if (isJsonMediaType(normalized)) {
1040 return "json"
1041 }
1042 if (normalized === "multipart/form-data") {
1043 return "multipart"
1044 }
1045 if (normalized === "application/x-www-form-urlencoded") {
1046 return "form-url-encoded"
1047 }
1048 if (isTextMediaType(normalized)) {
1049 return "text"
1050 }
1051 if (isBinaryMediaType(normalized)) {
1052 return "binary"
1053 }
1054 return
1055}
1056
1057const getResponseMediaTypeEncoding = (
1058 contentType: string
1059): ParsedOperation.ParsedOperationMediaTypeEncoding | undefined => {
1060 const normalized = normalizeMediaType(contentType)
1061 if (isJsonMediaType(normalized)) {
1062 return "json"
1063 }
1064 if (normalized === "application/x-www-form-urlencoded") {
1065 return "form-url-encoded"
1066 }
1067 if (isTextMediaType(normalized)) {
1068 return "text"
1069 }
1070 if (isBinaryMediaType(normalized)) {
1071 return "binary"
1072 }
1073 return
1074}
1075
1076const getEffectStreamEncoding = (mediaType: object): "uint8array" | "sse" | undefined => {
1077 const stream = (mediaType as Record<string, unknown>)["x-effect-stream"]
1078 if (!Predicate.isObject(stream)) {
1079 return
1080 }
1081 return stream.encoding === "uint8array" || stream.encoding === "sse" ? stream.encoding : undefined
1082}
1083
1084const getEffectStreamErrorSchema = (mediaType: object): JsonSchema.JsonSchema | undefined => {
1085 const stream = (mediaType as Record<string, unknown>)["x-effect-stream"]
1086 if (!Predicate.isObject(stream) || !Predicate.isObject(stream.errorSchema)) {
1087 return
1088 }
1089 return stream.errorSchema as JsonSchema.JsonSchema
1090}
1091
1092const makeSseEventSchema = (
1093 input: JsonSchema.JsonSchema,
1094 failureEvent: string | undefined
1095): JsonSchema.JsonSchema => {
1096 const eventSchema = normalizeSseEventSchema(input)
1097 if (failureEvent === undefined) {
1098 return eventSchema
1099 }
1100 return {
1101 anyOf: [
1102 eventSchema,
1103 {
1104 type: "object",
1105 properties: {
1106 id: { type: "string" },
1107 event: { const: failureEvent },
1108 data: { type: "string" }
1109 },
1110 required: ["event", "data"],
1111 additionalProperties: false
1112 }
1113 ]
1114 }
1115}
1116
1117const normalizeSseEventSchema = (input: JsonSchema.JsonSchema): JsonSchema.JsonSchema => {
1118 const schema = { ...input } as Record<string, any>
1119 for (const key of ["allOf", "anyOf", "oneOf"] as const) {
1120 if (Array.isArray(schema[key])) {
1121 schema[key] = schema[key].map(normalizeSseEventSchema)
1122 }
1123 }
1124 if (
1125 schema.type !== "object" ||
1126 !Predicate.isObject(schema.properties) ||
1127 !Object.hasOwn(schema.properties, "id") ||
1128 !Object.hasOwn(schema.properties, "event") ||
1129 !Object.hasOwn(schema.properties, "data")
1130 ) {
1131 return schema as JsonSchema.JsonSchema
1132 }
1133
1134 // OpenAPI represents `undefined` as required nullable; restore the SSE parser's optional wire field.
1135 schema.properties = {
1136 ...schema.properties,
1137 id: { type: "string" }
1138 }
1139 if (Array.isArray(schema.required)) {
1140 schema.required = schema.required.filter((name: unknown) => name !== "id")
1141 }
1142 return schema as JsonSchema.JsonSchema
1143}
1144
1145const resolveReference = (input: unknown, resolveRef: (ref: string) => unknown): any => {
1146 let current = input
1147 while (Predicate.isObject(current) && typeof current.$ref === "string") {
1148 current = resolveRef(current.$ref)
1149 }
1150 return current
1151}
1152
1153const cloneSecurityRequirements = (
1154 security: ReadonlyArray<Record<string, ReadonlyArray<string>>>
1155): Array<ParsedOperation.ParsedOperationSecurityRequirement> =>
1156 security.map((requirement) =>
1157 Object.fromEntries(
1158 Object.entries(requirement).map(([name, scopes]) => [name, [...scopes]])
1159 )
1160 )
1161
1162const parseSecuritySchemes = (
1163 spec: OpenAPISpec,
1164 resolveRef: (ref: string) => unknown
1165): Array<ParsedOperation.ParsedOpenApiSecurityScheme> => {
1166 const securitySchemes = spec.components?.securitySchemes ?? {}
1167 const parsed: Array<ParsedOperation.ParsedOpenApiSecurityScheme> = []
1168
1169 for (const [name, value] of Object.entries(securitySchemes)) {
1170 const scheme = resolveReference(value, resolveRef) as OpenAPISecurityScheme | undefined
1171 if (!Predicate.isObject(scheme)) {
1172 continue
1173 }
1174
1175 if (scheme.type === "http" && typeof scheme.scheme === "string") {
1176 const normalizedScheme = scheme.scheme.toLowerCase()
1177 if (normalizedScheme === "basic") {
1178 parsed.push({
1179 name,
1180 type: "basic",
1181 description: Utils.nonEmptyString(scheme.description),
1182 bearerFormat: undefined,
1183 scheme: undefined,
1184 key: undefined,
1185 in: undefined
1186 })
1187 } else if (normalizedScheme === "bearer") {
1188 parsed.push({
1189 name,
1190 type: "bearer",
1191 description: Utils.nonEmptyString(scheme.description),
1192 bearerFormat: Utils.nonEmptyString(scheme.bearerFormat),
1193 scheme: undefined,
1194 key: undefined,
1195 in: undefined
1196 })
1197 } else {
1198 parsed.push({
1199 name,
1200 type: "http",
1201 description: Utils.nonEmptyString(scheme.description),
1202 bearerFormat: Utils.nonEmptyString(scheme.bearerFormat),
1203 scheme: scheme.scheme,
1204 key: undefined,
1205 in: undefined
1206 })
1207 }
1208 continue
1209 }
1210
1211 if (
1212 scheme.type === "apiKey" &&
1213 (scheme.in === "header" || scheme.in === "query" || scheme.in === "cookie") &&
1214 typeof scheme.name === "string"
1215 ) {
1216 parsed.push({
1217 name,
1218 type: "apiKey",
1219 description: Utils.nonEmptyString(scheme.description),
1220 bearerFormat: undefined,
1221 scheme: undefined,
1222 key: scheme.name,
1223 in: scheme.in
1224 })
1225 }
1226 }
1227
1228 return parsed
1229}
1230
1231const warnForAndSecurityRequirements = (
1232 emitWarning: WarningEmitter,
1233 operation: ParsedOperation.ParsedOperation
1234): void => {
1235 if (operation.effectiveSecurity.some((requirement) => Object.keys(requirement).length === 0)) {
1236 return
1237 }
1238 for (const requirement of operation.effectiveSecurity) {
1239 const schemes = Object.keys(requirement)
1240 if (schemes.length <= 1) {
1241 continue
1242 }
1243 warnForOperation(emitWarning, operation, {
1244 code: "security-and-downgraded",
1245 message: `Security requirement requiring all of [${
1246 schemes.join(", ")
1247 }] was downgraded to a placeholder middleware.`
1248 })
1249 }
1250}
1251
1252const hasUnsupportedSuccessfulSseResponse = (
1253 responses: ReadonlyArray<readonly [string, unknown]>,
1254 hasExplicitSuccessResponse: boolean
1255): boolean => {
1256 for (const [status, response] of responses) {
1257 if (!Predicate.isObject(response)) {
1258 continue
1259 }
1260 const remappedStatus = remapDefaultResponseStatusForHttpApi(status, hasExplicitSuccessResponse)
1261 const statusCode = Number(remappedStatus)
1262 if (Number.isNaN(statusCode) || statusCode >= 400) {
1263 continue
1264 }
1265
1266 const content = Predicate.isObject(response.content)
1267 ? response.content as Record<string, any>
1268 : undefined
1269 if (Predicate.isUndefined(content)) {
1270 continue
1271 }
1272
1273 for (const [contentType, mediaType] of Object.entries(content)) {
1274 if (!Predicate.isObject(mediaType)) {
1275 continue
1276 }
1277 const streamEncoding = getEffectStreamEncoding(mediaType)
1278 if (streamEncoding === "sse") {
1279 if (Predicate.isUndefined(mediaType.schema) || Predicate.isUndefined(getEffectStreamErrorSchema(mediaType))) {
1280 return true
1281 }
1282 continue
1283 }
1284 if (contentType === "text/event-stream") {
1285 return true
1286 }
1287 }
1288 }
1289 return false
1290}
1291
1292const remapDefaultResponseStatusForHttpApi = (status: string, hasExplicitSuccessResponse: boolean): string =>
1293 status === "default" ? (hasExplicitSuccessResponse ? "500" : "200") : status
1294
1295const methodSupportsRequestBody = (method: Lowercase<HttpMethod>): boolean =>
1296 method !== "get" && method !== "head" && method !== "options" && method !== "trace"
1297
1298const warnForOperation = (
1299 emitWarning: WarningEmitter,
1300 operation: ParsedOperation.ParsedOperation,
1301 warning: {
1302 readonly code: OpenApiGeneratorWarningCode
1303 readonly message: string
1304 }
1305): void => {
1306 emitWarning({
1307 ...warning,
1308 path: operation.path,
1309 method: operation.method,
1310 operationId: operation.operationId
1311 })
1312}
1313
1314function getDialect(spec: OpenAPISpec): "openapi-3.0" | "openapi-3.1" {
1315 return spec.openapi.trim().startsWith("3.0") ? "openapi-3.0" : "openapi-3.1"
1316}
1317
1318/**
1319 * Layer providing an OpenAPI generator for Schema-backed HTTP client and HttpApi output.
1320 *
1321 * @category layers
1322 * @since 4.0.0
1323 */
1324export const layerTransformerSchema: Layer.Layer<OpenApiGenerator> = Layer.effect(OpenApiGenerator, make)
1325
1326/**
1327 * Layer providing an OpenAPI generator for type-only HTTP client output.
1328 *
1329 * @category layers
1330 * @since 4.0.0
1331 */
1332export const layerTransformerTs: Layer.Layer<OpenApiGenerator> = Layer.effect(OpenApiGenerator, make)
1333
1334const isSwaggerSpec = (spec: OpenAPISpec) => "swagger" in spec
1335
1336const convertSwaggerSpec = Effect.fn((spec: OpenAPISpec) =>
1337 Effect.callback<OpenAPISpec>((resume) => {
1338 SwaggerToOpenApi.convertObj(
1339 spec as any,
1340 { laxDefaults: true, laxurls: true, patch: true, warnOnly: true },
1341 (err, result) => {
1342 if (err) {
1343 resume(Effect.die(err))
1344 } else {
1345 resume(Effect.succeed(result.openapi as any))
1346 }
1347 }
1348 )
1349 }).pipe(Effect.withSpan("OpenApi.convertSwaggerSpec"))
1350)
1351
1352const processPath = (path: string): {
1353 readonly pathIds: Array<string>
1354 readonly pathTemplate: string
1355} => {
1356 const pathIds: Array<string> = []
1357 const fragments: Array<string> = []
1358 let offset = 0
1359 for (const match of path.matchAll(/{([^}]+)}/g)) {
1360 fragments.push(JSON.stringify(path.slice(offset, match.index)))
1361 const id = Utils.camelize(match[1])
1362 pathIds.push(id)
1363 fragments.push(`__encodePathParam(${id})`)
1364 offset = match.index + match[0].length
1365 }
1366 fragments.push(JSON.stringify(path.slice(offset)))
1367 const pathTemplate = fragments.join(" + ")
1368 return { pathIds, pathTemplate } as const
1369}