Skip to content

Commit

Permalink
Merge branch 'main' into add-blog-post-open-api
Browse files Browse the repository at this point in the history
  • Loading branch information
brettimus committed Dec 5, 2024
2 parents 786b72d + ef33428 commit f9f9313
Show file tree
Hide file tree
Showing 28 changed files with 1,425 additions and 560 deletions.
2 changes: 1 addition & 1 deletion api/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "0.12.2",
"version": "0.12.4",
"name": "@fiberplane/studio",
"description": "Local development debugging interface for Hono apps",
"author": "Fiberplane<info@fiberplane.com>",
Expand Down
3 changes: 2 additions & 1 deletion packages/source-analysis/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@fiberplane/source-analysis",
"version": "0.1.0",
"version": "0.3.0",
"type": "module",
"types": "./dist/index.d.ts",
"author": "Fiberplane<info@fiberplane.com>",
Expand All @@ -22,6 +22,7 @@
"devDependencies": {
"@cloudflare/workers-types": "^4.20241011.0",
"@fiberplane/hono-otel": "^0.3.1",
"@hono/zod-openapi": "^0.18.0",
"@types/node": "^22.8.7",
"@types/resolve": "^1.20.6",
"vitest": "^2.1.3"
Expand Down
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);
};
}
29 changes: 29 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,32 @@ 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 f9f9313

Please sign in to comment.