Skip to content

Commit 1d2eea9

Browse files
authored
Merge pull request #20 from rhyek/zod-openapi
zod-openapi library
2 parents d6858ca + 95dba4e commit 1d2eea9

Some content is hidden

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

54 files changed

+2385
-4263
lines changed

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,31 @@
11
# Changelog
22

3+
## 1.5.0 (2025-06-22)
4+
5+
### Features
6+
7+
- Replaced `nestjs-zod` with [zod-openapi](https://github.com/samchungy/zod-openapi). The main benefit is output schemas will now emit OpenApi schemas and consequently TypeScript definitions for endpoint payloads that consider `ZodEffects`.
8+
9+
Example:
10+
11+
```typescript
12+
const schema = z.object({
13+
age: z.number().default(30),
14+
});
15+
```
16+
17+
The above schema when used in `input` will still mark `age` as optional, but when used in `output` it will not.
18+
19+
```typescript
20+
type ExampleInput = {
21+
age?: number;
22+
};
23+
24+
type ExampleOutput = {
25+
age: number;
26+
};
27+
```
28+
329
## 1.4.0 (2025-06-13)
430

531
### Features

README.md

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ const { data: greeting, error, status } = useHelloWorld();
4242
- **File-based routing:** Endpoints' HTTP paths are based on their path on disk.
4343
- **Traditional setup**: Explicitly set HTTP paths and import endpoints like regular controlllers.
4444
- **Schema validation:** Compile and run-time validation of input and output values using Zod schemas.
45-
- **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/).
45+
- **End-to-end type safety:** Auto-generates `axios` and `@tanstack/react-query` client libraries. Internally uses `@nestjs/swagger`, [zod-openapi](https://github.com/samchungy/zod-openapi), and [orval](https://orval.dev/).
4646
- **HTTP adapter agnostic:** Works with both Express and Fastify NestJS applications.
4747
- **Supports CommonJS and ESM**
4848

@@ -341,6 +341,37 @@ More examples:
341341
- [axios](https://github.com/rhyek/nestjs-endpoints/blob/f9fc77c0af9439e35e2ed3f26aa3e645795ed44f/packages/test/test-app-express-cjs/test/client.e2e-spec.ts#L15)
342342
- [react-query](https://github.com/rhyek/nestjs-endpoints/tree/main/packages/test/test-react-query-client)
343343

344+
### Handling ZodEffects in output schemas
345+
346+
The following configuration will not work:
347+
348+
```typescript
349+
export default endpoint({
350+
...
351+
output: z.object({
352+
name: z.string()
353+
.transform((s) => s.toUpperCase())
354+
}),
355+
...
356+
})
357+
```
358+
359+
The `.transform` creates a `ZodEffect` whos output type in some cases cannot be known at run-time (only compile-time), and an OpenApi schema cannot be inferred. More info [here](https://github.com/samchungy/zod-openapi?tab=readme-ov-file#effecttype).
360+
361+
To fix it, do the following:
362+
363+
```typescript
364+
export default endpoint({
365+
...
366+
output: z.object({
367+
name: z.string()
368+
.transform((s) => s.toUpperCase())
369+
.openapi({ effectType: 'same' }),
370+
}),
371+
...
372+
})
373+
```
374+
344375
### Manual codegen with OpenAPI spec file
345376

346377
If you just need the OpenAPI spec file or prefer to configure orval or some other tool yourself, you can do the following:

eslint.config.js

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
// @ts-check
22
import eslint from '@eslint/js';
33
import importPlugin from 'eslint-plugin-import';
4-
import pluginJest from 'eslint-plugin-jest';
54
import eslintConfigPrettier from 'eslint-plugin-prettier/recommended';
65
import unusedImports from 'eslint-plugin-unused-imports';
76
import tseslint from 'typescript-eslint';
@@ -81,16 +80,6 @@ export default tseslint.config(
8180
'no-console': 'error',
8281
},
8382
},
84-
{
85-
files: ['packages/**/*.e2e-spec.ts'],
86-
...pluginJest.configs['flat/recommended'],
87-
rules: {
88-
...pluginJest.configs['flat/recommended'].rules,
89-
'jest/expect-expect': 'off',
90-
'jest/no-disabled-tests': 'error',
91-
'jest/no-focused-tests': 'error',
92-
},
93-
},
9483
{
9584
files: ['packages/test/test-react-query-client/**'],
9685
rules: {

package.json

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,27 +9,22 @@
99
"prepare": "husky"
1010
},
1111
"devDependencies": {
12-
"@eslint/js": "^9.20.0",
12+
"@eslint/js": "^9.29.0",
1313
"@nestjs/common": "^11.0.13",
1414
"@nestjs/core": "^11.0.13",
1515
"@nestjs/swagger": "^11.1.1",
1616
"@types/node": "^22.13.1",
1717
"@typescript-eslint/parser": "^8.23.0",
18-
"eslint": "^9.20.1",
19-
"eslint-config-prettier": "^9.0.0",
18+
"eslint": "^9.29.0",
19+
"eslint-config-prettier": "^10.1.5",
2020
"eslint-plugin-import": "^2.31.0",
21-
"eslint-plugin-jest": "^28.11.0",
22-
"eslint-plugin-prettier": "^5.0.0",
21+
"eslint-plugin-prettier": "^5.4.1",
2322
"eslint-plugin-unused-imports": "^4.1.4",
2423
"husky": "^9.1.7",
2524
"lint-staged": "^15.4.3",
2625
"prettier": "^3.0.0",
2726
"typescript-eslint": "^8.24.0",
28-
"zod": "^3.24.1"
29-
},
30-
"pnpm": {
31-
"overrides": {
32-
"reflect-metadata": "0.1.14"
33-
}
27+
"zod": "^3.25.67",
28+
"reflect-metadata": "^0.1.13"
3429
}
3530
}

packages/nestjs-endpoints/package.json

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -26,41 +26,37 @@
2626
"src",
2727
"dist"
2828
],
29+
"main": "./dist/cjs/index.js",
30+
"types": "./dist/esm/index.d.ts",
2931
"exports": {
3032
".": {
31-
"require": {
32-
"default": "./dist/cjs/index.js",
33-
"types": "./dist/cjs/index.d.ts"
34-
},
35-
"import": {
36-
"default": "./dist/esm/index.js",
37-
"types": "./dist/esm/index.d.ts"
38-
}
33+
"require": "./dist/cjs/index.js",
34+
"import": "./dist/esm/index.js"
3935
}
4036
},
41-
"types": "./dist/cjs/index.d.ts",
37+
"sideEffects": false,
4238
"scripts": {
43-
"dev": "tsc-watch --preserveWatchOutput --onSuccess \"../../scripts/build.sh\""
39+
"dev": "tsc-watch --preserveWatchOutput --onSuccess \"../../scripts/build.sh\"",
40+
"test": "vitest"
4441
},
4542
"dependencies": {
4643
"@orval/query": "~7.8.0",
4744
"callsites": "^3.1.0",
48-
"nestjs-zod": "npm:@rhyek/[email protected]",
49-
"orval": "~7.8.0"
45+
"openapi-types": "^12.1.3",
46+
"orval": "~7.8.0",
47+
"zod-openapi": "^4.2.4"
5048
},
5149
"peerDependencies": {
5250
"@nestjs/common": ">=10.0.0",
5351
"@nestjs/core": ">=10.0.0",
5452
"@nestjs/swagger": ">=7.0.0",
55-
"zod": ">=3.0.0"
53+
"zod": ">=3.25.0"
5654
},
5755
"devDependencies": {
58-
"@types/jest": "^29.5.2",
5956
"@types/node": "^22.13.1",
60-
"jest": "^29.5.0",
61-
"ts-jest": "^29.1.0",
6257
"tsc-alias": "^1.8.10",
6358
"tsc-watch": "^6.2.1",
64-
"typescript": "^5.7.3"
59+
"typescript": "^5.7.3",
60+
"vitest": "^3.2.4"
6561
}
6662
}

packages/nestjs-endpoints/src/codegen/setup.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Writable } from 'node:stream';
44
import { INestApplication } from '@nestjs/common';
55
import { DocumentBuilder } from '@nestjs/swagger';
66
import type { QueryOptions } from 'orval';
7-
import { setupOpenAPI } from '../setup';
7+
import { setupOpenAPI } from '../setup-openapi';
88
import { axios } from './builder/axios';
99
import { reactQuery } from './builder/react-query';
1010

packages/nestjs-endpoints/src/consts.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@ export const settings: {
88
basePath: string;
99
}) => void;
1010
}[];
11+
openapi: {
12+
components: {
13+
schemas: Record<string, any>;
14+
};
15+
};
1116
} = {
1217
endpoints: [],
18+
openapi: {
19+
components: {
20+
schemas: {},
21+
},
22+
},
1323
};
24+
25+
export const openApiVersion = '3.0.0';

packages/nestjs-endpoints/src/endpoint-fn.ts

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,20 @@ import {
1616
} from '@nestjs/common';
1717
import { HttpAdapterHost } from '@nestjs/core';
1818
import { ApiBody, ApiOperation, ApiResponse } from '@nestjs/swagger';
19+
import { z, ZodSchema } from 'zod';
20+
import { settings } from './consts';
1921
import {
20-
createZodDto,
2122
ZodSerializationException,
2223
ZodValidationException,
23-
} from 'nestjs-zod';
24-
import { z, ZodSchema } from 'zod';
25-
import { settings } from './consts';
24+
} from './exceptions';
2625
import {
2726
ApiQueries,
2827
getCallsiteFile,
2928
getEndpointHttpPath,
3029
getHttpPathPascalName,
3130
moduleAls,
3231
} from './helpers';
32+
import { zodToOpenApi } from './zod-to-openapi';
3333

3434
type HttpMethod =
3535
| 'get'
@@ -39,6 +39,7 @@ type HttpMethod =
3939
| 'patch'
4040
| 'head'
4141
| 'options';
42+
4243
const httpMethodDecorators = {
4344
get: Get,
4445
post: Post,
@@ -563,29 +564,37 @@ export function endpoint<
563564
...(decorators ?? []),
564565
];
565566
if (input) {
566-
const schema = input instanceof SchemaDef ? input.schema : input;
567-
const dto = createZodDto(schema as any);
567+
const schema: ZodSchema =
568+
input instanceof SchemaDef ? input.schema : input;
568569
const schemaName = httpPathPascalName + 'Input';
569-
Object.defineProperty(dto, 'name', { value: schemaName });
570570
if (httpMethod === 'get') {
571-
methodDecorators.push(ApiQueries(dto.schema as any));
571+
methodDecorators.push(ApiQueries(schema as any));
572572
} else {
573-
methodDecorators.push(ApiBody({ type: dto }));
573+
const { openApiSchema } = zodToOpenApi({
574+
schema,
575+
schemaType: 'input',
576+
ref: schemaName,
577+
});
578+
methodDecorators.push(ApiBody({ schema: openApiSchema }));
574579
}
575580
}
576581
if (outputSchemas) {
577582
for (const [status, schema] of Object.entries(outputSchemas)) {
578-
const s = schema instanceof SchemaDef ? schema.schema : schema;
579-
const dto = createZodDto(s as any);
583+
const s: ZodSchema =
584+
schema instanceof SchemaDef ? schema.schema : schema;
580585
const schemaName =
581586
httpPathPascalName +
582587
`${status === '200' ? '' : status}` +
583588
'Output';
584-
Object.defineProperty(dto, 'name', { value: schemaName });
589+
const { openApiSchema } = zodToOpenApi({
590+
schema: s,
591+
schemaType: 'output',
592+
ref: schemaName,
593+
});
585594
methodDecorators.push(
586595
ApiResponse({
587596
status: Number(status),
588-
type: dto,
597+
schema: openApiSchema,
589598
description:
590599
schema instanceof SchemaDef ? schema.description : undefined,
591600
}),
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import {
2+
BadRequestException,
3+
HttpStatus,
4+
InternalServerErrorException,
5+
} from '@nestjs/common';
6+
import { ZodError } from 'zod';
7+
8+
export class ZodValidationException extends BadRequestException {
9+
constructor(private error: ZodError) {
10+
super({
11+
statusCode: HttpStatus.BAD_REQUEST,
12+
message: 'Validation failed',
13+
errors: error.errors,
14+
});
15+
}
16+
17+
public getZodError() {
18+
return this.error;
19+
}
20+
}
21+
22+
export class ZodSerializationException extends InternalServerErrorException {
23+
constructor(private error: ZodError) {
24+
super();
25+
}
26+
27+
public getZodError() {
28+
return this.error;
29+
}
30+
}

packages/nestjs-endpoints/src/helpers.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import path from 'node:path';
33
import { applyDecorators } from '@nestjs/common';
44
import { ApiQuery, ApiQueryOptions } from '@nestjs/swagger';
55
import callsites from 'callsites';
6-
import { zodToOpenAPI } from 'nestjs-zod';
76
import { z, ZodRawShape } from 'zod';
7+
import { createSchema } from 'zod-openapi';
8+
import { zodToOpenApi } from './zod-to-openapi';
89

910
function isDirPathSegment(dir: string) {
1011
const segment = path.basename(dir);
@@ -70,16 +71,24 @@ export const ApiQueries = <T extends z.ZodObject<ZodRawShape>>(
7071
zodObject: T,
7172
) => {
7273
const optionsList = Object.keys(zodObject.shape).reduce<
73-
Array<ApiQueryOptions & { schema: ReturnType<typeof zodToOpenAPI> }>
74+
Array<
75+
ApiQueryOptions & {
76+
schema: ReturnType<typeof createSchema>['schema'];
77+
}
78+
>
7479
>((acc, name) => {
7580
const zodType = zodObject.shape[name];
76-
77-
if (zodType)
81+
if (zodType) {
82+
const { openApiSchema } = zodToOpenApi({
83+
schema: zodType,
84+
schemaType: 'input',
85+
});
7886
acc.push({
7987
name,
8088
required: !zodType.isOptional(),
81-
schema: (zodToOpenAPI as any)(zodType),
89+
schema: openApiSchema,
8290
});
91+
}
8392

8493
return acc;
8594
}, []);

0 commit comments

Comments
 (0)