Skip to content

Commit 8108d40

Browse files
authored
Merge pull request #15 from rhyek/include-orval-codegen
axios client working
2 parents 3b28b78 + 8cca48e commit 8108d40

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+3955
-1460
lines changed

README.md

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
## Introduction
66

7-
**nestjs-endpoints** is a lightweight tool for writing clean and succinct HTTP APIs with NestJS that encourages the [REPR](https://www.apitemplatepack.com/docs/introduction/repr-pattern/) design pattern, code colocation, and the Single Responsibility Principle.
7+
**nestjs-endpoints** is a lightweight tool for writing clean, succinct, end-to-end type-safe HTTP APIs with NestJS that encourages the [REPR](https://www.apitemplatepack.com/docs/introduction/repr-pattern/) design pattern, code colocation, and the Single Responsibility Principle.
88

99
It's inspired by the [Fast Endpoints](https://fast-endpoints.com/) .NET library, [tRPC](https://trpc.io/), and Next.js' file-based routing.
1010

@@ -27,11 +27,10 @@ Hello, World!%
2727

2828
- **Easy setup:** Automatically scans your entire project for endpoint files and loads them.
2929
- **Stable:** Produces regular **NestJS Controllers** under the hood.
30-
- **File-based routing:** Each endpoint's HTTP path is based on their path on disk.
31-
- **User-Friendly API:** Supports both basic and advanced per-endpoint configuration.
30+
- **File-based routing:** Endpoints' HTTP paths are based on their path on disk.
3231
- **Schema validation:** Compile and run-time validation of input and output values using Zod schemas.
32+
- **End-to-end type safety:** Auto-generates `axios` and `@tanstack/react-query` client libraries. Internally uses `@nestjs/swagger`, [nestjs-zod](https://github.com/BenLorantfy/nestjs-zod), and [orval](https://orval.dev/).
3333
- **HTTP adapter agnostic:** Works with both Express and Fastify NestJS applications.
34-
- **Client SDK codegen:** Annotates endpoints using `@nestjs/swagger` and [nestjs-zod](https://github.com/BenLorantfy/nestjs-zod) internally to output an OpenAPI document which [orval](https://orval.dev/) can use to generate a client library.
3534

3635
## Getting Started
3736

@@ -302,39 +301,75 @@ To call this endpoint:
302301
{"id":1,"date":"2021-11-03T00:00:00.000Z","address":"::1"}%
303302
```
304303

305-
## OpenAPI, Codegen setup (optional)
304+
## Codegen (optional)
306305

307-
It's a common practice to automatically generate a client SDK for your API that
308-
you can use in other backend or frontend projects and have the benefit of full-stack type-safety. tRPC and similar libraries have been written to facilitate this.
306+
You can automatically generate a client SDK for your API that can be used in other backend or frontend projects and have the benefit of end-to-end type safety. This will use [orval](https://orval.dev/) internally.
309307

310-
We can achieve the same here in two steps. We first build an OpenAPI document, then use that document's
311-
output with [orval](https://orval.dev/):
308+
### Simple
309+
310+
This is the preferred way of configuring codegen with nestjs-endpoints.
312311

313312
`src/main.ts`
314313

315314
```typescript
316-
import { setupOpenAPI } from 'nestjs-endpoints';
315+
import { setupCodegen } from 'nestjs-endpoints';
317316

318317
async function bootstrap() {
319318
const app = await NestFactory.create(AppModule);
320-
const { document, changed } = await setupOpenAPI(app, {
321-
configure: (builder) => builder.setTitle('My Api'),
322-
outputFile: process.cwd() + '/openapi.json',
319+
await setupCodegen(app, {
320+
clients: [
321+
{
322+
type: 'axios',
323+
outputFile: process.cwd() + '/generated/axios-client.ts',
324+
},
325+
{
326+
type: 'react-query',
327+
outputFile: process.cwd() + '/generated/react-query-client.tsx',
328+
},
329+
],
323330
});
324-
if (changed) {
325-
void import('orval').then(({ generate }) => generate());
326-
}
327331
await app.listen(3000);
328332
}
329333
```
330334

331-
And then you could have something like this available:
335+
And then you'll have these available to use:
332336

333337
```typescript
338+
// axios
334339
const { id } = await userCreate({
335340
name: 'Tom',
336341
337342
});
343+
const user = await userFind({ id });
344+
345+
// react-query
346+
const userCreate = useUserCreate();
347+
const { data: user, error, status } = useUserFind({ id: 1 });
338348
```
339349

340-
Have a look at [this](https://github.com/rhyek/nestjs-endpoints/tree/main/packages/test/test-app-express-cjs) test project to see how you might configure orval to generate an axios-based client and [here](https://github.com/rhyek/nestjs-endpoints/tree/main/packages/test/test-app-express-cjs/test/client.e2e-spec.ts) to understand how you would use it.
350+
Have a look at these examples to see to set up and consume these libraries:
351+
352+
- [axios](https://github.com/rhyek/nestjs-endpoints/tree/main/packages/test/test-app-express-cjs/test/client.e2e-spec.ts)
353+
- [react-query](https://github.com/rhyek/nestjs-endpoints/tree/main/packages/test/test-react-query-client)
354+
355+
### Manual
356+
357+
If you prefer to configure orval yourself and just need the OpenAPI spec file, you can do the following:
358+
359+
`src/main.ts`
360+
361+
```typescript
362+
import { setupOpenAPI } from 'nestjs-endpoints';
363+
364+
async function bootstrap() {
365+
const app = await NestFactory.create(AppModule);
366+
const { document, changed } = await setupOpenAPI(app, {
367+
configure: (builder) => builder.setTitle('My Api'),
368+
outputFile: process.cwd() + '/openapi.json',
369+
});
370+
if (changed) {
371+
void import('orval').then(({ generate }) => generate());
372+
}
373+
await app.listen(3000);
374+
}
375+
```

eslint.config.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,18 @@ export default tseslint.config(
9191
'jest/no-focused-tests': 'error',
9292
},
9393
},
94+
{
95+
files: ['packages/test/test-react-query-client/**'],
96+
rules: {
97+
'no-console': 'off',
98+
'@typescript-eslint/no-misused-promises': 'off',
99+
},
100+
},
101+
{
102+
files: ['packages/test/*/generated/**'],
103+
rules: {
104+
'@typescript-eslint/no-unnecessary-type-assertion': 'off',
105+
'import/order': 'off',
106+
},
107+
},
94108
);

package.json

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@
44
"private": true,
55
"type": "module",
66
"scripts": {
7-
"test": "./scripts/test.sh",
7+
"test": "./scripts/build.sh && ./scripts/test.sh",
88
"dev": "pnpm --filter nestjs-endpoints --filter test-app-express-cjs run --parallel dev",
99
"prepare": "husky"
1010
},
1111
"devDependencies": {
1212
"@eslint/js": "^9.20.0",
13-
"@nestjs/common": "^11.0.8",
14-
"@nestjs/core": "^11.0.8",
15-
"@nestjs/swagger": "^11.0.3",
13+
"@nestjs/common": "^11.0.13",
14+
"@nestjs/core": "^11.0.13",
15+
"@nestjs/swagger": "^11.1.1",
1616
"@types/node": "^22.13.1",
1717
"@typescript-eslint/parser": "^8.23.0",
1818
"eslint": "^9.20.1",
@@ -26,5 +26,10 @@
2626
"prettier": "^3.0.0",
2727
"typescript-eslint": "^8.24.0",
2828
"zod": "^3.24.1"
29+
},
30+
"pnpm": {
31+
"overrides": {
32+
"reflect-metadata": "0.1.14"
33+
}
2934
}
3035
}

packages/nestjs-endpoints/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,10 @@
4343
"dev": "tsc --watch --preserveWatchOutput"
4444
},
4545
"dependencies": {
46+
"@orval/query": "~7.8.0",
4647
"callsites": "^3.1.0",
47-
"nestjs-zod": "^4.2.0"
48+
"nestjs-zod": "^4.2.0",
49+
"orval": "~7.8.0"
4850
},
4951
"peerDependencies": {
5052
"@nestjs/common": ">=10.0.0",
@@ -53,8 +55,11 @@
5355
"zod": ">=3.0.0"
5456
},
5557
"devDependencies": {
58+
"@types/jest": "^29.5.2",
5659
"@types/node": "^22.13.1",
60+
"jest": "^29.5.0",
5761
"tsc-alias": "^1.8.10",
62+
"ts-jest": "^29.1.0",
5863
"typescript": "^5.7.3"
5964
}
6065
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { OutputClientFunc } from 'orval';
2+
3+
export const axios = (): OutputClientFunc => {
4+
return (clients) => {
5+
return {
6+
...clients.axios,
7+
dependencies: () => {
8+
// https://github.com/orval-labs/orval/blob/a154264719ccc49b3ab95dadbb3d62513110d8c3/packages/axios/src/index.ts#L22
9+
return [
10+
{
11+
exports: [
12+
{
13+
name: 'Axios',
14+
default: true,
15+
values: true,
16+
syntheticDefaultImport: true,
17+
},
18+
{ name: 'AxiosRequestConfig' },
19+
{ name: 'AxiosResponse' },
20+
{ name: 'CreateAxiosDefaults' },
21+
{ name: 'AxiosInstance' },
22+
],
23+
dependency: 'axios',
24+
},
25+
];
26+
},
27+
header: () => {
28+
return `
29+
export const createApiClient = (config?: CreateAxiosDefaults | AxiosInstance) => {
30+
const axios =
31+
config &&
32+
'defaults' in config &&
33+
'interceptors' in config &&
34+
typeof config.request === 'function'
35+
? config
36+
: Axios.create(config as CreateAxiosDefaults);
37+
38+
`;
39+
},
40+
footer: (params) => {
41+
const result = clients.axios.footer!(params);
42+
return result.replace(
43+
/return {(.+?)}/,
44+
(_, captured) => `return {${captured}, axios}`,
45+
);
46+
},
47+
};
48+
};
49+
};
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import type { OutputClientFunc } from 'orval';
2+
3+
export const reactQuery = (): OutputClientFunc => {
4+
const fns: string[] = [];
5+
return (clients) => {
6+
return {
7+
...clients['react-query'],
8+
dependencies: (...args) => {
9+
const deps = clients['react-query'].dependencies!(...args);
10+
deps.unshift({
11+
dependency: 'react',
12+
exports: [
13+
{
14+
name: 'React',
15+
default: true,
16+
values: true,
17+
syntheticDefaultImport: true,
18+
},
19+
],
20+
});
21+
deps
22+
.find((dep) => dep.dependency === 'axios')
23+
?.exports.push(
24+
{
25+
name: 'AxiosInstance',
26+
},
27+
{
28+
name: 'CreateAxiosDefaults',
29+
},
30+
);
31+
return deps;
32+
},
33+
header: (params) => {
34+
const operationNames = Object.values(params.verbOptions).map(
35+
(verb) => verb.operationName,
36+
);
37+
return `
38+
const Axios = axios;
39+
export const createApiClient = (config?: CreateAxiosDefaults | AxiosInstance) => {
40+
const axios =
41+
config &&
42+
'defaults' in config &&
43+
'interceptors' in config &&
44+
typeof config.request === 'function'
45+
? config
46+
: Axios.create(config as CreateAxiosDefaults);
47+
${fns.join('\n')}
48+
return {
49+
${operationNames.map((name) => ` ${name},`).join('\n')}
50+
axios
51+
};
52+
};
53+
54+
export type ApiClient = ReturnType<typeof createApiClient>;
55+
56+
export const ApiClientContext = React.createContext<ApiClient>(null as any);
57+
export const ApiClientProvider = ({ client, children }: { client: ApiClient; children: React.ReactNode }) => {
58+
return <ApiClientContext.Provider value={client}>{children}</ApiClientContext.Provider>
59+
};
60+
61+
export const useApiClient = () => {
62+
const client = React.useContext(ApiClientContext);
63+
if (!client) throw new Error('useApiClient must be used within a ApiClientProvider');
64+
return client;
65+
};
66+
`;
67+
},
68+
client: async (verbOptions, options, outputClient, output) => {
69+
const result = await clients['react-query'].client(
70+
verbOptions,
71+
options,
72+
outputClient,
73+
output,
74+
);
75+
76+
const lines = result.implementation.split('\n');
77+
let queryKeyLine: number | null = null;
78+
for (let i = 0; i < lines.length; i++) {
79+
if (lines[i].includes('QueryKey')) {
80+
queryKeyLine = i;
81+
break;
82+
}
83+
}
84+
if (queryKeyLine === null) {
85+
for (let i = 0; i < lines.length; i++) {
86+
if (lines[i].includes('MutationOptions')) {
87+
queryKeyLine = i;
88+
break;
89+
}
90+
}
91+
}
92+
if (queryKeyLine === null) {
93+
throw new Error('No query key found in implementation');
94+
}
95+
const fn = lines
96+
.slice(0, queryKeyLine)
97+
.join('\n')
98+
.replace(/export /, '');
99+
fns.push(fn);
100+
result.implementation = lines.slice(queryKeyLine).join('\n');
101+
result.implementation = result.implementation.replace(
102+
/const mutationOptions\s+=\s+(.+)\(options\);/,
103+
(_, captured) => {
104+
return `
105+
const client = useApiClient();
106+
const mutationOptions = ${captured}(Object.assign({ client }, options));
107+
`;
108+
},
109+
);
110+
result.implementation = result.implementation.replace(
111+
/const queryOptions\s+=\s+(.+)\(((?:params,)?options)\)/,
112+
(_, c1, c2) => {
113+
return `
114+
const client = useApiClient();
115+
const queryOptions = ${c1}(${c2.replace('options', 'Object.assign({ client }, options)')});
116+
`;
117+
},
118+
);
119+
result.implementation = result.implementation.replace(
120+
/options\?: {.+axios\?: AxiosRequestConfig/,
121+
(match) => {
122+
return `${match.replace('options?', 'options')}, client: ApiClient`;
123+
},
124+
);
125+
result.implementation = result.implementation.replace(
126+
/return\s+(.+)\((?:data,)?axiosOptions\)/,
127+
(match, captured) =>
128+
`${match.replace(captured, `options.client.${captured}`)}.then((res) => res.data);`,
129+
);
130+
result.implementation = result.implementation.replace(
131+
/const queryFn.+=>\s+(.+\()(?:params, )?{ signal, ...axiosOptions }\)/,
132+
(match, captured) =>
133+
`${match.replace(captured, `options.client.${captured}`)}.then((res) => res.data)`,
134+
);
135+
result.implementation = result.implementation.replaceAll(
136+
/Awaited<ReturnType<typeof (.+?)>>/g,
137+
(match, captured) =>
138+
`Awaited<ReturnType<ApiClient['${captured}']>>['data']`,
139+
);
140+
return result;
141+
},
142+
};
143+
};
144+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { setupCodegen } from './setup';

0 commit comments

Comments
 (0)