| 1 | /** |
| 2 | * Generate TypeScript source for JSON Schema declarations extracted from |
| 3 | * OpenAPI documents. |
| 4 | * |
| 5 | * This module is the schema-rendering stage of the OpenAPI generator. Callers |
| 6 | * register named OpenAPI 3.0 or 3.1 schemas, provide the reusable component |
| 7 | * definitions for the document, and receive source text containing exported |
| 8 | * TypeScript aliases plus Effect Schema runtime values. The generator first |
| 9 | * normalizes OpenAPI-specific schema shapes into Effect's JSON Schema model, |
| 10 | * then delegates recursive analysis and runtime expression construction to |
| 11 | * `SchemaRepresentation`. |
| 12 | * |
| 13 | * The renderer keeps the emitted module usable for both human maintainers and |
| 14 | * automated code-generation consumers by grouping recursive declarations, |
| 15 | * reusable references, and locally registered schemas. It also contains the |
| 16 | * HttpApi-specific multipart file substitutions that map generated schemas to |
| 17 | * Effect's multipart runtime types. |
| 18 | * |
| 19 | * @since 4.0.0 |
| 20 | */ |
| 21 | import * as Arr from "effect/Array" |
| 22 | import * as JsonPointer from "effect/JsonPointer" |
| 23 | import * as JsonSchema from "effect/JsonSchema" |
| 24 | import * as Rec from "effect/Record" |
| 25 | import * as Schema from "effect/Schema" |
| 26 | import * as SchemaRepresentation from "effect/SchemaRepresentation" |
| 27 | |
| 28 | type Source = "openapi-3.0" | "openapi-3.1" |
| 29 | interface GenerateOptions { |
| 30 | readonly onEnter?: ((js: JsonSchema.JsonSchema) => JsonSchema.JsonSchema) | undefined |
| 31 | readonly multipartSchemaRefs?: MultipartSchemaRefs | undefined |
| 32 | } |
| 33 | |
| 34 | interface HttpApiMultipartSchemaRefs { |
| 35 | readonly kind: "httpapi" |
| 36 | readonly singleFile: string |
| 37 | readonly files: string |
| 38 | } |
| 39 | |
| 40 | type MultipartSchemaRefs = HttpApiMultipartSchemaRefs | { readonly kind: "client"; readonly singleFile: string } |
| 41 | |
| 42 | /** |
| 43 | * Create a stateful JSON Schema code generator for OpenAPI-derived schemas. |
| 44 | * |
| 45 | * **Details** |
| 46 | * |
| 47 | * Schemas registered with the returned generator are converted into TypeScript |
| 48 | * type aliases and Effect Schema runtime declarations, with reusable OpenAPI |
| 49 | * component definitions supplied at generation time. |
| 50 | * |
| 51 | * @category code generation |
| 52 | * @since 4.0.0 |
| 53 | */ |
| 54 | export function make() { |
| 55 | return makeWithRepresentation() |
| 56 | } |
| 57 | |
| 58 | function makeWithRepresentation() { |
| 59 | const store = Object.create(null) as Record<string, JsonSchema.JsonSchema> |
| 60 | |
| 61 | function addSchema(name: string, schema: JsonSchema.JsonSchema): string { |
| 62 | if (Object.hasOwn(store, name)) { |
| 63 | throw new Error(`Schema ${name} already exists`) |
| 64 | } |
| 65 | store[name] = schema |
| 66 | return name |
| 67 | } |
| 68 | |
| 69 | function generate( |
| 70 | source: Source, |
| 71 | components: JsonSchema.Definitions, |
| 72 | typeOnly: boolean, |
| 73 | options?: GenerateOptions |
| 74 | ) { |
| 75 | const generated = makeCodeDocument(source, components, options) |
| 76 | if (generated === undefined) { |
| 77 | return "" |
| 78 | } |
| 79 | |
| 80 | const nonRecursiveReferences = generated.codeDocument.references.nonRecursives |
| 81 | const recursiveReferences = Object.entries(generated.codeDocument.references.recursives) |
| 82 | |
| 83 | const nonRecursives = nonRecursiveReferences.map(({ $ref, code }) => |
| 84 | renderSchemaTypeAndRuntime($ref, code, typeOnly, options) |
| 85 | ) |
| 86 | |
| 87 | const recursiveDeclarations: Array<string> = [] |
| 88 | const recursives: Array<string> = [] |
| 89 | |
| 90 | if (typeOnly) { |
| 91 | for (const [$ref, code] of recursiveReferences) { |
| 92 | recursives.push(renderSchemaTypeAndRuntime($ref, code, true, options)) |
| 93 | } |
| 94 | } else { |
| 95 | const recursivelyForwardReferenced = collectForwardReferencedRecursives( |
| 96 | nonRecursiveReferences, |
| 97 | recursiveReferences |
| 98 | ) |
| 99 | const recursiveInternalNames = makeRecursiveInternalNameMap( |
| 100 | recursivelyForwardReferenced, |
| 101 | [ |
| 102 | ...nonRecursiveReferences.map(({ $ref }) => $ref), |
| 103 | ...recursiveReferences.map(([$ref]) => $ref), |
| 104 | ...generated.nameMap |
| 105 | ] |
| 106 | ) |
| 107 | |
| 108 | for (const [$ref, code] of recursiveReferences) { |
| 109 | if (recursivelyForwardReferenced.has($ref)) { |
| 110 | const internalName = recursiveInternalNames.get($ref)! |
| 111 | recursiveDeclarations.push(renderRecursiveReferenceDeclaration($ref, code, internalName)) |
| 112 | recursives.push(`const ${internalName} = ${code.runtime}`) |
| 113 | continue |
| 114 | } |
| 115 | |
| 116 | recursives.push(renderSchemaTypeAndRuntime($ref, code, false, options)) |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | const codes = generated.codeDocument.codes.map((code, i) => |
| 121 | renderSchemaTypeAndRuntime(generated.nameMap[i], code, typeOnly, options) |
| 122 | ) |
| 123 | |
| 124 | return renderImportArtifacts(generated.codeDocument, !typeOnly) + |
| 125 | render("recursive declarations", recursiveDeclarations) + |
| 126 | render("non-recursive definitions", nonRecursives) + |
| 127 | render("recursive definitions", recursives) + |
| 128 | render("schemas", codes) |
| 129 | } |
| 130 | |
| 131 | function generateHttpApi( |
| 132 | source: Source, |
| 133 | components: JsonSchema.Definitions, |
| 134 | options?: GenerateOptions |
| 135 | ) { |
| 136 | const generated = makeCodeDocument(source, components, options) |
| 137 | if (generated === undefined) { |
| 138 | return "" |
| 139 | } |
| 140 | |
| 141 | const nonRecursiveReferences = generated.codeDocument.references.nonRecursives |
| 142 | const recursiveReferences = Object.entries(generated.codeDocument.references.recursives) |
| 143 | |
| 144 | const nonRecursives = nonRecursiveReferences.map(({ $ref, code }) => |
| 145 | renderSchemaTypeAndRuntime($ref, code, false, options) |
| 146 | ) |
| 147 | |
| 148 | const recursivelyForwardReferenced = collectForwardReferencedRecursives(nonRecursiveReferences, recursiveReferences) |
| 149 | const recursiveInternalNames = makeRecursiveInternalNameMap( |
| 150 | recursivelyForwardReferenced, |
| 151 | [ |
| 152 | ...nonRecursiveReferences.map(({ $ref }) => $ref), |
| 153 | ...recursiveReferences.map(([$ref]) => $ref), |
| 154 | ...generated.nameMap |
| 155 | ] |
| 156 | ) |
| 157 | |
| 158 | const recursiveDeclarations: Array<string> = [] |
| 159 | const recursives: Array<string> = [] |
| 160 | |
| 161 | for (const [$ref, code] of recursiveReferences) { |
| 162 | if (recursivelyForwardReferenced.has($ref)) { |
| 163 | const internalName = recursiveInternalNames.get($ref)! |
| 164 | recursiveDeclarations.push(renderRecursiveReferenceDeclaration($ref, code, internalName)) |
| 165 | recursives.push(`const ${internalName} = ${code.runtime}`) |
| 166 | continue |
| 167 | } |
| 168 | |
| 169 | recursives.push(renderSchemaTypeAndRuntime($ref, code, false, options)) |
| 170 | } |
| 171 | |
| 172 | const codes = generated.codeDocument.codes.map((code, i) => |
| 173 | renderSchemaTypeAndRuntime(generated.nameMap[i], code, false, options) |
| 174 | ) |
| 175 | |
| 176 | return renderImportArtifacts(generated.codeDocument, true) + |
| 177 | render("recursive declarations", recursiveDeclarations) + |
| 178 | render("non-recursive definitions", nonRecursives) + |
| 179 | render("recursive definitions", recursives) + |
| 180 | render("schemas", codes) |
| 181 | } |
| 182 | |
| 183 | function makeCodeDocument( |
| 184 | source: Source, |
| 185 | components: JsonSchema.Definitions, |
| 186 | options?: GenerateOptions |
| 187 | ): { |
| 188 | readonly nameMap: Array<string> |
| 189 | readonly codeDocument: SchemaRepresentation.CodeDocument |
| 190 | } | undefined { |
| 191 | const nameMap: Array<string> = [] |
| 192 | const schemas: Array<JsonSchema.JsonSchema> = [] |
| 193 | |
| 194 | let definitions: JsonSchema.Definitions = Rec.map( |
| 195 | components, |
| 196 | (js) => fromSchemaOpenApi(source, js).schema |
| 197 | ) |
| 198 | |
| 199 | for (const [name, js] of Object.entries(store)) { |
| 200 | nameMap.push(name) |
| 201 | schemas.push(fromSchemaOpenApi(source, js).schema) |
| 202 | } |
| 203 | |
| 204 | if (!Arr.isArrayNonEmpty(schemas)) { |
| 205 | return |
| 206 | } |
| 207 | if (options?.multipartSchemaRefs?.kind === "httpapi") { |
| 208 | definitions = omitSupersededMultipartDefinitions(definitions, schemas, options.multipartSchemaRefs) |
| 209 | } |
| 210 | |
| 211 | const document = { |
| 212 | dialect: "draft-2020-12" as const, |
| 213 | schemas, |
| 214 | definitions |
| 215 | } |
| 216 | const schemasWithExamples: Array<JsonSchema.JsonSchema> = [] |
| 217 | const preparedSchemas = new WeakMap<JsonSchema.JsonSchema, JsonSchema.JsonSchema>() |
| 218 | const preparedOutputs = new WeakSet<JsonSchema.JsonSchema>() |
| 219 | const importerOptions: SchemaRepresentation.FromJsonSchemaOptions = { |
| 220 | patterns: "apply", |
| 221 | onEnter(js: JsonSchema.JsonSchema) { |
| 222 | if (preparedOutputs.has(js)) return js |
| 223 | const cached = preparedSchemas.get(js) |
| 224 | if (cached !== undefined) return cached |
| 225 | |
| 226 | const out = { ...js } |
| 227 | const transformed = { ...(options?.onEnter === undefined ? out : options.onEnter(out)) } |
| 228 | preparedSchemas.set(js, transformed) |
| 229 | preparedOutputs.add(transformed) |
| 230 | if (Array.isArray(transformed.examples)) { |
| 231 | schemasWithExamples.push(transformed) |
| 232 | } |
| 233 | return transformed |
| 234 | } |
| 235 | } |
| 236 | let rootSchemas = SchemaRepresentation.fromJsonSchemaMultiDocument(document, importerOptions) |
| 237 | if (Arr.isArrayNonEmpty(schemasWithExamples)) { |
| 238 | const exampleSchemas = SchemaRepresentation.fromJsonSchemaMultiDocument( |
| 239 | { ...document, schemas: schemasWithExamples }, |
| 240 | importerOptions |
| 241 | ) |
| 242 | for (let i = 0; i < schemasWithExamples.length; i++) { |
| 243 | const node = schemasWithExamples[i] |
| 244 | const examples = node.examples as ReadonlyArray<unknown> |
| 245 | const validExamples = examples.filter(Schema.is(exampleSchemas[i])) |
| 246 | if (validExamples.length === examples.length) continue |
| 247 | if (validExamples.length === 0) { |
| 248 | delete node.examples |
| 249 | } else { |
| 250 | node.examples = validExamples |
| 251 | } |
| 252 | } |
| 253 | rootSchemas = SchemaRepresentation.fromJsonSchemaMultiDocument(document, importerOptions) |
| 254 | } |
| 255 | const codeDocument = SchemaRepresentation.toCodeDocument( |
| 256 | SchemaRepresentation.toRepresentations(Arr.map(rootSchemas, (schema) => schema.ast)) |
| 257 | ) |
| 258 | |
| 259 | return { |
| 260 | nameMap, |
| 261 | codeDocument |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | return { addSchema, generate, generateHttpApi } as const |
| 266 | } |
| 267 | |
| 268 | function fromSchemaOpenApi(source: Source, jsonSchema: JsonSchema.JsonSchema) { |
| 269 | switch (source) { |
| 270 | case "openapi-3.1": |
| 271 | return JsonSchema.fromSchemaOpenApi3_1(jsonSchema) |
| 272 | case "openapi-3.0": |
| 273 | return JsonSchema.fromSchemaOpenApi3_0(jsonSchema) |
| 274 | } |
| 275 | } |
| 276 | |
| 277 | function renderSchemaTypeAndRuntime( |
| 278 | $ref: string, |
| 279 | code: SchemaRepresentation.Code, |
| 280 | typeOnly: boolean, |
| 281 | options?: GenerateOptions |
| 282 | ) { |
| 283 | code = multipartCode($ref, options?.multipartSchemaRefs) ?? code |
| 284 | const strings = [`export type ${$ref} = ${code.Type}`] |
| 285 | if (!typeOnly) { |
| 286 | strings.push(`export const ${$ref} = ${code.runtime}`) |
| 287 | } |
| 288 | return strings.join("\n") |
| 289 | } |
| 290 | |
| 291 | function multipartCode( |
| 292 | $ref: string, |
| 293 | refs: MultipartSchemaRefs | undefined |
| 294 | ): SchemaRepresentation.Code | undefined { |
| 295 | if (refs === undefined) { |
| 296 | return undefined |
| 297 | } |
| 298 | if ($ref === refs.singleFile) { |
| 299 | return refs.kind === "httpapi" |
| 300 | ? { Type: "Multipart.PersistedFile", runtime: "Multipart.SingleFileSchema" } |
| 301 | : { |
| 302 | Type: "globalThis.File | globalThis.Blob", |
| 303 | runtime: `Schema.instanceOf(globalThis.Blob, { expected: "File | Blob" })` |
| 304 | } |
| 305 | } |
| 306 | if (refs.kind === "httpapi" && $ref === refs.files) { |
| 307 | return { Type: "ReadonlyArray<Multipart.PersistedFile>", runtime: "Multipart.FilesSchema" } |
| 308 | } |
| 309 | return undefined |
| 310 | } |
| 311 | |
| 312 | function renderRecursiveReferenceDeclaration( |
| 313 | $ref: string, |
| 314 | code: SchemaRepresentation.Code, |
| 315 | internalName: string |
| 316 | ): string { |
| 317 | return [ |
| 318 | `export type ${$ref} = ${code.Type}`, |
| 319 | `export const ${$ref} = Schema.suspend((): Schema.Codec<${$ref}> => ${internalName})` |
| 320 | ].join("\n") |
| 321 | } |
| 322 | |
| 323 | function render(title: string, as: ReadonlyArray<string>) { |
| 324 | if (as.length === 0) return "" |
| 325 | return "// " + title + "\n" + as.join("\n") + "\n" |
| 326 | } |
| 327 | |
| 328 | function renderImportArtifacts(codeDocument: SchemaRepresentation.CodeDocument, enabled: boolean): string { |
| 329 | if (!enabled) return "" |
| 330 | const imports = codeDocument.artifacts.flatMap((artifact) => |
| 331 | artifact._tag === "Import" ? [artifact.importDeclaration] : [] |
| 332 | ) |
| 333 | return imports.length === 0 ? "" : imports.join("\n") + "\n" |
| 334 | } |
| 335 | |
| 336 | function omitSupersededMultipartDefinitions( |
| 337 | definitions: JsonSchema.Definitions, |
| 338 | schemas: ReadonlyArray<JsonSchema.JsonSchema>, |
| 339 | multipartSchemaRefs: HttpApiMultipartSchemaRefs |
| 340 | ): JsonSchema.Definitions { |
| 341 | const rootReferences = collectReferenceKeys(schemas) |
| 342 | const multipartReferences = new Set([multipartSchemaRefs.singleFile, multipartSchemaRefs.files]) |
| 343 | const output: JsonSchema.Definitions = {} |
| 344 | |
| 345 | for (const [key, schema] of Object.entries(definitions)) { |
| 346 | const superseded = !multipartReferences.has(key) && !rootReferences.has(key) && |
| 347 | referencesAny(schema, multipartReferences) |
| 348 | if (!superseded) { |
| 349 | Object.defineProperty(output, key, { |
| 350 | value: schema, |
| 351 | enumerable: true, |
| 352 | configurable: true, |
| 353 | writable: true |
| 354 | }) |
| 355 | } |
| 356 | } |
| 357 | return output |
| 358 | } |
| 359 | |
| 360 | function collectReferenceKeys(input: unknown): Set<string> { |
| 361 | const references = new Set<string>() |
| 362 | visitReferences(input, ($ref) => { |
| 363 | const token = $ref.split("/").at(-1) |
| 364 | if (token !== undefined && token.length > 0) { |
| 365 | references.add(JsonPointer.unescapeToken(token)) |
| 366 | } |
| 367 | }) |
| 368 | return references |
| 369 | } |
| 370 | |
| 371 | function referencesAny(input: unknown, keys: ReadonlySet<string>): boolean { |
| 372 | const references = collectReferenceKeys(input) |
| 373 | for (const key of references) { |
| 374 | if (keys.has(key)) return true |
| 375 | } |
| 376 | return false |
| 377 | } |
| 378 | |
| 379 | function visitReferences(input: unknown, onReference: ($ref: string) => void): void { |
| 380 | if (Array.isArray(input)) { |
| 381 | for (const value of input) visitReferences(value, onReference) |
| 382 | return |
| 383 | } |
| 384 | if (typeof input !== "object" || input === null) return |
| 385 | for (const [key, value] of Object.entries(input)) { |
| 386 | if (key === "$ref" && typeof value === "string") { |
| 387 | onReference(value) |
| 388 | } else { |
| 389 | visitReferences(value, onReference) |
| 390 | } |
| 391 | } |
| 392 | } |
| 393 | |
| 394 | const tokenPattern = /[A-Za-z_$][A-Za-z0-9_$]*/g |
| 395 | |
| 396 | function collectForwardReferencedRecursives( |
| 397 | nonRecursives: ReadonlyArray<{ |
| 398 | readonly $ref: string |
| 399 | readonly code: SchemaRepresentation.Code |
| 400 | }>, |
| 401 | recursives: ReadonlyArray<readonly [string, SchemaRepresentation.Code]> |
| 402 | ): Set<string> { |
| 403 | const recursiveNames = new Set(recursives.map(([name]) => name)) |
| 404 | const recursiveIndexes = new Map(recursives.map(([name], index) => [name, index])) |
| 405 | const referenced = new Set<string>() |
| 406 | |
| 407 | for (const { code } of nonRecursives) { |
| 408 | for (const token of code.runtime.matchAll(tokenPattern)) { |
| 409 | const identifier = token[0] |
| 410 | if (recursiveNames.has(identifier)) { |
| 411 | referenced.add(identifier) |
| 412 | } |
| 413 | } |
| 414 | } |
| 415 | |
| 416 | for (let index = 0; index < recursives.length; index++) { |
| 417 | const [, code] = recursives[index] |
| 418 | for (const token of code.runtime.matchAll(tokenPattern)) { |
| 419 | const identifier = token[0] |
| 420 | const referencedIndex = recursiveIndexes.get(identifier) |
| 421 | if (referencedIndex !== undefined && referencedIndex > index) { |
| 422 | referenced.add(identifier) |
| 423 | } |
| 424 | } |
| 425 | } |
| 426 | |
| 427 | return referenced |
| 428 | } |
| 429 | |
| 430 | function makeRecursiveInternalNameMap( |
| 431 | recursiveNames: ReadonlySet<string>, |
| 432 | existingNames: ReadonlyArray<string> |
| 433 | ): Map<string, string> { |
| 434 | const usedNames = new Set(existingNames) |
| 435 | const internalNames = new Map<string, string>() |
| 436 | |
| 437 | for (const name of recursiveNames) { |
| 438 | let candidate = `__recursive_${name}` |
| 439 | while (usedNames.has(candidate)) { |
| 440 | candidate = `_${candidate}` |
| 441 | } |
| 442 | usedNames.add(candidate) |
| 443 | internalNames.set(name, candidate) |
| 444 | } |
| 445 | |
| 446 | return internalNames |
| 447 | } |