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 */
21import * as Arr from "effect/Array"
22import * as JsonPointer from "effect/JsonPointer"
23import * as JsonSchema from "effect/JsonSchema"
24import * as Rec from "effect/Record"
25import * as Schema from "effect/Schema"
26import * as SchemaRepresentation from "effect/SchemaRepresentation"
27
28type Source = "openapi-3.0" | "openapi-3.1"
29interface GenerateOptions {
30 readonly onEnter?: ((js: JsonSchema.JsonSchema) => JsonSchema.JsonSchema) | undefined
31 readonly multipartSchemaRefs?: MultipartSchemaRefs | undefined
32}
33
34interface HttpApiMultipartSchemaRefs {
35 readonly kind: "httpapi"
36 readonly singleFile: string
37 readonly files: string
38}
39
40type 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 */
54export function make() {
55 return makeWithRepresentation()
56}
57
58function 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
268function 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
277function 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
291function 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
312function 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
323function render(title: string, as: ReadonlyArray<string>) {
324 if (as.length === 0) return ""
325 return "// " + title + "\n" + as.join("\n") + "\n"
326}
327
328function 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
336function 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
360function 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
371function 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
379function 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
394const tokenPattern = /[A-Za-z_$][A-Za-z0-9_$]*/g
395
396function 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
430function 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}