Skip to content
Open
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
35 changes: 33 additions & 2 deletions packages/openapi-generator/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,13 +157,42 @@ const app = command({
...Object.values(route.response),
];
});
const componentLocations: Record<string, string> = {};
const componentCollisionCounters: Record<string, number> = {};
const componentNameMapping: Record<string, Record<string, string>> = {};

// Helper to generate unique component names (name, name1, name2, ...)
const getUniqueComponentName = (name: string): string => {
if (components[name] === undefined) {
return name;
}
const counter = (componentCollisionCounters[name] ?? 0) + 1;
componentCollisionCounters[name] = counter;
return `${name}${counter}`;
};

let schema: Schema | undefined;
while (((schema = queue.pop()), schema !== undefined)) {
const refs = getRefs(schema, project.getTypes());
for (const ref of refs) {
if (components[ref.name] !== undefined) {
if (
components[ref.name] !== undefined &&
componentLocations[ref.name] === ref.location
) {
continue;
}

const componentName =
components[ref.name] !== undefined
? getUniqueComponentName(ref.name)
: ref.name;

// Track the mapping: location -> originalName -> finalComponentName
if (componentNameMapping[ref.location] === undefined) {
componentNameMapping[ref.location] = {};
}
componentNameMapping[ref.location]![ref.name] = componentName;

const sourceFile = project.get(ref.location);
if (sourceFile === undefined) {
logError(`Could not find '${ref.name}' from '${ref.location}'`);
Expand Down Expand Up @@ -217,7 +246,8 @@ const app = command({
codecE.right.comment = comment;
}

components[ref.name] = codecE.right;
components[componentName] = codecE.right;
componentLocations[componentName] = ref.location;
queue.push(codecE.right);
}
}
Expand All @@ -231,6 +261,7 @@ const app = command({
servers,
apiSpec,
components,
componentNameMapping,
);

console.log(JSON.stringify(openapi, null, 2));
Expand Down
2 changes: 1 addition & 1 deletion packages/openapi-generator/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export { parseApiSpec, parseApiSpecComment } from './apiSpec';
export { parseCodecInitializer, parsePlainInitializer } from './codec';
export { parseCommentBlock, type JSDoc } from './jsdoc';
export { convertRoutesToOpenAPI } from './openapi';
export { convertRoutesToOpenAPI, type ComponentNameMapping } from './openapi';
export { optimize } from './optimize';
export { Project } from './project';
export { getRefs } from './ref';
Expand Down
87 changes: 57 additions & 30 deletions packages/openapi-generator/src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ import type { Route } from './route';
import type { Schema } from './ir';
import { Block } from 'comment-parser';

export type ComponentNameMapping = Record<string, Record<string, string>>;

export function schemaToOpenAPI(
schema: Schema,
componentNameMapping?: ComponentNameMapping,
): OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject | undefined {
schema = optimize(schema);

Expand Down Expand Up @@ -59,16 +62,19 @@ export function schemaToOpenAPI(
// Or should we just conflate explicit null and undefined properties?
return { nullable: true, enum: [] };
case 'ref':
// Resolve the component name using the mapping (handles collisions)
const resolvedName =
componentNameMapping?.[schema.location]?.[schema.name] ?? schema.name;
// if defaultOpenAPIObject is empty, no need to wrap the $ref in an allOf array
if (Object.keys(defaultOpenAPIObject).length === 0) {
return { $ref: `#/components/schemas/${schema.name}` };
return { $ref: `#/components/schemas/${resolvedName}` };
}
return {
allOf: [{ $ref: `#/components/schemas/${schema.name}` }],
allOf: [{ $ref: `#/components/schemas/${resolvedName}` }],
...defaultOpenAPIObject,
};
case 'array':
const innerSchema = schemaToOpenAPI(schema.items);
const innerSchema = schemaToOpenAPI(schema.items, componentNameMapping);
if (innerSchema === undefined) {
return undefined;
}
Expand Down Expand Up @@ -110,7 +116,7 @@ export function schemaToOpenAPI(
...defaultOpenAPIObject,
properties: Object.entries(schema.properties).reduce(
(acc, [name, prop]) => {
const innerSchema = schemaToOpenAPI(prop);
const innerSchema = schemaToOpenAPI(prop, componentNameMapping);
if (innerSchema === undefined) {
return acc;
}
Expand All @@ -124,7 +130,7 @@ export function schemaToOpenAPI(
case 'intersection':
return {
allOf: schema.schemas.flatMap((s) => {
const innerSchema = schemaToOpenAPI(s);
const innerSchema = schemaToOpenAPI(s, componentNameMapping);
if (innerSchema === undefined) {
return [];
}
Expand All @@ -149,11 +155,14 @@ export function schemaToOpenAPI(
const isOptional =
schema.schemas.length >= 2 && undefinedSchema && nonUndefinedSchema;
if (isOptional) {
return schemaToOpenAPI({
...nonUndefinedSchema,
comment: schema.comment,
...(nullSchema ? { nullable: true } : {}),
});
return schemaToOpenAPI(
{
...nonUndefinedSchema,
comment: schema.comment,
...(nullSchema ? { nullable: true } : {}),
},
componentNameMapping,
);
}

// This is an edge case for something like this -> t.union([WellDefinedCodec, t.unknown])
Expand All @@ -164,18 +173,24 @@ export function schemaToOpenAPI(
const nonUnknownSchemas = schema.schemas.filter((s) => s.type !== 'any');

if (nonUnknownSchemas.length === 1 && nonUnknownSchemas[0] !== undefined) {
return schemaToOpenAPI({
...nonUnknownSchemas[0],
comment: schema.comment,
...(nullSchema ? { nullable: true } : {}),
});
return schemaToOpenAPI(
{
...nonUnknownSchemas[0],
comment: schema.comment,
...(nullSchema ? { nullable: true } : {}),
},
componentNameMapping,
);
} else if (nonUnknownSchemas.length > 1) {
return schemaToOpenAPI({
type: 'union',
schemas: nonUnknownSchemas,
comment: schema.comment,
...(nullSchema ? { nullable: true } : {}),
});
return schemaToOpenAPI(
{
type: 'union',
schemas: nonUnknownSchemas,
comment: schema.comment,
...(nullSchema ? { nullable: true } : {}),
},
componentNameMapping,
);
}
}

Expand All @@ -184,7 +199,7 @@ export function schemaToOpenAPI(
nullable = true;
continue;
}
const innerSchema = schemaToOpenAPI(s);
const innerSchema = schemaToOpenAPI(s, componentNameMapping);
if (innerSchema !== undefined) {
oneOf.push(innerSchema);
}
Expand Down Expand Up @@ -215,11 +230,17 @@ export function schemaToOpenAPI(
return { ...(nullable ? { nullable } : {}), oneOf, ...defaultOpenAPIObject };
}
case 'record':
const additionalProperties = schemaToOpenAPI(schema.codomain);
const additionalProperties = schemaToOpenAPI(
schema.codomain,
componentNameMapping,
);
if (additionalProperties === undefined) return undefined;

if (schema.domain !== undefined) {
const keys = schemaToOpenAPI(schema.domain) as OpenAPIV3.SchemaObject;
const keys = schemaToOpenAPI(
schema.domain,
componentNameMapping,
) as OpenAPIV3.SchemaObject;
if (keys.type === 'string' && keys.enum !== undefined) {
const properties = keys.enum.reduce((acc, key) => {
return { ...acc, [key]: additionalProperties };
Expand Down Expand Up @@ -333,7 +354,10 @@ export function schemaToOpenAPI(
return openAPIObject;
}

function routeToOpenAPI(route: Route): [string, string, OpenAPIV3.OperationObject] {
function routeToOpenAPI(
route: Route,
componentNameMapping?: ComponentNameMapping,
): [string, string, OpenAPIV3.OperationObject] {
const jsdoc = route.comment !== undefined ? parseCommentBlock(route.comment) : {};
const operationId = jsdoc.tags?.operationId;
const tag = jsdoc.tags?.tag ?? '';
Expand Down Expand Up @@ -367,7 +391,9 @@ function routeToOpenAPI(route: Route): [string, string, OpenAPIV3.OperationObjec
: {
requestBody: {
content: {
'application/json': { schema: schemaToOpenAPI(route.body) },
'application/json': {
schema: schemaToOpenAPI(route.body, componentNameMapping),
},
},
},
};
Expand All @@ -387,7 +413,7 @@ function routeToOpenAPI(route: Route): [string, string, OpenAPIV3.OperationObjec
: {}),
parameters: route.parameters.map((p) => {
// Array types not allowed here
const schema = schemaToOpenAPI(p.schema);
const schema = schemaToOpenAPI(p.schema, componentNameMapping);

if (schema && 'description' in schema) {
delete schema.description;
Expand Down Expand Up @@ -420,7 +446,7 @@ function routeToOpenAPI(route: Route): [string, string, OpenAPIV3.OperationObjec
description,
content: {
'application/json': {
schema: schemaToOpenAPI(response),
schema: schemaToOpenAPI(response, componentNameMapping),
...(example !== undefined ? { example } : undefined),
},
},
Expand All @@ -436,10 +462,11 @@ export function convertRoutesToOpenAPI(
servers: OpenAPIV3.ServerObject[],
routes: Route[],
schemas: Record<string, Schema>,
componentNameMapping?: ComponentNameMapping,
): OpenAPIV3.Document {
const paths = routes.reduce(
(acc, route) => {
const [path, method, pathItem] = routeToOpenAPI(route);
const [path, method, pathItem] = routeToOpenAPI(route, componentNameMapping);
let pathObject = acc[path] ?? {};
pathObject[method] = pathItem;
return { ...acc, [path]: pathObject };
Expand All @@ -449,7 +476,7 @@ export function convertRoutesToOpenAPI(

const openapiSchemas = Object.entries(schemas).reduce(
(acc, [name, schema]) => {
const openapiSchema = schemaToOpenAPI(schema);
const openapiSchema = schemaToOpenAPI(schema, componentNameMapping);
if (openapiSchema === undefined) {
return acc;
} else if ('$ref' in openapiSchema) {
Expand Down
22 changes: 15 additions & 7 deletions packages/openapi-generator/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,39 @@ export class Project {
private processedFiles: Record<string, SourceFile>;
private pendingFiles: Set<string>;
private types: Record<string, string>;
private typeCollisionCounters: Record<string, number>;
private visitedPackages: Set<string>;

constructor(files: Record<string, SourceFile> = {}, knownImports = KNOWN_IMPORTS) {
this.processedFiles = files;
this.pendingFiles = new Set();
this.knownImports = knownImports;
this.types = {};
this.typeCollisionCounters = {};
this.visitedPackages = new Set();
}

add(path: string, sourceFile: SourceFile): void {
this.processedFiles[path] = sourceFile;
this.pendingFiles.delete(path);

// Update types mapping
// Update types mapping with collision handling
for (const exp of sourceFile.symbols.exports) {
this.types[exp.exportedName] = path;
const name = this.getUniqueTypeName(exp.exportedName);
this.types[name] = path;
}
}

private getUniqueTypeName(name: string): string {
if (this.types[name] === undefined) {
return name;
}

const counter = (this.typeCollisionCounters[name] ?? 0) + 1;
this.typeCollisionCounters[name] = counter;
return `${name}${counter}`;
}

get(path: string): SourceFile | undefined {
return this.processedFiles[path];
}
Expand All @@ -62,11 +75,6 @@ export class Project {
continue;
}

// map types to their file path
for (const exp of sourceFile.symbols.exports) {
this.types[exp.exportedName] = path;
}

this.add(path, sourceFile);

// Process imports
Expand Down
Loading