@trpc/openapi
Version:
1,539 lines (1,348 loc) • 44 kB
text/typescript
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,
});
}