diff --git a/__tests__/fixtures/docker/.openapi-codegen-manifest.json b/__tests__/fixtures/docker/.openapi-codegen-manifest.json index dfb93cc..b241c60 100644 --- a/__tests__/fixtures/docker/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/docker/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "fd12a9e2c870fa61399108a9eb477d7f", + "#generator": "d42d93cf60354fd3599a5861038e3bec", "commands.ts": "26c79ac152c76497332be8d70b6d9ef7", "types.ts": "72a44fac13cd4872db19c1a64b11a6ea", "main.ts": "0f596fab7f6e9bb140fcd133caccb38d", diff --git a/__tests__/fixtures/openai/.openapi-codegen-manifest.json b/__tests__/fixtures/openai/.openapi-codegen-manifest.json index 746b0bb..d7efbb3 100644 --- a/__tests__/fixtures/openai/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/openai/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "fd12a9e2c870fa61399108a9eb477d7f", + "#generator": "d42d93cf60354fd3599a5861038e3bec", "commands.ts": "7d2187eb106fc582735b22176033ea28", "types.ts": "e2ca3f6a2e1e1a382f4d11f187b341df", "main.ts": "5ba91c2efb44e3e5c5bd1e1a1b90bb51", diff --git a/__tests__/fixtures/petstore/.openapi-codegen-manifest.json b/__tests__/fixtures/petstore/.openapi-codegen-manifest.json index 459bbc0..f417a8c 100644 --- a/__tests__/fixtures/petstore/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/petstore/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "fd12a9e2c870fa61399108a9eb477d7f", + "#generator": "d42d93cf60354fd3599a5861038e3bec", "commands.ts": "608af748764e3adf1fd212532dfabd10", "types.ts": "ea65c3e67352d4e22b97af80085727b4", "main.ts": "64edb526dcbcbd345e631ccff959f11d", diff --git a/__tests__/fixtures/test1/.openapi-codegen-manifest.json b/__tests__/fixtures/test1/.openapi-codegen-manifest.json index 68ee426..a8f6694 100644 --- a/__tests__/fixtures/test1/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/test1/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "fd12a9e2c870fa61399108a9eb477d7f", + "#generator": "d42d93cf60354fd3599a5861038e3bec", "commands.ts": "19fd590dfc5cc8616a70dcf78326c027", "types.ts": "a5a7fef55f948f68f724f7e50ba282ca", "main.ts": "1e2091a697e1aa9d8b770d777c172bf8", diff --git a/lib/process-document.ts b/lib/process-document.ts index 55e5601..fbaf0cc 100644 --- a/lib/process-document.ts +++ b/lib/process-document.ts @@ -1,18 +1,22 @@ import nodePath from "node:path"; -import { $RefParser } from "@apidevtools/json-schema-ref-parser"; +import { $RefParser, type $Refs } from "@apidevtools/json-schema-ref-parser"; import type { oas30, oas31 } from "openapi3-ts"; import toposort from "toposort"; import { + type ClassDeclaration, + type ImportDeclaration, type InterfaceDeclaration, type JSDocStructure, Node, type OptionalKind, Project, Scope, + type SourceFile, StructureKind, SyntaxKind, type TypeAliasDeclaration, VariableDeclarationKind, + type WriterFunction, Writers, } from "ts-morph"; import type { Simplify } from "type-fest"; @@ -239,84 +243,87 @@ function createUnion(...types: (string | undefined)[]) { ); } -export async function processOpenApiDocument( - outputDir: string, - schema: Simplify, - tags?: string[], - options?: CodegenOptions, -) { - const project = new Project(); - - const commandsFile = project.createSourceFile( - nodePath.join(outputDir, "commands.ts"), - "", - { - overwrite: true, - }, - ); - - // Subclasses that attach `static responseSchema`. Consumers import from - // this module to opt into runtime response validation, or alias - // `./commands` to it in dev. The base command module imports zero - // schemas, so prod bundles stay small - const commandsValidatedFile = project.createSourceFile( - nodePath.join(outputDir, "commands-validated.ts"), - "", - { - overwrite: true, - }, - ); +type NamedDeclaration = InterfaceDeclaration | TypeAliasDeclaration; - const typesFile = project.createSourceFile( - nodePath.join(outputDir, "types.ts"), - "", +type DeprecationDocs = (OptionalKind | string)[]; - { - overwrite: true, - }, - ); - - const mainFile = project.createSourceFile( - nodePath.join(outputDir, "main.ts"), - "", - { - overwrite: true, - }, - ); +type OperationParameterObjects = { + path: oas30.ParameterObject[]; + query: oas30.ParameterObject[]; + header: oas30.ParameterObject[]; +}; - // Enums file for runtime enum values - const enumsFile = project.createSourceFile( - nodePath.join(outputDir, "enums.ts"), - "", - { - overwrite: true, - }, - ); +type OperationContext = { + commandName: string; + commandClass: ClassDeclaration; + deprecationDocs: DeprecationDocs; + queryParameters: oas30.ParameterObject[]; + queryType: TypeAliasDeclaration | undefined; + headerParameters: oas30.ParameterObject[]; + headerType: TypeAliasDeclaration | undefined; + pathParameters: oas30.ParameterObject[]; + pathType: TypeAliasDeclaration | undefined; + jsonRequestBodyObject: oas31.MediaTypeObject | undefined; + jsonBodyType: NamedDeclaration | undefined; + nonJsonBodyType: TypeAliasDeclaration | undefined; + wrapJsonBody: boolean; + inputType: TypeAliasDeclaration; + inputTypeNode: string | WriterFunction; +}; - // Validators file for Valibot schemas - const valibotFile = createValibotFile(project, outputDir); +type OperationWithId = oas31.OperationObject & { operationId: string }; - // Track registered validators by their $ref path - const validators = new Map(); +function hasOperationId( + operationObject: oas31.OperationObject, +): operationObject is OperationWithId { + return "operationId" in operationObject; +} - // Track all operations for middleware generation - const allOperations: OperationMiddlewareInfo[] = []; +const nonJsonBodyPropName = "body"; +const inputBodyName = "body"; - const outputTypes = new Set< - InterfaceDeclaration | TypeAliasDeclaration | string - >(); +function createOutputFiles(project: Project, outputDir: string) { + const sourceFile = (name: string) => + project.createSourceFile(nodePath.join(outputDir, name), "", { + overwrite: true, + }); - // Input type-arg expressions used in `Command` per operation. The - // client's `` union is built from these so commands from - // other generated clients fail the constraint on `.json()` - const inputTypeArgs = new Set(); + return { + commandsFile: sourceFile("commands.ts"), + + // Subclasses that attach `static responseSchema`. Consumers import from + // this module to opt into runtime response validation, or alias + // `./commands` to it in dev. The base command module imports zero + // schemas, so prod bundles stay small + commandsValidatedFile: sourceFile("commands-validated.ts"), + typesFile: sourceFile("types.ts"), + mainFile: sourceFile("main.ts"), + enumsFile: sourceFile("enums.ts"), + valibotFile: createValibotFile(project, outputDir), + }; +} - // Bare type names referenced by `inputTypeArgs` expressions, collected at - // the source where they are still separate from the wrapped strings - const inputTypeNames = new Set(); +type OutputFiles = ReturnType; + +type DocumentContext = OutputFiles & { + refs: $Refs; + typesImportDecl: ImportDeclaration; + typesAndInterfaces: Map; + validators: Map; + allOperations: OperationMiddlewareInfo[]; + outputTypes: Set; + inputTypeArgs: Set; + inputTypeNames: Set; + validatedSubclasses: { commandName: string; responseSchema: string }[]; + validatedReExports: string[]; + inputOnly: boolean | undefined; +}; - const refs = await $RefParser.resolve(schema); +function typesModuleSpecifierOf(typesFile: SourceFile) { + return `./${typesFile.getBaseNameWithoutExtension()}.js`; +} +function addModulePreambles({ commandsFile, typesFile }: OutputFiles) { commandsFile.addImportDeclaration({ namedImports: [ // command classes @@ -367,18 +374,9 @@ export async function processOpenApiDocument( isTypeOnly: true, }); - const valibotModuleSpecifier = `./${valibotFile.getBaseNameWithoutExtension()}.js`; - - // Commands with a response schema get a subclass in the validated module. - // The rest re-export the base. Both keep the same exported name so - // consumers can swap modules and leave import sites alone - const validatedSubclasses: { commandName: string; responseSchema: string }[] = - []; - const validatedReExports: string[] = []; - - const typesModuleSpecifier = `./${typesFile.getBaseNameWithoutExtension()}.js`; + const typesModuleSpecifier = typesModuleSpecifierOf(typesFile); - const typesImportDecl = + return ( commandsFile .getImportDeclaration( (decl) => @@ -388,1028 +386,1268 @@ export async function processOpenApiDocument( commandsFile.addImportDeclaration({ moduleSpecifier: typesModuleSpecifier, namedImports: [], - }); + }) + ); +} - const ensureImport = ( - type: TypeAliasDeclaration | InterfaceDeclaration | undefined, - alias?: string, - ) => { - if ( - type && - !typesImportDecl - .getNamedImports() - .some((namedImport) => namedImport.getName() === type.getName()) - ) { - typesImportDecl?.addNamedImport({ - name: type.getName(), - ...(alias && { alias }), - }); - typesImportDecl.setIsTypeOnly(true); - } - }; +function ensureTypeImport( + typesImportDecl: ImportDeclaration, + type: NamedDeclaration | undefined, +) { + if ( + type && + !typesImportDecl + .getNamedImports() + .some((namedImport) => namedImport.getName() === type.getName()) + ) { + typesImportDecl.addNamedImport({ name: type.getName() }); + typesImportDecl.setIsTypeOnly(true); + } +} - const typesAndInterfaces = new Map< - string, - InterfaceDeclaration | TypeAliasDeclaration - >(); - - const schemaGraph = Object.entries(schema.components?.schemas || {}).flatMap( - ([schemaName, schemaObject]) => { - const deps = getDependents(schemaObject); - // oxlint-disable-next-line block65/no-explicit-return-type -- inference widens the pair to string[], and toposort takes a mutable tuple - return deps.map((dep): [string, string] => [ - `#/components/schemas/${schemaName}`, - dep, - ]); - }, - ); +function sortedComponentSchemas(schema: oas31.OpenAPIObject) { + const schemas = Object.entries(schema.components?.schemas || {}); + + const schemaGraph = schemas.flatMap(([schemaName, schemaObject]) => { + const deps = getDependents(schemaObject); + // oxlint-disable-next-line block65/no-explicit-return-type -- inference widens the pair to string[], and toposort takes a mutable tuple + return deps.map((dep): [string, string] => [ + `#/components/schemas/${schemaName}`, + dep, + ]); + }); const sorted = toposort(schemaGraph).toReversed(); - const sortedSchemas = Object.entries( - schema.components?.schemas || {}, - ).toSorted( + return schemas.toSorted( ([a], [b]) => sorted.indexOf(`#/components/schemas/${a}`) - sorted.indexOf(`#/components/schemas/${b}`), ); +} + +// Add enum values to enums file +function addEnumValues( + enumsFile: SourceFile, + schemaName: string, + schemaObject: oas31.SchemaObject | oas31.ReferenceObject, +) { + if ( + "$ref" in schemaObject || + !("enum" in schemaObject) || + !Array.isArray(schemaObject.enum) + ) { + return; + } + + const values = schemaObject.enum.filter((v): v is string => v !== null); + + if (values.length === 0) { + return; + } + + enumsFile.addVariableStatement({ + isExported: true, + declarationKind: VariableDeclarationKind.Const, + docs: schemaObject.description + ? [ + { + description: wordWrap(schemaObject.description), + tags: (schemaObject.deprecated + ? [{ tagName: "deprecated" }] + : [] + ).filter(Boolean), + }, + ] + : [], + declarations: [ + { + name: camelCase(schemaName), + initializer: Writers.assertion((writer) => { + writer.write("["); + values.forEach((value, index) => { + writer.write(JSON.stringify(value)); + if (index < values.length - 1) { + writer.write(", "); + } + }); + writer.write("]"); + }, "const"), + }, + ], + }); +} - for (const [schemaName, schemaObject] of sortedSchemas) { +function registerComponentSchemas( + documentCtx: DocumentContext, + schema: oas31.OpenAPIObject, +) { + for (const [schemaName, schemaObject] of sortedComponentSchemas(schema)) { registerTypesFromSchema( - typesAndInterfaces, - typesFile, + documentCtx.typesAndInterfaces, + documentCtx.typesFile, schemaName, schemaObject, ); registerValidatorFromSchema( - validators, - valibotFile, + documentCtx.validators, + documentCtx.valibotFile, schemaName, schemaObject, - options?.inputOnly, + documentCtx.inputOnly, ); - // Add enum values to enums file - if ( - !("$ref" in schemaObject) && - "enum" in schemaObject && - Array.isArray(schemaObject.enum) - ) { - const values = schemaObject.enum.filter((v): v is string => v !== null); + addEnumValues(documentCtx.enumsFile, schemaName, schemaObject); + } +} - if (values.length > 0) { - enumsFile.addVariableStatement({ - isExported: true, - declarationKind: VariableDeclarationKind.Const, - docs: schemaObject.description - ? [ - { - description: wordWrap(schemaObject.description), - tags: (schemaObject.deprecated - ? [{ tagName: "deprecated" }] - : [] - ).filter(Boolean), - }, - ] - : [], - declarations: [ +function declareCommandClass( + commandsFile: SourceFile, + method: string, + operationObject: OperationWithId, +) { + const isOperationDeprecated = operationObject.deprecated === true; + const deprecationDocs: DeprecationDocs = isOperationDeprecated + ? [ + { + kind: StructureKind.JSDoc, + tags: [ { - name: camelCase(schemaName), - initializer: Writers.assertion((writer) => { - writer.write("["); - values.forEach((value, index) => { - writer.write(JSON.stringify(value)); - if (index < values.length - 1) { - writer.write(", "); - } - }); - writer.write("]"); - }, "const"), + tagName: "deprecated", }, ], - }); - } + }, + ] + : []; + + const commandName = pascalCase( + operationObject.operationId.replace(/command$/i, ""), + "Command", + ); + + const commandClass = commandsFile.addClass({ + name: commandName, + isExported: true, + extends: "Command", + docs: [...deprecationDocs], + properties: [ + { + name: "method", + initializer: Writers.assertion((w) => w.quote(method), "const"), + hasOverrideKeyword: true, + scope: Scope.Public, + }, + ], + }); + + const jsdoc = commandClass.addJsDoc({ + description: `\n${wordWrap(operationObject.description || commandName)}\n`, + + tags: operationObject.summary + ? [ + { + tagName: "summary", + text: wordWrap(operationObject.summary), + }, + ] + : [], + }); + + if (isOperationDeprecated) { + jsdoc.addTag({ + tagName: "deprecated", + }); + } + + return { commandName, commandClass, deprecationDocs }; +} + +// valibot coercion inspects the schema, so a $ref has to go first +function withResolvedSchema(refs: $Refs, parameter: oas30.ParameterObject) { + const resolvedSchema = + parameter.schema && "$ref" in parameter.schema + ? (refs.get(parameter.schema.$ref) ?? undefined) + : undefined; + + const paramWithResolvedSchema: oas30.ParameterObject = { + ...parameter, + ...(resolvedSchema && + typeof resolvedSchema === "object" && + !Array.isArray(resolvedSchema) && { + schema: resolvedSchema, + }), + }; + + return paramWithResolvedSchema; +} + +function collectParameters( + refs: $Refs, + path: string, + pathItemObject: oas31.PathItemObject, + operationObject: OperationWithId, +) { + const pathParameters: oas30.ParameterObject[] = []; + const queryParameters: oas30.ParameterObject[] = []; + const headerParameters: oas30.ParameterObject[] = []; + + for (const parameter of [ + ...(operationObject.parameters || []), + ...(pathItemObject.parameters || []), + ]) { + const resolvedParameter: unknown = + "$ref" in parameter ? refs.get(parameter.$ref) : parameter; + + if (!isParameterObject(resolvedParameter)) { + throw new Error( + `${operationObject.operationId}: ${"$ref" in parameter ? parameter.$ref : "a parameter"} does not resolve to a parameter object`, + ); + } + + if (resolvedParameter.in === "path") { + pathParameters.push(resolvedParameter); + } + + if (resolvedParameter.in === "query") { + queryParameters.push(withResolvedSchema(refs, resolvedParameter)); + } + + if (resolvedParameter.in === "header") { + headerParameters.push(withResolvedSchema(refs, resolvedParameter)); + } + + // OpenAPI 3.2's `in: "querystring"` hands over the whole query + // string as one content-typed value. The generator expresses a + // query as named parameters, so generating this operation would + // drop its query in silence + if (isQuerystringLocation(resolvedParameter.in)) { + throw new Error( + `${operationObject.operationId}: parameter "${resolvedParameter.name}" uses \`in: querystring\`, which this generator does not support. Declare the members as \`in: query\` parameters instead.`, + ); } } - for (const [path, pathItemObject] of Object.entries( - schema.paths || {}, - )) { - if (pathItemObject) { - for (const [method, operationObject] of Object.entries(pathItemObject) - // ensure op is an object - .filter( - (e): e is [string, oas31.OperationObject] => typeof e[1] === "object", - ) - // tags - .filter(([, o]) => !tags || o.tags?.some((t) => tags?.includes(t)))) { - if ( - typeof operationObject === "object" && - "operationId" in operationObject - ) { - const isOperationDeprecated = operationObject.deprecated === true; - const deprecationDocs: (OptionalKind | string)[] = - isOperationDeprecated - ? [ - { - kind: StructureKind.JSDoc, - tags: [ - { - tagName: "deprecated", - }, - ], - }, - ] - : []; - - const pathParameters: oas30.ParameterObject[] = []; - - const commandName = pascalCase( - operationObject.operationId.replace(/command$/i, ""), - "Command", - ); + // Extract path parameters from URL pattern that weren't declared this + // is technically against the spec but we are the good guys + for (const [, paramName] of path.matchAll(/\{(\w+)\}/g)) { + const alreadyDeclared = pathParameters.some((p) => p.name === paramName); + + if (!alreadyDeclared && paramName) { + pathParameters.push({ + name: paramName, + in: "path", + required: true, + schema: { type: "string" }, + }); + } + } + + const parameters: OperationParameterObjects = { + path: pathParameters, + query: queryParameters, + header: headerParameters, + }; + + return parameters; +} + +function addQueryStyles( + commandClass: ClassDeclaration, + queryParameters: oas30.ParameterObject[], +) { + // rest-client reads form with explode as the default, so only a + // departure from it is listed + const queryStyleEntries = queryParameters + .map( + (parameter) => + [parameter.name, queryParameterEncoding(parameter)] as const, + ) + .filter(([, encoding]) => encoding.style !== "form" || !encoding.explode); + + if (queryStyleEntries.length > 0) { + commandClass.addProperty({ + name: "queryStyles", + hasOverrideKeyword: true, + scope: Scope.Public, + initializer: (writer) => { + writer.write("{"); + writer.indent(() => { + for (const [name, encoding] of queryStyleEntries) { + writer.writeLine( + `${JSON.stringify(name)}: { style: ${JSON.stringify(encoding.style)}, explode: ${encoding.explode} },`, + ); + } + }); + writer.write("} as const"); + }, + }); + } +} + +function parameterProperty( + typesAndInterfaces: Map, + parameter: oas30.ParameterObject, + name: string, + propertyName: string, +) { + if (!parameter.schema) { + return { + name: propertyName, + hasQuestionToken: !parameter.required, + }; + } + + const type = schemaToType( + typesAndInterfaces, + parameter.required + ? { + required: [name], + } + : {}, + name, + parameter.schema, + { + // query parameters can't be strictly "boolean" + booleanAsStringish: true, + integerAsStringish: true, + }, + ); + + const resolvedType = type.type; + + return { + ...type, + name: propertyName, + hasQuestionToken: !parameter.required, + ...(resolvedType !== undefined && { + type: resolvedType, + }), + }; +} + +function addQueryType( + documentCtx: DocumentContext, + { + commandClass, + deprecationDocs, + }: OperationContext | ReturnType, + queryParameters: oas30.ParameterObject[], +) { + const queryType = + queryParameters.length > 0 + ? documentCtx.typesFile.addTypeAlias({ + name: pascalCase(commandClass.getName() || "INVALID", "Query"), + docs: deprecationDocs, + isExported: true, + type: Writers.objectType({ + properties: queryParameters.map((qp) => { + const name = castToValidJsIdentifier(qp.name); + + return parameterProperty( + documentCtx.typesAndInterfaces, + qp, + name, + name, + ); + }), + }), + }) + : undefined; + + ensureTypeImport(documentCtx.typesImportDecl, queryType); + + return queryType; +} + +function addHeaderType( + documentCtx: DocumentContext, + { commandClass, deprecationDocs }: ReturnType, + headerParameters: oas30.ParameterObject[], +) { + const headerType = + headerParameters.length > 0 + ? documentCtx.typesFile.addTypeAlias({ + name: pascalCase(commandClass.getName() || "INVALID", "Header"), + docs: deprecationDocs, + isExported: true, + type: Writers.objectType({ + properties: headerParameters.map((hp) => { + const name = hp.name.toLowerCase(); + + return parameterProperty( + documentCtx.typesAndInterfaces, + hp, + name, + JSON.stringify(name), + ); + }), + }), + }) + : undefined; + + ensureTypeImport(documentCtx.typesImportDecl, headerType); + + return headerType; +} + +function jsonBodyTypeOf( + documentCtx: DocumentContext, + deprecationDocs: DeprecationDocs, + operationId: string, + jsonRequestBodyObject: oas31.MediaTypeObject | undefined, +) { + if (!jsonRequestBodyObject?.schema) { + return; + } + + if ("$ref" in jsonRequestBodyObject.schema) { + return documentCtx.typesAndInterfaces.get( + jsonRequestBodyObject.schema.$ref, + ); + } + + if ( + jsonRequestBodyObject.schema.type === "array" && + "items" in jsonRequestBodyObject.schema && + "$ref" in jsonRequestBodyObject.schema.items + ) { + return documentCtx.typesAndInterfaces.get( + jsonRequestBodyObject.schema.items.$ref, + ); + } + + // Named for the media type because only an application/json + // request body is generated + const name = castToValidJsIdentifier(pascalCase(operationId, "JsonBody")); + + const type = schemaToType( + documentCtx.typesAndInterfaces, + jsonRequestBodyObject.schema.required + ? { + required: [name], + } + : {}, + name, + jsonRequestBodyObject.schema, + ); + + return documentCtx.typesFile.addTypeAlias({ + name, + docs: deprecationDocs, + type: typeof type.type === "function" ? type.type : String(type.type), + }); +} + +function resolveBodyTypes( + documentCtx: DocumentContext, + { commandClass, deprecationDocs }: ReturnType, + operationObject: OperationWithId, +) { + const requestBodyObject = + operationObject.requestBody && !("$ref" in operationObject.requestBody) + ? operationObject.requestBody + : undefined; + + const jsonRequestBodyObject = requestBodyObject?.content["application/json"]; + + const jsonBodyType = jsonBodyTypeOf( + documentCtx, + deprecationDocs, + operationObject.operationId, + jsonRequestBodyObject, + ); + + const nonJsonBodyEntries = requestBodyObject?.content + ? Object.entries(requestBodyObject.content).filter( + ([, o]) => o !== jsonRequestBodyObject, + ) + : []; - const commandClassDeclaration = commandsFile.addClass({ - name: commandName, - isExported: true, - extends: "Command", - docs: [...deprecationDocs], + if (jsonBodyType && nonJsonBodyEntries.length > 0) { + console.warn( + commandClass.getName(), + "Non-json and json body types are not supported together yet", + ); + } + + const nonJsonBodyType = + !jsonBodyType && nonJsonBodyEntries.length > 0 + ? documentCtx.typesFile.addTypeAlias({ + docs: deprecationDocs, + name: pascalCase( + `${commandClass.getName() || "INVALID"} Body NonJson`, + ), + isExported: true, + type: Writers.objectType({ properties: [ { - name: "method", - initializer: Writers.assertion((w) => w.quote(method), "const"), - hasOverrideKeyword: true, - scope: Scope.Public, + name: nonJsonBodyPropName, + type: createUnion( + ...nonJsonBodyEntries.map(([contentType, _mediaTypeObj]) => { + const nonJsonBody = documentCtx.typesFile.addTypeAlias({ + name: pascalCase( + `${commandClass.getName() || "INVALID"} Body ${contentType}`, + ), + type: "NonNullable", + }); + + return nonJsonBody.getName(); + }), + ), }, ], - }); + }), + }) + : undefined; + + return { jsonRequestBodyObject, jsonBodyType, nonJsonBodyType }; +} + +function addParamsType( + documentCtx: DocumentContext, + { commandClass, deprecationDocs }: ReturnType, + pathParameters: oas30.ParameterObject[], +) { + return pathParameters.length > 0 + ? documentCtx.typesFile.addTypeAlias({ + name: pascalCase(`${commandClass.getName() || "INVALID"}Params`), + docs: deprecationDocs, + type: Writers.objectType({ + properties: pathParameters.map((p) => { + const name = castToValidJsIdentifier(p.name); + + const type = schemaToType( + documentCtx.typesAndInterfaces, + p.required + ? { + required: [name], + } + : {}, + name, + p.schema || { + type: "string", + description: + "// TODO: check this? no path param schema was found", + }, + { + // parameters can't be strictly "boolean" + booleanAsStringish: true, + integerAsStringish: true, + }, + ); + + return { + ...type, + name, + type: type.type || unspecifiedKeyword, + }; + }), + }), + isExported: true, + }) + : undefined; +} + +function addInputType( + documentCtx: DocumentContext, + { commandClass }: ReturnType, + { + jsonRequestBodyObject, + jsonBodyType, + nonJsonBodyType, + }: ReturnType, + paramsType: TypeAliasDeclaration | undefined, + queryType: TypeAliasDeclaration | undefined, +) { + const bodyType = + (jsonBodyType && + documentCtx.typesFile.addTypeAlias({ + name: pascalCase(commandClass.getName() || "", "Body"), + type: jsonBodyType.getName(), + isExported: true, + })) || + nonJsonBodyType; + + if (bodyType) { + ensureTypeImport(documentCtx.typesImportDecl, bodyType); + } + + // An array body intersected with the parameters reads as the array + // alone, and the parameters vanish. It goes under `body`, the same + // way a non-JSON body already does + const jsonBodySchema = jsonRequestBodyObject?.schema; + const jsonBodyIsArray = + !!jsonBodySchema && + !("$ref" in jsonBodySchema) && + jsonBodySchema.type === "array"; + const wrapJsonBody = jsonBodyIsArray && (!!paramsType || !!queryType); + + const wrappedJsonBodyType = + wrapJsonBody && jsonBodyType + ? documentCtx.typesFile.addTypeAlias({ + name: pascalCase(commandClass.getName() || "", "BodyWrapper"), + type: Writers.objectType({ + properties: [{ name: inputBodyName, type: jsonBodyType.getName() }], + }), + }) + : undefined; + + const inputTypeNode = createIntersection( + wrappedJsonBodyType?.getName() || + jsonBodyType?.getName() || + nonJsonBodyType?.getName(), + paramsType?.getName(), + queryType?.getName(), + ); + + const inputType = documentCtx.typesFile.addTypeAlias({ + name: pascalCase(commandClass.getName() || "", "Input"), + type: inputTypeNode, + isExported: true, + }); + ensureTypeImport(documentCtx.typesImportDecl, inputType); + + return { inputType, inputTypeNode, wrapJsonBody }; +} + +function firstJsonResponseSchema(operationObject: OperationWithId) { + // Resolve the first 2xx JSON response schema (inline or $ref) so the + // validator pipeline treats responses the same as request bodies + const firstSuccess = Object.entries(operationObject.responses ?? {}).find( + ([s]) => s.startsWith("2"), + ); + + if (!firstSuccess) { + return; + } + + const [statusCode, response] = firstSuccess; + + if (statusCode === "204" || "$ref" in response) { + return; + } + + return response.content?.["application/json"]?.schema; +} + +// Generate the valibot validator for the operation input +function registerOperationValidators( + documentCtx: DocumentContext, + { + commandName, + queryParameters, + headerParameters, + pathParameters, + jsonRequestBodyObject, + }: OperationContext, + operationObject: OperationWithId, +) { + const responseSchema = firstJsonResponseSchema(operationObject); + + const operationSchemas = createValidatorForOperationInput( + documentCtx.validators, + documentCtx.valibotFile, + commandName, + { + ...(jsonRequestBodyObject?.schema && { + body: jsonRequestBodyObject?.schema, + }), + ...(responseSchema && { + response: responseSchema, + }), + params: pathParameters, + query: queryParameters, + header: headerParameters, + }, + documentCtx.inputOnly, + ); + + // Track operation for middleware generation (use coerced schemas) + const middlewareExportName = castToValidJsIdentifier( + operationObject.operationId.replace(/Command$/i, ""), + ); + checkQueryParameters(operationObject.operationId, queryParameters); + + // Consumers read one variant, so --input-only substitutes it here + const wireSchemas = documentCtx.inputOnly + ? operationSchemas.input + : operationSchemas.wire; + + documentCtx.allOperations.push({ + exportName: middlewareExportName, + schemas: wireSchemas, + queryParams: queryParameters + .map((parameter) => queryParameterSpec(parameter)) + .filter((spec) => spec !== undefined), + }); + + return wireSchemas; +} + +function widenedInputType(inputTypeName: string, hasNonJsonBody: boolean) { + // Widen optional fields with `| undefined` at the serialization + // boundary. Outbound payloads are JSON.stringified, which drops + // `undefined`, so callers can pass `{ field: undefined }` even + // under exactOptionalPropertyTypes. A non-JSON `body` field holds + // a BodyInit class instance, which UndefinedOnPartialDeep would + // mangle, so widen everything else and re-intersect `body` + return hasNonJsonBody + ? `UndefinedOnPartialDeep> & Pick<${inputTypeName}, "body">` + : `UndefinedOnPartialDeep<${inputTypeName}>`; +} + +function addInputTypeArgument( + documentCtx: DocumentContext, + { commandClass, inputType, inputTypeNode, nonJsonBodyType }: OperationContext, +) { + const inputTypeArg = widenedInputType(inputType.getName(), !!nonJsonBodyType); + + // `A | never` is `A`, so the member is left out + if (inputTypeNode !== neverKeyword) { + documentCtx.inputTypeArgs.add(inputTypeArg); + documentCtx.inputTypeNames.add(inputType.getName()); + } - const jsDocStructure = { - description: `\n${wordWrap( - operationObject.description || commandName, - )}\n`, - - tags: operationObject.summary - ? [ - { - tagName: "summary", - text: wordWrap(operationObject.summary), - }, - ] - : [], - }; - - const jsdoc = commandClassDeclaration.addJsDoc(jsDocStructure); - - if (isOperationDeprecated) { - jsdoc.addTag({ - tagName: "deprecated", - }); - } + commandClass.getExtends()?.addTypeArgument(inputTypeArg); +} - const requestBodyObject = - operationObject.requestBody && - !("$ref" in operationObject.requestBody) - ? operationObject.requestBody - : undefined; - - const queryParameters: oas30.ParameterObject[] = []; - const headerParameters: oas30.ParameterObject[] = []; - - for (const parameter of [ - ...(operationObject.parameters || []), - ...(pathItemObject.parameters || []), - ]) { - const resolvedParameter: unknown = - "$ref" in parameter ? refs.get(parameter.$ref) : parameter; - - if (!isParameterObject(resolvedParameter)) { - throw new Error( - `${operationObject.operationId}: ${"$ref" in parameter ? parameter.$ref : "a parameter"} does not resolve to a parameter object`, - ); - } +function addReferencedOutput( + documentCtx: DocumentContext, + { commandClass }: OperationContext, + outputRef: string, + isArray: boolean, +) { + const outputType = documentCtx.typesAndInterfaces.get(outputRef); - if (resolvedParameter.in === "path") { - pathParameters.push(resolvedParameter); - } + if (outputType) { + documentCtx.outputTypes.add(outputType); + ensureTypeImport(documentCtx.typesImportDecl, outputType); + } - if ( - resolvedParameter.in === "query" || - resolvedParameter.in === "header" - ) { - // Resolve $ref schemas so the valibot coercion - // pipeline can inspect the underlying type - const resolvedSchema = - resolvedParameter.schema && "$ref" in resolvedParameter.schema - ? (refs.get(resolvedParameter.schema.$ref) ?? undefined) - : undefined; - - const paramWithResolvedSchema: oas30.ParameterObject = { - ...resolvedParameter, - ...(resolvedSchema && - typeof resolvedSchema === "object" && - !Array.isArray(resolvedSchema) && { - schema: resolvedSchema, - }), - }; + const outputTypeName = `${outputType?.getName()}${isArray ? "[]" : ""}`; - if (resolvedParameter.in === "query") { - queryParameters.push(paramWithResolvedSchema); - } else { - headerParameters.push(paramWithResolvedSchema); - } - } + if (isArray) { + documentCtx.outputTypes.add(outputTypeName); + } - // OpenAPI 3.2's `in: "querystring"` hands over the whole query - // string as one content-typed value. The generator expresses a - // query as named parameters, so generating this operation would - // drop its query in silence - if (isQuerystringLocation(resolvedParameter.in)) { - throw new Error( - `${operationObject.operationId}: parameter "${resolvedParameter.name}" uses \`in: querystring\`, which this generator does not support. Declare the members as \`in: query\` parameters instead.`, - ); - } - } + commandClass.getExtends()?.addTypeArgument(outputTypeName); - // Extract path parameters from URL pattern that weren't declared this - // is technically against the spec but we are the good guys - for (const [, paramName] of path.matchAll(/\{(\w+)\}/g)) { - const alreadyDeclared = pathParameters.some( - (p) => p.name === paramName, - ); + // Handler-return alias for `c.json(...)` on the server side. + // The value is JSON.stringified, which drops `undefined`, so + // optional fields may hold `undefined`. Mirrors the `input*` + // prefix used for the lax variant in the valibot module + documentCtx.typesFile.addTypeAlias({ + name: pascalCase("Input", commandClass.getName() || "INVALID", "Response"), + type: `UndefinedOnPartialDeep<${outputTypeName}>`, + isExported: true, + }); +} - if (!alreadyDeclared && paramName) { - pathParameters.push({ - name: paramName, - in: "path", - required: true, - schema: { type: "string" }, - }); - } - } +function addInlineOutput( + documentCtx: DocumentContext, + { commandClass }: OperationContext, + schema: oas31.SchemaObject | oas31.ReferenceObject, +) { + const outputType = schemaToType( + documentCtx.typesAndInterfaces, + {}, + "", + schema, + ); - // rest-client reads form with explode as the default, so only a - // departure from it is listed - const queryStyleEntries = queryParameters - .map( - (parameter) => - [parameter.name, queryParameterEncoding(parameter)] as const, - ) - .filter( - ([, encoding]) => encoding.style !== "form" || !encoding.explode, - ); + const responseTypeAlias = documentCtx.typesFile.addTypeAlias({ + name: pascalCase(commandClass.getName() || "INVALID", "Output"), + type: + typeof outputType.type === "function" + ? outputType.type + : Writers.unionType(`${outputType.type}`, "undefined"), + isExported: true, + }); - if (queryStyleEntries.length > 0) { - commandClassDeclaration.addProperty({ - name: "queryStyles", - hasOverrideKeyword: true, - scope: Scope.Public, - initializer: (writer) => { - writer.write("{"); - writer.indent(() => { - for (const [name, encoding] of queryStyleEntries) { - writer.writeLine( - `${JSON.stringify(name)}: { style: ${JSON.stringify(encoding.style)}, explode: ${encoding.explode} },`, - ); - } - }); - writer.write("} as const"); - }, - }); - } + ensureTypeImport(documentCtx.typesImportDecl, responseTypeAlias); - const queryType = - queryParameters.length > 0 - ? typesFile.addTypeAlias({ - name: pascalCase( - commandClassDeclaration.getName() || "INVALID", - "Query", - ), - docs: deprecationDocs, - isExported: true, - type: Writers.objectType({ - properties: queryParameters.map((qp) => { - const name = castToValidJsIdentifier(qp.name); - - if (!qp.schema) { - return { - name, - hasQuestionToken: !qp.required, - }; - } - - const type = schemaToType( - typesAndInterfaces, - qp.required - ? { - required: [name], - } - : {}, - name, - qp.schema, - { - // query parameters can't be strictly "boolean" - booleanAsStringish: true, - integerAsStringish: true, - }, - ); - - const resolvedType = type.type; - - return { - ...type, - name, - hasQuestionToken: !qp.required, - ...(resolvedType !== undefined && { - type: resolvedType, - }), - }; - }), - }), - }) - : undefined; - - ensureImport(queryType); - - const headerType = - headerParameters.length > 0 - ? typesFile.addTypeAlias({ - name: pascalCase( - commandClassDeclaration.getName() || "INVALID", - "Header", - ), - docs: deprecationDocs, - isExported: true, - type: Writers.objectType({ - properties: headerParameters.map((hp) => { - const name = hp.name.toLowerCase(); - - if (!hp.schema) { - return { - name: JSON.stringify(name), - hasQuestionToken: !hp.required, - }; - } - - const type = schemaToType( - typesAndInterfaces, - hp.required - ? { - required: [name], - } - : {}, - name, - hp.schema, - { - booleanAsStringish: true, - integerAsStringish: true, - }, - ); - - const resolvedType = type.type; - - return { - ...type, - name: JSON.stringify(name), - hasQuestionToken: !hp.required, - ...(resolvedType !== undefined && { - type: resolvedType, - }), - }; - }), - }), - }) - : undefined; + commandClass.getExtends()?.addTypeArgument(responseTypeAlias.getName()); + documentCtx.outputTypes.add(responseTypeAlias); - ensureImport(headerType); + documentCtx.typesFile.addTypeAlias({ + name: pascalCase("Input", commandClass.getName() || "INVALID", "Response"), + type: `UndefinedOnPartialDeep<${responseTypeAlias.getName()}>`, + isExported: true, + }); +} - const jsonRequestBodyObject = - requestBodyObject?.content["application/json"]; +function jsonOutputRef(jsonResponse: oas31.MediaTypeObject) { + const arrayRef = + jsonResponse.schema && + "items" in jsonResponse.schema && + "$ref" in jsonResponse.schema.items && + jsonResponse.schema.items.$ref; - const jsonBodyType = iife(() => { - if (!jsonRequestBodyObject?.schema) { - return; - } + const regularRef = + jsonResponse.schema && + "$ref" in jsonResponse.schema && + jsonResponse.schema.$ref; - if ("$ref" in jsonRequestBodyObject.schema) { - return typesAndInterfaces.get(jsonRequestBodyObject.schema.$ref); - } + const outputRef = arrayRef || regularRef; - if ( - jsonRequestBodyObject.schema.type === "array" && - "items" in jsonRequestBodyObject.schema && - "$ref" in jsonRequestBodyObject.schema.items - ) { - return typesAndInterfaces.get( - jsonRequestBodyObject.schema.items.$ref, - ); - } + return outputRef ? { ref: outputRef, isArray: !!arrayRef } : undefined; +} - // Named for the media type because only an application/json - // request body is generated - const name = castToValidJsIdentifier( - pascalCase(operationObject.operationId || "", "JsonBody"), - ); +function addOutputTypeArgument( + documentCtx: DocumentContext, + operationCtx: OperationContext, + operationObject: OperationWithId, +) { + const { commandClass } = operationCtx; - const type = schemaToType( - typesAndInterfaces, - jsonRequestBodyObject.schema.required - ? { - required: [name], - } - : {}, - name, - jsonRequestBodyObject.schema, - ); + // this is just like a 204 response + let hasOutputType = false; - return typesFile.addTypeAlias({ - name, - docs: deprecationDocs, - type: - typeof type.type === "function" ? type.type : String(type.type), - }); - }); + if ( + !operationObject.responses || + Object.keys(operationObject.responses).length === 0 + ) { + commandClass.getExtends()?.addTypeArgument(unspecifiedKeyword); + hasOutputType = true; + } - const nonJsonBodyEntries = requestBodyObject?.content - ? Object.entries(requestBodyObject.content).filter( - ([, o]) => o !== jsonRequestBodyObject, - ) - : []; + for (const [statusCode, response] of Object.entries({ + ...operationObject.responses, + }).filter(([s]) => s.startsWith("2"))) { + // Output is one type argument, so the first usable 2xx response + // settles it. An operation documenting both a 200 and a 204 would + // otherwise add a second argument, which lands in the query slot + // and is not a query type + if (hasOutputType) { + break; + } - if (jsonBodyType && nonJsonBodyEntries.length > 0) { - console.warn( - commandClassDeclaration.getName(), - "Non-json and json body types are not supported together yet", - ); - } + // early out if response is 204 + if (statusCode === "204") { + commandClass.getExtends()?.addTypeArgument(emptyKeyword); - const nonJsonBodyPropName = "body"; - const inputBodyName = "body"; - - const nonJsonBodyType = - !jsonBodyType && nonJsonBodyEntries.length > 0 - ? typesFile.addTypeAlias({ - docs: deprecationDocs, - name: pascalCase( - `${commandClassDeclaration.getName() || "INVALID"} Body NonJson`, - ), - isExported: true, - type: Writers.objectType({ - properties: [ - { - name: nonJsonBodyPropName, - type: createUnion( - ...nonJsonBodyEntries.map( - ([contentType, _mediaTypeObj]) => { - const nonJsonBody = typesFile.addTypeAlias({ - name: pascalCase( - `${commandClassDeclaration.getName() || "INVALID"} Body ${contentType}`, - ), - type: "NonNullable", - }); - - return nonJsonBody.getName(); - }, - ), - ), - }, - ], - }), - }) - : undefined; - - const paramsType = - pathParameters.length > 0 - ? typesFile.addTypeAlias({ - name: pascalCase( - `${commandClassDeclaration.getName() || "INVALID"}Params`, - ), - docs: deprecationDocs, - type: Writers.objectType({ - properties: pathParameters.map((p) => { - const name = castToValidJsIdentifier(p.name); - - const type = schemaToType( - typesAndInterfaces, - p.required - ? { - required: [name], - } - : {}, - name, - p.schema || { - type: "string", - description: - "// TODO: check this? no path param schema was found", - }, - { - // parameters can't be strictly "boolean" - booleanAsStringish: true, - integerAsStringish: true, - }, - ); - - return { - ...type, - name, - type: type.type || unspecifiedKeyword, - }; - }), - }), - isExported: true, - }) - : undefined; - - const bodyType = - (jsonBodyType && - typesFile.addTypeAlias({ - name: pascalCase( - commandClassDeclaration.getName() || "", - "Body", - ), - type: jsonBodyType.getName(), - isExported: true, - })) || - nonJsonBodyType; + documentCtx.outputTypes.add(emptyKeyword); + hasOutputType = true; + break; + } - if (bodyType) { - ensureImport(bodyType); - } + // we dont support refs as response objects + if ("$ref" in response) { + break; + } - // An array body intersected with the parameters reads as the array - // alone, and the parameters vanish. It goes under `body`, the same - // way a non-JSON body already does - const jsonBodySchema = jsonRequestBodyObject?.schema; - const jsonBodyIsArray = - !!jsonBodySchema && - !("$ref" in jsonBodySchema) && - jsonBodySchema.type === "array"; - const wrapJsonBody = jsonBodyIsArray && (!!paramsType || !!queryType); - - const wrappedJsonBodyType = - wrapJsonBody && jsonBodyType - ? typesFile.addTypeAlias({ - name: pascalCase( - commandClassDeclaration.getName() || "", - "BodyWrapper", - ), - type: Writers.objectType({ - properties: [ - { name: inputBodyName, type: jsonBodyType.getName() }, - ], - }), - }) - : undefined; - - const inputTypeNode = createIntersection( - wrappedJsonBodyType?.getName() || - jsonBodyType?.getName() || - nonJsonBodyType?.getName(), - paramsType?.getName(), - queryType?.getName(), - ); + const jsonResponse = response.content?.["application/json"]; - const inputType = typesFile.addTypeAlias({ - name: pascalCase(commandClassDeclaration.getName() || "", "Input"), - type: inputTypeNode, - isExported: true, - }); - ensureImport(inputType); + if (!jsonResponse) { + break; + } - // Resolve the first 2xx JSON response schema (inline or $ref) so the - // validator pipeline treats responses the same as request bodies - const firstJsonResponseSchema = iife(() => { - const firstSuccess = Object.entries( - operationObject.responses ?? {}, - ).find(([s]) => s.startsWith("2")); + const outputRef = jsonOutputRef(jsonResponse); - if (!firstSuccess) { - return; - } + if (outputRef) { + addReferencedOutput( + documentCtx, + operationCtx, + outputRef.ref, + outputRef.isArray, + ); + hasOutputType = true; + } else if (jsonResponse.schema) { + addInlineOutput(documentCtx, operationCtx, jsonResponse.schema); + hasOutputType = true; + } + } - const [statusCode, response] = firstSuccess; + if (!hasOutputType) { + commandClass.getExtends()?.addTypeArgument(unspecifiedKeyword); + } +} - if (statusCode === "204" || "$ref" in response) { - return; - } +function registerValidatedCommand( + documentCtx: DocumentContext, + commandName: string, + wireSchemas: { response?: string }, +) { + // Static schema attachment is deferred to the validated module, so + // the base command module imports zero schemas. rest-client reads + // the response schema from the validated subclass, and the server + // middleware imports body, param and query schemas directly + if (wireSchemas.response) { + documentCtx.validatedSubclasses.push({ + commandName, + responseSchema: wireSchemas.response, + }); + } else { + documentCtx.validatedReExports.push(commandName); + } +} - return response.content?.["application/json"]?.schema; - }); +function addQueryAndHeaderTypeArguments({ + commandClass, + queryType, + headerType, +}: OperationContext) { + // query + if (queryType) { + commandClass.getExtends()?.addTypeArgument(queryType.getName()); + } - // Generate the valibot validator for the operation input - const operationSchemas = createValidatorForOperationInput( - validators, - valibotFile, - commandName, - { - ...(jsonRequestBodyObject?.schema && { - body: jsonRequestBodyObject?.schema, - }), - ...(firstJsonResponseSchema && { - response: firstJsonResponseSchema, - }), - params: pathParameters, - query: queryParameters, - header: headerParameters, - }, - options?.inputOnly, - ); + // headers type argument (4th generic on Command) + if (headerType) { + // fill in query slot if missing + if (!queryType) { + commandClass.getExtends()?.addTypeArgument(neverKeyword); + } - // Track operation for middleware generation (use coerced schemas) - const middlewareExportName = castToValidJsIdentifier( - operationObject.operationId.replace(/Command$/i, ""), - ); - checkQueryParameters(operationObject.operationId, queryParameters); - - allOperations.push({ - exportName: middlewareExportName, - schemas: options?.inputOnly - ? operationSchemas.input - : operationSchemas.wire, - queryParams: queryParameters - .map((parameter) => queryParameterSpec(parameter)) - .filter((spec) => spec !== undefined), - }); + commandClass.getExtends()?.addTypeArgument(headerType.getName()); + } +} - // Widen optional fields with `| undefined` at the serialization - // boundary. Outbound payloads are JSON.stringified, which drops - // `undefined`, so callers can pass `{ field: undefined }` even - // under exactOptionalPropertyTypes. A non-JSON `body` field holds - // a BodyInit class instance, which UndefinedOnPartialDeep would - // mangle, so widen everything else and re-intersect `body` - const inputTypeArg = (() => { - if (!inputType) { - return unspecifiedKeyword; - } +function constructorInputs( + { + queryParameters, + queryType, + headerParameters, + headerType, + pathParameters, + pathType, + jsonBodyType, + nonJsonBodyType, + }: OperationContext, + path: string, +) { + const hasPathParams = path.includes("{"); + const pathname = hasPathParams + ? `encodePath\`${path.replaceAll("{", "${")}\`` + : `"${path}"`; - const inputTypeName = inputType.getName(); + const hasJsonBody = !!jsonBodyType; - return nonJsonBodyType - ? `UndefinedOnPartialDeep> & Pick<${inputTypeName}, "body">` - : `UndefinedOnPartialDeep<${inputTypeName}>`; - })(); + const hasNonJsonBody = !!nonJsonBodyType; - // `A | never` is `A`, so the member is left out - if (inputTypeNode !== neverKeyword) { - inputTypeArgs.add(inputTypeArg); - inputTypeNames.add(inputType.getName()); - } + const hasQuery = + !!queryType && + !isUnspecifiedKeyword(queryType) && + queryParameters.length > 0; - commandClassDeclaration.getExtends()?.addTypeArgument(inputTypeArg); + const hasParams = + !!pathType && !isUnspecifiedKeyword(pathType) && pathParameters.length > 0; - // this is just like a 204 response - let hasOutputType = false; + const hasHeaders = !!headerType && headerParameters.length > 0; - if ( - !operationObject.responses || - Object.keys(operationObject.responses).length === 0 - ) { - commandClassDeclaration - .getExtends() - ?.addTypeArgument(unspecifiedKeyword); - hasOutputType = true; - } + const allInputOptional = + !hasParams && + !hasJsonBody && + !hasNonJsonBody && + queryParameters.every((qp) => !qp.required); - for (const [statusCode, response] of Object.entries({ - ...operationObject.responses, - }).filter(([s]) => s.startsWith("2"))) { - // Output is one type argument, so the first usable 2xx response - // settles it. An operation documenting both a 200 and a 204 would - // otherwise add a second argument, which lands in the query slot - // and is not a query type - if (hasOutputType) { - break; - } + const allHeadersOptional = headerParameters.every((hp) => !hp.required); - // early out if response is 204 - if (statusCode === "204") { - commandClassDeclaration - .getExtends() - ?.addTypeArgument(emptyKeyword); + const queryParameterNames = queryParameters + .map((q) => q.name) + .map((name) => castToValidJsIdentifier(name)); - outputTypes.add(emptyKeyword); - hasOutputType = true; - break; - } + const pathParameterNames = pathParameters + .map((q) => q.name) + .map((name) => castToValidJsIdentifier(name)); - // we dont support refs as response objects - if ("$ref" in response) { - break; - } + return { + pathname, + hasJsonBody, + hasNonJsonBody, + hasQuery, + hasParams, + hasHeaders, + allInputOptional, + allHeadersOptional, + queryParameterNames, + pathParameterNames, + }; +} - const jsonResponse = response.content?.["application/json"]; +type ConstructorInputs = ReturnType; - if (!jsonResponse) { - break; - } +function inputDestructuring( + { + hasJsonBody, + hasNonJsonBody, + queryParameterNames, + pathParameterNames, + }: ConstructorInputs, + wrapJsonBody: boolean, +) { + const paramsToDestructure = [...pathParameterNames, ...queryParameterNames]; + + switch (true) { + case paramsToDestructure.length > 0 && hasNonJsonBody: + return `{${[...paramsToDestructure, nonJsonBodyPropName].join(", ")} }`; + case paramsToDestructure.length > 0 && hasJsonBody: + return `{${[ + ...paramsToDestructure, + wrapJsonBody ? inputBodyName : `...${inputBodyName}`, + ].join(", ")} }`; + case paramsToDestructure.length > 0 && !hasJsonBody: + return `{${paramsToDestructure.join(", ")} }`; + case hasNonJsonBody: + return `{${nonJsonBodyPropName}}`; + case hasJsonBody: + return inputBodyName; + default: + return "_"; + } +} - const arrayRef = - jsonResponse.schema && - "items" in jsonResponse.schema && - "$ref" in jsonResponse.schema.items && - jsonResponse.schema.items.$ref; - - const regularRef = - jsonResponse.schema && - "$ref" in jsonResponse.schema && - jsonResponse.schema.$ref; - - const outputRef = arrayRef || regularRef; - - if (outputRef) { - const outputType = typesAndInterfaces.get(outputRef); - - if (outputType) { - outputTypes.add(outputType); - ensureImport(outputType); - } - - const outputTypeName = `${outputType?.getName()}${ - arrayRef ? "[]" : "" - }`; - - if (arrayRef) { - outputTypes.add(outputTypeName); - } - - commandClassDeclaration - .getExtends() - ?.addTypeArgument(outputTypeName); - hasOutputType = true; - - // Handler-return alias for `c.json(...)` on the server side. - // The value is JSON.stringified, which drops `undefined`, so - // optional fields may hold `undefined`. Mirrors the `input*` - // prefix used for the lax variant in the valibot module - typesFile.addTypeAlias({ - name: pascalCase( - "Input", - commandClassDeclaration.getName() || "INVALID", - "Response", - ), - type: `UndefinedOnPartialDeep<${outputTypeName}>`, - isExported: true, - }); - } else if (jsonResponse.schema) { - const outputType = schemaToType( - typesAndInterfaces, - {}, - "", - jsonResponse.schema, - ); +function superArguments({ + pathname, + hasJsonBody, + hasNonJsonBody, + hasQuery, + hasHeaders, + queryParameterNames, +}: ConstructorInputs) { + const headersArg = hasHeaders ? "headers" : undefined; + + const queryOrHeaderArgs = iife(() => { + if (hasQuery) { + return [`stripUndefined({${queryParameterNames.join(", ")}})`]; + } + if (hasHeaders) { + return [emptyKeyword]; + } + return []; + }); - const responseTypeAlias = typesFile.addTypeAlias({ - name: pascalCase( - commandClassDeclaration.getName() || "INVALID", - "Output", - ), - type: - typeof outputType.type === "function" - ? outputType.type - : Writers.unionType(`${outputType.type}`, "undefined"), - isExported: true, - }); - - ensureImport(responseTypeAlias); - - commandClassDeclaration - .getExtends() - ?.addTypeArgument(responseTypeAlias.getName()); - outputTypes.add(responseTypeAlias); - hasOutputType = true; - - typesFile.addTypeAlias({ - name: pascalCase( - "Input", - commandClassDeclaration.getName() || "INVALID", - "Response", - ), - type: `UndefinedOnPartialDeep<${responseTypeAlias.getName()}>`, - isExported: true, - }); - } - } + if (hasJsonBody) { + return [ + pathname, + `jsonStringify(${inputBodyName})`, + ...queryOrHeaderArgs, + ...(headersArg ? [headersArg] : []), + ]; + } - if (!hasOutputType) { - commandClassDeclaration - .getExtends() - ?.addTypeArgument(unspecifiedKeyword); - } + if (hasNonJsonBody) { + return [ + pathname, + nonJsonBodyPropName, + ...queryOrHeaderArgs, + ...(headersArg ? [headersArg] : []), + ]; + } - // Static schema attachment is deferred to the validated module, so - // the base command module imports zero schemas. rest-client reads - // the response schema from the validated subclass, and the server - // middleware imports body, param and query schemas directly. The - // wire variant is what rest-client and hono consume, falling back - // to the input variant under --input-only - const wireSchemas = options?.inputOnly - ? operationSchemas.input - : operationSchemas.wire; - - if (wireSchemas.response) { - validatedSubclasses.push({ - commandName, - responseSchema: wireSchemas.response, - }); - } else { - validatedReExports.push(commandName); - } + if (hasQuery) { + return [ + pathname, + emptyKeyword, + `stripUndefined({${queryParameterNames.join(", ")}})`, + ...(headersArg ? [headersArg] : []), + ]; + } - // query - if (queryType) { - commandClassDeclaration - .getExtends() - ?.addTypeArgument(queryType.getName()); - } + if (hasHeaders && headersArg) { + return [pathname, emptyKeyword, emptyKeyword, headersArg]; + } - // headers type argument (4th generic on Command) - if (headerType) { - // fill in query slot if missing - if (!queryType) { - commandClassDeclaration - .getExtends() - ?.addTypeArgument(neverKeyword); - } + return [pathname]; +} - commandClassDeclaration - .getExtends() - ?.addTypeArgument(headerType.getName()); - } +function addInputConstructor( + { commandClass, inputType, headerType, wrapJsonBody }: OperationContext, + inputs: ConstructorInputs, +) { + const { hasNonJsonBody, hasHeaders, allInputOptional, allHeadersOptional } = + inputs; - const hasPathParams = path.includes("{"); - const pathname = hasPathParams - ? `encodePath\`${path.replaceAll("{", "${")}\`` - : `"${path}"`; + const ctor = commandClass.addConstructor(); - const hasJsonBody = !!jsonBodyType; + if (!isUnspecifiedKeyword(inputType)) { + const cctorParam = ctor.addParameter({ + name: "input", + type: widenedInputType(inputType.getName(), hasNonJsonBody), + ...(allInputOptional && { hasQuestionToken: true }), + }); + + if (hasHeaders && headerType) { + ctor.addParameter({ + name: "headers", + type: headerType.getName(), + ...(allHeadersOptional && { hasQuestionToken: true }), + }); + } + + ctor.addStatements([ + { + kind: StructureKind.VariableStatement, + declarationKind: VariableDeclarationKind.Const, + declarations: [ + { + kind: StructureKind.VariableDeclaration, + initializer: allInputOptional + ? `${cctorParam.getName()} ?? {}` + : cctorParam.getName(), + name: inputDestructuring(inputs, wrapJsonBody), + }, + ], + }, + "super();", + ]); + } - const hasNonJsonBody = !!nonJsonBodyType; + const superKeyword = ctor.getFirstDescendantByKind(SyntaxKind.SuperKeyword); - const hasQuery = - queryType && - !isUnspecifiedKeyword(queryType) && - queryParameters.length > 0; + const callExpr = superKeyword?.getParentIfKindOrThrow( + SyntaxKind.CallExpression, + ); - const hasParams = - paramsType && - !isUnspecifiedKeyword(paramsType) && - pathParameters.length > 0; + // type narrowing + if (Node.isCallExpression(callExpr)) { + callExpr.addArguments(superArguments(inputs)); + } +} - const hasHeaders = !!headerType && headerParameters.length > 0; +function addCommandConstructor(operationCtx: OperationContext, path: string) { + const inputs = constructorInputs(operationCtx, path); + const { hasNonJsonBody, hasJsonBody, hasQuery, hasParams, hasHeaders } = + inputs; - const allInputOptional = - !hasParams && - !hasJsonBody && - !hasNonJsonBody && - queryParameters.every((qp) => !qp.required); + if (hasNonJsonBody || hasJsonBody || hasQuery || hasParams || hasHeaders) { + addInputConstructor(operationCtx, inputs); + } else { + const ctor = operationCtx.commandClass.addConstructor(); + ctor.addStatements([`super(${inputs.pathname});`]); + } +} - const allHeadersOptional = headerParameters.every( - (hp) => !hp.required, - ); +function processOperation( + documentCtx: DocumentContext, + path: string, + pathItemObject: oas31.PathItemObject, + method: string, + operationObject: OperationWithId, +) { + const command = declareCommandClass( + documentCtx.commandsFile, + method, + operationObject, + ); + const { + path: pathParameters, + query: queryParameters, + header: headerParameters, + } = collectParameters( + documentCtx.refs, + path, + pathItemObject, + operationObject, + ); - if ( - hasNonJsonBody || - hasJsonBody || - hasQuery || - hasParams || - hasHeaders - ) { - const ctor = commandClassDeclaration.addConstructor(); - - const queryParameterNames = queryParameters - .map((q) => q.name) - .map((name) => castToValidJsIdentifier(name)); - - const pathParameterNames = pathParameters - .map((q) => q.name) - .map((name) => castToValidJsIdentifier(name)); - - const paramsToDestructure = [ - ...pathParameterNames, - ...queryParameterNames, - ]; - - if (!isUnspecifiedKeyword(inputType)) { - const cctorParam = ctor.addParameter({ - name: "input", - type: hasNonJsonBody - ? `UndefinedOnPartialDeep> & Pick<${inputType.getName()}, "body">` - : `UndefinedOnPartialDeep<${inputType.getName()}>`, - ...(allInputOptional && { hasQuestionToken: true }), - }); - - if (hasHeaders) { - ctor.addParameter({ - name: "headers", - type: headerType.getName(), - ...(allHeadersOptional && { hasQuestionToken: true }), - }); - } - - ctor.addStatements([ - { - kind: StructureKind.VariableStatement, - declarationKind: VariableDeclarationKind.Const, - declarations: [ - { - kind: StructureKind.VariableDeclaration, - initializer: allInputOptional - ? `${cctorParam.getName()} ?? {}` - : cctorParam.getName(), - name: iife(() => { - switch (true) { - case paramsToDestructure.length > 0 && hasNonJsonBody: - return `{${[ - ...paramsToDestructure, - nonJsonBodyPropName, - ].join(", ")} }`; - case paramsToDestructure.length > 0 && hasJsonBody: - return `{${[ - ...paramsToDestructure, - wrapJsonBody - ? inputBodyName - : `...${inputBodyName}`, - ].join(", ")} }`; - case paramsToDestructure.length > 0 && !hasJsonBody: - return `{${paramsToDestructure.join(", ")} }`; - case hasNonJsonBody: - return `{${nonJsonBodyPropName}}`; - case hasJsonBody: - return inputBodyName; - default: - return "_"; - } - }), - }, - ], - }, - "super();", - ]); - } + addQueryStyles(command.commandClass, queryParameters); + + const queryType = addQueryType(documentCtx, command, queryParameters); + const headerType = addHeaderType(documentCtx, command, headerParameters); + const body = resolveBodyTypes(documentCtx, command, operationObject); + const pathType = addParamsType(documentCtx, command, pathParameters); + const input = addInputType(documentCtx, command, body, pathType, queryType); + + const operationCtx: OperationContext = { + ...command, + queryParameters, + queryType, + headerParameters, + headerType, + pathParameters, + pathType, + ...body, + ...input, + }; - const superKeyword = ctor.getFirstDescendantByKind( - SyntaxKind.SuperKeyword, - ); + const wireSchemas = registerOperationValidators( + documentCtx, + operationCtx, + operationObject, + ); - const callExpr = superKeyword?.getParentIfKindOrThrow( - SyntaxKind.CallExpression, - ); + addInputTypeArgument(documentCtx, operationCtx); + addOutputTypeArgument(documentCtx, operationCtx, operationObject); + registerValidatedCommand(documentCtx, operationCtx.commandName, wireSchemas); + addQueryAndHeaderTypeArguments(operationCtx); + addCommandConstructor(operationCtx, path); +} - const headersArg = hasHeaders ? "headers" : undefined; - - const queryOrHeaderArgs = iife(() => { - if (hasQuery) { - return [`stripUndefined({${queryParameterNames.join(", ")}})`]; - } - if (hasHeaders) { - return [emptyKeyword]; - } - return []; - }); - - // type narrowing - if (Node.isCallExpression(callExpr)) { - if (hasJsonBody) { - callExpr.addArguments([ - pathname, - `jsonStringify(${inputBodyName})`, - ...queryOrHeaderArgs, - ...(headersArg ? [headersArg] : []), - ]); - } else if (hasNonJsonBody) { - callExpr.addArguments([ - pathname, - nonJsonBodyPropName, - ...queryOrHeaderArgs, - ...(headersArg ? [headersArg] : []), - ]); - } else if (hasQuery) { - callExpr.addArguments([ - pathname, - emptyKeyword, - `stripUndefined({${queryParameterNames.join(", ")}})`, - ...(headersArg ? [headersArg] : []), - ]); - } else if (hasHeaders && headersArg) { - callExpr.addArguments([ - pathname, - emptyKeyword, - emptyKeyword, - headersArg, - ]); - } else { - callExpr.addArguments([pathname]); - } - } - } else { - const ctor = commandClassDeclaration.addConstructor(); - ctor.addStatements([`super(${pathname});`]); - } +function emitOperations( + documentCtx: DocumentContext, + schema: oas31.OpenAPIObject, + tags: string[] | undefined, +) { + for (const [path, pathItemObject] of Object.entries( + schema.paths || {}, + )) { + if (pathItemObject) { + for (const [method, operationObject] of Object.entries(pathItemObject) + // ensure op is an object + .filter( + (e): e is [string, oas31.OperationObject] => typeof e[1] === "object", + ) + // tags + .filter(([, o]) => !tags || o.tags?.some((t) => tags?.includes(t)))) { + if ( + typeof operationObject === "object" && + hasOperationId(operationObject) + ) { + processOperation( + documentCtx, + path, + pathItemObject, + method, + operationObject, + ); } } } } +} + +function addClientConstructor( + clientClassDeclaration: ClassDeclaration, + schema: oas31.OpenAPIObject, + configType: string, +) { + const ctor = clientClassDeclaration.addConstructor(); + + const baseUrl = ctor.addParameter({ + name: "baseUrl", + type: Writers.unionType("string", "URL"), + initializer: `new URL('${ + new URL(`${schema.servers?.[0]?.url || "https://api.example.com"}/`).href + }')`, + }); + + const configParam = ctor.addParameter({ + name: "config", + type: configType, + hasQuestionToken: true, + }); + + ctor.addStatements(["super();"]); + + const superKeyword = ctor.getFirstDescendantByKind(SyntaxKind.SuperKeyword); + const callExpr = superKeyword?.getParentIfKindOrThrow( + SyntaxKind.CallExpression, + ); + + // type narrowing + if (Node.isCallExpression(callExpr)) { + callExpr?.addArguments([baseUrl.getName(), configParam.getName()]); + } +} + +function emitClientModule( + documentCtx: DocumentContext, + schema: oas31.OpenAPIObject, +) { + const { mainFile, typesFile, outputTypes, inputTypeArgs, inputTypeNames } = + documentCtx; const serviceClientClassName = "RestServiceClient"; const fetcherName = "createIsomorphicNativeFetcher"; @@ -1467,7 +1705,7 @@ export async function processOpenApiDocument( if (importNames.size > 0) { mainFile.addImportDeclaration({ - moduleSpecifier: typesModuleSpecifier, + moduleSpecifier: typesModuleSpecifierOf(typesFile), namedImports: [...importNames].toSorted(), isTypeOnly: true, }); @@ -1487,34 +1725,16 @@ export async function processOpenApiDocument( extends: `${serviceClientClassName}<${allInputs?.getName() || unspecifiedKeyword}, ${allOutputs?.getName() || unspecifiedKeyword}>`, }); - const ctor = clientClassDeclaration.addConstructor(); - - const baseUrl = ctor.addParameter({ - name: "baseUrl", - type: Writers.unionType("string", "URL"), - initializer: `new URL('${ - new URL(`${schema.servers?.[0]?.url || "https://api.example.com"}/`).href - }')`, - }); - - const configParam = ctor.addParameter({ - name: "config", - type: configType, - hasQuestionToken: true, - }); - - ctor.addStatements(["super();"]); - - const superKeyword = ctor.getFirstDescendantByKind(SyntaxKind.SuperKeyword); - const callExpr = superKeyword?.getParentIfKindOrThrow( - SyntaxKind.CallExpression, - ); - - // type narrowing - if (Node.isCallExpression(callExpr)) { - callExpr?.addArguments([baseUrl.getName(), configParam.getName()]); - } + addClientConstructor(clientClassDeclaration, schema, configType); +} +function emitValidatedModule({ + commandsFile, + commandsValidatedFile, + valibotFile, + validatedSubclasses, + validatedReExports, +}: DocumentContext) { // Build the validated module. Subclasses attach `static responseSchema`, // and commands lacking one are re-exported unchanged so the module keeps // export parity with the base and stays alias-safe @@ -1534,7 +1754,7 @@ export async function processOpenApiDocument( }); commandsValidatedFile.addImportDeclaration({ - moduleSpecifier: valibotModuleSpecifier, + moduleSpecifier: `./${valibotFile.getBaseNameWithoutExtension()}.js`, namespaceImport: schemasNs, }); @@ -1560,16 +1780,14 @@ export async function processOpenApiDocument( namedExports: validatedReExports.toSorted().map((name) => ({ name })), }); } +} - mainFile.organizeImports(); - - // tidies up any unused type-fest imports - typesFile.fixUnusedIdentifiers(); - commandsFile.fixUnusedIdentifiers(); - commandsValidatedFile.fixUnusedIdentifiers(); - valibotFile.fixUnusedIdentifiers(); - - // Generate hono file +// Generate hono file +function emitHonoModule( + project: Project, + outputDir: string, + allOperations: OperationMiddlewareInfo[], +) { const honoFile = createHonoFile(project, outputDir); // Collect all schema names needed @@ -1593,6 +1811,10 @@ export async function processOpenApiDocument( honoFile.fixUnusedIdentifiers(); + return honoFile; +} + +function trimDefaultOutputArguments(commandsFile: SourceFile) { // `Command` defaults its output to `unknown`, so an explicit `unknown` // repeats the default. A trailing argument can go, while an earlier one // holds the position of the arguments after it @@ -1605,14 +1827,54 @@ export async function processOpenApiDocument( base.removeTypeArgument(last); } } +} - return { - commandsFile, - commandsValidatedFile, - typesFile, - mainFile, - valibotFile, - honoFile, - enumsFile, +export async function processOpenApiDocument( + outputDir: string, + schema: Simplify, + tags?: string[], + options?: CodegenOptions, +) { + const project = new Project(); + const files = createOutputFiles(project, outputDir); + const refs = await $RefParser.resolve(schema); + const typesImportDecl = addModulePreambles(files); + + const documentCtx: DocumentContext = { + ...files, + refs, + typesImportDecl, + typesAndInterfaces: new Map(), + validators: new Map(), + allOperations: [], + outputTypes: new Set(), + inputTypeArgs: new Set(), + inputTypeNames: new Set(), + validatedSubclasses: [], + validatedReExports: [], + inputOnly: options?.inputOnly, }; + + registerComponentSchemas(documentCtx, schema); + emitOperations(documentCtx, schema, tags); + emitClientModule(documentCtx, schema); + emitValidatedModule(documentCtx); + + files.mainFile.organizeImports(); + + // tidies up any unused type-fest imports + files.typesFile.fixUnusedIdentifiers(); + files.commandsFile.fixUnusedIdentifiers(); + files.commandsValidatedFile.fixUnusedIdentifiers(); + files.valibotFile.fixUnusedIdentifiers(); + + const honoFile = emitHonoModule( + project, + outputDir, + documentCtx.allOperations, + ); + + trimDefaultOutputArguments(files.commandsFile); + + return { ...files, honoFile }; } diff --git a/lib/process-schema.ts b/lib/process-schema.ts index 0f9c248..62e738a 100644 --- a/lib/process-schema.ts +++ b/lib/process-schema.ts @@ -12,7 +12,6 @@ import { Writers, } from "ts-morph"; import { - iife, isNotNullOrUndefined, isNotReferenceObject, isReferenceObject, @@ -176,73 +175,85 @@ function maybeIntersection(...types: (string | WriterFunction)[]) { : Writers.intersectionType(first, second, ...rest); } -export function schemaToType( - typesAndInterfaces: Map< - string, - InterfaceDeclaration | TypeAliasDeclaration | EnumDeclaration - >, - parentSchema: oas31.SchemaObject | oas30.SchemaObject, - propertyName: string, - schemaObject: oas31.SchemaObject | oas30.SchemaObject | oas31.ReferenceObject, - options: { - booleanAsStringish?: boolean; - integerAsStringish?: boolean; - } = {}, -): OptionalKind { - const name = `"${propertyName}"`; - const hasQuestionToken = - parentSchema.type === "object" && - !parentSchema.required?.includes(propertyName); +type TypesAndInterfaces = Map< + string, + InterfaceDeclaration | TypeAliasDeclaration | EnumDeclaration +>; - if ("$ref" in schemaObject) { - const existingSchema = typesAndInterfaces.get(schemaObject.$ref); +type SchemaToTypeOptions = { + booleanAsStringish?: boolean; + integerAsStringish?: boolean; +}; - if (!existingSchema) { - console.warn("ref used before available: schema=%j", schemaObject); +// Propagates JSDoc from the referenced type to the property +function refPropertyDocs( + existingSchema: InterfaceDeclaration | TypeAliasDeclaration | EnumDeclaration, +) { + const refDocs = existingSchema.getJsDocs(); + const docs: OptionalKind[] = refDocs + .map((jsDoc) => { + const description = jsDoc.getDescription(); + const tags = jsDoc + .getTags() + .map((tag) => { + const text = tag.getCommentText(); + return text ? { tagName: tag.getTagName(), text } : undefined; + }) + .filter((tag): tag is { tagName: string; text: string } => !!tag); + + if (tags.length > 0) { + return description ? { description, tags } : { tags }; + } - return { - name, - hasQuestionToken, - type: "never", - docs: [ - { - description: `WARN: $ref used before available - schema=${JSON.stringify(schemaObject)}`, - }, - ], - }; - } + return description ? { description } : {}; + }) + // an empty JSDoc block maps to {} + .filter((doc) => Object.keys(doc).length > 0); - // Propagate JSDoc from the referenced type to the property - const refDocs = existingSchema.getJsDocs(); - const docs: OptionalKind[] = refDocs - .map((jsDoc) => { - const description = jsDoc.getDescription(); - const tags = jsDoc - .getTags() - .map((tag) => { - const text = tag.getCommentText(); - return text ? { tagName: tag.getTagName(), text } : undefined; - }) - .filter((tag): tag is { tagName: string; text: string } => !!tag); - - if (tags.length > 0) { - return description ? { description, tags } : { tags }; - } - - return description ? { description } : {}; - }) - // an empty JSDoc block maps to {} - .filter((doc) => Object.keys(doc).length > 0); + return docs; +} - return { - name, - hasQuestionToken, - type: existingSchema.getName(), - ...(docs.length > 0 && { docs }), +function refType( + typesAndInterfaces: TypesAndInterfaces, + schemaObject: oas31.ReferenceObject, +) { + const existingSchema = typesAndInterfaces.get(schemaObject.$ref); + + if (!existingSchema) { + console.warn("ref used before available: schema=%j", schemaObject); + + const property: Pick< + OptionalKind, + "type" | "docs" + > = { + type: "never", + docs: [ + { + description: `WARN: $ref used before available - schema=${JSON.stringify(schemaObject)}`, + }, + ], }; + + return property; } - const jsdocTags = [ + const docs = refPropertyDocs(existingSchema); + + const property: Pick< + OptionalKind, + "type" | "docs" + > = { + type: existingSchema.getName(), + ...(docs.length > 0 && { docs }), + }; + + return property; +} + +function schemaJsDocTags( + schemaObject: oas31.SchemaObject | oas30.SchemaObject, +) { + return [ ...(schemaObject.default ? [{ tagName: "default", text: String(schemaObject.default) }] : []), @@ -279,6 +290,10 @@ export function schemaToType( : []), ...(schemaObject.deprecated ? [{ tagName: "deprecated" }] : []), ]; +} + +function schemaDocs(schemaObject: oas31.SchemaObject | oas30.SchemaObject) { + const jsdocTags = schemaJsDocTags(schemaObject); const maybeJsDoc = { ...(schemaObject.description && { @@ -290,332 +305,314 @@ export function schemaToType( const docs: (OptionalKind | string)[] = Object.keys(maybeJsDoc).length > 1 ? [maybeJsDoc] : []; - if (Array.isArray(schemaObject.type)) { - // - if (schemaObject.type.length === 1) { - return { - name, - hasQuestionToken, - type: maybeWithNullUnion( - schemaObject.type[0] || "unknown", // weird edge case - schemaTypeIsNull(schemaObject), - ), - docs, - }; - } + return docs; +} + +function typeArrayType( + typesAndInterfaces: TypesAndInterfaces, + schemaObject: oas31.SchemaObject | oas30.SchemaObject, + propertyName: string, + types: (oas31.SchemaObjectType | oas30.SchemaObjectType)[], + options: SchemaToTypeOptions, +) { + if (types.length === 1) { + return maybeWithNullUnion( + types[0] || "unknown", // weird edge case + schemaTypeIsNull(schemaObject), + ); + } + + return maybeUnion( + ...types.map((type) => { + const schema = + type === "array" + ? ({ + items: {}, + ...schemaObject, + type: "array", + } satisfies typeof schemaObject) + : ({ + ...schemaObject, + type, + } satisfies typeof schemaObject); + + return ( + schemaToType( + typesAndInterfaces, + schemaObject, + `"${propertyName}"`, + schema, + options, + ).type || "never" + ); + }), + ); +} + +function arrayType( + typesAndInterfaces: TypesAndInterfaces, + propertyName: string, + schemaObject: oas31.SchemaObject | oas30.SchemaObject, + options: SchemaToTypeOptions, +) { + const type = schemaToType( + typesAndInterfaces, + schemaObject, + propertyName, + schemaObject.items || {}, + options, + ); + + if (typeof type.type === "function") { + const typeWriter = type.type; return { - name, - hasQuestionToken, - type: maybeUnion( - ...schemaObject.type.map((type) => { - const schema = - type === "array" - ? ({ - items: {}, - ...schemaObject, - type: "array", - } satisfies typeof schemaObject) - : ({ - ...schemaObject, - type, - } satisfies typeof schemaObject); - - return ( - schemaToType( - typesAndInterfaces, - schemaObject, - name, - schema, - options, - ).type || "never" - ); - }), + type: (writer: CodeBlockWriter) => { + writer.write("readonly "); + writer.write("("); + typeWriter(writer); + writer.write(")[]"); + }, + isReadonly: !!type.isReadonly, + }; + } + + return { + type: `readonly (${type.type})[]`, + isReadonly: !!type.isReadonly, + }; +} + +function combinatorType( + typesAndInterfaces: TypesAndInterfaces, + parentSchema: oas31.SchemaObject | oas30.SchemaObject, + propertyName: string, + schemaObject: oas31.SchemaObject | oas30.SchemaObject, + options: SchemaToTypeOptions, +) { + const schemaItems = + schemaObject.allOf || schemaObject.oneOf || schemaObject.anyOf || []; + + if (!("allOf" in schemaObject)) { + const literalUnion = literalUnionType(schemaItems); + + if (literalUnion !== undefined) { + return maybeWithNullUnion(literalUnion, schemaTypeIsNull(schemaObject)); + } + } + + const types = schemaItems + .map((schema) => + schemaToType( + typesAndInterfaces, + parentSchema, + propertyName, + schema, + options, ), - docs, + ) + .map((t) => t.type); + + const [onlyType] = types; + + // only one type, so just return that type + if (types.length === 1 && onlyType !== undefined) { + return onlyType; + } + + const intersect = "allOf" in schemaObject; + + const filteredTypes = types.filter((value) => isNotNullOrUndefined(value)); + const hasNullType = types.some((t) => t === "null"); + const isNullable = schemaTypeIsNull(schemaObject); + + if (intersect) { + // For allOf, intersect the non-null types and add null when nullable + const nonNullTypes = filteredTypes.filter((t) => t !== "null"); + const intersectionType = maybeIntersection(...nonNullTypes); + + return isNullable ? maybeUnion(intersectionType, "null") : intersectionType; + } + + // For oneOf and anyOf, union every type, adding null when nullable + return hasNullType || isNullable + ? maybeUnion(...filteredTypes.filter((t) => t !== "null"), "null") + : maybeUnion(...filteredTypes); +} + +// Keys unique to objects stand in for the `type` a document often omits +function isObjectSchema(schemaObject: oas31.SchemaObject | oas30.SchemaObject) { + return ( + schemaObject.type === "object" || + (schemaObject.type === undefined && + (schemaObject.properties !== undefined || + schemaObject.additionalProperties !== undefined)) + ); +} + +function objectType( + typesAndInterfaces: TypesAndInterfaces, + propertyName: string, + schemaObject: oas31.SchemaObject | oas30.SchemaObject, + options: SchemaToTypeOptions, +) { + // type=object and enum null is common openapi workaround + // we convert it to null type + if (schemaObject.enum?.every((e) => e === null)) { + return { + type: "null", + isReadonly: !!schemaObject.readOnly, }; } - if ("const" in schemaObject) { + if ( + schemaObject.properties && + Object.keys(schemaObject.properties).length > 0 + ) { return { - name, - hasQuestionToken, - type: Array.isArray(schemaObject.const) - ? maybeUnion(...schemaObject.const) - : JSON.stringify(schemaObject.const), + type: Writers.objectType({ + properties: Object.entries(schemaObject.properties).map( + ([key, schema]) => { + const type = schemaToType( + typesAndInterfaces, + schemaObject, + key, + schema, + options, + ); - docs, + return type; + }, + ), + }), }; } - if (schemaObject.type === "array") { - const type = schemaToType( + if ( + typeof schemaObject.additionalProperties === "object" && + schemaObject.additionalProperties !== null + ) { + // A record value is always present, so it stays required and the + // parent contributes an empty set of keys + const value = schemaToType( typesAndInterfaces, - schemaObject, + {}, propertyName, - schemaObject.items || {}, + schemaObject.additionalProperties, options, ); - if (typeof type.type === "function") { - const typeWriter = type.type; - - return { - name, - hasQuestionToken, - type: (writer: CodeBlockWriter) => { - writer.write("readonly "); - writer.write("("); - typeWriter(writer); - writer.write(")[]"); - }, - isReadonly: !!type.isReadonly, - docs, - }; - } - return { - name, - hasQuestionToken, - type: `readonly (${type.type})[]`, - isReadonly: !!type.isReadonly, - docs, + type: recordType(value.type ?? "Jsonifiable"), + isReadonly: !!schemaObject.readOnly, }; } + return { + type: "Record", + }; +} + +function stringType(schemaObject: oas31.SchemaObject | oas30.SchemaObject) { + if ("enum" in schemaObject) { + return maybeUnion(...schemaObject.enum.map((e) => JSON.stringify(e))); + } + if ( - "allOf" in schemaObject || - "oneOf" in schemaObject || - "anyOf" in schemaObject + "x-typescript-hint" in schemaObject && + typeof schemaObject["x-typescript-hint"] === "string" ) { - const schemaItems = - schemaObject.allOf || schemaObject.oneOf || schemaObject.anyOf || []; - - if (!("allOf" in schemaObject)) { - const literalUnion = literalUnionType(schemaItems); - - if (literalUnion !== undefined) { - return { - name, - hasQuestionToken, - type: maybeWithNullUnion( - literalUnion, - schemaTypeIsNull(schemaObject), - ), - docs, - }; - } - } + return schemaObject["x-typescript-hint"]; + } - const types = schemaItems - .map((schema) => - schemaToType( - typesAndInterfaces, - parentSchema, - propertyName, - schema, - options, - ), - ) - .map((t) => t.type); - - const [onlyType] = types; - - // only one type, so just return that type - if (types.length === 1 && onlyType !== undefined) { - return { - name, - hasQuestionToken, - type: onlyType, - docs, - }; - } + const temporal = temporalStringType(schemaObject.format); - const intersect = "allOf" in schemaObject; - - const filteredTypes = types.filter((value) => isNotNullOrUndefined(value)); - const hasNullType = types.some((t) => t === "null"); - const isNullable = schemaTypeIsNull(schemaObject); - - if (intersect) { - // For allOf, intersect the non-null types and add null when nullable - const nonNullTypes = filteredTypes.filter((t) => t !== "null"); - const intersectionType = maybeIntersection(...nonNullTypes); - - return { - name, - hasQuestionToken, - type: isNullable - ? maybeUnion(intersectionType, "null") - : intersectionType, - docs, - }; - } + if (temporal) { + return maybeWithNullUnion(temporal, schemaTypeIsNull(schemaObject)); + } + + return "string"; +} - // For oneOf and anyOf, union every type, adding null when nullable +function schemaObjectType( + typesAndInterfaces: TypesAndInterfaces, + parentSchema: oas31.SchemaObject | oas30.SchemaObject, + propertyName: string, + schemaObject: oas31.SchemaObject | oas30.SchemaObject, + options: SchemaToTypeOptions, +) { + if (Array.isArray(schemaObject.type)) { return { - name, - hasQuestionToken, - type: - hasNullType || isNullable - ? maybeUnion(...filteredTypes.filter((t) => t !== "null"), "null") - : maybeUnion(...filteredTypes), - docs, + type: typeArrayType( + typesAndInterfaces, + schemaObject, + propertyName, + schemaObject.type, + options, + ), }; } - // A document often omits `type` from a schema that plainly describes an - // object, so keys unique to objects stand in for it - const describesObject = - schemaObject.type === "object" || - (schemaObject.type === undefined && - (schemaObject.properties !== undefined || - schemaObject.additionalProperties !== undefined)); - - if (describesObject) { - // type=object and enum null is common openapi workaround - // we convert it to null type - if (schemaObject.enum?.every((e) => e === null)) { - return { - name, - hasQuestionToken, - type: "null", - isReadonly: !!schemaObject.readOnly, - docs, - }; - } + if ("const" in schemaObject) { + return { + type: Array.isArray(schemaObject.const) + ? maybeUnion(...schemaObject.const) + : JSON.stringify(schemaObject.const), + }; + } - if ( - schemaObject.properties && - Object.keys(schemaObject.properties).length > 0 - ) { - return { - name, - hasQuestionToken, - type: Writers.objectType({ - properties: Object.entries(schemaObject.properties).map( - ([key, schema]) => { - const type = schemaToType( - typesAndInterfaces, - schemaObject, - key, - schema, - options, - ); - - return type; - }, - ), - }), - docs, - }; - } + if (schemaObject.type === "array") { + return arrayType(typesAndInterfaces, propertyName, schemaObject, options); + } - if ( - typeof schemaObject.additionalProperties === "object" && - schemaObject.additionalProperties !== null - ) { - // A record value is always present, so it stays required and the - // parent contributes an empty set of keys - const value = schemaToType( + if ( + "allOf" in schemaObject || + "oneOf" in schemaObject || + "anyOf" in schemaObject + ) { + return { + type: combinatorType( typesAndInterfaces, - {}, + parentSchema, propertyName, - schemaObject.additionalProperties, + schemaObject, options, - ); - - return { - name, - hasQuestionToken, - type: recordType(value.type ?? "Jsonifiable"), - isReadonly: !!schemaObject.readOnly, - docs, - }; - } - - return { - name, - hasQuestionToken, - type: "Record", - docs, + ), }; } + if (isObjectSchema(schemaObject)) { + return objectType(typesAndInterfaces, propertyName, schemaObject, options); + } + if (schemaObject.type === "integer" || schemaObject.type === "number") { return { - name, - hasQuestionToken, type: maybeWithNullUnion( numericType(isInt64Schema(schemaObject), options.integerAsStringish), schemaTypeIsNull(schemaObject), ), - docs, }; } if (schemaObject.type === "boolean") { return { - name, - hasQuestionToken, type: maybeWithNullUnion( options.booleanAsStringish ? Writers.unionType('"true"', '"false"') : "boolean", schemaTypeIsNull(schemaObject), ), - docs, }; } if (schemaObject.type === "string") { - if ("enum" in schemaObject) { - return { - name, - hasQuestionToken, - type: maybeUnion(...schemaObject.enum.map((e) => JSON.stringify(e))), - docs, - }; - } - - if ( - "x-typescript-hint" in schemaObject && - typeof schemaObject["x-typescript-hint"] === "string" - ) { - return { - name, - hasQuestionToken, - type: schemaObject["x-typescript-hint"], - docs, - }; - } - - const temporal = temporalStringType(schemaObject.format); - - if (temporal) { - return { - name, - hasQuestionToken, - type: maybeWithNullUnion(temporal, schemaTypeIsNull(schemaObject)), - docs, - }; - } - - return { - name, - hasQuestionToken, - type: "string", - docs, - }; + return { type: stringType(schemaObject) }; } // empty schemaObject if (Object.keys(schemaObject).length === 0) { return { - name, - hasQuestionToken, type: maybeWithNullUnion("Jsonifiable", schemaTypeIsNull(schemaObject)), - docs, isReadonly: !!schemaObject.readOnly, }; } @@ -626,12 +623,7 @@ export function schemaToType( ("nullable" in schemaObject && schemaObject.nullable) || ("enum" in schemaObject && schemaObject.enum?.every((e) => e === "null")) ) { - return { - name, - hasQuestionToken, - type: "null", - docs, - }; + return { type: "null" }; } console.warn( @@ -641,12 +633,172 @@ export function schemaToType( // parentSchema ); - return { + return { type: "unknown" }; +} + +export function schemaToType( + typesAndInterfaces: Map< + string, + InterfaceDeclaration | TypeAliasDeclaration | EnumDeclaration + >, + parentSchema: oas31.SchemaObject | oas30.SchemaObject, + propertyName: string, + schemaObject: oas31.SchemaObject | oas30.SchemaObject | oas31.ReferenceObject, + options: { + booleanAsStringish?: boolean; + integerAsStringish?: boolean; + } = {}, +): OptionalKind { + const name = `"${propertyName}"`; + const hasQuestionToken = + parentSchema.type === "object" && + !parentSchema.required?.includes(propertyName); + + if (isReferenceObject(schemaObject)) { + const property: OptionalKind = { + name, + hasQuestionToken, + ...refType(typesAndInterfaces, schemaObject), + }; + + return property; + } + + const docs = schemaDocs(schemaObject); + + const property: OptionalKind = { name, hasQuestionToken, - type: "unknown", + ...schemaObjectType( + typesAndInterfaces, + parentSchema, + propertyName, + schemaObject, + options, + ), docs, }; + + return property; +} + +function resolveRef(typesAndInterfaces: TypesAndInterfaces, ref: string) { + const declaration = typesAndInterfaces.get(ref); + + if (!declaration) { + throw new Error(`ref used before available: ${ref}`); + } + + return declaration; +} + +function registerAlias( + typesAndInterfaces: TypesAndInterfaces, + typesFile: SourceFile, + schemaName: string, + type: string | WriterFunction, + description?: string, +) { + const typeAlias = typesFile.addTypeAlias({ + name: pascalCase(schemaName), + isExported: true, + type, + }); + + if (description) { + typeAlias.addJsDoc({ + description: wordWrap(description), + }); + } + + typesAndInterfaces.set(`#/components/schemas/${schemaName}`, typeAlias); +} + +function combinatorAliasType( + typesAndInterfaces: TypesAndInterfaces, + schemaName: string, + schemaObject: oas30.SchemaObject | oas31.SchemaObject, +) { + const schemaItems = + schemaObject.allOf || schemaObject.oneOf || schemaObject.anyOf || []; + + const intersect = "allOf" in schemaObject; + + const typeAliases = schemaItems + .filter((value) => isReferenceObject(value)) + .map((s) => resolveRef(typesAndInterfaces, s.$ref)); + + const objectTypesFromNonRefSchemas = schemaItems + .filter((value) => isNotReferenceObject(value)) + .filter((schema) => schema.type === "object") + .map((subSchemaObject) => + Writers.objectType({ + properties: Object.entries(subSchemaObject.properties || {}).map( + ([propertyName, propertySchema]) => + schemaToType( + typesAndInterfaces, + subSchemaObject, + propertyName, + propertySchema, + ), + ), + }), + ) + .filter((value) => isNotNullOrUndefined(value)); + + const nonObjectTypesFromNonRefSchemas = schemaItems + .filter((value) => isNotReferenceObject(value)) + .filter((schema) => schema.type !== "object") + .map((subSchemaObject) => + schemaToType( + typesAndInterfaces, + {}, // no parent schema + schemaName, + subSchemaObject, + ), + ) + .filter((value) => isNotNullOrUndefined(value)); + + // concat and dedupe + const typeArgs = [ + ...new Set([ + ...typeAliases.map((t) => t.getName()), + ...objectTypesFromNonRefSchemas, + ...nonObjectTypesFromNonRefSchemas + .map((t) => + // a writer's text is unavailable here, so wrapping applies to strings + t.isReadonly && typeof t.type === "string" + ? `Readonly<${t.type}>` + : t.type, + ) + .filter((value) => isNotNullOrUndefined(value)), + ]), + ]; + + return intersect ? maybeIntersection(...typeArgs) : maybeUnion(...typeArgs); +} + +function stringAliasType( + schemaObject: oas30.SchemaObject | oas31.SchemaObject, +) { + // custom extension + if ( + "x-typescript-hint" in schemaObject && + typeof schemaObject["x-typescript-hint"] === "string" + ) { + return maybeWithNullUnion( + schemaObject["x-typescript-hint"], + schemaTypeIsNull(schemaObject), + ); + } + + // RFC 3339 temporal formats (date, date-time, time, duration) + const temporal = temporalStringType(schemaObject.format); + + return maybeWithNullUnion( + temporal || "string", + schemaTypeIsNull(schemaObject), + ); } export function registerTypesFromSchema( @@ -662,21 +814,12 @@ export function registerTypesFromSchema( | oas31.SchemaObject | oas31.ReferenceObject, ) { + const register = (type: string | WriterFunction, description?: string) => + registerAlias(typesAndInterfaces, typesFile, schemaName, type, description); + // deal with refs if ("$ref" in schemaObject) { - const iface = typesAndInterfaces.get(schemaObject.$ref); - - if (!iface) { - throw new Error(`ref used before available: ${schemaObject.$ref}`); - } - - const typeAlias = typesFile.addTypeAlias({ - name: pascalCase(schemaName), - isExported: true, - type: iface.getName(), - }); - - typesAndInterfaces.set(`#/components/schemas/${schemaName}`, typeAlias); + register(resolveRef(typesAndInterfaces, schemaObject.$ref).getName()); } // deal with unions and intersections @@ -685,144 +828,36 @@ export function registerTypesFromSchema( "oneOf" in schemaObject || "anyOf" in schemaObject ) { - const schemaItems = - schemaObject.allOf || schemaObject.oneOf || schemaObject.anyOf || []; - - const intersect = "allOf" in schemaObject; - - const typeAliases = schemaItems - .filter((value) => isReferenceObject(value)) - .map((s) => { - const alias = typesAndInterfaces.get(s.$ref); - if (!alias) { - throw new Error(`ref used before available: ${s.$ref}`); - } - return alias; - }); - - const objectTypesFromNonRefSchemas = schemaItems - .filter((value) => isNotReferenceObject(value)) - .filter((schema) => schema.type === "object") - .map((subSchemaObject) => - Writers.objectType({ - properties: Object.entries(subSchemaObject.properties || {}).map( - ([propertyName, propertySchema]) => - schemaToType( - typesAndInterfaces, - subSchemaObject, - propertyName, - propertySchema, - ), - ), - }), - ) - .filter((value) => isNotNullOrUndefined(value)); - - const nonObjectTypesFromNonRefSchemas = schemaItems - .filter((value) => isNotReferenceObject(value)) - .filter((schema) => schema.type !== "object") - .map((subSchemaObject) => - schemaToType( - typesAndInterfaces, - {}, // no parent schema - schemaName, - subSchemaObject, - ), - ) - .filter((value) => isNotNullOrUndefined(value)); - - // concat and dedupe - const typeArgs = [ - ...new Set([ - ...typeAliases.map((t) => t.getName()), - ...objectTypesFromNonRefSchemas, - ...nonObjectTypesFromNonRefSchemas - .map((t) => - // a writer's text is unavailable here, so wrapping applies to strings - t.isReadonly && typeof t.type === "string" - ? `Readonly<${t.type}>` - : t.type, - ) - .filter((value) => isNotNullOrUndefined(value)), - ]), - ]; - - const typeAlias = typesFile.addTypeAlias({ - name: pascalCase(schemaName), - isExported: true, - type: intersect - ? maybeIntersection(...typeArgs) - : maybeUnion(...typeArgs), - }); - - if (schemaObject.description) { - typeAlias.addJsDoc({ - description: wordWrap(schemaObject.description), - }); - } - - typesAndInterfaces.set(`#/components/schemas/${schemaName}`, typeAlias); + register( + combinatorAliasType(typesAndInterfaces, schemaName, schemaObject), + schemaObject.description, + ); } // deal with type arrays, added in OpenAPI 3.1 else if (Array.isArray(schemaObject.type)) { - const prop = schemaToType(typesAndInterfaces, {}, schemaName, schemaObject); - - const typeAlias = typesFile.addTypeAlias({ - name: pascalCase(schemaName), - isExported: true, - type: prop.type || "unknown", - }); - - if (schemaObject.description) { - typeAlias.addJsDoc({ - description: wordWrap(schemaObject.description), - }); - } - - typesAndInterfaces.set(`#/components/schemas/${schemaName}`, typeAlias); + register( + schemaToType(typesAndInterfaces, {}, schemaName, schemaObject).type || + "unknown", + schemaObject.description, + ); } // deal with const values else if ("const" in schemaObject) { - const constDeclaration = typesFile.addTypeAlias({ - isExported: true, - name: pascalCase(schemaName), - type: JSON.stringify(schemaObject.const), - }); - - if (schemaObject.description) { - constDeclaration.addJsDoc({ - description: wordWrap(schemaObject.description), - }); - } - - typesAndInterfaces.set( - `#/components/schemas/${schemaName}`, - constDeclaration, - ); + register(JSON.stringify(schemaObject.const), schemaObject.description); } // deal with objects else if (!schemaObject.type || schemaObject.type === "object") { - const newIf = typesFile.addTypeAlias({ - name: pascalCase(schemaName), - isExported: true, - // Reuses the walk an inline schema takes, so a named schema and an - // inline one of the same shape agree. It also spells the value type - // in TypeScript, since JSON Schema names such as `integer` differ - type: - schemaToType(typesAndInterfaces, {}, schemaName, schemaObject).type ?? + // Reuses the walk an inline schema takes, so a named schema and an + // inline one of the same shape agree. It also spells the value type + // in TypeScript, since JSON Schema names such as `integer` differ + register( + schemaToType(typesAndInterfaces, {}, schemaName, schemaObject).type ?? "Record", - }); - - if (schemaObject.description) { - newIf.addJsDoc({ - description: wordWrap(schemaObject.description), - }); - } - - typesAndInterfaces.set(`#/components/schemas/${schemaName}`, newIf); + schemaObject.description, + ); } // deal with enums @@ -847,82 +882,26 @@ export function registerTypesFromSchema( // deal with non-enum strings else if (schemaObject.type === "string" && !schemaObject.enum) { - const typeAlias = typesFile.addTypeAlias({ - name: pascalCase(schemaName), - isExported: true, - // default - type: maybeWithNullUnion("string", schemaTypeIsNull(schemaObject)), - - // RFC 3339 temporal formats (date, date-time, time, duration) - ...iife(() => { - const temporal = temporalStringType(schemaObject.format); - return temporal - ? { - type: maybeWithNullUnion( - temporal, - schemaTypeIsNull(schemaObject), - ), - } - : {}; - }), - - // custom extension - ...("x-typescript-hint" in schemaObject && - typeof schemaObject["x-typescript-hint"] === "string" && { - type: maybeWithNullUnion( - schemaObject["x-typescript-hint"], - schemaTypeIsNull(schemaObject), - ), - }), - }); - - if (schemaObject.description) { - typeAlias.addJsDoc({ - description: wordWrap(schemaObject.description), - }); - } - - typesAndInterfaces.set(`#/components/schemas/${schemaName}`, typeAlias); + register(stringAliasType(schemaObject), schemaObject.description); } // deal with numberish things else if (schemaObject.type === "number" || schemaObject.type === "integer") { - const typeAlias = typesFile.addTypeAlias({ - name: pascalCase(schemaName), - isExported: true, - type: maybeWithNullUnion( + register( + maybeWithNullUnion( numericType(isInt64Schema(schemaObject), false), schemaTypeIsNull(schemaObject), ), - }); - - if (schemaObject.description) { - typeAlias.addJsDoc({ - description: wordWrap(schemaObject.description), - }); - } - - typesAndInterfaces.set(`#/components/schemas/${schemaName}`, typeAlias); + schemaObject.description, + ); } // deal with boolean things else if (schemaObject.type === "boolean") { - const typeAlias = typesFile.addTypeAlias({ - name: pascalCase(schemaName), - isExported: true, - type: maybeWithNullUnion( - schemaObject.type, - schemaTypeIsNull(schemaObject), - ), - }); - - if (schemaObject.description) { - typeAlias.addJsDoc({ - description: wordWrap(schemaObject.description), - }); - } - - typesAndInterfaces.set(`#/components/schemas/${schemaName}`, typeAlias); + register( + maybeWithNullUnion(schemaObject.type, schemaTypeIsNull(schemaObject)), + schemaObject.description, + ); } // deal with arrays of refs @@ -931,25 +910,10 @@ export function registerTypesFromSchema( schemaObject.items && "$ref" in schemaObject.items ) { - const iface = typesAndInterfaces.get(schemaObject.items.$ref); - - if (!iface) { - throw new Error(`ref used before available: ${schemaObject.items.$ref}`); - } - - const typeAlias = typesFile.addTypeAlias({ - name: pascalCase(schemaName), - isExported: true, - type: `${iface.getName()}[]`, - }); - - if (schemaObject.description) { - typeAlias.addJsDoc({ - description: wordWrap(schemaObject.description), - }); - } - - typesAndInterfaces.set(`#/components/schemas/${schemaName}`, typeAlias); + register( + `${resolveRef(typesAndInterfaces, schemaObject.items.$ref).getName()}[]`, + schemaObject.description, + ); } else { console.warn( `unsupported ${schemaObject.type} schema object: %j`, diff --git a/lib/valibot.ts b/lib/valibot.ts index a88aba0..d3de707 100644 --- a/lib/valibot.ts +++ b/lib/valibot.ts @@ -300,202 +300,427 @@ function writeStrictObjectEntries( }); } -function schemaToValidator( - validators: Map, - schema: oas30.SchemaObject | oas31.SchemaObject | oas31.ReferenceObject, - mode: SchemaMode, -): WriterFunction | string { - if ("$ref" in schema) { - return resolveRef(validators, schema.$ref, mode); - } - - const isNullable = schemaIsNullable(schema); +type AnySchema = oas30.SchemaObject | oas31.SchemaObject; +// Narrows the inferred output to the `x-typescript-hint` type +function extensionHintSchema(schema: AnySchema) { const typescriptHint = "x-typescript-hint" in schema && typeof schema["x-typescript-hint"] === "string" ? schema["x-typescript-hint"] : undefined; - const typescriptHintSchema = typescriptHint - ? `v.custom<${typescriptHint}>(() => true)` - : undefined; - - // Handle const values, added in OpenAPI 3.1 - if ("const" in schema) { - return schema.const === null - ? vcall("null") - : maybeNullable( - vcall("literal", JSON.stringify(schema.const)), - isNullable, - ); - } + return typescriptHint ? `v.custom<${typescriptHint}>(() => true)` : undefined; +} +function enumValidator(values: unknown[], isNullable: boolean) { // Enums short-circuit every type-specific constraint. Valid values are // exactly the enum members, under any declared type, format or minLength. // Layering string() or minLength() on top yields a misleading error about // the wrong contract, so emit a bare picklist - if (schema.enum) { - const hasNull = schema.enum.some((value) => value === null); - const members = schema.enum.filter((value) => value !== null); - const [first, ...rest] = members; - - if (first === undefined) { - return vcall("null"); - } + const hasNull = values.some((value) => value === null); + const members = values.filter((value) => value !== null); + const [first, ...rest] = members; - // `picklist` only accepts string | number | bigint members - const picklistable = members.every( - (value) => typeof value === "string" || typeof value === "number", - ); + if (first === undefined) { + return vcall("null"); + } - if (picklistable) { - return maybeNullable( - vcall( - "picklist", - members.map((value) => JSON.stringify(value)), - ), - isNullable || hasNull, - ); - } + // `picklist` only accepts string | number | bigint members + const picklistable = members.every( + (value) => typeof value === "string" || typeof value === "number", + ); - // Boolean and other literals use `literal()` instead, or a `union` of - // them when there is more than one - const base = - rest.length === 0 - ? vcall("literal", JSON.stringify(first)) - : vcall( - "union", - members.map((value) => vcall("literal", JSON.stringify(value))), - ); - - return maybeNullable(base, isNullable || hasNull); + if (picklistable) { + return maybeNullable( + vcall( + "picklist", + members.map((value) => JSON.stringify(value)), + ), + isNullable || hasNull, + ); } - // Handle type arrays, added in OpenAPI 3.1 - if (Array.isArray(schema.type)) { - const nonNullTypes = schema.type.filter((t) => t !== "null"); - const [singleType] = nonNullTypes; + // Boolean and other literals use `literal()` instead, or a `union` of + // them when there is more than one + const base = + rest.length === 0 + ? vcall("literal", JSON.stringify(first)) + : vcall( + "union", + members.map((value) => vcall("literal", JSON.stringify(value))), + ); - if (nonNullTypes.length === 1 && singleType) { - return maybeNullable( - schemaToValidator( - validators, - { - ...schema, - type: singleType, - } satisfies typeof schema, - mode, - ), - isNullable, - ); - } + return maybeNullable(base, isNullable || hasNull); +} + +// Handle type arrays, added in OpenAPI 3.1 +function typeArrayValidator( + validators: Map, + schema: AnySchema, + types: oas31.SchemaObjectType[], + mode: SchemaMode, + isNullable: boolean, +) { + const nonNullTypes = types.filter((t) => t !== "null"); + const [singleType] = nonNullTypes; - const variants = nonNullTypes.map((t) => + if (nonNullTypes.length === 1 && singleType) { + return maybeNullable( schemaToValidator( validators, { ...schema, - type: t, + type: singleType, } satisfies typeof schema, mode, ), - ); - - return maybeNullable( - variants.length > 0 ? vcall("union", variants) : vcall("unknown"), isNullable, ); } - if (schema.type === "string") { - const shouldTrim = - mode === "wire" && - !schema.pattern && - (!schema.format || !noTrimFormats.has(schema.format)); + const variants = nonNullTypes.map((t) => + schemaToValidator( + validators, + { + ...schema, + type: t, + } satisfies typeof schema, + mode, + ), + ); - return maybeNullable( - maybePipe( - vcall("string"), - shouldTrim ? vcall("trim") : undefined, - schema.format === "email" ? vcall("email") : undefined, - schema.format === "uuid" ? vcall("uuid") : undefined, - temporalRegexConstraint(schema.format), + return maybeNullable( + variants.length > 0 ? vcall("union", variants) : vcall("unknown"), + isNullable, + ); +} - schema.minLength === undefined - ? undefined - : vcall("minLength", numericLiteral(schema.minLength)), - schema.maxLength === undefined - ? undefined - : vcall("maxLength", numericLiteral(schema.maxLength)), - schema.pattern - ? vcall("regex", `new RegExp(${regexSource(schema.pattern)})`) - : undefined, - // A hint set by the `x-typescript-hint` extension wins over the format - typescriptHint ? undefined : temporalHintSchema(schema.format), - typescriptHintSchema, - ), - isNullable, - ); +function stringValidator( + schema: AnySchema, + mode: SchemaMode, + isNullable: boolean, + typescriptHintSchema: string | undefined, +) { + const shouldTrim = + mode === "wire" && + !schema.pattern && + (!schema.format || !noTrimFormats.has(schema.format)); + + return maybeNullable( + maybePipe( + vcall("string"), + shouldTrim ? vcall("trim") : undefined, + schema.format === "email" ? vcall("email") : undefined, + schema.format === "uuid" ? vcall("uuid") : undefined, + temporalRegexConstraint(schema.format), + + schema.minLength === undefined + ? undefined + : vcall("minLength", numericLiteral(schema.minLength)), + schema.maxLength === undefined + ? undefined + : vcall("maxLength", numericLiteral(schema.maxLength)), + schema.pattern + ? vcall("regex", `new RegExp(${regexSource(schema.pattern)})`) + : undefined, + // A hint set by the `x-typescript-hint` extension wins over the format + typescriptHintSchema ? undefined : temporalHintSchema(schema.format), + typescriptHintSchema, + ), + isNullable, + ); +} + +function int64Validator( + schema: AnySchema, + mode: SchemaMode, + isNullable: boolean, + typescriptHintSchema: string | undefined, +) { + const baseValidator = maybePipe( + vcall("bigint"), + schema.minimum === undefined + ? undefined + : vcall("minValue", bigintLiteral(schema.minimum)), + schema.maximum === undefined + ? undefined + : vcall("maxValue", bigintLiteral(schema.maximum)), + typescriptHintSchema, + ); + + if (mode === "input") { + return maybeNullable(baseValidator, isNullable); } - if (schema.type === "integer" && schema.format === "int64") { - const baseValidator = maybePipe( - vcall("bigint"), + return maybeNullable( + vcall("union", [ + vcall( + "pipe", + vcall("string"), + vcall("decimal"), + vcall("toBigint"), + baseValidator, + ), + vcall( + "pipe", + vcall("number"), + vcall("integer"), + vcall("toBigint"), + baseValidator, + ), + baseValidator, + ]), + isNullable, + ); +} + +function numberValidator( + schema: AnySchema, + isNullable: boolean, + typescriptHintSchema: string | undefined, +) { + const isInteger = schema.type === "integer"; + + return maybeNullable( + maybePipe( + vcall("number"), + isInteger ? vcall("integer") : undefined, schema.minimum === undefined ? undefined - : vcall("minValue", bigintLiteral(schema.minimum)), + : vcall("minValue", numericLiteral(schema.minimum)), schema.maximum === undefined ? undefined - : vcall("maxValue", bigintLiteral(schema.maximum)), + : vcall("maxValue", numericLiteral(schema.maximum)), typescriptHintSchema, + ), + isNullable, + ); +} + +function writeSpreadEntries( + writer: CodeBlockWriter, + validator: WriterFunction | string, +) { + writer.write("..."); + + if (typeof validator === "function") { + validator(writer); + } else { + writer.write(validator); + } + + writer.writeLine(".entries,"); +} + +function writeAllOfMember( + writer: CodeBlockWriter, + validators: Map, + member: AnySchemaOrRef, + mode: SchemaMode, +) { + if ("$ref" in member) { + writeSpreadEntries(writer, resolveRef(validators, member.$ref, mode)); + + return; + } + + const isObjectShape = + member.type === "object" || + (member.properties !== undefined && member.type === undefined); + + if (isObjectShape) { + writeStrictObjectEntries( + writer, + validators, + member.properties ?? {}, + new Set(member.required), + mode, ); - if (mode === "input") { - return maybeNullable(baseValidator, isNullable); - } + return; + } + // Nested combinators and unusual shapes recurse, spreading the + // result's entries. Valid as long as the recursion yields an + // object-like schema, and GIGO otherwise + writeSpreadEntries(writer, schemaToValidator(validators, member, mode)); +} + +function allOfObjectValidator( + validators: Map, + schema: AnySchema, + allOfMembers: AnySchemaOrRef[], + mode: SchemaMode, + isNullable: boolean, +) { + // allOf of object schemas composes into a single v.strictObject. Inline + // object members contribute their properties directly, and $ref members + // are spread via `.entries`. v.intersect of strictObjects is + // unsatisfiable when member property sets differ, because each + // strictObject independently rejects keys the others contribute + return maybeNullable( + maybePipe( + // only a document that says additionalProperties false gets a + // strict object + vcall( + schema.additionalProperties === false ? "strictObject" : "looseObject", + (writer: CodeBlockWriter) => { + writer.writeLine("{"); + writer.indent(() => { + allOfMembers.forEach((member) => { + writeAllOfMember(writer, validators, member, mode); + }); + }); + writer.write("}"); + }, + ), + ...minMaxProperties(schema), + ), + isNullable, + ); +} + +function combinatorValidator( + validators: Map, + schema: AnySchema, + combinator: AnySchemaOrRef[], + mode: SchemaMode, + isNullable: boolean, +) { + const allOfMembers = schema.allOf; + + if (allOfMembers) { + return allOfObjectValidator( + validators, + schema, + allOfMembers, + mode, + isNullable, + ); + } + + const variants = combinator.map((s) => + schemaToValidator(validators, s, mode), + ); + + if (schema.oneOf && schema.discriminator?.propertyName) { return maybeNullable( - vcall("union", [ - vcall( - "pipe", - vcall("string"), - vcall("decimal"), - vcall("toBigint"), - baseValidator, - ), - vcall( - "pipe", - vcall("number"), - vcall("integer"), - vcall("toBigint"), - baseValidator, - ), - baseValidator, - ]), + vcall( + "variant", + JSON.stringify(schema.discriminator.propertyName), + variants, + ), isNullable, ); } - if (schema.type === "number" || schema.type === "integer") { - const isInteger = schema.type === "integer"; + return maybeNullable(vcall("union", variants), isNullable); +} + +function objectValidator( + validators: Map, + schema: AnySchema, + mode: SchemaMode, + isNullable: boolean, +) { + const props = schema.properties ?? {}; + + // `additionalProperties` names the schema every key outside `properties` + // has to satisfy, so those keys are part of the contract. `true` and an + // empty schema allow any key, and so does an absent keyword + const restSchema = + typeof schema.additionalProperties === "object" && + schema.additionalProperties !== null + ? schema.additionalProperties + : undefined; + const restAllowsAnything = + restSchema !== undefined && Object.keys(restSchema).length === 0; + const rest = + restSchema && !restAllowsAnything + ? schemaToValidator(validators, restSchema, mode) + : undefined; + const allowsAnyKey = schema.additionalProperties !== false; + if (Object.keys(props).length === 0) { return maybeNullable( maybePipe( - vcall("number"), - isInteger ? vcall("integer") : undefined, - schema.minimum === undefined - ? undefined - : vcall("minValue", numericLiteral(schema.minimum)), - schema.maximum === undefined - ? undefined - : vcall("maxValue", numericLiteral(schema.maximum)), - typescriptHintSchema, + vcall("record", vcall("string"), rest ?? vcall("unknown")), + ...minMaxProperties(schema), ), isNullable, ); } + const requiredProps = new Set(schema.required); + + const entries = (writer: CodeBlockWriter) => { + writer.writeLine("{"); + writer.indent(() => { + writeStrictObjectEntries(writer, validators, props, requiredProps, mode); + }); + writer.write("}"); + }; + + return maybeNullable( + maybePipe( + rest + ? vcall("objectWithRest", entries, rest) + : vcall(allowsAnyKey ? "looseObject" : "strictObject", entries), + ...minMaxProperties(schema), + ), + isNullable, + ); +} + +function schemaToValidator( + validators: Map, + schema: oas30.SchemaObject | oas31.SchemaObject | oas31.ReferenceObject, + mode: SchemaMode, +): WriterFunction | string { + if ("$ref" in schema) { + return resolveRef(validators, schema.$ref, mode); + } + + const isNullable = schemaIsNullable(schema); + const typescriptHintSchema = extensionHintSchema(schema); + + // Handle const values, added in OpenAPI 3.1 + if ("const" in schema) { + return schema.const === null + ? vcall("null") + : maybeNullable( + vcall("literal", JSON.stringify(schema.const)), + isNullable, + ); + } + + if (schema.enum) { + return enumValidator(schema.enum, isNullable); + } + + if (Array.isArray(schema.type)) { + return typeArrayValidator( + validators, + schema, + schema.type, + mode, + isNullable, + ); + } + + if (schema.type === "string") { + return stringValidator(schema, mode, isNullable, typescriptHintSchema); + } + + if (schema.type === "integer" && schema.format === "int64") { + return int64Validator(schema, mode, isNullable, typescriptHintSchema); + } + + if (schema.type === "number" || schema.type === "integer") { + return numberValidator(schema, isNullable, typescriptHintSchema); + } + if (schema.type === "boolean") { return maybeNullable(vcall("boolean"), isNullable); } @@ -522,161 +747,20 @@ function schemaToValidator( const combinator = schema.oneOf || schema.anyOf || schema.allOf; if (combinator) { - // allOf of object schemas composes into a single v.strictObject. Inline - // object members contribute their properties directly, and $ref members - // are spread via `.entries`. v.intersect of strictObjects is - // unsatisfiable when member property sets differ, because each - // strictObject independently rejects keys the others contribute - const allOfMembers = schema.allOf; - - if (allOfMembers) { - return maybeNullable( - maybePipe( - // only a document that says additionalProperties false gets a - // strict object - vcall( - schema.additionalProperties === false - ? "strictObject" - : "looseObject", - (writer: CodeBlockWriter) => { - writer.writeLine("{"); - writer.indent(() => { - allOfMembers.forEach((member) => { - if ("$ref" in member) { - const resolved = resolveRef(validators, member.$ref, mode); - writer.write("..."); - - if (typeof resolved === "function") { - resolved(writer); - } else { - writer.write(resolved); - } - - writer.writeLine(".entries,"); - - return; - } - - const isObjectShape = - member.type === "object" || - (member.properties !== undefined && - member.type === undefined); - - if (isObjectShape) { - writeStrictObjectEntries( - writer, - validators, - member.properties ?? {}, - new Set(member.required), - mode, - ); - - return; - } - - // Nested combinators and unusual shapes recurse, spreading the - // result's entries. Valid as long as the recursion yields an - // object-like schema, and GIGO otherwise - const validator = schemaToValidator(validators, member, mode); - writer.write("..."); - - if (typeof validator === "function") { - validator(writer); - } else { - writer.write(validator); - } - - writer.writeLine(".entries,"); - }); - }); - writer.write("}"); - }, - ), - ...minMaxProperties(schema), - ), - isNullable, - ); - } - - const variants = combinator.map((s) => - schemaToValidator(validators, s, mode), - ); - - if (schema.oneOf && schema.discriminator?.propertyName) { - return maybeNullable( - vcall( - "variant", - JSON.stringify(schema.discriminator.propertyName), - variants, - ), - isNullable, - ); - } - - return maybeNullable(vcall("union", variants), isNullable); - } - - if (schema.type === "object" || schema.properties || !schema.type) { - const props = schema.properties ?? {}; - - // `additionalProperties` names the schema every key outside `properties` - // has to satisfy, so those keys are part of the contract. `true` and an - // empty schema allow any key, and so does an absent keyword - const restSchema = - typeof schema.additionalProperties === "object" && - schema.additionalProperties !== null - ? schema.additionalProperties - : undefined; - const restAllowsAnything = - restSchema !== undefined && Object.keys(restSchema).length === 0; - const rest = - restSchema && !restAllowsAnything - ? schemaToValidator(validators, restSchema, mode) - : undefined; - const allowsAnyKey = schema.additionalProperties !== false; - - if (Object.keys(props).length === 0) { - return maybeNullable( - maybePipe( - vcall("record", vcall("string"), rest ?? vcall("unknown")), - ...minMaxProperties(schema), - ), - isNullable, - ); - } - - const requiredProps = new Set(schema.required); - - const entries = (writer: CodeBlockWriter) => { - writer.writeLine("{"); - writer.indent(() => { - writeStrictObjectEntries( - writer, - validators, - props, - requiredProps, - mode, - ); - }); - writer.write("}"); - }; - - return maybeNullable( - maybePipe( - rest - ? vcall("objectWithRest", entries, rest) - : vcall(allowsAnyKey ? "looseObject" : "strictObject", entries), - ...minMaxProperties(schema), - ), + return combinatorValidator( + validators, + schema, + combinator, + mode, isNullable, ); } - if (schema.type === "null") { - return vcall("null"); + if (schema.type === "object" || schema.properties || !schema.type) { + return objectValidator(validators, schema, mode, isNullable); } - return vcall("unknown"); + return schema.type === "null" ? vcall("null") : vcall("unknown"); } export function createValibotFile(project: Project, outputDir: string) { @@ -902,183 +986,160 @@ type OperationSchemaNames = { header?: string; }; -/** - * Creates validator schemas for operation input (body, params, query) in the - * valibot file. Returns the schema names for use in middleware generation - */ -export function createValidatorForOperationInput( - validatorSchemas: Map, - valibotFile: SourceFile, - commandName: string, - input: { - body?: oas30.SchemaObject | oas31.SchemaObject | oas31.ReferenceObject; - response?: oas30.SchemaObject | oas31.SchemaObject | oas31.ReferenceObject; - params: oas30.ParameterObject[]; - query: oas30.ParameterObject[]; - header: oas30.ParameterObject[]; - }, - inputOnly?: boolean, -): { input: OperationSchemaNames; wire: OperationSchemaNames } { - const emitSchemaPair = ( - segment: "body" | "response", - schema: oas30.SchemaObject | oas31.SchemaObject | oas31.ReferenceObject, - ) => { - const inputName = camelcase(["input", commandName, segment, "schema"]); - const wireName = camelcase([commandName, segment, "schema"]); - - valibotFile.addVariableStatement({ - isExported: true, - declarationKind: VariableDeclarationKind.Const, - declarations: [ - { - name: inputName, - initializer: schemaToValidator(validatorSchemas, schema, "input"), - }, - ], - }); - - if (!inputOnly) { - valibotFile.addVariableStatement({ - isExported: true, - declarationKind: VariableDeclarationKind.Const, - declarations: [ - { - name: wireName, - initializer: schemaToValidator(validatorSchemas, schema, "wire"), - }, - ], - }); - } +type OperationTarget = { + validatorSchemas: Map; + valibotFile: SourceFile; + commandName: string; + inputOnly: boolean | undefined; +}; - return { inputName, wireName }; - }; +// Emits the input schema and, unless input-only, the wire schema of a segment +function emitNamePair( + target: OperationTarget, + segment: string, + initializer: (mode: SchemaMode) => WriterFunction | string, +): SchemaNamePair { + const { valibotFile, commandName, inputOnly } = target; + const inputName = camelcase(["input", commandName, segment, "schema"]); + const wireName = camelcase([commandName, segment, "schema"]); - // Params/Query (Strict Objects) - const addParams = ( - type: "params" | "query", - list: oas30.ParameterObject[], - ) => { - const inputName = camelcase(["input", commandName, type, "schema"]); - const wireName = camelcase([commandName, type, "schema"]); - - const isHttpParam = type === "query"; - - const buildPropertyMap = (mode: SchemaMode) => - Object.fromEntries( - list.map((p) => { - const paramSchema = p.schema ?? { type: "string" as const }; - const optionalWrapper = - mode === "input" ? "optional" : "exactOptional"; - const validator = - mode === "wire" && isHttpParam - ? asHttpParamValidator(validatorSchemas, paramSchema) - : schemaToValidator(validatorSchemas, paramSchema, mode); - - return [ - JSON.stringify(p.name), - p.required ? validator : vcall(optionalWrapper, validator), - ]; - }), - ); + valibotFile.addVariableStatement({ + isExported: true, + declarationKind: VariableDeclarationKind.Const, + declarations: [ + { + name: inputName, + initializer: initializer("input"), + }, + ], + }); + if (!inputOnly) { valibotFile.addVariableStatement({ isExported: true, declarationKind: VariableDeclarationKind.Const, declarations: [ { - name: inputName, - initializer: vcall( - "strictObject", - Writers.object(buildPropertyMap("input")), - ), + name: wireName, + initializer: initializer("wire"), }, ], }); + } - if (!inputOnly) { - valibotFile.addVariableStatement({ - isExported: true, - declarationKind: VariableDeclarationKind.Const, - declarations: [ - { - name: wireName, - initializer: vcall( - "strictObject", - Writers.object(buildPropertyMap("wire")), - ), - }, - ], - }); - } + return { inputName, wireName }; +} - return { inputName, wireName }; - }; +function emitSchemaPair( + target: OperationTarget, + segment: "body" | "response", + schema: oas30.SchemaObject | oas31.SchemaObject | oas31.ReferenceObject, +) { + return emitNamePair(target, segment, (mode) => + schemaToValidator(target.validatorSchemas, schema, mode), + ); +} - // Header schema (non-strict to allow extra HTTP headers) - const addHeader = () => { - const inputName = camelcase(["input", commandName, "header", "schema"]); - const wireName = camelcase([commandName, "header", "schema"]); - - const buildPropertyMap = (mode: SchemaMode) => - Object.fromEntries( - input.header.map((p) => { - const paramSchema = p.schema ?? { type: "string" as const }; - const optionalWrapper = - mode === "input" ? "optional" : "exactOptional"; - const validator = - mode === "wire" - ? asHttpParamValidator(validatorSchemas, paramSchema) - : schemaToValidator(validatorSchemas, paramSchema, mode); - - return [ - JSON.stringify(p.name.toLowerCase()), - p.required ? validator : vcall(optionalWrapper, validator), - ]; - }), - ); +function parameterPropertyMap( + validatorSchemas: Map, + list: oas30.ParameterObject[], + mode: SchemaMode, + isHttpParam: boolean, + toKey: (name: string) => string, +) { + return Object.fromEntries( + list.map((p) => { + const paramSchema = p.schema ?? { type: "string" as const }; + const optionalWrapper = mode === "input" ? "optional" : "exactOptional"; + const validator = + mode === "wire" && isHttpParam + ? asHttpParamValidator(validatorSchemas, paramSchema) + : schemaToValidator(validatorSchemas, paramSchema, mode); + + return [ + JSON.stringify(toKey(p.name)), + p.required ? validator : vcall(optionalWrapper, validator), + ]; + }), + ); +} - valibotFile.addVariableStatement({ - isExported: true, - declarationKind: VariableDeclarationKind.Const, - declarations: [ - { - name: inputName, - initializer: vcall( - "object", - Writers.object(buildPropertyMap("input")), - ), - }, - ], - }); +// Params/Query (Strict Objects) +function addParams( + target: OperationTarget, + type: "params" | "query", + list: oas30.ParameterObject[], +) { + const isHttpParam = type === "query"; + + return emitNamePair(target, type, (mode) => + vcall( + "strictObject", + Writers.object( + parameterPropertyMap( + target.validatorSchemas, + list, + mode, + isHttpParam, + (name) => name, + ), + ), + ), + ); +} - if (!inputOnly) { - valibotFile.addVariableStatement({ - isExported: true, - declarationKind: VariableDeclarationKind.Const, - declarations: [ - { - name: wireName, - initializer: vcall( - "object", - Writers.object(buildPropertyMap("wire")), - ), - }, - ], - }); - } +// Header schema (non-strict to allow extra HTTP headers) +function addHeader(target: OperationTarget, list: oas30.ParameterObject[]) { + return emitNamePair(target, "header", (mode) => + vcall( + "object", + Writers.object( + parameterPropertyMap( + target.validatorSchemas, + list, + mode, + true, + (name) => name.toLowerCase(), + ), + ), + ), + ); +} - return { inputName, wireName }; - }; +/** + * Creates validator schemas for operation input (body, params, query) in the + * valibot file. Returns the schema names for use in middleware generation + */ +export function createValidatorForOperationInput( + validatorSchemas: Map, + valibotFile: SourceFile, + commandName: string, + input: { + body?: oas30.SchemaObject | oas31.SchemaObject | oas31.ReferenceObject; + response?: oas30.SchemaObject | oas31.SchemaObject | oas31.ReferenceObject; + params: oas30.ParameterObject[]; + query: oas30.ParameterObject[]; + header: oas30.ParameterObject[]; + }, + inputOnly?: boolean, +): { input: OperationSchemaNames; wire: OperationSchemaNames } { + const target = { validatorSchemas, valibotFile, commandName, inputOnly }; const named = typedEntries({ - json: input.body ? emitSchemaPair("body", input.body) : undefined, + json: input.body ? emitSchemaPair(target, "body", input.body) : undefined, response: input.response - ? emitSchemaPair("response", input.response) + ? emitSchemaPair(target, "response", input.response) : undefined, param: - input.params.length > 0 ? addParams("params", input.params) : undefined, - query: input.query.length > 0 ? addParams("query", input.query) : undefined, - header: input.header.length > 0 ? addHeader() : undefined, + input.params.length > 0 + ? addParams(target, "params", input.params) + : undefined, + query: + input.query.length > 0 + ? addParams(target, "query", input.query) + : undefined, + header: + input.header.length > 0 ? addHeader(target, input.header) : undefined, }).filter( (entry): entry is [keyof OperationSchemaNames, SchemaNamePair] => entry[1] !== undefined, diff --git a/oxlint.config.ts b/oxlint.config.ts index 18b6e6c..c4058df 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -4,5 +4,5 @@ import * as codegenPlugin from "./lib/oxlint.ts"; export default defineConfig({ groups: { vitest: "on" }, - overrides: [...codegenPlugin.defineOverrides("__tests__/fixtures/*")], + overrides: codegenPlugin.defineOverrides("__tests__/fixtures/*"), });