Skip to content

Commit

Permalink
Add zod-openapi support
Browse files Browse the repository at this point in the history
  • Loading branch information
flenter committed Dec 2, 2024
1 parent f852339 commit 1824870
Show file tree
Hide file tree
Showing 15 changed files with 1,090 additions and 530 deletions.
97 changes: 72 additions & 25 deletions packages/source-analysis/src/RoutesMonitor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EventEmitter } from "node:events";
import { statSync } from "node:fs";
import path from "node:path";
import {
type FileRemovedEvent,
Expand All @@ -13,9 +14,10 @@ import { getParsedTsConfig, getTsLib, startServer } from "./service";
import type {
TsISnapShot,
TsLanguageService,
TsPackageType,
TsProgram,
TsType,
} from "./types";
import { debounce } from "./utils";

type AnalysisStarted = {
type: "analysisStarted";
Expand Down Expand Up @@ -68,7 +70,7 @@ export class RoutesMonitor extends EventEmitter<AnalysisEvents> {
/**
* Reference to the users' typescript instance
*/
private _ts: TsType;
private _ts: TsPackageType;

/**
* The language service used to analyze the routes
Expand Down Expand Up @@ -109,7 +111,32 @@ export class RoutesMonitor extends EventEmitter<AnalysisEvents> {

constructor(projectRoot: string) {
super();
this.projectRoot = projectRoot;
const folder = path.isAbsolute(projectRoot)
? projectRoot
: path.resolve(projectRoot);

// check if folder exists
try {
const isDirectory = statSync(folder).isDirectory();
if (!isDirectory) {
throw new Error("Not a directory");
}
} catch (e: unknown) {
if (e instanceof Error) {
logger.error(`Folder ${folder} does not exist. Error: ${e.message}`);
} else {
logger.error("Unknown error while checking folder", {
folder: projectRoot,
error: e,
});
}

logger.warn(
"This is likely to cause issues with the file watcher & source analysis",
);
}

this.projectRoot = folder;
this._ts = getTsLib(this.projectRoot);

// Use the tsconfig include option to determine which files/locations to watch
Expand Down Expand Up @@ -185,7 +212,6 @@ export class RoutesMonitor extends EventEmitter<AnalysisEvents> {
return;
}

logger.debug("Starting to monitor", this.projectRoot);
this.addEventListenersToFileWatcher();
await this.fileWatcher.start();

Expand All @@ -199,12 +225,52 @@ export class RoutesMonitor extends EventEmitter<AnalysisEvents> {
readFile: this.readFile.bind(this),
ts: this._ts,
});
this._program = this._service.getProgram() ?? null;

await this.isCompilerReady();

this._isRunning = true;
}

private async isCompilerReady(): Promise<boolean> {
if (!this._service) {
throw new Error("Service not initialized");
}

// Sometimes the test suite failed (finding 0 routes) so now we check if all files
// we are watching are in the program before we continue
let retryCount = 0;
let valid = true;
do {
valid = true;
this._program = this._service.getProgram() ?? null;
const sourceFiles = this._program?.getSourceFiles();
const sourceFileNames = sourceFiles?.map((sf) => sf.fileName);
for (const watchedFilePath of this.fileWatcher.knownFileNamesArray) {
if (sourceFileNames?.includes(watchedFilePath) !== true) {
const retryDelay = 100 + retryCount * 100;
logger.warn(
`File ${watchedFilePath} not (yet) available in the Typescript program, retrying...`,
{ retryCount, retryDelay },
);

valid = false;
await new Promise((resolve) => setTimeout(resolve, retryDelay));
break;
}
}
} while (!valid && retryCount++ < 5);

if (retryCount >= 5) {
logger.error(
`Failed to monitor all files after ${retryCount} retries. Code analysis might be incomplete.`,
);
}

return valid;
}

public updateRoutesResult() {
if (!this.isRunning) {
if (!this._isRunning) {
throw new Error("Monitor not running");
}

Expand Down Expand Up @@ -255,7 +321,6 @@ export class RoutesMonitor extends EventEmitter<AnalysisEvents> {

return this._ts.sys.readFile(fileName);
}

private getScriptSnapshot(fileName: string): TsISnapShot | undefined {
if (this._aggressiveCaching) {
const info = this.getFileInfo(fileName);
Expand Down Expand Up @@ -314,7 +379,6 @@ export class RoutesMonitor extends EventEmitter<AnalysisEvents> {
}
});
}

public findHonoRoutes() {
this.fileExistsCache = {};

Expand Down Expand Up @@ -367,20 +431,3 @@ export class RoutesMonitor extends EventEmitter<AnalysisEvents> {
return Object.keys(this.fileMap);
}
}

function debounce<T extends (...args: Array<unknown>) => void | Promise<void>>(
func: T,
wait: number,
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout> | null = null;

return (...args: Parameters<T>) => {
if (timeout !== null) {
clearTimeout(timeout);
}

timeout = setTimeout(() => {
func(...args);
}, wait);
};
}
30 changes: 30 additions & 0 deletions packages/source-analysis/src/RoutesResult/RoutesResult.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,33 @@ test("multiple", async () => {
monitor.stop();
}
});

test("zod-openapi", async () => {
const absolutePath = path.join(__dirname, "../../test-cases/zod-openapi");
const monitor = createRoutesMonitor(absolutePath);
monitor.autoCreateResult = false;
try {
await monitor.start();
const factory = monitor.updateRoutesResult();
assert(factory.rootId);

let request = new Request("http://localhost/users", { method: "POST" });
let response = await factory.currentApp.fetch(request);
expect(await response.text()).toEqual("Ok");
expect(factory.getHistoryLength()).toBe(2);
expect(factory.hasVisited(factory.rootId)).toBeTruthy();
expect(factory.getFilesForHistory()).toMatchSnapshot();

factory.resetHistory();
// Try to visit another route
request = new Request("http://localhost/users", { method: "GET" });
response = await factory.currentApp.fetch(request);
expect(await response.text()).toEqual("Ok");
expect(factory.getHistoryLength()).toBe(2);
expect(factory.hasVisited(factory.rootId)).toBeTruthy();
expect(factory.getFilesForHistory()).toMatchSnapshot();

} finally {
monitor.stop();
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,111 @@ const app = new Hono();
app.post("/hello-world", (c) => c.json({ hello: "world" })
/* EOF: other.ts */"
`;

exports[`zod-openapi 1`] = `
"/* index.ts */
import { createRoute,z,z } from "@hono/zod-openapi";
import { basicAuth } from "hono/basic-auth";
const app = new OpenAPIHono();
const NewUserSchema = z
.object({
name: z.string().openapi({
example: "John Doe",
description: "The name of the user",
}),
age: z.number().openapi({
example: 42,
}),
})
.openapi("NewUser")
const UserSchema = z
.object({
id: z.number().openapi({
example: 123,
}),
name: z.string().openapi({
example: "John Doe",
}),
age: z.number().openapi({
example: 42,
}),
})
.openapi("User")
app.post("/users", const createUserRoute = createRoute({
method: "post",
path: "/users",
middleware: [
basicAuth({
username: "goose",
password: "honkhonk",
}),
] as const, // Use \`as const\` to ensure TypeScript infers the middleware's Context correctly
request: {
body: {
content: {
"application/json": {
schema: NewUserSchema,
},
},
},
},
responses: {
201: {
content: {
"application/json": {
schema: UserSchema,
},
},
description: "Retrieve the user",
},
},
})
/* EOF: index.ts */"
`;

exports[`zod-openapi 2`] = `
"/* index.ts */
import { createRoute,z,z } from "@hono/zod-openapi";
import { basicAuth } from "hono/basic-auth";
const app = new OpenAPIHono();
const UserSchema = z
.object({
id: z.number().openapi({
example: 123,
}),
name: z.string().openapi({
example: "John Doe",
}),
age: z.number().openapi({
example: 42,
}),
})
.openapi("User")
app.get("/users", createRoute({
method: "get",
path: "/users",
middleware: [
basicAuth({
username: "goose",
password: "honkhonk",
}),
] as const, // Use \`as const\` to ensure TypeScript infers the middleware's Context correctly
request: {},
responses: {
20: {
content: {
"application/json": {
schema: z.Array(UserSchema),
},
},
description: "Retrieve all users",
},
},
})
/* EOF: index.ts */"
`;
2 changes: 1 addition & 1 deletion packages/source-analysis/src/RoutesResult/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ function extractRouteTreeContent(includeIds: boolean, resource: RouteTree) {
? `// id:${resource.id}
`
: ""
}const ${resource.name} = new Hono();`;
}const ${resource.name} = new ${resource.library === "hono" ? "Hono" : "OpenAPIHono"}();`;
if (resource.baseUrl) {
content += `\n${resource.name}.baseUrl = "${resource.baseUrl}";`;
}
Expand Down
Loading

0 comments on commit 1824870

Please sign in to comment.