Skip to content

Commit 94b06ba

Browse files
committed
Merge branch 'feat/root-path-option'
2 parents edeb4d5 + 077512c commit 94b06ba

File tree

5 files changed

+241
-12
lines changed

5 files changed

+241
-12
lines changed

src/core/generateOpenApiSpec.test.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,4 +205,179 @@ describe("generateOpenApiSpec", () => {
205205

206206
readFileSpy.mockRestore();
207207
});
208+
209+
it("should handle rootPath option correctly", async () => {
210+
const repoName = "omermecitoglu/example-user-service";
211+
const branchName = "main";
212+
const filePath = "src/app/users/route.ts";
213+
const response = await fetch(`https://raw.githubusercontent.com/${repoName}/refs/heads/${branchName}/${filePath}`);
214+
const example = await response.text();
215+
216+
jest.spyOn(next, "findAppFolderPath").mockResolvedValueOnce("/app");
217+
(getDirectoryItems as jest.Mock<typeof getDirectoryItems>).mockResolvedValue([
218+
"/app/api/v1/test/route.ts",
219+
"/app/api/v1/users/route.ts",
220+
]);
221+
(filterDirectoryItems as jest.Mock<typeof filterDirectoryItems>).mockReturnValue([
222+
"/app/api/v1/test/route.ts",
223+
"/app/api/v1/users/route.ts",
224+
]);
225+
const readFileSpy = jest.spyOn(fs, "readFile").mockImplementation(routePath => {
226+
switch (routePath) {
227+
case "/app/api/v1/test/route.ts":
228+
return Promise.resolve(example);
229+
case "/app/api/v1/users/route.ts":
230+
return Promise.resolve("export async function GET(request: Request) {}");
231+
default:
232+
throw new Error("Unexpected route path");
233+
}
234+
// do nothing
235+
});
236+
(getPackageMetadata as jest.Mock).mockReturnValue({
237+
serviceName: "Test Service",
238+
version: "1.0.0",
239+
});
240+
241+
const result = await generateOpenApiSpec(schemas, { rootPath: "/api/v1" });
242+
243+
expect(result).toEqual({
244+
openapi: "3.1.0",
245+
info: {
246+
title: "Test Service",
247+
version: "1.0.0",
248+
},
249+
paths: {
250+
"/test": {
251+
get: {
252+
summary: "Get all users",
253+
description: "Retrieve a list of users",
254+
operationId: "getUsers",
255+
parameters: [
256+
{
257+
description: "List of the column names",
258+
in: "query",
259+
name: "select",
260+
required: false,
261+
schema: {
262+
default: [],
263+
description: "List of the column names",
264+
items: {
265+
enum: [
266+
"id",
267+
"name",
268+
],
269+
type: "string",
270+
},
271+
type: "array",
272+
},
273+
},
274+
],
275+
requestBody: undefined,
276+
responses: {
277+
200: {
278+
content: {
279+
"application/json": {
280+
schema: {
281+
items: {
282+
$ref: "#/components/schemas/UserDTO",
283+
},
284+
type: "array",
285+
},
286+
},
287+
},
288+
description: "Returns a list of users",
289+
},
290+
400: {
291+
content: undefined,
292+
description: "Bad Request",
293+
},
294+
500: {
295+
content: undefined,
296+
description: "Internal Server Error",
297+
},
298+
},
299+
tags: [
300+
"Users",
301+
],
302+
},
303+
post: {
304+
description: "Create a new user",
305+
operationId: "createUser",
306+
parameters: undefined,
307+
requestBody: {
308+
content: {
309+
"application/json": {
310+
schema: {
311+
$ref: "#/components/schemas/NewUserDTO",
312+
},
313+
},
314+
},
315+
required: true,
316+
},
317+
responses: {
318+
201: {
319+
content: {
320+
"application/json": {
321+
schema: {
322+
$ref: "#/components/schemas/UserDTO",
323+
},
324+
},
325+
},
326+
description: "User created successfully",
327+
},
328+
400: {
329+
content: undefined,
330+
description: "Bad Request",
331+
},
332+
409: {
333+
content: undefined,
334+
description: "Email already exists",
335+
},
336+
500: {
337+
338+
description: "Internal Server Error",
339+
},
340+
},
341+
summary: "Create user",
342+
tags: [
343+
"Users",
344+
],
345+
},
346+
},
347+
},
348+
components: {
349+
schemas: {
350+
UserDTO: {
351+
type: "object",
352+
properties: {
353+
id: {
354+
type: "string",
355+
},
356+
name: {
357+
type: "string",
358+
},
359+
},
360+
additionalProperties: false,
361+
required: ["id", "name"],
362+
},
363+
NewUserDTO: {
364+
type: "object",
365+
properties: {
366+
id: {
367+
type: "string",
368+
},
369+
name: {
370+
type: "string",
371+
},
372+
},
373+
additionalProperties: false,
374+
required: ["name"],
375+
},
376+
},
377+
},
378+
tags: [],
379+
});
380+
381+
readFileSpy.mockRestore();
382+
});
208383
});

src/core/generateOpenApiSpec.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import path from "node:path";
12
import getPackageMetadata from "@omer-x/package-metadata";
23
import clearUnusedSchemas from "./clearUnusedSchemas";
34
import { filterDirectoryItems, getDirectoryItems } from "./dir";
@@ -13,18 +14,21 @@ type GeneratorOptions = {
1314
include?: string[],
1415
exclude?: string[],
1516
routeDefinerName?: string,
17+
rootPath?: string,
1618
};
1719

1820
export default async function generateOpenApiSpec(schemas: Record<string, ZodType>, {
1921
include: includeOption = [],
2022
exclude: excludeOption = [],
2123
routeDefinerName = "defineRoute",
24+
rootPath: additionalRootPath,
2225
}: GeneratorOptions = {}) {
2326
const verifiedOptions = verifyOptions(includeOption, excludeOption);
2427
const appFolderPath = await findAppFolderPath();
2528
if (!appFolderPath) throw new Error("This is not a Next.js application!");
26-
const routes = await getDirectoryItems(appFolderPath, "route.ts");
27-
const verifiedRoutes = filterDirectoryItems(appFolderPath, routes, verifiedOptions.include, verifiedOptions.exclude);
29+
const rootPath = additionalRootPath ? path.resolve(appFolderPath, "./" + additionalRootPath) : appFolderPath;
30+
const routes = await getDirectoryItems(rootPath, "route.ts");
31+
const verifiedRoutes = filterDirectoryItems(rootPath, routes, verifiedOptions.include, verifiedOptions.exclude);
2832
const validRoutes: RouteRecord[] = [];
2933
for (const route of verifiedRoutes) {
3034
const isDocumented = await isDocumentedRoute(route);
@@ -35,7 +39,7 @@ export default async function generateOpenApiSpec(schemas: Record<string, ZodTyp
3539
validRoutes.push(createRouteRecord(
3640
method.toLocaleLowerCase(),
3741
route,
38-
appFolderPath,
42+
rootPath,
3943
routeHandler.apiData,
4044
));
4145
}

src/core/getRoutePathName.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import path from "node:path";
2+
import { describe, expect, it, jest } from "@jest/globals";
3+
import getRoutePathName from "./getRoutePathName";
4+
5+
describe("getRoutePathName", () => {
6+
const rootPath = "/home/omer/Projects/nextjs-app/src/app";
7+
8+
it("should return correct route path by removing rootPath and adjusting path", () => {
9+
const result = getRoutePathName(
10+
"/home/omer/Projects/nextjs-app/src/app/users/[id]/route.ts",
11+
rootPath
12+
);
13+
expect(result).toBe("/users/{id}");
14+
});
15+
16+
it("should replace backslashes with forward slashes", () => {
17+
jest.spyOn(path, "dirname").mockReturnValueOnce("C:\\users\\omer\\Projects\\nextjs-app\\src\\app\\users\\[id]");
18+
jest.spyOn(path, "relative").mockReturnValueOnce("users\\[id]");
19+
const result = getRoutePathName(
20+
"C:\\users\\omer\\Projects\\nextjs-app\\src\\app\\users\\[id]\\route.ts",
21+
"C:\\users\\omer\\Projects\\nextjs-app\\src\\app",
22+
);
23+
expect(result).toBe("/users/{id}");
24+
});
25+
26+
it("should handle nested folders with parameters", () => {
27+
const result = getRoutePathName(
28+
"/home/omer/Projects/nextjs-app/src/app/users/[user]/[post]/route.ts",
29+
rootPath
30+
);
31+
expect(result).toBe("/users/{user}/{post}");
32+
});
33+
34+
it("should remove '/route.ts' if present", () => {
35+
const result = getRoutePathName(
36+
"/home/omer/Projects/nextjs-app/src/app/users/test/route.ts",
37+
rootPath
38+
);
39+
expect(result).toBe("/users/test");
40+
});
41+
42+
it("should handle cases with no parameters", () => {
43+
const result = getRoutePathName(
44+
"/home/omer/Projects/nextjs-app/src/app/users/home/route.ts",
45+
rootPath
46+
);
47+
expect(result).toBe("/users/home");
48+
});
49+
});

src/core/getRoutePathName.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import path from "node:path";
2+
3+
export default function getRoutePathName(filePath: string, rootPath: string) {
4+
const dirName = path.dirname(filePath);
5+
return "/" + path.relative(rootPath, dirName)
6+
.replaceAll("[", "{")
7+
.replaceAll("]", "}")
8+
.replaceAll("\\", "/");
9+
}

src/core/route.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import getRoutePathName from "./getRoutePathName";
12
import maskOperationSchemas from "./operation-mask";
23
import type { OperationObject } from "@omer-x/openapi-types/operation";
34
import type { PathsObject } from "@omer-x/openapi-types/paths";
@@ -9,15 +10,6 @@ export type RouteRecord = {
910
apiData: OperationObject,
1011
};
1112

12-
function getRoutePathName(filePath: string, rootPath: string) {
13-
return filePath
14-
.replace(rootPath, "")
15-
.replace("[", "{")
16-
.replace("]", "}")
17-
.replaceAll("\\", "/")
18-
.replace("/route.ts", "");
19-
}
20-
2113
export function createRouteRecord(method: string, filePath: string, rootPath: string, apiData: OperationObject) {
2214
return {
2315
method: method.toLocaleLowerCase(),

0 commit comments

Comments
 (0)