Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/tspec/src/generator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ export const generateTspec = async (
}

logger.log('Generating OpenAPI spec...');
const openapi = generateOpenApiFromNest(app, {
const openapi = await generateOpenApiFromNest(app, {
title: params.openapi?.title,
version: params.openapi?.version,
description: params.openapi?.description,
Expand Down
208 changes: 193 additions & 15 deletions packages/tspec/src/generator/schemaBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ export interface TypeDefinition {
name: string;
properties: PropertyDefinition[];
description?: string;
/**
* Index signature type for Record<K, V>, Map<K, V>, or { [key: string]: T }
* TODO(cleanup): This field can be removed once TJS is fully integrated.
* @deprecated - Will be removed when TJS fallback is no longer needed
*/
indexSignature?: {
keyType: string;
valueType: string;
};
/**
* Type parameter names for generic types (e.g., ['T'] for DataResponse<T>, ['K', 'V'] for Map<K, V>)
* TODO(cleanup): This field can be removed once TJS is fully integrated.
* @deprecated - Will be removed when TJS fallback is no longer needed
*/
typeParameters?: string[];
}

export interface PropertyDefinition {
Expand All @@ -40,6 +55,15 @@ export interface PropertyDefinition {
maxLength?: number;
pattern?: string;
default?: unknown;
/**
* Index signature for Record<K, V>, Map<K, V>, or { [key: string]: T } properties
* TODO(cleanup): This field can be removed once TJS is fully integrated.
* @deprecated - Will be removed when TJS fallback is no longer needed
*/
indexSignature?: {
keyType: string;
valueType: string;
};
}

export interface EnumDefinition {
Expand All @@ -52,6 +76,8 @@ export interface SchemaBuilderContext {
schemas: Record<string, OpenAPIV3.SchemaObject>;
typeDefinitions: Map<string, TypeDefinition>;
enumDefinitions: Map<string, EnumDefinition>;
/** TJS-generated schemas as fallback for types not found in typeDefinitions */
tjsSchemas?: Record<string, OpenAPIV3.SchemaObject>;
}

/**
Expand Down Expand Up @@ -160,26 +186,45 @@ export const buildSchemaRef = (
}

// Handle generic types like DataResponse<T>, PaginatedResponse<T>, Record<K, V>
// TODO(cleanup): This entire generic handling block can be removed once TJS is fully integrated.
// TJS already resolves generics, so this manual parsing is only needed as fallback.
// @deprecated - Will be removed when TJS fallback is no longer needed
const genericMatch = typeName.match(/^(\w+)<(.+)>$/);
if (genericMatch) {
const [, wrapperType, innerType] = genericMatch;

// Look up the wrapper type definition and substitute the type parameter
// Look up the wrapper type definition
const wrapperDef = typeDefinitions.get(wrapperType);

// TODO(cleanup): indexSignature handling - TJS handles this automatically
// @deprecated - Will be removed when TJS fallback is no longer needed
// If type definition has indexSignature info (from TypeScript compiler), use it
if (wrapperDef?.indexSignature) {
const valueSchema = buildSchemaRef(wrapperDef.indexSignature.valueType, context);
const isUnknownValue = wrapperDef.indexSignature.valueType === 'unknown' ||
wrapperDef.indexSignature.valueType === 'any' ||
Object.keys(valueSchema).length === 0;
return {
type: 'object',
additionalProperties: isUnknownValue ? true : valueSchema,
};
}

// TODO(cleanup): Type parameter substitution - TJS handles this automatically
// @deprecated - Will be removed when TJS fallback is no longer needed
// Substitute type parameter for wrapper types with properties
if (wrapperDef && wrapperDef.properties.length > 0) {
const properties: Record<string, OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject> = {};
const required: string[] = [];

// Parse type arguments from innerType (e.g., "User" or "string, number")
const typeArgs = parseTypeArguments(innerType);
// Get type parameter names from wrapper definition (e.g., ['T'] or ['K', 'V'])
const typeParams = wrapperDef.typeParameters || ['T']; // fallback to 'T' for backward compatibility

for (const prop of wrapperDef.properties) {
// Substitute type parameter T with the actual inner type
let propType = prop.type;
if (propType === 'T') {
propType = innerType;
} else if (propType === 'T[]') {
propType = `${innerType}[]`;
} else if (propType.includes('<T>')) {
propType = propType.replace('<T>', `<${innerType}>`);
}
// Substitute type parameters with actual type arguments
let propType = substituteTypeParameters(prop.type, typeParams, typeArgs);

properties[prop.name] = buildSchemaRef(propType, context);
if (prop.required) {
Expand All @@ -194,8 +239,28 @@ export const buildSchemaRef = (
};
}

// Fallback: just resolve the inner type if wrapper definition not found
return buildSchemaRef(innerType, context);
// TODO(cleanup): Record/Map string parsing - TJS handles this automatically
// @deprecated - Will be removed when TJS fallback is no longer needed
// Fallback: parse Record<K, V> and Map<K, V> from string when no TypeScript info available
if (wrapperType === 'Record' || wrapperType === 'Map') {
const commaIndex = innerType.indexOf(',');
if (commaIndex !== -1) {
const valueType = innerType.slice(commaIndex + 1).trim();
const valueSchema = buildSchemaRef(valueType, context);
const isUnknownValue = valueType === 'unknown' || valueType === 'any' ||
Object.keys(valueSchema).length === 0;
return {
type: 'object',
additionalProperties: isUnknownValue ? true : valueSchema,
};
}
}

// Fallback for other unknown generic types
return {
type: 'object',
additionalProperties: true,
};
}

// Handle Date type
Expand All @@ -222,13 +287,43 @@ export const buildSchemaRef = (

// Register as a reference schema with resolved properties
if (!schemas[schemaName]) {
const { tjsSchemas } = context;
const typeDef = typeDefinitions.get(typeName);
if (typeDef && typeDef.properties.length > 0) {

// Priority 1: Handle types with index signature (Record<K, V>, Map<K, V>, { [key: string]: T })
if (typeDef?.indexSignature) {
const valueSchema = buildSchemaRef(typeDef.indexSignature.valueType, context);
const isUnknownValue = typeDef.indexSignature.valueType === 'unknown' ||
typeDef.indexSignature.valueType === 'any' ||
Object.keys(valueSchema).length === 0;
schemas[schemaName] = {
type: 'object',
description: typeDef.description,
additionalProperties: isUnknownValue ? true : valueSchema,
};
}
// Priority 2: Use manual parsing with typeDefinitions (preserves JSDoc tags)
else if (typeDef && typeDef.properties.length > 0) {
const properties: Record<string, OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject> = {};
const required: string[] = [];

for (const prop of typeDef.properties) {
const baseSchema = buildSchemaRef(prop.type, context);
let baseSchema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject;

// If property has index signature info (from TypeScript's type checker),
// build schema with additionalProperties
if (prop.indexSignature) {
const valueSchema = buildSchemaRef(prop.indexSignature.valueType, context);
const isUnknownValue = prop.indexSignature.valueType === 'unknown' ||
prop.indexSignature.valueType === 'any' ||
Object.keys(valueSchema).length === 0;
baseSchema = {
type: 'object',
additionalProperties: isUnknownValue ? true : valueSchema,
};
} else {
baseSchema = buildSchemaRef(prop.type, context);
}

// Merge JSDoc tags into the schema (skip for $ref schemas)
const propSchema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject = '$ref' in baseSchema
Expand All @@ -247,7 +342,13 @@ export const buildSchemaRef = (
properties,
required: required.length > 0 ? required : undefined,
};
} else {
}
// Priority 3: Use TJS schema as fallback for types not found in typeDefinitions
else if (tjsSchemas && (tjsSchemas[typeName] || tjsSchemas[schemaName])) {
schemas[schemaName] = tjsSchemas[typeName] || tjsSchemas[schemaName];
}
// Priority 4: Empty object schema as last resort
else {
schemas[schemaName] = {
type: 'object',
description: `Schema for ${typeName}`,
Expand All @@ -258,6 +359,81 @@ export const buildSchemaRef = (
return { $ref: `#/components/schemas/${schemaName}` };
};

/**
* Parse type arguments from a comma-separated string.
* Handles nested generics like "Map<string, number>, User"
*
* TODO(cleanup): This function can be removed once TJS is fully integrated.
* TJS already resolves generics, so this manual parsing is only needed as fallback.
* @deprecated - Will be removed when TJS fallback is no longer needed
*/
const parseTypeArguments = (innerType: string): string[] => {
const args: string[] = [];
let depth = 0;
let current = '';

for (const char of innerType) {
if (char === '<') {
depth++;
current += char;
} else if (char === '>') {
depth--;
current += char;
} else if (char === ',' && depth === 0) {
args.push(current.trim());
current = '';
} else {
current += char;
}
}

if (current.trim()) {
args.push(current.trim());
}

return args;
};

/**
* Substitute type parameters with actual type arguments.
* e.g., substituteTypeParameters("T", ["T"], ["User"]) => "User"
* substituteTypeParameters("T[]", ["T"], ["User"]) => "User[]"
* substituteTypeParameters("Array<T>", ["T"], ["User"]) => "Array<User>"
*
* TODO(cleanup): This function can be removed once TJS is fully integrated.
* TJS already resolves generics, so this manual parsing is only needed as fallback.
* @deprecated - Will be removed when TJS fallback is no longer needed
*/
const substituteTypeParameters = (
propType: string,
typeParams: string[],
typeArgs: string[],
): string => {
let result = propType;

for (let i = 0; i < typeParams.length && i < typeArgs.length; i++) {
const param = typeParams[i];
const arg = typeArgs[i];

// Replace exact match (e.g., "T" -> "User")
if (result === param) {
return arg;
}

// Replace array type (e.g., "T[]" -> "User[]")
if (result === `${param}[]`) {
return `${arg}[]`;
}

// Replace in generic (e.g., "Array<T>" -> "Array<User>", "Map<K, V>" -> "Map<string, number>")
// Use word boundary to avoid replacing partial matches
const regex = new RegExp(`\\b${param}\\b`, 'g');
result = result.replace(regex, arg);
}

return result;
};

// Parse inline object types like { status: string; message: string; }
const parseInlineObjectType = (
typeName: string,
Expand Down Expand Up @@ -302,8 +478,10 @@ const parseInlineObjectType = (
export const createSchemaBuilderContext = (
typeDefinitions?: Map<string, TypeDefinition>,
enumDefinitions?: Map<string, EnumDefinition>,
tjsSchemas?: Record<string, any>,
): SchemaBuilderContext => ({
schemas: {},
typeDefinitions: typeDefinitions || new Map(),
enumDefinitions: enumDefinitions || new Map(),
tjsSchemas,
});
15 changes: 12 additions & 3 deletions packages/tspec/src/nestjs/openapiGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
createSchemaBuilderContext,
SchemaBuilderContext,
} from '../generator/schemaBuilder';
import { convertToOpenapiSchemas } from '../generator/openapiSchemaConverter';

export interface GenerateOpenApiOptions {
title?: string;
Expand Down Expand Up @@ -42,12 +43,20 @@ const buildPropertySchema = (
return mergeJsDocAnnotations(baseSchema, prop);
};

export const generateOpenApiFromNest = (
export const generateOpenApiFromNest = async (
app: ParsedNestApp,
options: GenerateOpenApiOptions = {},
): OpenAPIV3.Document => {
): Promise<OpenAPIV3.Document> => {
const paths: OpenAPIV3.PathsObject = {};
const context = createSchemaBuilderContext(app.typeDefinitions, app.enumDefinitions);

// If TJS schemas are available, convert them to OpenAPI format and use them
// This gives us fully resolved schemas without manual parsing
let tjsOpenApiSchemas: Record<string, OpenAPIV3.SchemaObject> | undefined;
if (app.tjsSchemas) {
tjsOpenApiSchemas = await convertToOpenapiSchemas(app.tjsSchemas);
}

const context = createSchemaBuilderContext(app.typeDefinitions, app.enumDefinitions, tjsOpenApiSchemas);

for (const controller of app.controllers) {
const basePath = controller.path ? `/${controller.path}`.replace(/\/+/g, '/') : '';
Expand Down
Loading