UNPKG

@trpc/openapi

Version:

OpenAPI document generator for tRPC routers

1,539 lines (1,348 loc) 44 kB
import * as fs from 'node:fs'; import * as path from 'node:path'; import * as ts from 'typescript'; import { applyDescriptions, collectRuntimeDescriptions, tryImportRouter, type RuntimeDescriptions, } from './schemaExtraction'; import type { Document, OperationObject, PathItemObject, PathsObject, SchemaObject, } from './types'; interface ProcedureInfo { path: string; type: 'query' | 'mutation' | 'subscription'; inputSchema: SchemaObject | null; outputSchema: SchemaObject | null; description?: string; } /** State extracted from the router's root config. */ interface RouterMeta { errorSchema: SchemaObject | null; schemas?: Record<string, SchemaObject>; } export interface GenerateOptions { /** * The name of the exported router symbol. * @default 'AppRouter' */ exportName?: string; /** Title for the generated OpenAPI `info` object. */ title?: string; /** Version string for the generated OpenAPI `info` object. */ version?: string; } // --------------------------------------------------------------------------- // Flag helpers // --------------------------------------------------------------------------- const PRIMITIVE_FLAGS = ts.TypeFlags.String | ts.TypeFlags.Number | ts.TypeFlags.Boolean | ts.TypeFlags.StringLiteral | ts.TypeFlags.NumberLiteral | ts.TypeFlags.BooleanLiteral; function hasFlag(type: ts.Type, flag: ts.TypeFlags): boolean { return (type.getFlags() & flag) !== 0; } function isPrimitive(type: ts.Type): boolean { return hasFlag(type, PRIMITIVE_FLAGS); } function isObjectType(type: ts.Type): boolean { return hasFlag(type, ts.TypeFlags.Object); } function isOptionalSymbol(sym: ts.Symbol): boolean { return (sym.flags & ts.SymbolFlags.Optional) !== 0; } // --------------------------------------------------------------------------- // JSON Schema conversion — shared state // --------------------------------------------------------------------------- /** Shared state threaded through the type-to-schema recursion. */ interface SchemaCtx { checker: ts.TypeChecker; visited: Set<ts.Type>; /** Collected named schemas for components/schemas. */ schemas: Record<string, SchemaObject>; /** Map from TS type identity to its registered schema name. */ typeToRef: Map<ts.Type, string>; } // --------------------------------------------------------------------------- // Brand unwrapping // --------------------------------------------------------------------------- /** * If `type` is a branded intersection (primitive & object), return just the * primitive part. Otherwise return the type as-is. */ function unwrapBrand(type: ts.Type): ts.Type { if (!type.isIntersection()) { return type; } const primitives = type.types.filter(isPrimitive); const hasObject = type.types.some(isObjectType); const [first] = primitives; if (first && hasObject) { return first; } return type; } // --------------------------------------------------------------------------- // Schema naming helpers // --------------------------------------------------------------------------- const ANONYMOUS_NAMES = new Set(['__type', '__object', 'Object', '']); const INTERNAL_COMPUTED_PROPERTY_SYMBOL = /^__@.*@\d+$/; /** Try to determine a meaningful name for a TS type (type alias or interface). */ function getTypeName(type: ts.Type): string | null { const aliasName = type.aliasSymbol?.getName(); if (aliasName && !ANONYMOUS_NAMES.has(aliasName)) { return aliasName; } const symName = type.getSymbol()?.getName(); if (symName && !ANONYMOUS_NAMES.has(symName) && !symName.startsWith('__')) { return symName; } return null; } // Skips asyncGenerator and branded symbols etc when creating types // Symbols can't be serialised function shouldSkipPropertySymbol(prop: ts.Symbol): boolean { return ( prop.declarations?.some((declaration) => { const declarationName = ts.getNameOfDeclaration(declaration); if (!declarationName || !ts.isComputedPropertyName(declarationName)) { return false; } return INTERNAL_COMPUTED_PROPERTY_SYMBOL.test(prop.getName()); }) ?? false ); } function getReferencedSchema( schema: SchemaObject | null, schemas: Record<string, SchemaObject>, ): SchemaObject | null { const ref = schema?.$ref; if (!ref?.startsWith('#/components/schemas/')) { return schema; } const refName = ref.slice('#/components/schemas/'.length); return refName ? (schemas[refName] ?? null) : schema; } function ensureUniqueName( name: string, existing: Record<string, unknown>, ): string { if (!(name in existing)) { return name; } let i = 2; while (`${name}${i}` in existing) { i++; } return `${name}${i}`; } function schemaRef(name: string): SchemaObject { return { $ref: `#/components/schemas/${name}` }; } function isSelfSchemaRef(schema: SchemaObject, name: string): boolean { return schema.$ref === schemaRef(name).$ref; } function isNonEmptySchema(s: SchemaObject): boolean { for (const _ in s) return true; return false; } // --------------------------------------------------------------------------- // Type → JSON Schema (with component extraction) // --------------------------------------------------------------------------- /** * Convert a TS type to a JSON Schema. If the type has been pre-registered * (or has a meaningful TS name), it is stored in `ctx.schemas` and a `$ref` * is returned instead of an inline schema. * * Named types (type aliases, interfaces) are auto-registered before conversion * so that recursive references (including through unions and intersections) * resolve to a `$ref` instead of causing infinite recursion. */ function typeToJsonSchema( type: ts.Type, ctx: SchemaCtx, depth = 0, ): SchemaObject { // If this type is already registered as a named schema, return a $ref. const existingRef = ctx.typeToRef.get(type); if (existingRef) { const storedSchema = ctx.schemas[existingRef]; if ( storedSchema && (isNonEmptySchema(storedSchema) || ctx.visited.has(type)) ) { return schemaRef(existingRef); } // First encounter for a pre-registered placeholder: convert once, but keep // returning $ref for recursive edges while the type is actively visiting. ctx.schemas[existingRef] = storedSchema ?? {}; const schema = convertTypeToSchema(type, ctx, depth); if (!isSelfSchemaRef(schema, existingRef)) { ctx.schemas[existingRef] = schema; } return schemaRef(existingRef); } const schema = convertTypeToSchema(type, ctx, depth); // If a recursive reference was detected during conversion (via handleCyclicRef // or convertPlainObject's auto-registration), the type is now registered in // typeToRef. If the stored schema is still the empty placeholder, fill it in // with the actual converted schema. Either way, return a $ref. const postConvertRef = ctx.typeToRef.get(type); if (postConvertRef) { const stored = ctx.schemas[postConvertRef]; if ( stored && !isNonEmptySchema(stored) && !isSelfSchemaRef(schema, postConvertRef) ) { ctx.schemas[postConvertRef] = schema; } return schemaRef(postConvertRef); } // Extract JSDoc from type alias symbol (e.g. `/** desc */ type Foo = string`) if (!schema.description && !schema.$ref && type.aliasSymbol) { const aliasJsDoc = getJsDocComment(type.aliasSymbol, ctx.checker); if (aliasJsDoc) { schema.description = aliasJsDoc; } } return schema; } // --------------------------------------------------------------------------- // Cyclic reference handling // --------------------------------------------------------------------------- /** * When we encounter a type we're already visiting, it's recursive. * Register it as a named schema and return a $ref. */ function handleCyclicRef(type: ts.Type, ctx: SchemaCtx): SchemaObject { let refName = ctx.typeToRef.get(type); if (!refName) { const name = getTypeName(type) ?? 'RecursiveType'; refName = ensureUniqueName(name, ctx.schemas); ctx.typeToRef.set(type, refName); ctx.schemas[refName] = {}; // placeholder — filled by the outer call } return schemaRef(refName); } // --------------------------------------------------------------------------- // Primitive & literal type conversion // --------------------------------------------------------------------------- function convertPrimitiveOrLiteral( type: ts.Type, flags: ts.TypeFlags, checker: ts.TypeChecker, ): SchemaObject | null { if (flags & ts.TypeFlags.String) { return { type: 'string' }; } if (flags & ts.TypeFlags.Number) { return { type: 'number' }; } if (flags & ts.TypeFlags.Boolean) { return { type: 'boolean' }; } if (flags & ts.TypeFlags.Null) { return { type: 'null' }; } if (flags & ts.TypeFlags.Undefined) { return {}; } if (flags & ts.TypeFlags.Void) { return {}; } if (flags & ts.TypeFlags.Any || flags & ts.TypeFlags.Unknown) { return {}; } if (flags & ts.TypeFlags.Never) { return { not: {} }; } if (flags & ts.TypeFlags.BigInt || flags & ts.TypeFlags.BigIntLiteral) { return { type: 'integer', format: 'bigint' }; } if (flags & ts.TypeFlags.StringLiteral) { return { type: 'string', const: (type as ts.StringLiteralType).value }; } if (flags & ts.TypeFlags.NumberLiteral) { return { type: 'number', const: (type as ts.NumberLiteralType).value }; } if (flags & ts.TypeFlags.BooleanLiteral) { const isTrue = checker.typeToString(type) === 'true'; return { type: 'boolean', const: isTrue }; } return null; } // --------------------------------------------------------------------------- // Union type conversion // --------------------------------------------------------------------------- function convertUnionType( type: ts.UnionType, ctx: SchemaCtx, depth: number, ): SchemaObject { const members = type.types; // Strip undefined / void members (they make the field optional, not typed) const defined = members.filter( (m) => !hasFlag(m, ts.TypeFlags.Undefined | ts.TypeFlags.Void), ); if (defined.length === 0) { return {}; } const hasNull = defined.some((m) => hasFlag(m, ts.TypeFlags.Null)); const nonNull = defined.filter((m) => !hasFlag(m, ts.TypeFlags.Null)); // TypeScript represents `boolean` as `true | false`. Collapse boolean // literal pairs back into a single boolean, even when mixed with other types. // e.g. `string | true | false` → treat as `string | boolean` const boolLiterals = nonNull.filter((m) => hasFlag(unwrapBrand(m), ts.TypeFlags.BooleanLiteral), ); const hasBoolPair = boolLiterals.length === 2 && boolLiterals.some( (m) => ctx.checker.typeToString(unwrapBrand(m)) === 'true', ) && boolLiterals.some( (m) => ctx.checker.typeToString(unwrapBrand(m)) === 'false', ); // Build the effective non-null members, collapsing boolean literal pairs const effective = hasBoolPair ? nonNull.filter( (m) => !hasFlag(unwrapBrand(m), ts.TypeFlags.BooleanLiteral), ) : nonNull; // Pure boolean (or boolean | null) — no other types if (hasBoolPair && effective.length === 0) { return hasNull ? { type: ['boolean', 'null'] } : { type: 'boolean' }; } // Collapse unions of same-type literals into a single `enum` array. // e.g. "FOO" | "BAR" → { type: "string", enum: ["FOO", "BAR"] } const collapsedEnum = tryCollapseLiteralUnion(effective, hasNull); if (collapsedEnum) { return collapsedEnum; } const schemas = effective .map((m) => typeToJsonSchema(m, ctx, depth + 1)) .filter(isNonEmptySchema); // Re-inject the collapsed boolean if (hasBoolPair) { schemas.push({ type: 'boolean' }); } if (hasNull) { schemas.push({ type: 'null' }); } if (schemas.length === 0) { return {}; } const [firstSchema] = schemas; if (schemas.length === 1 && firstSchema !== undefined) { return firstSchema; } // When all schemas are simple type-only schemas (no other properties), // collapse into a single `type` array. e.g. string | null → type: ["string", "null"] if (schemas.every(isSimpleTypeSchema)) { return { type: schemas.map((s) => s.type as string) }; } // Detect discriminated unions: all oneOf members are objects sharing a common // required property whose value is a `const`. If found, add a `discriminator`. const discriminatorProp = detectDiscriminatorProperty(schemas); if (discriminatorProp) { return { oneOf: schemas, discriminator: { propertyName: discriminatorProp }, }; } return { oneOf: schemas }; } /** * If every schema in a oneOf is an object with a common required property * whose value is a `const`, return that property name. Otherwise return null. */ function detectDiscriminatorProperty(schemas: SchemaObject[]): string | null { if (schemas.length < 2) { return null; } // All schemas must be object types with properties if (!schemas.every((s) => s.type === 'object' && s.properties)) { return null; } // Find properties that exist in every schema, are required, and have a `const` value const first = schemas[0]; if (!first?.properties) { return null; } const firstProps = Object.keys(first.properties); for (const prop of firstProps) { const allHaveConst = schemas.every((s) => { const propSchema = s.properties?.[prop]; return ( propSchema !== undefined && propSchema.const !== undefined && s.required?.includes(prop) ); }); if (allHaveConst) { return prop; } } return null; } /** A schema that is just `{ type: "somePrimitive" }` with no other keys. */ function isSimpleTypeSchema(s: SchemaObject): boolean { const keys = Object.keys(s); return keys.length === 1 && keys[0] === 'type' && typeof s.type === 'string'; } /** * If every non-null member is a string or number literal of the same kind, * collapse them into a single `{ type, enum }` schema. */ function tryCollapseLiteralUnion( nonNull: ts.Type[], hasNull: boolean, ): SchemaObject | null { if (nonNull.length <= 1) { return null; } const allLiterals = nonNull.every((m) => hasFlag(m, ts.TypeFlags.StringLiteral | ts.TypeFlags.NumberLiteral), ); if (!allLiterals) { return null; } const [first] = nonNull; if (!first) { return null; } const isString = hasFlag(first, ts.TypeFlags.StringLiteral); const targetFlag = isString ? ts.TypeFlags.StringLiteral : ts.TypeFlags.NumberLiteral; const allSameKind = nonNull.every((m) => hasFlag(m, targetFlag)); if (!allSameKind) { return null; } const values = nonNull.map((m) => isString ? (m as ts.StringLiteralType).value : (m as ts.NumberLiteralType).value, ); const baseType = isString ? 'string' : 'number'; return { type: hasNull ? [baseType, 'null'] : baseType, enum: values, }; } // --------------------------------------------------------------------------- // Intersection type conversion // --------------------------------------------------------------------------- function convertIntersectionType( type: ts.IntersectionType, ctx: SchemaCtx, depth: number, ): SchemaObject { // Branded types (e.g. z.string().brand<'X'>()) appear as an intersection of // a primitive with a phantom object. Strip the object members — they are // always brand metadata. const hasPrimitiveMember = type.types.some(isPrimitive); const nonBrand = hasPrimitiveMember ? type.types.filter((m) => !isObjectType(m)) : type.types; const schemas = nonBrand .map((m) => typeToJsonSchema(m, ctx, depth + 1)) .filter(isNonEmptySchema); if (schemas.length === 0) { return {}; } const [onlySchema] = schemas; if (schemas.length === 1 && onlySchema !== undefined) { return onlySchema; } // When all members are plain inline object schemas (no $ref), merge them // into a single object instead of wrapping in allOf. if (schemas.every(isInlineObjectSchema)) { return mergeObjectSchemas(schemas); } return { allOf: schemas }; } /** True when the schema is an inline `{ type: "object", ... }` (not a $ref). */ function isInlineObjectSchema(s: SchemaObject): boolean { return s.type === 'object' && !s.$ref; } /** * Merge multiple `{ type: "object" }` schemas into one. * Falls back to `allOf` if any property names conflict across schemas. */ function mergeObjectSchemas(schemas: SchemaObject[]): SchemaObject { // Check for property name conflicts before merging. const seen = new Set<string>(); for (const s of schemas) { if (s.properties) { for (const prop of Object.keys(s.properties)) { if (seen.has(prop)) { // Conflicting property — fall back to allOf to preserve both definitions. return { allOf: schemas }; } seen.add(prop); } } } const properties: Record<string, SchemaObject> = {}; const required: string[] = []; let additionalProperties: SchemaObject | boolean | undefined; for (const s of schemas) { if (s.properties) { Object.assign(properties, s.properties); } if (s.required) { required.push(...s.required); } if (s.additionalProperties !== undefined) { additionalProperties = s.additionalProperties; } } const result: SchemaObject = { type: 'object' }; if (Object.keys(properties).length > 0) { result.properties = properties; } if (required.length > 0) { result.required = required; } if (additionalProperties !== undefined) { result.additionalProperties = additionalProperties; } return result; } // --------------------------------------------------------------------------- // Object type conversion // --------------------------------------------------------------------------- function convertWellKnownType( type: ts.Type, ctx: SchemaCtx, depth: number, ): SchemaObject | null { const symName = type.getSymbol()?.getName(); if (symName === 'Date') { return { type: 'string', format: 'date-time' }; } if (symName === 'Uint8Array' || symName === 'Buffer') { return { type: 'string', format: 'binary' }; } // Unwrap Promise<T> if (symName === 'Promise') { const [inner] = ctx.checker.getTypeArguments(type as ts.TypeReference); return inner ? typeToJsonSchema(inner, ctx, depth + 1) : {}; } return null; } function convertArrayType( type: ts.Type, ctx: SchemaCtx, depth: number, ): SchemaObject { const [elem] = ctx.checker.getTypeArguments(type as ts.TypeReference); const schema: SchemaObject = { type: 'array' }; if (elem) { schema.items = typeToJsonSchema(elem, ctx, depth + 1); } return schema; } function convertTupleType( type: ts.Type, ctx: SchemaCtx, depth: number, ): SchemaObject { const args = ctx.checker.getTypeArguments(type as ts.TypeReference); const schemas = args.map((a) => typeToJsonSchema(a, ctx, depth + 1)); return { type: 'array', prefixItems: schemas, items: false, minItems: args.length, maxItems: args.length, }; } function convertPlainObject( type: ts.Type, ctx: SchemaCtx, depth: number, ): SchemaObject { const { checker } = ctx; const stringIndexType = type.getStringIndexType(); const typeProps = type.getProperties(); // Pure index-signature Record type (no named props) if (typeProps.length === 0 && stringIndexType) { return { type: 'object', additionalProperties: typeToJsonSchema(stringIndexType, ctx, depth + 1), }; } // Auto-register types with a meaningful TS name BEFORE converting // properties, so that circular or shared refs discovered during recursion // resolve to a $ref via the `typeToJsonSchema` wrapper. let autoRegName: string | null = null; const tsName = getTypeName(type); const isNamedUnregisteredType = tsName !== null && typeProps.length > 0 && !ctx.typeToRef.has(type); if (isNamedUnregisteredType) { autoRegName = ensureUniqueName(tsName, ctx.schemas); ctx.typeToRef.set(type, autoRegName); ctx.schemas[autoRegName] = {}; // placeholder for circular ref guard } ctx.visited.add(type); const properties: Record<string, SchemaObject> = {}; const required: string[] = []; for (const prop of typeProps) { if (shouldSkipPropertySymbol(prop)) { continue; } const propType = checker.getTypeOfSymbol(prop); const propSchema = typeToJsonSchema(propType, ctx, depth + 1); // Extract JSDoc comment from the property symbol as a description const jsDoc = getJsDocComment(prop, checker); if (jsDoc && !propSchema.description && !propSchema.$ref) { propSchema.description = jsDoc; } properties[prop.name] = propSchema; if (!isOptionalSymbol(prop)) { required.push(prop.name); } } ctx.visited.delete(type); const result: SchemaObject = { type: 'object' }; if (Object.keys(properties).length > 0) { result.properties = properties; } if (required.length > 0) { result.required = required; } if (stringIndexType) { result.additionalProperties = typeToJsonSchema( stringIndexType, ctx, depth + 1, ); } else if (Object.keys(properties).length > 0) { result.additionalProperties = false; } // autoRegName covers named types (early-registered). For anonymous // recursive types, a recursive call may have registered this type during // property conversion — check typeToRef as a fallback. const registeredName = autoRegName ?? ctx.typeToRef.get(type); if (registeredName) { ctx.schemas[registeredName] = result; return schemaRef(registeredName); } return result; } function convertObjectType( type: ts.Type, ctx: SchemaCtx, depth: number, ): SchemaObject { const wellKnown = convertWellKnownType(type, ctx, depth); if (wellKnown) { return wellKnown; } if (ctx.checker.isArrayType(type)) { return convertArrayType(type, ctx, depth); } if (ctx.checker.isTupleType(type)) { return convertTupleType(type, ctx, depth); } return convertPlainObject(type, ctx, depth); } // --------------------------------------------------------------------------- // Core dispatcher // --------------------------------------------------------------------------- /** Core type-to-schema conversion (no ref handling). */ function convertTypeToSchema( type: ts.Type, ctx: SchemaCtx, depth: number, ): SchemaObject { if (ctx.visited.has(type)) { return handleCyclicRef(type, ctx); } const flags = type.getFlags(); const primitive = convertPrimitiveOrLiteral(type, flags, ctx.checker); if (primitive) { return primitive; } if (type.isUnion()) { ctx.visited.add(type); const result = convertUnionType(type, ctx, depth); ctx.visited.delete(type); return result; } if (type.isIntersection()) { ctx.visited.add(type); const result = convertIntersectionType(type, ctx, depth); ctx.visited.delete(type); return result; } if (isObjectType(type)) { return convertObjectType(type, ctx, depth); } return {}; } // --------------------------------------------------------------------------- // Router / procedure type walker // --------------------------------------------------------------------------- /** State shared across the router-walk recursion. */ interface WalkCtx { procedures: ProcedureInfo[]; seen: Set<ts.Type>; schemaCtx: SchemaCtx; /** Runtime descriptions keyed by procedure path (when a router instance is available). */ runtimeDescriptions: Map<string, RuntimeDescriptions>; } /** * Inspect `_def.type` and return the procedure type string, or null if this is * not a procedure (e.g. a nested router). */ function getProcedureTypeName( defType: ts.Type, checker: ts.TypeChecker, ): ProcedureInfo['type'] | null { const typeSym = defType.getProperty('type'); if (!typeSym) { return null; } const typeType = checker.getTypeOfSymbol(typeSym); const raw = checker.typeToString(typeType).replace(/['"]/g, ''); if (raw === 'query' || raw === 'mutation' || raw === 'subscription') { return raw; } return null; } function isVoidLikeInput(inputType: ts.Type | null): boolean { if (!inputType) { return true; } const isVoidOrUndefinedOrNever = hasFlag( inputType, ts.TypeFlags.Void | ts.TypeFlags.Undefined | ts.TypeFlags.Never, ); if (isVoidOrUndefinedOrNever) { return true; } const isUnionOfVoids = inputType.isUnion() && inputType.types.every((t) => hasFlag(t, ts.TypeFlags.Void | ts.TypeFlags.Undefined), ); return isUnionOfVoids; } interface ProcedureDef { defType: ts.Type; typeName: string; path: string; description?: string; symbol: ts.Symbol; } function shouldIncludeProcedureInOpenAPI(type: ProcedureInfo['type']): boolean { return type !== 'subscription'; } function getProcedureInputTypeName(type: ts.Type, path: string): string { const directName = getTypeName(type); if (directName) { return directName; } for (const sym of [type.aliasSymbol, type.getSymbol()].filter( (candidate): candidate is ts.Symbol => !!candidate, )) { for (const declaration of sym.declarations ?? []) { const declarationName = ts.getNameOfDeclaration(declaration)?.getText(); if ( declarationName && !ANONYMOUS_NAMES.has(declarationName) && !declarationName.startsWith('__') ) { return declarationName; } } } const fallbackName = path .split('.') .filter(Boolean) .map((segment) => segment .split(/[^A-Za-z0-9]+/) .filter(Boolean) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(''), ) .join(''); return `${fallbackName || 'Procedure'}Input`; } function isUnknownLikeType(type: ts.Type): boolean { return hasFlag(type, ts.TypeFlags.Unknown | ts.TypeFlags.Any); } function isCollapsedProcedureInputType(type: ts.Type): boolean { return ( isUnknownLikeType(type) || (isObjectType(type) && type.getProperties().length === 0 && !type.getStringIndexType()) ); } function recoverProcedureInputType( def: ProcedureDef, checker: ts.TypeChecker, ): ts.Type | null { let initializer: ts.Expression | null = null; for (const declaration of def.symbol.declarations ?? []) { if (ts.isPropertyAssignment(declaration)) { initializer = declaration.initializer; break; } if (ts.isVariableDeclaration(declaration) && declaration.initializer) { initializer = declaration.initializer; break; } } if (!initializer) { return null; } let recovered: ts.Type | null = null; // Walk the builder chain and keep the last `.input(...)` parser output type. const visit = (expr: ts.Expression): void => { if (!ts.isCallExpression(expr)) { return; } const callee = expr.expression; if (!ts.isPropertyAccessExpression(callee)) { return; } visit(callee.expression); if (callee.name.text !== 'input') { return; } const [parserExpr] = expr.arguments; if (!parserExpr) { return; } const parserType = checker.getTypeAtLocation(parserExpr); const standardSym = parserType.getProperty('~standard'); if (!standardSym) { return; } const standardType = checker.getTypeOfSymbolAtLocation( standardSym, parserExpr, ); const typesSym = standardType.getProperty('types'); if (!typesSym) { return; } const typesType = checker.getNonNullableType( checker.getTypeOfSymbolAtLocation(typesSym, parserExpr), ); const outputSym = typesType.getProperty('output'); if (!outputSym) { return; } const outputType = checker.getTypeOfSymbolAtLocation(outputSym, parserExpr); if (!isUnknownLikeType(outputType)) { recovered = outputType; } }; visit(initializer); return recovered; } function extractProcedure(def: ProcedureDef, ctx: WalkCtx): void { const { schemaCtx } = ctx; const { checker } = schemaCtx; const $typesSym = def.defType.getProperty('$types'); if (!$typesSym) { return; } const $typesType = checker.getTypeOfSymbol($typesSym); const inputSym = $typesType.getProperty('input'); const outputSym = $typesType.getProperty('output'); const inputType = inputSym ? checker.getTypeOfSymbol(inputSym) : null; const outputType = outputSym ? checker.getTypeOfSymbol(outputSym) : null; const resolvedInputType = inputType && isCollapsedProcedureInputType(inputType) ? (recoverProcedureInputType(def, checker) ?? inputType) : inputType; let inputSchema: SchemaObject | null = null; if (!resolvedInputType || isVoidLikeInput(resolvedInputType)) { // null is fine } else { // Pre-register recovered parser output types so recursive edges resolve to a // stable component ref instead of collapsing into `{}`. const ensureRecoveredInputRegistration = (type: ts.Type): void => { if (schemaCtx.typeToRef.has(type)) { return; } const refName = ensureUniqueName( getProcedureInputTypeName(type, def.path), schemaCtx.schemas, ); schemaCtx.typeToRef.set(type, refName); schemaCtx.schemas[refName] = {}; }; if (resolvedInputType !== inputType) { ensureRecoveredInputRegistration(resolvedInputType); } const initialSchema = typeToJsonSchema(resolvedInputType, schemaCtx); if ( !isNonEmptySchema(initialSchema) && !schemaCtx.typeToRef.has(resolvedInputType) ) { ensureRecoveredInputRegistration(resolvedInputType); inputSchema = typeToJsonSchema(resolvedInputType, schemaCtx); } else { inputSchema = initialSchema; } } const outputSchema: SchemaObject | null = outputType ? typeToJsonSchema(outputType, schemaCtx) : null; // Overlay extracted schema descriptions onto the type-checker-generated schemas. const runtimeDescs = ctx.runtimeDescriptions.get(def.path); if (runtimeDescs) { const resolvedInputSchema = getReferencedSchema( inputSchema, schemaCtx.schemas, ); const resolvedOutputSchema = getReferencedSchema( outputSchema, schemaCtx.schemas, ); if (resolvedInputSchema && runtimeDescs.input) { applyDescriptions( resolvedInputSchema, runtimeDescs.input, schemaCtx.schemas, ); } if (resolvedOutputSchema && runtimeDescs.output) { applyDescriptions( resolvedOutputSchema, runtimeDescs.output, schemaCtx.schemas, ); } } ctx.procedures.push({ path: def.path, type: def.typeName as 'query' | 'mutation' | 'subscription', inputSchema, outputSchema, description: def.description, }); } /** Extract the JSDoc comment text from a symbol, if any. */ function getJsDocComment( sym: ts.Symbol, checker: ts.TypeChecker, ): string | undefined { const normalize = (filePath: string): string => filePath.replace(/\\/g, '/'); const declarations = sym.declarations ?? []; const isExternalNodeModulesDeclaration = declarations.length > 0 && declarations.every((declaration) => { const sourceFile = declaration.getSourceFile(); if (!sourceFile.isDeclarationFile) { return false; } const declarationPath = normalize(sourceFile.fileName); if (!declarationPath.includes('/node_modules/')) { return false; } try { const realPath = normalize(fs.realpathSync.native(sourceFile.fileName)); // Keep JSDoc for workspace packages linked into node_modules // (e.g. monorepos using pnpm/yarn workspaces). The resolved target // may sit outside the current cwd, so avoid cwd-based checks here. if (!realPath.includes('/node_modules/')) { return false; } } catch { // Fall back to treating the declaration as external. } return true; }); if (isExternalNodeModulesDeclaration) { return undefined; } const parts = sym.getDocumentationComment(checker); if (parts.length === 0) { return undefined; } const text = parts.map((p) => p.text).join(''); return text || undefined; } interface WalkTypeOpts { type: ts.Type; ctx: WalkCtx; currentPath: string; description?: string; symbol?: ts.Symbol; } function walkType(opts: WalkTypeOpts): void { const { type, ctx, currentPath, description, symbol } = opts; if (ctx.seen.has(type)) { return; } const defSym = type.getProperty('_def'); if (!defSym) { // No `_def` — this is a plain RouterRecord or an unrecognised type. // Walk its own properties so nested procedures are found. if (isObjectType(type)) { ctx.seen.add(type); walkRecord(type, ctx, currentPath); ctx.seen.delete(type); } return; } const { checker } = ctx.schemaCtx; const defType = checker.getTypeOfSymbol(defSym); const procedureTypeName = getProcedureTypeName(defType, checker); if (procedureTypeName) { if (!shouldIncludeProcedureInOpenAPI(procedureTypeName)) { return; } extractProcedure( { defType, typeName: procedureTypeName, path: currentPath, description, symbol: symbol ?? type.getSymbol() ?? defSym, }, ctx, ); return; } // Router? (_def.router === true) const routerSym = defType.getProperty('router'); if (!routerSym) { return; } const isRouter = checker.typeToString(checker.getTypeOfSymbol(routerSym)) === 'true'; if (!isRouter) { return; } const recordSym = defType.getProperty('record'); if (!recordSym) { return; } ctx.seen.add(type); const recordType = checker.getTypeOfSymbol(recordSym); walkRecord(recordType, ctx, currentPath); ctx.seen.delete(type); } function walkRecord(recordType: ts.Type, ctx: WalkCtx, prefix: string): void { for (const prop of recordType.getProperties()) { const propType = ctx.schemaCtx.checker.getTypeOfSymbol(prop); const fullPath = prefix ? `${prefix}.${prop.name}` : prop.name; const description = getJsDocComment(prop, ctx.schemaCtx.checker); walkType({ type: propType, ctx, currentPath: fullPath, description, symbol: prop, }); } } // --------------------------------------------------------------------------- // TypeScript program helpers // --------------------------------------------------------------------------- function loadCompilerOptions(startDir: string): ts.CompilerOptions { const configPath = ts.findConfigFile( startDir, (f) => ts.sys.fileExists(f), 'tsconfig.json', ); if (!configPath) { return { target: ts.ScriptTarget.ES2020, moduleResolution: ts.ModuleResolutionKind.Bundler, skipLibCheck: true, noEmit: true, }; } const configFile = ts.readConfigFile(configPath, (f) => ts.sys.readFile(f)); const parsed = ts.parseJsonConfigFileContent( configFile.config, ts.sys, path.dirname(configPath), ); const options: ts.CompilerOptions = { ...parsed.options, noEmit: true }; // `parseJsonConfigFileContent` only returns explicitly-set values. TypeScript // itself infers moduleResolution from `module` at compile time, but we have to // do it manually here for the compiler host to resolve imports correctly. if (options.moduleResolution === undefined) { const mod = options.module; if (mod === ts.ModuleKind.Node16 || mod === ts.ModuleKind.NodeNext) { options.moduleResolution = ts.ModuleResolutionKind.NodeNext; } else if ( mod === ts.ModuleKind.Preserve || mod === ts.ModuleKind.ES2022 || mod === ts.ModuleKind.ESNext ) { options.moduleResolution = ts.ModuleResolutionKind.Bundler; } else { options.moduleResolution = ts.ModuleResolutionKind.Node10; } } return options; } // --------------------------------------------------------------------------- // Error shape extraction // --------------------------------------------------------------------------- /** * Walk `_def._config.$types.errorShape` on the router type and convert * it to a JSON Schema. Returns `null` when the path cannot be resolved * (e.g. older tRPC versions or missing type info). */ function extractErrorSchema( routerType: ts.Type, checker: ts.TypeChecker, schemaCtx: SchemaCtx, ): SchemaObject | null { const walk = (type: ts.Type, keys: string[]): ts.Type | null => { const [head, ...rest] = keys; if (!head) { return type; } const sym = type.getProperty(head); if (!sym) { return null; } return walk(checker.getTypeOfSymbol(sym), rest); }; const errorShapeType = walk(routerType, [ '_def', '_config', '$types', 'errorShape', ]); if (!errorShapeType) { return null; } if (hasFlag(errorShapeType, ts.TypeFlags.Any)) { return null; } return typeToJsonSchema(errorShapeType, schemaCtx); } // --------------------------------------------------------------------------- // OpenAPI document builder // --------------------------------------------------------------------------- /** Fallback error schema when the router type doesn't expose an error shape. */ const DEFAULT_ERROR_SCHEMA: SchemaObject = { type: 'object', properties: { message: { type: 'string' }, code: { type: 'string' }, data: { type: 'object' }, }, required: ['message', 'code'], }; /** * Wrap a procedure's output schema in the tRPC success envelope. * * tRPC HTTP responses are always serialised as: * `{ result: { data: T } }` * * When the procedure has no output the envelope is still present but * the `data` property is omitted. */ function wrapInSuccessEnvelope( outputSchema: SchemaObject | null, ): SchemaObject { const hasOutput = outputSchema !== null && isNonEmptySchema(outputSchema); const resultSchema: SchemaObject = { type: 'object', properties: { ...(hasOutput ? { data: outputSchema } : {}), }, ...(hasOutput ? { required: ['data'] } : {}), }; return { type: 'object', properties: { result: resultSchema, }, required: ['result'], }; } function buildProcedureOperation( proc: ProcedureInfo, method: 'get' | 'post', ): OperationObject { const [tag = proc.path] = proc.path.split('.'); const operation: OperationObject = { operationId: proc.path, ...(proc.description ? { description: proc.description } : {}), tags: [tag], responses: { '200': { description: 'Successful response', content: { 'application/json': { schema: wrapInSuccessEnvelope(proc.outputSchema), }, }, }, default: { $ref: '#/components/responses/Error' }, }, }; if (proc.inputSchema === null) { return operation; } if (method === 'get') { operation.parameters = [ { name: 'input', in: 'query', required: true, // FIXME: OAS 3.1.1 says a parameter MUST use either schema+style OR content, not both. // style should be removed here, but hey-api requires it to generate a correct query serializer. style: 'deepObject', content: { 'application/json': { schema: proc.inputSchema } }, }, ]; } else { operation.requestBody = { required: true, content: { 'application/json': { schema: proc.inputSchema } }, }; } return operation; } function buildOpenAPIDocument( procedures: ProcedureInfo[], options: GenerateOptions, meta: RouterMeta = { errorSchema: null }, ): Document { const paths: PathsObject = {}; for (const proc of procedures) { if (!shouldIncludeProcedureInOpenAPI(proc.type)) { continue; } const opPath = `/${proc.path}`; const method = proc.type === 'query' ? 'get' : 'post'; const pathItem: PathItemObject = paths[opPath] ?? {}; paths[opPath] = pathItem; pathItem[method] = buildProcedureOperation( proc, method, ) as PathItemObject[typeof method]; } const hasNamedSchemas = meta.schemas !== undefined && Object.keys(meta.schemas).length > 0; return { openapi: '3.1.1', jsonSchemaDialect: 'https://spec.openapis.org/oas/3.1/dialect/base', info: { title: options.title ?? 'tRPC API', version: options.version ?? '0.0.0', }, paths, components: { ...(hasNamedSchemas && meta.schemas ? { schemas: meta.schemas } : {}), responses: { Error: { description: 'Error response', content: { 'application/json': { schema: { type: 'object', properties: { error: meta.errorSchema ?? DEFAULT_ERROR_SCHEMA, }, required: ['error'], }, }, }, }, }, }, }; } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Analyse the given TypeScript router file using the TypeScript compiler and * return an OpenAPI 3.1 document describing all query and mutation procedures. * * @param routerFilePath - Absolute or relative path to the file that exports * the AppRouter. * @param options - Optional generation settings (export name, title, version). */ export async function generateOpenAPIDocument( routerFilePath: string, options: GenerateOptions = {}, ): Promise<Document> { const resolvedPath = path.resolve(routerFilePath); const exportName = options.exportName ?? 'AppRouter'; const compilerOptions = loadCompilerOptions(path.dirname(resolvedPath)); const program = ts.createProgram([resolvedPath], compilerOptions); const checker = program.getTypeChecker(); const sourceFile = program.getSourceFile(resolvedPath); if (!sourceFile) { throw new Error(`Could not load TypeScript file: ${resolvedPath}`); } const moduleSymbol = checker.getSymbolAtLocation(sourceFile); if (!moduleSymbol) { throw new Error(`No module exports found in: ${resolvedPath}`); } const tsExports = checker.getExportsOfModule(moduleSymbol); const routerSymbol = tsExports.find((sym) => sym.getName() === exportName); if (!routerSymbol) { const available = tsExports.map((e) => e.getName()).join(', '); throw new Error( `No export named '${exportName}' found in: ${resolvedPath}\n` + `Available exports: ${available || '(none)'}`, ); } // Prefer the value declaration for value exports; fall back to the declared // type for `export type AppRouter = …` aliases. let routerType: ts.Type; if (routerSymbol.valueDeclaration) { routerType = checker.getTypeOfSymbolAtLocation( routerSymbol, routerSymbol.valueDeclaration, ); } else { routerType = checker.getDeclaredTypeOfSymbol(routerSymbol); } const schemaCtx: SchemaCtx = { checker, visited: new Set(), schemas: {}, typeToRef: new Map(), }; // Try to dynamically import the router to extract schema descriptions const runtimeDescriptions = new Map<string, RuntimeDescriptions>(); const router = await tryImportRouter(resolvedPath, exportName); if (router) { collectRuntimeDescriptions(router, '', runtimeDescriptions); } const walkCtx: WalkCtx = { procedures: [], seen: new Set(), schemaCtx, runtimeDescriptions, }; walkType({ type: routerType, ctx: walkCtx, currentPath: '' }); const errorSchema = extractErrorSchema(routerType, checker, schemaCtx); return buildOpenAPIDocument(walkCtx.procedures, options, { errorSchema, schemas: schemaCtx.schemas, }); }