1/**
2 * Renders parsed OpenAPI operations into generated Effect HttpApi source code.
3 *
4 * This module maps normalized OpenAPI metadata, tags, request bodies,
5 * responses, and security requirements into import declarations, HttpApi
6 * groups, endpoint definitions, schema annotations, and middleware classes.
7 * The rendered TypeScript can be emitted by the OpenAPI generator as an
8 * executable Effect `HttpApi` module.
9 *
10 * @since 4.0.0
11 */
12import type {
13 ParsedOpenApi,
14 ParsedOpenApiSecurityScheme,
15 ParsedOpenApiTag,
16 ParsedOperation,
17 ParsedOperationMediaTypeSchema,
18 ParsedOperationResponse
19} from "./ParsedOperation.ts"
20import * as Utils from "./Utils.ts"
21
22interface GroupRenderModel {
23 readonly identifier: string
24 readonly topLevel: boolean
25 readonly metadata: ParsedOpenApiTag | undefined
26 readonly operations: ReadonlyArray<ParsedOperation>
27 readonly constName: string
28}
29
30interface SecurityRenderModel {
31 readonly securityDeclarations: ReadonlyArray<string>
32 readonly middlewareDeclarations: ReadonlyArray<string>
33 readonly endpointMiddlewares: ReadonlyMap<string, ReadonlyArray<string>>
34}
35
36const fallbackGroupIdentifier = "default"
37
38/**
39 * Render the import declarations required by generated HttpApi source.
40 *
41 * **Details**
42 *
43 * The schema namespace import is named by the caller so generated code can
44 * avoid collisions with symbols already present in the output module. Multipart
45 * support is included only when the parsed OpenAPI document needs it.
46 *
47 * @category code generation
48 * @since 4.0.0
49 */
50export const imports = (
51 importName: string,
52 options?: {
53 readonly multipart?: boolean | undefined
54 }
55): string =>
56 [
57 `import * as ${importName} from "effect/Schema"`,
58 ...(options?.multipart === true ? [`import { Multipart } from "effect/http"`] : []),
59 `import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, HttpApiSecurity, OpenApi } from "effect/http-api"`
60 ].join("\n")
61
62/**
63 * Convert a parsed OpenAPI document into Effect HttpApi source code.
64 *
65 * **Details**
66 *
67 * The generated implementation contains security declarations, reusable
68 * middleware classes, HttpApi groups, endpoint definitions, and OpenAPI
69 * annotations derived from the parsed operation metadata.
70 *
71 * @category code generation
72 * @since 4.0.0
73 */
74export const toImplementation = (
75 _importName: string,
76 name: string,
77 parsed: ParsedOpenApi
78): string => {
79 const security = buildSecurityRenderModel(parsed)
80 const groups = groupOperations(parsed)
81 const groupSources = groups.map((group) => renderGroup(group, security.endpointMiddlewares))
82 const metadataAnnotations = renderApiAnnotations(parsed)
83
84 let apiValue = `export class ${name} extends HttpApi.make(${JSON.stringify(name)})`
85 for (const annotation of metadataAnnotations) {
86 apiValue += `\n .${annotation}`
87 }
88 if (groups.length > 0) {
89 apiValue += `\n .add(${groups.map((group) => group.constName).join(", ")})`
90 }
91 apiValue += ` {}`
92
93 return [
94 ...security.securityDeclarations,
95 ...security.middlewareDeclarations,
96 ...groupSources,
97 apiValue
98 ].join("\n\n")
99}
100
101const groupOperations = (parsed: ParsedOpenApi): ReadonlyArray<GroupRenderModel> => {
102 const tagMetadata = new Map(parsed.tags.map((tag) => [tag.name, tag]))
103 const byIdentifier = new Map<string, {
104 readonly identifier: string
105 topLevel: boolean
106 readonly metadata: ParsedOpenApiTag | undefined
107 readonly operations: Array<ParsedOperation>
108 }>()
109
110 for (const operation of parsed.operations) {
111 const identifier = operation.tags[0] ?? fallbackGroupIdentifier
112 const topLevel = operation.tags.length === 0
113 const existing = byIdentifier.get(identifier)
114 if (existing) {
115 if (topLevel) {
116 existing.topLevel = true
117 }
118 existing.operations.push(operation)
119 continue
120 }
121 byIdentifier.set(identifier, {
122 identifier,
123 topLevel,
124 metadata: tagMetadata.get(identifier),
125 operations: [operation]
126 })
127 }
128
129 const allocateName = makeNameAllocator()
130 const groups: Array<GroupRenderModel> = []
131 for (const group of byIdentifier.values()) {
132 const baseName = ensureIdentifier(group.identifier, "Group")
133 groups.push({
134 ...group,
135 constName: allocateName(`${baseName}Group`)
136 })
137 }
138 return groups
139}
140
141const renderGroup = (
142 group: GroupRenderModel,
143 endpointMiddlewares: ReadonlyMap<string, ReadonlyArray<string>>
144): string => {
145 let source = `class ${group.constName} extends HttpApiGroup.make(${JSON.stringify(group.identifier)}${
146 group.topLevel ? ", { topLevel: true }" : ""
147 })`
148
149 const allocateEndpointName = makeNameAllocator()
150 const endpointSources = group.operations.map((operation) =>
151 renderEndpoint(
152 operation,
153 allocateEndpointName(operation.id),
154 endpointMiddlewares.get(toOperationKey(operation)) ?? []
155 )
156 )
157 if (endpointSources.length > 0) {
158 source += `\n .add(${endpointSources.join(", \n ")})`
159 }
160
161 if (group.metadata?.description !== undefined) {
162 source += `\n .annotate(OpenApi.Description, ${JSON.stringify(group.metadata.description)})`
163 }
164 if (group.metadata?.externalDocs !== undefined) {
165 source += `\n .annotate(OpenApi.ExternalDocs, ${JSON.stringify(group.metadata.externalDocs)})`
166 }
167
168 source += ` {}`
169
170 return source
171}
172
173const renderEndpoint = (
174 operation: ParsedOperation,
175 endpointName: string,
176 endpointMiddlewares: ReadonlyArray<string>
177): string => {
178 const options: Array<string> = []
179 if (operation.pathSchema !== undefined) {
180 options.push(`params: ${operation.pathSchema}`)
181 }
182 if (operation.querySchema !== undefined) {
183 options.push(`query: ${operation.querySchema}`)
184 }
185 if (operation.headersSchema !== undefined) {
186 options.push(`headers: ${operation.headersSchema}`)
187 }
188
189 const payload = renderPayload(operation)
190 if (payload !== undefined) {
191 options.push(`payload: ${payload}`)
192 }
193
194 const success = renderResponseSet(operation.responses, "success")
195 if (success !== undefined) {
196 options.push(`success: ${success}`)
197 }
198
199 const error = renderResponseSet(operation.responses, "error")
200 if (error !== undefined) {
201 options.push(`error: ${error}`)
202 }
203
204 const endpoint = options.length === 0
205 ? `HttpApiEndpoint.${operation.method}(${JSON.stringify(endpointName)}, ${
206 JSON.stringify(toHttpApiPath(operation.path))
207 })`
208 : `HttpApiEndpoint.${operation.method}(${JSON.stringify(endpointName)}, ${
209 JSON.stringify(toHttpApiPath(operation.path))
210 }, { ${options.join(", ")} })`
211
212 const annotations: Array<string> = []
213 if (operation.operationId !== undefined) {
214 annotations.push(`annotate(OpenApi.Identifier, ${JSON.stringify(operation.operationId)})`)
215 }
216 if (operation.metadata.summary !== undefined) {
217 annotations.push(`annotate(OpenApi.Summary, ${JSON.stringify(operation.metadata.summary)})`)
218 }
219 if (operation.metadata.description !== undefined) {
220 annotations.push(`annotate(OpenApi.Description, ${JSON.stringify(operation.metadata.description)})`)
221 }
222 if (operation.metadata.deprecated) {
223 annotations.push(`annotate(OpenApi.Deprecated, true)`)
224 }
225 if (operation.metadata.externalDocs !== undefined) {
226 annotations.push(`annotate(OpenApi.ExternalDocs, ${JSON.stringify(operation.metadata.externalDocs)})`)
227 }
228
229 if (annotations.length === 0 && endpointMiddlewares.length === 0) {
230 return endpoint
231 }
232
233 let out = endpoint
234 for (const middleware of endpointMiddlewares) {
235 out += `\n .middleware(${middleware})`
236 }
237 for (const annotation of annotations) {
238 out += `\n .${annotation}`
239 }
240 return out
241}
242
243const renderPayload = (operation: ParsedOperation): string | undefined => {
244 if (!methodSupportsBody(operation.method)) {
245 return
246 }
247 const payloads = operation.requestBodyRepresentable.map((schema) => renderMediaSchema(schema))
248 if (payloads.length === 0) {
249 return
250 }
251
252 if (operation.requestBody?.required === false) {
253 payloads.unshift("HttpApiSchema.NoContent")
254 }
255
256 return joinSchemas(payloads)
257}
258
259const renderResponseSet = (
260 responses: ReadonlyArray<ParsedOperationResponse>,
261 target: "success" | "error"
262): string | undefined => {
263 const rendered: Array<string> = []
264
265 for (const response of responses) {
266 const status = toStatus(response.status)
267 if (status === undefined) {
268 continue
269 }
270
271 const isSuccess = status < 400
272 if ((target === "success") !== isSuccess) {
273 continue
274 }
275
276 if (response.isEmpty) {
277 rendered.push(`HttpApiSchema.Empty(${status})`)
278 continue
279 }
280
281 for (const media of response.representable) {
282 rendered.push(applyStatus(renderMediaSchema(media), status, target))
283 }
284 }
285
286 if (rendered.length === 0) {
287 return
288 }
289
290 return joinSchemas(rendered)
291}
292
293const joinSchemas = (schemas: ReadonlyArray<string>): string =>
294 schemas.length === 1 ? schemas[0] : `[${schemas.join(", ")}]`
295
296const renderMediaSchema = (media: ParsedOperationMediaTypeSchema): string => {
297 if (media.effectStream === "sse") {
298 const options = media.contentType === "text/event-stream"
299 ? `{ events: ${media.schema}, error: ${media.errorSchema} }`
300 : `{ contentType: ${JSON.stringify(media.contentType)}, events: ${media.schema}, error: ${media.errorSchema} }`
301 return `HttpApiSchema.StreamSse(${options})`
302 }
303
304 if (media.effectStream === "uint8array") {
305 if (media.contentType === "application/octet-stream") {
306 return "HttpApiSchema.StreamUint8Array()"
307 }
308 return `HttpApiSchema.StreamUint8Array({ contentType: ${JSON.stringify(media.contentType)} })`
309 }
310
311 switch (media.encoding) {
312 case "json": {
313 if (media.contentType === "application/json") {
314 return media.schema
315 }
316 return `${media.schema}.pipe(HttpApiSchema.asJson({ contentType: ${JSON.stringify(media.contentType)} }))`
317 }
318 case "multipart": {
319 return `${media.schema}.pipe(HttpApiSchema.asMultipart())`
320 }
321 case "form-url-encoded": {
322 if (media.contentType === "application/x-www-form-urlencoded") {
323 return `${media.schema}.pipe(HttpApiSchema.asFormUrlEncoded())`
324 }
325 return `${media.schema}.pipe(HttpApiSchema.asFormUrlEncoded({ contentType: ${
326 JSON.stringify(media.contentType)
327 } }))`
328 }
329 case "text": {
330 if (media.contentType === "text/plain") {
331 return `${media.schema}.pipe(HttpApiSchema.asText())`
332 }
333 return `${media.schema}.pipe(HttpApiSchema.asText({ contentType: ${JSON.stringify(media.contentType)} }))`
334 }
335 case "binary": {
336 if (media.contentType === "application/octet-stream") {
337 return `${media.schema}.pipe(HttpApiSchema.asUint8Array())`
338 }
339 return `${media.schema}.pipe(HttpApiSchema.asUint8Array({ contentType: ${JSON.stringify(media.contentType)} }))`
340 }
341 }
342}
343
344const renderApiAnnotations = (parsed: ParsedOpenApi): ReadonlyArray<string> => {
345 const annotations: Array<string> = [
346 `annotate(OpenApi.Title, ${JSON.stringify(parsed.metadata.title)})`,
347 `annotate(OpenApi.Version, ${JSON.stringify(parsed.metadata.version)})`
348 ]
349
350 if (parsed.metadata.summary !== undefined) {
351 annotations.push(`annotate(OpenApi.Summary, ${JSON.stringify(parsed.metadata.summary)})`)
352 }
353 if (parsed.metadata.description !== undefined) {
354 annotations.push(`annotate(OpenApi.Description, ${JSON.stringify(parsed.metadata.description)})`)
355 }
356 if (parsed.metadata.license !== undefined) {
357 annotations.push(`annotate(OpenApi.License, ${JSON.stringify(parsed.metadata.license)})`)
358 }
359 if (parsed.metadata.servers !== undefined) {
360 annotations.push(`annotate(OpenApi.Servers, ${JSON.stringify(parsed.metadata.servers)})`)
361 }
362
363 return annotations
364}
365
366const buildSecurityRenderModel = (parsed: ParsedOpenApi): SecurityRenderModel => {
367 const allocateName = makeNameAllocator()
368 const securityDeclarations: Array<string> = []
369 const middlewareDeclarations: Array<string> = []
370 const endpointMiddlewares = new Map<string, ReadonlyArray<string>>()
371 const schemeDeclarations = new Map<string, string>()
372 const middlewareNames = new Map<string, string>()
373
374 for (const securityScheme of parsed.securitySchemes) {
375 const baseName = ensureIdentifier(securityScheme.name, "Security")
376 const declarationName = allocateName(`${baseName}Security`)
377 schemeDeclarations.set(securityScheme.name, declarationName)
378 securityDeclarations.push(`export const ${declarationName} = ${renderSecurityScheme(securityScheme)}`)
379 }
380
381 for (const operation of parsed.operations) {
382 if (operation.effectiveSecurity.length === 0) {
383 continue
384 }
385 if (operation.effectiveSecurity.some((requirement) => Object.keys(requirement).length === 0)) {
386 continue
387 }
388
389 const operationMiddlewareNames: Array<string> = []
390 const seenOrSchemes = new Set<string>()
391 const andRequirements: Array<ReadonlyArray<string>> = []
392
393 for (const requirement of operation.effectiveSecurity) {
394 const schemes = Object.keys(requirement)
395 if (schemes.length === 1) {
396 const schemeName = schemes[0]
397 if (schemeDeclarations.has(schemeName) && !seenOrSchemes.has(schemeName)) {
398 seenOrSchemes.add(schemeName)
399 }
400 } else if (schemes.length > 1) {
401 andRequirements.push([...schemes].sort())
402 }
403 }
404
405 const orSchemeNames = Array.from(seenOrSchemes).sort()
406 if (orSchemeNames.length > 0) {
407 const className = getOrSecurityMiddlewareName(orSchemeNames)
408 operationMiddlewareNames.push(className)
409 }
410
411 const seenAndRequirements = new Set<string>()
412 for (const requirement of andRequirements) {
413 const key = requirement.join("\u0000")
414 if (seenAndRequirements.has(key)) {
415 continue
416 }
417 seenAndRequirements.add(key)
418 const className = getAndSecurityMiddlewareName(requirement)
419 operationMiddlewareNames.push(className)
420 }
421
422 if (operationMiddlewareNames.length > 0) {
423 endpointMiddlewares.set(toOperationKey(operation), operationMiddlewareNames)
424 }
425 }
426
427 return {
428 securityDeclarations,
429 middlewareDeclarations,
430 endpointMiddlewares
431 }
432
433 function getOrSecurityMiddlewareName(schemes: ReadonlyArray<string>): string {
434 const key = `or:${schemes.join("\u0000")}`
435 const existing = middlewareNames.get(key)
436 if (existing !== undefined) {
437 return existing
438 }
439
440 const className = allocateName(`${getSecurityMiddlewareBaseName(schemes, "Or")}SecurityMiddleware`)
441 const securityEntries = schemes.map((name) => `${JSON.stringify(name)}: ${schemeDeclarations.get(name)!}`).join(
442 ", "
443 )
444 middlewareDeclarations.push(
445 `export class ${className} extends HttpApiMiddleware.Service<${className}>()(${
446 JSON.stringify(`${schemes.join(" | ")} security`)
447 }, { security: { ${securityEntries} } }) {}`
448 )
449 middlewareNames.set(key, className)
450 return className
451 }
452
453 function getAndSecurityMiddlewareName(schemes: ReadonlyArray<string>): string {
454 const key = `and:${schemes.join("\u0000")}`
455 const existing = middlewareNames.get(key)
456 if (existing !== undefined) {
457 return existing
458 }
459
460 const className = allocateName(`${getSecurityMiddlewareBaseName(schemes, "And")}SecurityMiddleware`)
461 middlewareDeclarations.push(
462 `class ${className} extends HttpApiMiddleware.Service<${className}>()(${
463 JSON.stringify(`${schemes.join(" & ")} security`)
464 }) {}`
465 )
466 middlewareNames.set(key, className)
467 return className
468 }
469}
470
471const getSecurityMiddlewareBaseName = (
472 schemes: ReadonlyArray<string>,
473 joiner: "And" | "Or"
474): string => {
475 const [head, ...tail] = schemes.map((scheme) => ensureIdentifier(scheme, "Security"))
476 return tail.length === 0 ? head : [head, ...tail.map((scheme) => `${joiner}${scheme}`)].join("")
477}
478
479const renderSecurityScheme = (securityScheme: ParsedOpenApiSecurityScheme): string => {
480 let source: string
481 switch (securityScheme.type) {
482 case "basic": {
483 source = "HttpApiSecurity.basic"
484 break
485 }
486 case "bearer": {
487 source = "HttpApiSecurity.bearer"
488 break
489 }
490 case "http": {
491 source = `HttpApiSecurity.http({ scheme: ${JSON.stringify(securityScheme.scheme!)} })`
492 break
493 }
494 case "apiKey": {
495 source = `HttpApiSecurity.apiKey({ key: ${JSON.stringify(securityScheme.key!)}, in: ${
496 JSON.stringify(securityScheme.in!)
497 } })`
498 break
499 }
500 }
501
502 if (securityScheme.description !== undefined) {
503 source += `.pipe(HttpApiSecurity.annotate(OpenApi.Description, ${JSON.stringify(securityScheme.description)}))`
504 }
505 if (
506 (securityScheme.type === "bearer" || securityScheme.type === "http") && securityScheme.bearerFormat !== undefined
507 ) {
508 source += `.pipe(HttpApiSecurity.annotate(OpenApi.Format, ${JSON.stringify(securityScheme.bearerFormat)}))`
509 }
510
511 return source
512}
513
514const toOperationKey = (operation: ParsedOperation): string => `${operation.method}:${operation.path}`
515
516const toHttpApiPath = (path: string): string => path.replace(/{([^}]+)}/g, ":$1")
517
518const toStatus = (status: string): number | undefined => {
519 if (!/^\d{3}$/.test(status)) {
520 return
521 }
522 return Number(status)
523}
524
525const applyStatus = (schema: string, status: number, target: "success" | "error"): string => {
526 if ((target === "success" && status === 200) || (target === "error" && status === 500)) {
527 return schema
528 }
529 return `${schema}.pipe(HttpApiSchema.status(${status}))`
530}
531
532const methodSupportsBody = (method: ParsedOperation["method"]): boolean =>
533 method !== "get" && method !== "head" && method !== "options" && method !== "trace"
534
535const ensureIdentifier = (value: string, fallback: string): string => {
536 const sanitized = Utils.identifier(value)
537 return sanitized.length > 0 ? sanitized : fallback
538}
539
540const makeNameAllocator = () => {
541 const used = new Set<string>()
542 return (base: string) => {
543 let candidate = base
544 let index = 2
545 while (used.has(candidate)) {
546 candidate = `${base}${index}`
547 index += 1
548 }
549 used.add(candidate)
550 return candidate
551 }
552}