Skip to content
70 changes: 70 additions & 0 deletions backend/__tests__/actions/swagger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,4 +190,74 @@ describe("swagger", () => {
expect(resolved.required).toContain("email");
expect(resolved.required).toContain("password");
});

test("swagger documents response types from TypeScript return types", async () => {
const res = await fetch(url + "/api/swagger");
const response = (await res.json()) as ActionResponse<Swagger>;

// Check the status action response schema
const statusPath = response.paths["/status"]!.get!;
expect(statusPath.responses["200"]).toBeDefined();
const responseContent =
statusPath.responses["200"]!.content["application/json"];
expect(responseContent.schema).toBeDefined();

// Should be a $ref to a response schema
const schema = responseContent.schema;
expect(schema.$ref).toBeDefined();
const refName = schema.$ref.replace("#/components/schemas/", "");
expect(refName).toBe("status_Response");

// The response schema should exist in components
const resolved = response.components.schemas[refName];
expect(resolved).toBeDefined();
expect(resolved.type).toBe("object");
expect(resolved.properties).toBeDefined();

// Check that the status response properties are present
expect(resolved.properties.name).toBeDefined();
expect(resolved.properties.pid).toBeDefined();
expect(resolved.properties.version).toBeDefined();
expect(resolved.properties.uptime).toBeDefined();
expect(resolved.properties.consumedMemoryMB).toBeDefined();

// Verify correct types are inferred (not generic objects)
expect(resolved.properties.name.type).toBe("string");
expect(resolved.properties.pid.type).toBe("number");
expect(resolved.properties.version.type).toBe("string");
expect(resolved.properties.uptime.type).toBe("number");
expect(resolved.properties.consumedMemoryMB.type).toBe("number");

// Ensure these are NOT incorrectly typed as objects with additionalProperties
expect(resolved.properties.name.additionalProperties).toBeUndefined();
expect(resolved.properties.pid.additionalProperties).toBeUndefined();

// Check required fields
expect(resolved.required).toContain("name");
expect(resolved.required).toContain("pid");
expect(resolved.required).toContain("version");
expect(resolved.required).toContain("uptime");
expect(resolved.required).toContain("consumedMemoryMB");
});

test("swagger documents response types for UserCreate action", async () => {
const res = await fetch(url + "/api/swagger");
const response = (await res.json()) as ActionResponse<Swagger>;

// Check the user:create action response schema
const userCreatePath = response.paths["/user"]!.put!;
const responseContent =
userCreatePath.responses["200"]!.content["application/json"];
const schema = responseContent.schema;

expect(schema.$ref).toBeDefined();
const refName = schema.$ref.replace("#/components/schemas/", "");
expect(refName).toBe("user_create_Response");

// The response schema should have a user property
const resolved = response.components.schemas[refName];
expect(resolved).toBeDefined();
expect(resolved.type).toBe("object");
expect(resolved.properties.user).toBeDefined();
});
});
17 changes: 15 additions & 2 deletions backend/actions/swagger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,21 @@ export class Swagger implements Action {
};
}

// Build responses (200 will be generic unless action has a known output schema)
const responses = { ...swaggerResponses };
// Build responses - use generated schema if available
const responses = JSON.parse(JSON.stringify(swaggerResponses));
const responseSchema = api.swagger?.responseSchemas[action.name];
if (responseSchema) {
const schemaName = `${action.name.replace(/:/g, "_")}_Response`;
components.schemas[schemaName] = responseSchema;
responses["200"] = {
description: "successful operation",
content: {
"application/json": {
schema: { $ref: `#/components/schemas/${schemaName}` },
},
},
};
}

// Add path/method
if (!paths[path]) paths[path] = {};
Expand Down
2 changes: 2 additions & 0 deletions backend/bunfig.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[test]
timeout = 15000
1 change: 1 addition & 0 deletions backend/initializers/servers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export class Servers extends Initializer {
super(namespace);
this.loadPriority = 800;
this.startPriority = 550;
this.stopPriority = 100;
this.runModes = [RUN_MODE.SERVER];
}

Expand Down
263 changes: 263 additions & 0 deletions backend/initializers/swagger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
import path from "path";
import { Project, Type, ts } from "ts-morph";
import { api, logger } from "../api";
import { Initializer } from "../classes/Initializer";

const namespace = "swagger";

declare module "../classes/API" {
export interface API {
[namespace]: Awaited<ReturnType<SwaggerInitializer["initialize"]>>;
}
}

type JSONSchema = {
type?: string;
properties?: Record<string, JSONSchema>;
items?: JSONSchema;
oneOf?: JSONSchema[];
required?: string[];
additionalProperties?: boolean | JSONSchema;
enum?: (string | number | boolean | null)[];
const?: unknown;
$ref?: string;
description?: string;
};

/**
* Convert a ts-morph Type to JSON Schema format
*/
function typeToJsonSchema(
type: Type,
visited: Set<string> = new Set(),
): JSONSchema {
const typeText = type.getText();

// Prevent infinite recursion for circular types
if (visited.has(typeText)) {
return { type: "object", additionalProperties: true };
}

// Handle Promise<T> - unwrap to T
if (
type.getSymbol()?.getName() === "Promise" ||
typeText.startsWith("Promise<")
) {
const typeArgs = type.getTypeArguments();
if (typeArgs.length > 0) {
return typeToJsonSchema(typeArgs[0], visited);
}
}

// Handle primitives
if (type.isString() || type.isStringLiteral()) {
if (type.isStringLiteral()) {
return { type: "string", const: type.getLiteralValue() };
}
return { type: "string" };
}

if (type.isNumber() || type.isNumberLiteral()) {
if (type.isNumberLiteral()) {
return { type: "number", const: type.getLiteralValue() };
}
return { type: "number" };
}

if (type.isBoolean() || type.isBooleanLiteral()) {
if (type.isBooleanLiteral()) {
return { type: "boolean", const: type.getLiteralValue() };
}
return { type: "boolean" };
}

if (type.isNull()) {
return { type: "null" as any };
}

if (type.isUndefined()) {
return { type: "undefined" as any };
}

// Handle arrays
if (type.isArray()) {
const elementType = type.getArrayElementType();
if (elementType) {
return {
type: "array",
items: typeToJsonSchema(elementType, visited),
};
}
return { type: "array" };
}

// Handle unions (but not boolean which is true | false)
if (type.isUnion() && !type.isBoolean()) {
const unionTypes = type.getUnionTypes();
// Filter out undefined for optional properties
const nonUndefinedTypes = unionTypes.filter((t) => !t.isUndefined());
if (nonUndefinedTypes.length === 1) {
return typeToJsonSchema(nonUndefinedTypes[0], visited);
}
return {
oneOf: nonUndefinedTypes.map((t) => typeToJsonSchema(t, visited)),
};
}

// Handle objects/interfaces
if (type.isObject()) {
// Check if it's a Date
if (type.getSymbol()?.getName() === "Date") {
return { type: "string", description: "ISO 8601 date string" };
}

// Add to visited set to prevent recursion
const newVisited = new Set(visited);
newVisited.add(typeText);

const properties: Record<string, JSONSchema> = {};
const required: string[] = [];

const typeProperties = type.getProperties();
for (const prop of typeProperties) {
const propName = prop.getName();
// Skip internal properties
if (propName.startsWith("_")) continue;

// Try to get the type - use getTypeAtLocation if declaration exists, otherwise use getDeclaredType
let propType: Type | undefined;
const valueDecl = prop.getValueDeclaration();
if (valueDecl) {
propType = prop.getTypeAtLocation(valueDecl);
} else {
// For computed types without a direct declaration, get the type from declarations
const declarations = prop.getDeclarations();
if (declarations.length > 0) {
propType = prop.getTypeAtLocation(declarations[0]);
}
}

if (!propType) continue;

properties[propName] = typeToJsonSchema(propType, newVisited);

// Check if property is optional
if (!prop.isOptional()) {
required.push(propName);
}
}

const schema: JSONSchema = {
type: "object",
properties,
};

if (required.length > 0) {
schema.required = required;
}

return schema;
}

// Handle any/unknown
if (type.isAny() || type.isUnknown()) {
return { type: "object", additionalProperties: true };
}

// Fallback for complex types
return { type: "object", additionalProperties: true };
}

export class SwaggerInitializer extends Initializer {
constructor() {
super(namespace);
this.loadPriority = 150; // After actions (100)
}

async initialize() {
const responseSchemas: Record<string, JSONSchema> = {};

try {
const tsConfigPath = path.join(api.rootDir, "tsconfig.json");
const hasTsConfig = await Bun.file(tsConfigPath).exists();

const project = new Project({
...(hasTsConfig ? { tsConfigFilePath: tsConfigPath } : {}),
skipAddingFilesFromTsConfig: true,
compilerOptions: {
strict: true,
target: ts.ScriptTarget.ESNext,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
},
});

// Add all source files so types can be resolved across the codebase
project.addSourceFilesAtPaths(path.join(api.rootDir, "**/*.ts"));
// Exclude test files
for (const sourceFile of project.getSourceFiles()) {
if (sourceFile.getFilePath().includes("__tests__")) {
project.removeSourceFile(sourceFile);
}
}

// Process each source file
for (const sourceFile of project.getSourceFiles()) {
const classes = sourceFile.getClasses();

for (const classDecl of classes) {
// Check if class implements Action (has name property and run method)
const nameProperty = classDecl.getProperty("name");
const runMethod =
classDecl.getMethod("run") || classDecl.getProperty("run"); // run can be a property with arrow function

if (!nameProperty || !runMethod) continue;

// Get action name from the name property initializer
const nameInitializer = nameProperty.getInitializer();
if (!nameInitializer) continue;

let actionName = nameInitializer.getText();
// Remove quotes from string literal
actionName = actionName.replace(/^["']|["']$/g, "");

// Get return type of run method
let returnType: Type | undefined;

if (runMethod.getKind() === ts.SyntaxKind.MethodDeclaration) {
// It's a method
const method = classDecl.getMethod("run");
if (method) {
returnType = method.getReturnType();
}
} else {
// It's a property (arrow function)
const prop = classDecl.getProperty("run");
if (prop) {
const propType = prop.getType();
// Get the return type from the function type
const callSignatures = propType.getCallSignatures();
if (callSignatures.length > 0) {
returnType = callSignatures[0].getReturnType();
}
}
}

if (returnType) {
const schema = typeToJsonSchema(returnType);
responseSchemas[actionName] = schema;
logger.debug(`Generated response schema for action: ${actionName}`);
}
}
}

logger.info(
`Generated ${Object.keys(responseSchemas).length} response schemas for swagger`,
);
} catch (error) {
logger.error(`Failed to generate swagger response schemas: ${error}`);
}

return { responseSchemas };
}
}
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"ioredis": "^5.9.2",
"node-resque": "^9.4.0",
"pg": "^8.17.2",
"ts-morph": "^27.0.2",
"typescript": "^5.9.3",
"zod": "^4.3.5"
},
Expand Down
Binary file modified bun.lockb
Binary file not shown.
Loading