Skip to content

Commit 99402aa

Browse files
committed
feat: support route definition through zod
1 parent e033d40 commit 99402aa

File tree

21 files changed

+2530
-362
lines changed

21 files changed

+2530
-362
lines changed

.changeset/fresh-actors-vanish.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"example": minor
3+
"@ddadaal/next-typed-api-routes-cli": minor
4+
"@ddadaal/next-typed-api-routes-runtime": minor
5+
---
6+
7+
Support route definition through zod

example/jest.config.mjs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import nextJest from "next/jest.js";
2+
3+
const createJestConfig = nextJest({
4+
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
5+
dir: "./",
6+
7+
});
8+
9+
// Add any custom config to be passed to Jest
10+
/** @type {import('jest').Config} */
11+
const config = {
12+
// Add more setup options before each test is run
13+
// setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
14+
15+
testEnvironment: "./tests/testEnv.ts",
16+
setupFilesAfterEnv: ["<rootDir>/tests/jest.setup.js"],
17+
18+
};
19+
20+
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
21+
export default createJestConfig(config);

example/package.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"dev": "next dev",
77
"build": "next build",
88
"start": "next start",
9-
"lint": "next lint"
9+
"lint": "next lint",
10+
"test": "jest"
1011
},
1112
"dependencies": {
1213
"@ddadaal/next-typed-api-routes-runtime": "workspace:*",
@@ -18,6 +19,11 @@
1819
"@ddadaal/next-typed-api-routes-cli": "workspace:*",
1920
"@next/bundle-analyzer": "^12.0.4",
2021
"@types/react": "18.0.12",
21-
"@babel/core": ">=7.0.0 <8.0.0"
22+
"@babel/core": ">=7.0.0 <8.0.0",
23+
"jest": "29.5.0",
24+
"jest-environment-jsdom": "29.5.0",
25+
"@testing-library/react": "14.0.0",
26+
"@testing-library/jest-dom": "5.16.5",
27+
"whatwg-fetch": "3.6.2"
2228
}
2329
}

example/src/apis/api.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
/* eslint-disable max-len */
22

3-
import { fromApi } from "@ddadaal/next-typed-api-routes-runtime/lib/client";
3+
import { fromApi, fromZodRoute } from "@ddadaal/next-typed-api-routes-runtime/lib/client";
44
import { join } from "path";
55
import type { LoginSchema } from "src/pages/api/login/[username]";
66
import type { RegisterSchema } from "src/pages/api/register/index";
7+
import type { ZodRouteSchema } from "src/pages/api/zodRoute/[test]";
8+
79

810

911
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || "";
@@ -12,4 +14,5 @@ const basePath = process.env.NEXT_PUBLIC_BASE_PATH || "";
1214
export const api = {
1315
login: fromApi<LoginSchema>("GET", join(basePath, "/api/login/[username]")),
1416
register: fromApi<RegisterSchema>("POST", join(basePath, "/api/register")),
17+
zodRoute: fromZodRoute<typeof ZodRouteSchema>("POST", join(basePath, "/api/zodRoute/[test]")),
1518
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { z, zodRoute, zodRouteSchema } from "@ddadaal/next-typed-api-routes-runtime";
2+
3+
export const ZodRouteSchema = zodRouteSchema({
4+
method: "POST",
5+
body: z.object({ info: z.string() }),
6+
query: z.object({ test: z.string() }),
7+
responses: {
8+
200: z.object({ hello: z.string() }),
9+
404: z.object({ error: z.string() }),
10+
},
11+
});
12+
13+
export default zodRoute(ZodRouteSchema, async (req) => {
14+
return { 200: { hello: `${req.body.info} + ${req.query.test}` } };
15+
});

example/tests/apis.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { api } from "../src/apis/api";
2+
3+
it("should call zodRoute", async () => {
4+
const resp = await api.zodRoute({ body: { info: "test" }, query: { test: "123" } });
5+
6+
expect(resp.hello).toBe("test 123");
7+
});

example/tests/jest.setup.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import '@testing-library/jest-dom/extend-expect';
2+
3+

example/tests/testEnv.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import JSDOMEnvironment from "jest-environment-jsdom";
2+
3+
// eslint-disable-next-line max-len
4+
// https://github.com/facebook/jest/blob/v29.4.3/website/versioned_docs/version-29.4/Configuration.md#testenvironment-string
5+
export default class FixJSDOMEnvironment extends JSDOMEnvironment {
6+
constructor(...args: ConstructorParameters<typeof JSDOMEnvironment>) {
7+
super(...args);
8+
9+
// FIXME https://github.com/jsdom/jsdom/issues/1724
10+
this.global.fetch = fetch;
11+
this.global.Headers = Headers;
12+
this.global.Request = Request;
13+
this.global.Response = Response;
14+
}
15+
}

package.json

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,17 @@
1111
"@types/eslint": "8.4.2",
1212
"@types/jest": "28.1.1",
1313
"@types/node": "17.0.40",
14-
"@typescript-eslint/eslint-plugin": "5.27.0",
15-
"@typescript-eslint/parser": "5.27.0",
16-
"eslint": "8.17.0",
17-
"@ddadaal/eslint-config": "1.5.0",
14+
"@typescript-eslint/eslint-plugin": "5.59.7",
15+
"@typescript-eslint/parser": "5.59.7",
16+
"eslint": "8.41.0",
17+
"@ddadaal/eslint-config": "1.9.0",
1818
"eslint-plugin-react": "7.30.0",
1919
"rimraf": "^3.0.2",
2020
"standard-version": "^9.3.2",
21-
"typescript": "4.7.3",
22-
"eslint-plugin-import": "2.26.0",
23-
"eslint-plugin-simple-import-sort": "7.0.0",
24-
"eslint-import-resolver-typescript": "2.7.1",
21+
"typescript": "5.0.4",
22+
"eslint-plugin-import": "2.27.5",
23+
"eslint-plugin-simple-import-sort": "10.0.0",
24+
"eslint-import-resolver-typescript": "3.5.5",
2525
"@changesets/changelog-git": "0.1.11",
2626
"jest": "28.1.0",
2727
"ts-jest": "28.0.4",

packages/cli/src/generateClients.ts

Lines changed: 85 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@ interface Import {
2121
relativePath: string;
2222
}
2323

24+
export type ApiType = "zod" | "ajv";
25+
2426
interface Endpoint {
25-
schemaName: string;
26-
interfaceName: string;
27+
functionName: string;
28+
importName: string;
29+
type: ApiType;
2730
method: string;
2831
url: string;
2932
}
@@ -50,49 +53,86 @@ async function getApiObject(
5053
ts.ScriptTarget.Latest,
5154
);
5255

53-
// find the interface with name ending with "Schema"
54-
const interfaceStatement = sourceFile.statements.find((x) =>
55-
ts.isInterfaceDeclaration(x)
56-
&& x.name.text.endsWith("Schema") && x.name.text.length > "Schema".length);
56+
let found: { typeName: string; method: string; apiType: ApiType } | undefined = undefined;
57+
58+
for (const statement of sourceFile.statements) {
59+
60+
// if there is a object that ends with Schema, consider it as a zod schema
61+
if (ts.isVariableStatement(statement)) {
62+
const declaration = statement.declarationList.declarations[0];
63+
64+
if (ts.isIdentifier(declaration.name)
65+
&& declaration.name.text.endsWith("Schema") && declaration.name.text.length > "Schema".length
66+
&& declaration.initializer && ts.isCallExpression(declaration.initializer)
67+
&& ts.isObjectLiteralExpression(declaration.initializer.arguments[0])
68+
) {
69+
const schema = declaration.initializer.arguments[0];
70+
71+
for (const property of schema.properties) {
72+
73+
if (ts.isPropertyAssignment(property) &&
74+
property.name && ts.isIdentifier(property.name)
75+
&& property.name.escapedText === "method"
76+
&& ts.isStringLiteral(property.initializer)
77+
) {
78+
found = {
79+
apiType: "zod",
80+
typeName: declaration.name.text,
81+
method: property.initializer.text,
82+
};
83+
84+
break;
85+
}
86+
}
87+
}
88+
}
5789

58-
if (!interfaceStatement) {
59-
continue;
60-
}
90+
if (found) {
91+
break;
92+
}
6193

62-
const interfaceDeclaration = interfaceStatement as ts.InterfaceDeclaration;
6394

64-
const interfaceName = interfaceDeclaration.name.text;
95+
if (ts.isInterfaceDeclaration(statement)
96+
&& statement.name.text.endsWith("Schema") && statement.name.text.length > "Schema".length) {
6597

66-
// Get method from method property
67-
let methodName: string | undefined = undefined;
68-
for (const s of interfaceDeclaration.members) {
69-
if (ts.isPropertySignature(s) &&
70-
ts.isIdentifier(s.name) && s.name.text === "method" &&
71-
s.type && ts.isLiteralTypeNode(s.type) && ts.isStringLiteral(s.type.literal)
72-
) {
73-
methodName = s.type.literal.text;
74-
break;
75-
}
76-
}
98+
const interfaceName = statement.name.text;
7799

78-
if (!methodName) {
79-
continue;
100+
// Get method from method property
101+
for (const s of statement.members) {
102+
if (ts.isPropertySignature(s) &&
103+
ts.isIdentifier(s.name) && s.name.text === "method" &&
104+
s.type && ts.isLiteralTypeNode(s.type) && ts.isStringLiteral(s.type.literal)
105+
) {
106+
107+
found = {
108+
typeName: interfaceName,
109+
method: s.type.literal.text,
110+
apiType: "ajv",
111+
};
112+
113+
break;
114+
}
115+
}
116+
117+
}
80118
}
81119

120+
if (!found) { continue; }
82121
// Get URL from file path
83122
imports.push({
84-
interfaceName,
123+
interfaceName: found.typeName,
85124
relativePath: relativePath + "/" + filename,
86125
});
87126

88127
// LoginSchema -> login
89-
const schemaName = interfaceName[0].toLocaleLowerCase()
90-
+ interfaceName.substr(1, interfaceName.length - "Schema".length - 1);
128+
const functionName = found.typeName[0].toLocaleLowerCase()
129+
+ found.typeName.substring(1, found.typeName.length - "Schema".length);
91130

92131
endpoints.push({
93-
method: methodName,
94-
schemaName,
95-
interfaceName,
132+
method: found.method,
133+
importName: found.typeName,
134+
functionName,
135+
type: found.apiType,
96136
url: "/api/" + relativePath + (filename === "index" ? "" : ("/" + filename)),
97137
});
98138
}
@@ -139,13 +179,26 @@ export async function generateClients({
139179
export const ${apiObjectName} = {
140180
${endpoints.map((e) =>
141181
// eslint-disable-next-line max-len
142-
` ${e.schemaName}: fromApi<${e.interfaceName}>("${e.method}", join(basePath, "${e.url}")),`,
182+
` ${e.functionName}: ${e.type === "ajv"
183+
? `fromApi<${e.importName}>`
184+
: `fromZodRoute<typeof ${e.importName}>`
185+
}("${e.method}", join(basePath, "${e.url}")),`,
143186
).join(EOL)}
144187
};
145188
`;
146189

190+
const importsFromRootPackage: string[] = [];
191+
192+
if (endpoints.find((x) => x.type === "ajv")) {
193+
importsFromRootPackage.push("fromApi");
194+
}
195+
196+
if (endpoints.find((x) => x.type === "zod")) {
197+
importsFromRootPackage.push("fromZodRoute");
198+
}
199+
147200
const fetchApiImportDeclaration = `
148-
import { fromApi } from "${fetchImport}";
201+
import { ${importsFromRootPackage.join(", ")} } from "${fetchImport}";
149202
import { join } from "path";
150203
`;
151204

0 commit comments

Comments
 (0)