@voznov/zod-dto-nestjs 0.3.3 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +156 -38
- package/dist/index.cjs +130 -50
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +96 -15
- package/dist/index.d.ts +96 -15
- package/dist/index.js +127 -48
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
NestJS adapter for [`@voznov/zod-dto`](https://www.npmjs.com/package/@voznov/zod-dto) — validation pipe + automatic Swagger integration.
|
|
4
4
|
|
|
5
|
+
## Highlights
|
|
6
|
+
|
|
7
|
+
- **Auto-Swagger without `@ApiProperty`.** Importing this package registers an `onCreate` hook that decorates every `ZodDto`-derived class with `@ApiProperty` metadata derived from its Zod tree — no manual decorators, no schema duplication. Nested DTOs, discriminated unions, defaults (`z.default`), descriptions (`z.describe`), and recursive cycles (`lazyDto`) all forward into the spec automatically. Import order doesn't matter — DTOs created before the import are retroactively decorated.
|
|
8
|
+
- **`@ZodResponse` with compile-time return-type check.** A single decorator validates the response at runtime _and_ emits `@ApiResponse` / `@ApiOperation`. The method's return type is constrained against the schema via TypeScript overloads — `Promise<NoteDto[]>` mismatches surface as `tsc` errors instead of 500s in production. Multi-response (`@ZodResponse([{ schema: A, status: 200 }, { throws: NotFoundError, status: 404 }])`) emits one `ApiResponse` per entry, dispatches `res.status(...)` on the matched return spec, and validates `HttpException` bodies against throws-specs at matching status.
|
|
9
|
+
|
|
5
10
|
## Install
|
|
6
11
|
|
|
7
12
|
```bash
|
|
@@ -25,10 +30,12 @@ import { z } from 'zod';
|
|
|
25
30
|
|
|
26
31
|
class CreateUserDto extends ZodDto(z.object({ name: z.string(), email: z.email() })) {}
|
|
27
32
|
class UserIdParam extends ZodDto(z.object({ id: z.uuid() })) {}
|
|
28
|
-
class ListUsersQuery extends ZodDto(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
class ListUsersQuery extends ZodDto(
|
|
34
|
+
z.object({
|
|
35
|
+
page: z.coerce.number().int().min(1).default(1),
|
|
36
|
+
limit: z.coerce.number().int().min(1).max(100).default(20),
|
|
37
|
+
}),
|
|
38
|
+
) {}
|
|
32
39
|
|
|
33
40
|
@Controller('users')
|
|
34
41
|
export class UsersController {
|
|
@@ -40,12 +47,16 @@ export class UsersController {
|
|
|
40
47
|
// `@Param() params: UserIdParam` — `id` validated as UUID, format: 'uuid' in the spec.
|
|
41
48
|
// (Cleaner than `@Param('id', ParseUUIDPipe) id: string`, and the spec carries the format.)
|
|
42
49
|
@Get(':id')
|
|
43
|
-
findOne(@Param() params: UserIdParam) {
|
|
50
|
+
findOne(@Param() params: UserIdParam) {
|
|
51
|
+
/* ... */
|
|
52
|
+
}
|
|
44
53
|
|
|
45
54
|
// `@Query() query: ListUsersQuery` — every Zod field becomes one OpenAPI query parameter,
|
|
46
55
|
// with per-field validation, defaults, and descriptions, no extra `@ApiQuery` decorators needed.
|
|
47
56
|
@Get()
|
|
48
|
-
list(@Query() query: ListUsersQuery) {
|
|
57
|
+
list(@Query() query: ListUsersQuery) {
|
|
58
|
+
/* ... */
|
|
59
|
+
}
|
|
49
60
|
}
|
|
50
61
|
```
|
|
51
62
|
|
|
@@ -69,7 +80,7 @@ Supported shapes: scalars, objects (nested), arrays, tuples, records, enums, lit
|
|
|
69
80
|
|
|
70
81
|
`.refine(...)` validators run at runtime (in `ZodValidationPipe` for requests, in `@ZodSerialize` / `@ZodResponse` for responses) but are **not** reflected in the spec — JSON Schema can't express custom predicates, and a single `description` for a chain of refines (`.refine(...).refine(...).refine(...)`) would be ambiguous. Put human-readable docs in `.describe(...)` instead.
|
|
71
82
|
|
|
72
|
-
> ⚠️ **`in` / `out` hooks are runtime-only.** The walker only reads the schema's structure, not the options passed to `ZodDto(schema, { in, out })`. A `out: ({ password, ...rest }) => rest` correctly strips `password` from the response
|
|
83
|
+
> ⚠️ **`in` / `out` hooks are runtime-only.** The walker only reads the schema's structure, not the options passed to `ZodDto(schema, { in, out })`. A `out: ({ password, ...rest }) => rest` correctly strips `password` from the response _body_, but the OpenAPI schema still lists `password` as a property — the spec lies about a field that runtime drops. Same for `in`: snake_case→camelCase aliases applied via `in` are invisible in the spec, so docs show only the camelCase shape. If spec-correctness matters, either omit the field from the schema itself (`schema.omit({ password: true })`) or maintain a separate response DTO.
|
|
73
84
|
|
|
74
85
|
### Reference nested DTOs by class, not by raw schema
|
|
75
86
|
|
|
@@ -100,7 +111,7 @@ class CategoryDto extends ZodDto(
|
|
|
100
111
|
// → at the type level, `instance.children[0].name` is `string`, not `unknown`.
|
|
101
112
|
```
|
|
102
113
|
|
|
103
|
-
`lazyDto<T>(thunk)` is a thin wrapper over `z.lazy` with two type-level tweaks: the explicit generic carries the
|
|
114
|
+
`lazyDto<T>(thunk)` is a thin wrapper over `z.lazy` with two type-level tweaks: the explicit generic carries the _instance type_ `T`, and the thunk argument is typed `() => any` so TS skips body return-type inference (the source of the circular-class error). Plain `z.lazy(...)` works at runtime too, but you'd need a separate `type Category = {...}` + `(): z.ZodType<Category>` annotation to keep the inferred field types non-`unknown`.
|
|
104
115
|
|
|
105
116
|
If the recursive position resolves to an anonymous `ZodObject` (no DTO wrap), the walker emits an empty `{}` placeholder there — it won't crash, but Swagger UI will show `any` instead of the recursive structure. Wrap it in `ZodDto` if you want the cycle visible in your docs.
|
|
106
117
|
|
|
@@ -121,7 +132,7 @@ app.useGlobalPipes(
|
|
|
121
132
|
|
|
122
133
|
## Response validation — `@ZodSerialize` / `@ZodResponse`
|
|
123
134
|
|
|
124
|
-
Method decorators that parse the return value of a controller (or any class) method through a Zod schema. If the method returns something that doesn't match the schema, a `ZodDtoSerializationError` is thrown — caught at runtime
|
|
135
|
+
Method decorators that parse the return value of a controller (or any class) method through a Zod schema. If the method returns something that doesn't match the schema, a `ZodDtoSerializationError` is thrown — caught at runtime _before_ the value reaches the client, so server-side bugs are surfaced as 500s instead of leaking malformed payloads or extra fields.
|
|
125
136
|
|
|
126
137
|
- **`@ZodSerialize`** — runtime parsing only. Use on services, repositories, internal methods.
|
|
127
138
|
- **`@ZodResponse`** — `@ZodSerialize` + auto-emit `@ApiResponse` Swagger metadata (and register inner DTOs via `@ApiExtraModels`). Use on controller routes.
|
|
@@ -138,11 +149,15 @@ export class ZodExceptionFilter implements ExceptionFilter {
|
|
|
138
149
|
catch(error: ZodDtoValidationError, host: ArgumentsHost) {
|
|
139
150
|
const isServerBug = error instanceof ZodDtoSerializationError;
|
|
140
151
|
const status = isServerBug ? HttpStatus.INTERNAL_SERVER_ERROR : HttpStatus.BAD_REQUEST;
|
|
141
|
-
host
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
152
|
+
host
|
|
153
|
+
.switchToHttp()
|
|
154
|
+
.getResponse()
|
|
155
|
+
.status(status)
|
|
156
|
+
.json({
|
|
157
|
+
statusCode: status,
|
|
158
|
+
message: error.message,
|
|
159
|
+
issues: isServerBug ? undefined : error.issues,
|
|
160
|
+
});
|
|
146
161
|
}
|
|
147
162
|
}
|
|
148
163
|
|
|
@@ -150,10 +165,9 @@ export class ZodExceptionFilter implements ExceptionFilter {
|
|
|
150
165
|
app.useGlobalFilters(new ZodExceptionFilter());
|
|
151
166
|
```
|
|
152
167
|
|
|
153
|
-
|
|
168
|
+
`@ZodSerialize` has two overloads — **strict** (schema given explicitly, return-type compile-time-checked against the schema) and **loose** (no schema, resolves from `design:returntype` at runtime). `@ZodResponse` adds two more on top: a `ResponseSpec` object form (`{ schema, status?, description?, throws?, ...ToDtoOptions }`) and a `ResponseSpec[]` array form for multi-response.
|
|
154
169
|
|
|
155
|
-
|
|
156
|
-
- **Loose** — no schema. Resolves from `design:returntype` metadata at runtime (`: NoteDto` annotation suffices). No compile-time check; doesn't work on generic return types (`NoteDto[]`, `Promise<NoteDto>`, unions) since TypeScript erases generics in metadata — pass the schema explicitly in that case.
|
|
170
|
+
Common pattern: pass the schema explicitly when the return type isn't a bare DTO class. Loose form sees `Promise` / `Array` constructors stripped of their generic argument, so `: Promise<NoteDto>` / `: NoteDto[]` won't resolve. The strict form errors as `TS1241: Unable to resolve signature of method decorator...` on schema/return mismatch — the actual mismatch is on the deepest line of the message (`Type 'X' is not assignable to type 'NoteDto | Promise<NoteDto>'`).
|
|
157
171
|
|
|
158
172
|
```ts
|
|
159
173
|
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
|
@@ -165,18 +179,40 @@ export class NotesController {
|
|
|
165
179
|
// Strict + auto-Swagger: tsc enforces the return type, spec gets `$ref` to NoteDto.
|
|
166
180
|
@Get(':id')
|
|
167
181
|
@ZodResponse(NoteDto)
|
|
168
|
-
findOne(@Param() p: NoteIdParam): NoteDto {
|
|
182
|
+
findOne(@Param() p: NoteIdParam): NoteDto {
|
|
183
|
+
/* ... */
|
|
184
|
+
}
|
|
169
185
|
|
|
170
186
|
// Async + array: tsc enforces `Promise<NoteDto[]>`. Generics erased in design:returntype,
|
|
171
187
|
// so the schema must be passed explicitly here.
|
|
172
188
|
@Get()
|
|
173
189
|
@ZodResponse(z.array(NoteDto))
|
|
174
|
-
async list(): Promise<NoteDto[]> {
|
|
190
|
+
async list(): Promise<NoteDto[]> {
|
|
191
|
+
/* ... */
|
|
192
|
+
}
|
|
175
193
|
|
|
176
|
-
// Override status (default 200) + description
|
|
194
|
+
// Override status (default 200) + description via the spec object form. Second argument
|
|
195
|
+
// is the ApiOperation metadata (summary / tags / deprecated / operationId / description).
|
|
177
196
|
@Post()
|
|
178
|
-
@ZodResponse(NoteDto,
|
|
179
|
-
create(@Body() body: CreateNoteDto): NoteDto {
|
|
197
|
+
@ZodResponse({ schema: NoteDto, status: 201, description: 'note created' }, { summary: 'Create a note' })
|
|
198
|
+
create(@Body() body: CreateNoteDto): NoteDto {
|
|
199
|
+
/* ... */
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Multi-response: 200 returns the note, 404 throws `HttpException(notFoundBody, 404)` and we
|
|
203
|
+
// validate that the thrown body matches `NotFoundDto` (shape drift surfaces as ZodDtoSerializationError).
|
|
204
|
+
// `throws:` schemas are NOT in the method's return-type union — the method just returns `NoteDto`.
|
|
205
|
+
@Get('many/:id')
|
|
206
|
+
@ZodResponse(
|
|
207
|
+
[
|
|
208
|
+
{ schema: NoteDto, status: 200, description: 'Found' },
|
|
209
|
+
{ throws: NotFoundDto, status: 404, description: 'Not found' },
|
|
210
|
+
],
|
|
211
|
+
{ summary: 'Get a note' },
|
|
212
|
+
)
|
|
213
|
+
findOneOrThrow(@Param() p: NoteIdParam): NoteDto {
|
|
214
|
+
/* throws HttpException(..., 404) on miss */
|
|
215
|
+
}
|
|
180
216
|
}
|
|
181
217
|
```
|
|
182
218
|
|
|
@@ -188,28 +224,109 @@ import { ZodSerialize } from '@voznov/zod-dto-nestjs';
|
|
|
188
224
|
class NotesService {
|
|
189
225
|
// Throws ZodDtoSerializationError if the return shape doesn't match.
|
|
190
226
|
@ZodSerialize(NoteDto)
|
|
191
|
-
findOne(id: string): NoteDto {
|
|
227
|
+
findOne(id: string): NoteDto {
|
|
228
|
+
/* ... */
|
|
229
|
+
}
|
|
192
230
|
|
|
193
231
|
// Loose: schema resolved from `design:returntype` (NoteDto class). Won't work for `Promise<...>` / `NoteDto[]` — generic erased to `Promise` / `Array`. Use the strict overload for those.
|
|
194
232
|
@ZodSerialize()
|
|
195
|
-
default(): NoteDto {
|
|
233
|
+
default(): NoteDto {
|
|
234
|
+
/* ... */
|
|
235
|
+
}
|
|
196
236
|
}
|
|
197
237
|
```
|
|
198
238
|
|
|
199
239
|
### Options
|
|
200
240
|
|
|
201
|
-
Both decorators accept the full `ToDtoOptions` bag (`preprocessors`, `observers`, `errorClass` — same semantics as [`toDto.with`](https://www.npmjs.com/package/@voznov/zod-dto), but applied to the method's
|
|
241
|
+
Both decorators accept the full `ToDtoOptions` bag (`preprocessors`, `observers`, `errorClass` — same semantics as [`toDto.with`](https://www.npmjs.com/package/@voznov/zod-dto), but applied to the method's _return_ value instead of an input). `@ZodSerialize` takes them as its second argument; `@ZodResponse` takes them **inside the `ResponseSpec`** (per-status). The default `errorClass` is `ZodDtoSerializationError` (vs `ZodDtoValidationError` for `toDto`), so an exception filter can split client errors from server bugs (see below).
|
|
202
242
|
|
|
203
|
-
`@ZodResponse`
|
|
243
|
+
`@ZodResponse` takes two arguments:
|
|
204
244
|
|
|
205
|
-
|
|
206
|
-
-
|
|
245
|
+
1. **Response spec** — what shape the route returns / throws. Accepts one of:
|
|
246
|
+
- a Zod schema or DTO class — simple form, defaults `status` to 200;
|
|
247
|
+
- a `ResponseSpec` object — `{ schema?, throws?, status?, description? }` plus `ToDtoOptions` (`preprocessors`, `observers`, `errorClass`);
|
|
248
|
+
- an array of `ResponseSpec` — multi-response: one `@ApiResponse` per entry, the return value is parsed against the return-specs, thrown `HttpException` bodies are validated against the throws-specs at the matching status (see "throws-marked specs" below);
|
|
249
|
+
- `undefined` (loose form) — resolves the schema from `design:returntype`.
|
|
250
|
+
2. **Operation metadata** — `ApiOperationOptions` (`summary`, `tags`, `description`, `deprecated`, `operationId`, ...). Forwarded straight to `@ApiOperation` so you don't need a second decorator for the common case. An explicit `@ApiOperation({...})` stacked on top still wins on conflicting keys.
|
|
207
251
|
|
|
208
252
|
```ts
|
|
209
|
-
@ZodResponse(NoteDto,
|
|
253
|
+
@ZodResponse({ schema: NoteDto, status: 201, observers: [(note) => metrics.recordCreate(note)] }, { summary: 'Create a note', tags: ['notes'] })
|
|
210
254
|
async create(): Promise<NoteDto> { /* ... */ }
|
|
211
255
|
```
|
|
212
256
|
|
|
257
|
+
> Note on `description`. In the response spec it's the **response description** (the OpenAPI status object's description). In the operation argument it's the **operation description** (the long-form route description). The split is intentional — the previous single `description: string` slot was ambiguous.
|
|
258
|
+
|
|
259
|
+
### Throws-marked specs — validate `HttpException` body against a schema
|
|
260
|
+
|
|
261
|
+
In a `ResponseSpec` array, `{ throws: ErrorDto, status: 4xx }` declares that when the method throws an `HttpException` whose status matches, its body must match `ErrorDto`. Shape drift → `ZodDtoSerializationError` (a server-side 500 — your declared error contract no longer matches what the code actually throws). On match the original exception re-throws untouched, so global exception filters still own the wire format.
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
@Get(':id')
|
|
265
|
+
@ZodResponse([
|
|
266
|
+
{ schema: NoteDto, status: 200 },
|
|
267
|
+
{ throws: NotFoundError, status: 404 }, // method throws HttpException({code, message}, 404) on miss
|
|
268
|
+
])
|
|
269
|
+
findOne(@Param() p: NoteIdParam): NoteDto { // return-type union is just NoteDto — throws-spec doesn't widen it
|
|
270
|
+
const note = this.svc.find(p.id);
|
|
271
|
+
if (!note) throw new HttpException({ code: 'NOT_FOUND', message: `note ${p.id} missing` }, 404);
|
|
272
|
+
return note;
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Key properties:
|
|
277
|
+
|
|
278
|
+
- **`throws:` schemas are excluded from the method's return-type constraint** — `findOne(): NoteDto` compiles even when the array also declares `{throws: NotFoundError, status: 404}`.
|
|
279
|
+
- **HttpException with a status that's not declared as throws passes through** — typical `throw new BadRequestException(...)` (400) won't be touched unless you declared a `{throws: ..., status: 400}` spec.
|
|
280
|
+
- **Non-HttpException throws pass through unchanged** — vanilla `Error` doesn't get validated.
|
|
281
|
+
|
|
282
|
+
### Behind the scenes
|
|
283
|
+
|
|
284
|
+
`@ZodResponse` doesn't wrap the method; it auto-registers a per-method `ZodResponseInterceptor` via `UseInterceptors`. After a successful return-side parse the interceptor writes the response directly via the platform's native chain (Express `res.status(s).json(b)` or Fastify `reply.code(s).send(b)`, picked by feature detection), then emits `EMPTY` so NestJS's default `applicationRef.reply()` doesn't run a second `res.status(verbDefault)` over the dispatched status. Direct-write is the only reliable way to dispatch a non-default status on a per-spec basis: NestJS's `httpStatusCode` is resolved once at bootstrap from `@HttpCode` metadata or the verb default and re-applied to the response on every request, so a runtime `res.status(...)` from inside `map()` would be clobbered.
|
|
285
|
+
|
|
286
|
+
### Composition with other interceptors
|
|
287
|
+
|
|
288
|
+
`@ZodResponse` is a **terminal response contract** — what it declared in `@ApiResponse` (status + body schema) must match what actually goes out, otherwise the Swagger doc lies to clients. So the interceptor owns the write end-to-end and, by design, doesn't cooperate with interceptors that would alter the body or status after it.
|
|
289
|
+
|
|
290
|
+
What composes and what doesn't:
|
|
291
|
+
|
|
292
|
+
| Interceptor pattern | Works? | Why |
|
|
293
|
+
| --------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
|
294
|
+
| Latency / metrics via `finalize` | ✅ | `EMPTY` completes cleanly through `.pipe(...)`, `finalize` fires on completion. |
|
|
295
|
+
| `tap({ complete })` / `tap({ error })` observers | ✅ | Same — completion / error propagate; only `next` is suppressed. |
|
|
296
|
+
| Body-reading loggers (`tap(body => log(body))`) | ❌ | `next` never fires; the body has already been written by `@ZodResponse`. |
|
|
297
|
+
| Body-transforming wrappers (envelope, msgpack, ...) | ❌ | If inner: their transform never runs (`EMPTY`). If outer: they wrap the value before `@ZodResponse` parses → `ZodDtoSerializationError`. |
|
|
298
|
+
| Header / status mutators outside `@ZodResponse` | ❌ | The status `@ZodResponse` dispatched is the contract; later mutators would desync it from the Swagger declaration. |
|
|
299
|
+
|
|
300
|
+
Concrete observability — drop in as a class- or global-level interceptor; works on every route, with or without `@ZodResponse`:
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
import { type CallHandler, type ExecutionContext, Injectable, Logger, type NestInterceptor } from '@nestjs/common';
|
|
304
|
+
import { type Observable, tap } from 'rxjs';
|
|
305
|
+
|
|
306
|
+
@Injectable()
|
|
307
|
+
export class LatencyInterceptor implements NestInterceptor {
|
|
308
|
+
private readonly logger = new Logger(LatencyInterceptor.name);
|
|
309
|
+
|
|
310
|
+
intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
|
|
311
|
+
const start = process.hrtime.bigint();
|
|
312
|
+
const req = ctx.switchToHttp().getRequest<{ method: string; url: string }>();
|
|
313
|
+
|
|
314
|
+
return next.handle().pipe(
|
|
315
|
+
tap({
|
|
316
|
+
// `complete` fires on the inner Observable's completion. `@ZodResponse` emits `EMPTY` after writing
|
|
317
|
+
// the response, which completes immediately — so this callback runs for every `@ZodResponse` route.
|
|
318
|
+
complete: () =>
|
|
319
|
+
this.logger.log(`${req.method} ${req.url} → ${Number(process.hrtime.bigint() - start) / 1e6} ms`),
|
|
320
|
+
}),
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
If you need to _read_ the response body downstream (audit logging, response tracing), `@ZodResponse` doesn't expose the parsed value to outer interceptors — use `observers: [...]` on the spec (`@ZodResponse({ schema: Dto, observers: [(parsed) => audit.log(parsed)] })`) or hook on `res.on('finish')` / `reply.then(...)` at the platform layer.
|
|
327
|
+
|
|
328
|
+
If you need to _transform_ the body (envelope, msgpack), the contract you declared in `@ApiResponse` would no longer match the wire shape — bake the envelope into the schema instead (`z.object({ data: Dto, meta: MetaSchema })`), then `@ZodResponse` documents and validates the _actual_ outgoing shape. That's the only way to keep the Swagger doc honest.
|
|
329
|
+
|
|
213
330
|
### Async refines on the response schema
|
|
214
331
|
|
|
215
332
|
When the method returns a Promise, the decorator awaits it and parses through `safeParseAsync` — that's the only signal it uses. So if your schema has async validation (`z.string().refine(async ...)`, async transforms — typical when the same schema is reused for request validation), make the method `async` and you'll get the async parse path; failures still throw `ZodDtoSerializationError` and flow through your exception filter, not raw Zod async errors.
|
|
@@ -224,15 +341,16 @@ class Repo {
|
|
|
224
341
|
|
|
225
342
|
## API
|
|
226
343
|
|
|
227
|
-
| Export
|
|
228
|
-
|
|
|
229
|
-
| `ZodValidationPipe`
|
|
230
|
-
| `ZodValidationPipeOptions`
|
|
231
|
-
| `ZodSerialize(schema?, options?)`
|
|
232
|
-
| `ZodResponse(
|
|
233
|
-
| `
|
|
234
|
-
| `
|
|
235
|
-
| `
|
|
344
|
+
| Export | Description |
|
|
345
|
+
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
346
|
+
| `ZodValidationPipe` | `PipeTransform` for `@Body()` / `@Param()` / `@Query()`. Accepts `{ createError?: (issues: string[]) => Error }`. |
|
|
347
|
+
| `ZodValidationPipeOptions` | Options type for `ZodValidationPipe`. |
|
|
348
|
+
| `ZodSerialize(schema?, options?)` | Method decorator: runtime-parse the return value through `schema` (or via `design:returntype` if omitted). Throws `ZodDtoSerializationError` on mismatch. |
|
|
349
|
+
| `ZodResponse(response, operation?)` | Auto-registers `ZodResponseInterceptor` for runtime validation + writes `res.status(...).json(...)` directly, emits `@ApiResponse` (and `@ApiExtraModels` for inner DTOs). First arg: schema / `ResponseSpec` / `ResponseSpec[]` / `undefined`. Second arg: `ApiOperationOptions`. |
|
|
350
|
+
| `ResponseSpec<S, T>` | `{ schema?: S; throws?: T; status?: number; description?: string } & ToDtoOptions` — entry shape for `@ZodResponse`'s first argument. `schema` validates returns, `throws` validates `HttpException.getResponse()` at matching status. |
|
|
351
|
+
| `ZodResponseInterceptor` | Per-method `NestInterceptor` auto-registered by `@ZodResponse`. Exposed for manual `app.useGlobalInterceptors(...)` use if needed. |
|
|
352
|
+
| `ZodDtoSerializationError` | Subclass of `ZodDtoValidationError` thrown when a method's return value or a throws-spec's `HttpException` body fails validation. |
|
|
353
|
+
| `applySwaggerDecorators(schema)` | Low-level: apply `@ApiProperty` metadata to a schema. Auto-invoked via `registerOnCreate`; export is for manual/edge-case use. |
|
|
236
354
|
|
|
237
355
|
## License
|
|
238
356
|
|
package/dist/index.cjs
CHANGED
|
@@ -12,11 +12,11 @@ var __export = (target, all) => {
|
|
|
12
12
|
for (var name in all)
|
|
13
13
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
14
14
|
};
|
|
15
|
-
var __copyProps = (to,
|
|
16
|
-
if (
|
|
17
|
-
for (let key of __getOwnPropNames(
|
|
15
|
+
var __copyProps = (to, from2, except, desc) => {
|
|
16
|
+
if (from2 && typeof from2 === "object" || typeof from2 === "function") {
|
|
17
|
+
for (let key of __getOwnPropNames(from2))
|
|
18
18
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
19
|
-
__defProp(to, key, { get: () =>
|
|
19
|
+
__defProp(to, key, { get: () => from2[key], enumerable: !(desc = __getOwnPropDesc(from2, key)) || desc.enumerable });
|
|
20
20
|
}
|
|
21
21
|
return to;
|
|
22
22
|
};
|
|
@@ -1129,17 +1129,16 @@ var index_exports = {};
|
|
|
1129
1129
|
__export(index_exports, {
|
|
1130
1130
|
ZodDtoSerializationError: () => ZodDtoSerializationError,
|
|
1131
1131
|
ZodResponse: () => ZodResponse,
|
|
1132
|
+
ZodResponseInterceptor: () => ZodResponseInterceptor,
|
|
1132
1133
|
ZodSerialize: () => ZodSerialize,
|
|
1133
1134
|
ZodValidationPipe: () => ZodValidationPipe,
|
|
1134
1135
|
applySwaggerDecorators: () => applySwaggerDecorators
|
|
1135
1136
|
});
|
|
1136
1137
|
module.exports = __toCommonJS(index_exports);
|
|
1137
|
-
var
|
|
1138
|
+
var import_zod_dto5 = require("@voznov/zod-dto");
|
|
1138
1139
|
|
|
1139
1140
|
// src/swagger.ts
|
|
1140
1141
|
var import_swagger = require("@nestjs/swagger");
|
|
1141
|
-
var import_open_api_spec = require("@nestjs/swagger/dist/interfaces/open-api-spec.interface");
|
|
1142
|
-
var import_schema_object_metadata = require("@nestjs/swagger/dist/interfaces/schema-object-metadata.interface");
|
|
1143
1142
|
var import_zod_dto = require("@voznov/zod-dto");
|
|
1144
1143
|
var import_zod = require("zod");
|
|
1145
1144
|
|
|
@@ -1169,6 +1168,7 @@ var schemaObjectToApiPropertyOptions = (so, selfRequired) => {
|
|
|
1169
1168
|
}
|
|
1170
1169
|
return { ...so, required: selfRequired };
|
|
1171
1170
|
};
|
|
1171
|
+
var unwrapSimpleOneOf = (so) => Object.keys(so).length === 1 && Array.isArray(so.oneOf) && so.oneOf.length === 1 ? so.oneOf[0] : so;
|
|
1172
1172
|
var leaf = (so) => ({
|
|
1173
1173
|
so,
|
|
1174
1174
|
selfRequired: true,
|
|
@@ -1207,11 +1207,11 @@ var applyDecoratorsImpl = (schema) => {
|
|
|
1207
1207
|
(0, import_swagger.ApiExtraModels)(...[...innerSchemas.values()].filter((innerSchema) => innerSchema !== schema))(schema);
|
|
1208
1208
|
return { so: { oneOf: (0, import_swagger.refs)(schema) }, selfRequired: true, innerSchemas: /* @__PURE__ */ new Set([schema]) };
|
|
1209
1209
|
}
|
|
1210
|
-
return { so: { type: "object", properties: mapValues(properties,
|
|
1210
|
+
return { so: { type: "object", properties: mapValues(properties, unwrapSimpleOneOf), required }, selfRequired: true, innerSchemas };
|
|
1211
1211
|
}
|
|
1212
1212
|
if (schema instanceof import_zod.z.ZodRecord) {
|
|
1213
1213
|
const { so } = applySwaggerDecorators(schema._zod.def.valueType);
|
|
1214
|
-
return leaf({ type: "object", additionalProperties: so
|
|
1214
|
+
return leaf({ type: "object", additionalProperties: unwrapSimpleOneOf(so) });
|
|
1215
1215
|
}
|
|
1216
1216
|
if (schema instanceof import_zod.z.ZodOptional || schema instanceof import_zod.z.ZodExactOptional) {
|
|
1217
1217
|
return { ...applySwaggerDecorators(schema.unwrap()), selfRequired: false };
|
|
@@ -1257,12 +1257,12 @@ var applyDecoratorsImpl = (schema) => {
|
|
|
1257
1257
|
if (!selfRequiredElement) {
|
|
1258
1258
|
throw new Error("Not required array item is not supported in Swagger. Use nullable instead.");
|
|
1259
1259
|
}
|
|
1260
|
-
return { so: { type: "array", items: so
|
|
1260
|
+
return { so: { type: "array", items: unwrapSimpleOneOf(so) }, selfRequired: true, innerSchemas };
|
|
1261
1261
|
}
|
|
1262
1262
|
if (schema instanceof import_zod.z.ZodTuple) {
|
|
1263
1263
|
const itemSchemas = schema._zod.def.items.map((item) => {
|
|
1264
1264
|
const { so } = applySwaggerDecorators(item);
|
|
1265
|
-
return so
|
|
1265
|
+
return unwrapSimpleOneOf(so);
|
|
1266
1266
|
});
|
|
1267
1267
|
const unique = [...new Map(itemSchemas.map((s) => [JSON.stringify(s), s])).values()];
|
|
1268
1268
|
const items = unique.length === 1 ? unique[0] : { oneOf: unique };
|
|
@@ -1318,7 +1318,7 @@ var applyDecoratorsImpl = (schema) => {
|
|
|
1318
1318
|
throw new Error("Not required option in oneOf is not supported in Swagger. Use nullable instead.");
|
|
1319
1319
|
}
|
|
1320
1320
|
innerSchemas_.forEach((innerSchema) => innerSchemas.add(innerSchema));
|
|
1321
|
-
return so
|
|
1321
|
+
return unwrapSimpleOneOf(so);
|
|
1322
1322
|
});
|
|
1323
1323
|
if (schema instanceof import_zod.z.ZodDiscriminatedUnion) {
|
|
1324
1324
|
const propertyName = schema._zod.def.discriminator;
|
|
@@ -1341,9 +1341,7 @@ var applyDecoratorsImpl = (schema) => {
|
|
|
1341
1341
|
const left = applySwaggerDecorators(schema._zod.def.left);
|
|
1342
1342
|
const right = applySwaggerDecorators(schema._zod.def.right);
|
|
1343
1343
|
const innerSchemas = /* @__PURE__ */ new Set([...left.innerSchemas, ...right.innerSchemas]);
|
|
1344
|
-
|
|
1345
|
-
const rightSo = right.so.oneOf?.length === 1 ? right.so.oneOf[0] : right.so;
|
|
1346
|
-
return { so: { allOf: [leftSo, rightSo] }, selfRequired: true, innerSchemas };
|
|
1344
|
+
return { so: { allOf: [unwrapSimpleOneOf(left.so), unwrapSimpleOneOf(right.so)] }, selfRequired: true, innerSchemas };
|
|
1347
1345
|
}
|
|
1348
1346
|
if (schema instanceof import_zod.z.ZodAny || schema instanceof import_zod.z.ZodUnknown) {
|
|
1349
1347
|
return leaf({});
|
|
@@ -1380,67 +1378,149 @@ ZodValidationPipe = __decorateClass([
|
|
|
1380
1378
|
|
|
1381
1379
|
// src/zod-serialize.ts
|
|
1382
1380
|
var import_reflect_metadata = __toESM(require_Reflect(), 1);
|
|
1383
|
-
var import_swagger2 = require("@nestjs/swagger");
|
|
1384
1381
|
var import_zod_dto3 = require("@voznov/zod-dto");
|
|
1385
1382
|
var import_zod2 = require("zod");
|
|
1383
|
+
var isSchemaLike = (value) => value instanceof import_zod2.z.ZodType || (0, import_zod_dto3.isZodDtoClass)(value);
|
|
1386
1384
|
var ZodDtoSerializationError = class extends import_zod_dto3.ZodDtoValidationError {
|
|
1387
1385
|
};
|
|
1388
|
-
var
|
|
1389
|
-
if (schema !== void 0 && !(schema instanceof import_zod2.z.ZodType) && !(0, import_zod_dto3.isZodDtoClass)(schema)) {
|
|
1390
|
-
throw new Error(`${decoratorName} on ${target.constructor.name}.${String(propertyKey)}: schema argument must be a Zod type or DTO class (got ${typeof schema}).`);
|
|
1391
|
-
}
|
|
1392
|
-
if (schema) return schema;
|
|
1386
|
+
var resolveDesignReturnType = (target, propertyKey, decoratorName) => {
|
|
1393
1387
|
const rt = Reflect.getMetadata("design:returntype", target, propertyKey);
|
|
1394
|
-
if (rt
|
|
1388
|
+
if (isSchemaLike(rt)) return rt;
|
|
1395
1389
|
const rtName = rt instanceof Object && "name" in rt && typeof rt.name === "string" ? rt.name : "unknown";
|
|
1396
1390
|
throw new Error(
|
|
1397
1391
|
`${decoratorName} on ${target.constructor.name}.${String(propertyKey)}: no Zod schema provided and design:returntype "${rtName}" is not a Zod schema. Pass it explicitly, e.g. ${decoratorName}(z.array(MySchema)).`
|
|
1398
1392
|
);
|
|
1399
1393
|
};
|
|
1400
|
-
var
|
|
1394
|
+
var resolveSerializeSchema = (schema, target, propertyKey) => {
|
|
1395
|
+
if (schema !== void 0 && !isSchemaLike(schema)) {
|
|
1396
|
+
throw new Error(`@ZodSerialize on ${target.constructor.name}.${String(propertyKey)}: schema argument must be a Zod type or DTO class (got ${typeof schema}).`);
|
|
1397
|
+
}
|
|
1398
|
+
if (schema) return schema;
|
|
1399
|
+
return resolveDesignReturnType(target, propertyKey, "@ZodSerialize");
|
|
1400
|
+
};
|
|
1401
|
+
var wrapMethod = (decoratorName, wrapper) => (target, methodName, descriptor) => {
|
|
1401
1402
|
const originalMethod = descriptor.value;
|
|
1402
1403
|
if (typeof originalMethod !== "function") {
|
|
1403
1404
|
throw new Error(`${decoratorName}: ${target.constructor.name}.${String(methodName)} is not a method`);
|
|
1404
1405
|
}
|
|
1405
|
-
|
|
1406
|
-
const serialize = import_zod_dto3.toDto.with({ errorClass: ZodDtoSerializationError, ...options });
|
|
1407
|
-
descriptor.value = {
|
|
1408
|
-
[methodName](...args) {
|
|
1409
|
-
const out = originalMethod.apply(this, args);
|
|
1410
|
-
if (out instanceof Promise) {
|
|
1411
|
-
return out.then(async (v) => serialize.async(resolved, v));
|
|
1412
|
-
}
|
|
1413
|
-
return serialize(resolved, out);
|
|
1414
|
-
}
|
|
1415
|
-
}[methodName];
|
|
1406
|
+
descriptor.value = { [methodName]: wrapper(originalMethod) }[methodName];
|
|
1416
1407
|
redecorateFromReflect(originalMethod, descriptor.value);
|
|
1417
1408
|
};
|
|
1418
|
-
var wrapApiResponse = (schema, options) => (target, propertyKey, descriptor) => {
|
|
1419
|
-
const resolved = resolveSchema(schema, target, propertyKey, "@ZodResponse");
|
|
1420
|
-
const { so, innerSchemas } = applySwaggerDecorators(resolved);
|
|
1421
|
-
const schemaForApi = Object.keys(so).length === 1 && Array.isArray(so.oneOf) && so.oneOf.length === 1 ? so.oneOf[0] : so;
|
|
1422
|
-
(0, import_swagger2.ApiResponse)({ status: options?.status ?? 200, description: options?.description, schema: schemaForApi })(target, propertyKey, descriptor);
|
|
1423
|
-
if (innerSchemas.size > 0) {
|
|
1424
|
-
const classTarget = typeof target === "function" ? target : target.constructor;
|
|
1425
|
-
(0, import_swagger2.ApiExtraModels)(...innerSchemas)(classTarget);
|
|
1426
|
-
}
|
|
1427
|
-
};
|
|
1428
1409
|
function ZodSerialize(schema, options) {
|
|
1429
|
-
return
|
|
1410
|
+
return (target, methodName, descriptor) => {
|
|
1411
|
+
const resolved = resolveSerializeSchema(schema, target, methodName);
|
|
1412
|
+
const serialize = import_zod_dto3.toDto.with({ errorClass: ZodDtoSerializationError, ...options });
|
|
1413
|
+
wrapMethod(
|
|
1414
|
+
"@ZodSerialize",
|
|
1415
|
+
(originalMethod) => function(...args) {
|
|
1416
|
+
const out = originalMethod.apply(this, args);
|
|
1417
|
+
if (out instanceof Promise) {
|
|
1418
|
+
return out.then(async (v) => serialize.async(resolved, v));
|
|
1419
|
+
}
|
|
1420
|
+
return serialize(resolved, out);
|
|
1421
|
+
}
|
|
1422
|
+
)(target, methodName, descriptor);
|
|
1423
|
+
};
|
|
1430
1424
|
}
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1425
|
+
|
|
1426
|
+
// src/zod-response.ts
|
|
1427
|
+
var import_reflect_metadata2 = __toESM(require_Reflect(), 1);
|
|
1428
|
+
var import_common2 = require("@nestjs/common");
|
|
1429
|
+
var import_swagger2 = require("@nestjs/swagger");
|
|
1430
|
+
var import_zod_dto4 = require("@voznov/zod-dto");
|
|
1431
|
+
var import_rxjs = require("rxjs");
|
|
1432
|
+
var import_zod3 = require("zod");
|
|
1433
|
+
var writePlatformResponse = (res, status, body) => {
|
|
1434
|
+
if (typeof res.json === "function" && typeof res.status === "function") {
|
|
1435
|
+
res.status(status).json(body);
|
|
1436
|
+
} else if (typeof res.send === "function" && typeof res.code === "function") {
|
|
1437
|
+
res.code(status).send(body);
|
|
1438
|
+
} else {
|
|
1439
|
+
throw new Error("@ZodResponse: unsupported HTTP platform \u2014 response object has neither Express (.status/.json) nor Fastify (.code/.send) chain.");
|
|
1440
|
+
}
|
|
1441
|
+
};
|
|
1442
|
+
var ZodResponseInterceptor = class {
|
|
1443
|
+
constructor(returnEntries, throwsEntries) {
|
|
1444
|
+
this.returnEntries = returnEntries;
|
|
1445
|
+
this.throwsEntries = throwsEntries;
|
|
1446
|
+
}
|
|
1447
|
+
intercept(ctx, next) {
|
|
1448
|
+
return next.handle().pipe(
|
|
1449
|
+
(0, import_rxjs.mergeMap)((value) => this.returnEntries.length === 0 ? (0, import_rxjs.of)(value) : this.writeReturn(ctx, value)),
|
|
1450
|
+
(0, import_rxjs.catchError)((err) => (0, import_rxjs.from)(this.handleThrow(err)))
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
writeReturn(ctx, value) {
|
|
1454
|
+
return (0, import_rxjs.from)(this.parseReturn(value)).pipe(
|
|
1455
|
+
(0, import_rxjs.mergeMap)(({ status, body }) => {
|
|
1456
|
+
writePlatformResponse(ctx.switchToHttp().getResponse(), status, body);
|
|
1457
|
+
return import_rxjs.EMPTY;
|
|
1458
|
+
})
|
|
1459
|
+
);
|
|
1460
|
+
}
|
|
1461
|
+
async parseReturn(value) {
|
|
1462
|
+
let lastError;
|
|
1463
|
+
for (const entry of this.returnEntries) {
|
|
1464
|
+
try {
|
|
1465
|
+
const body = await entry.serialize(value);
|
|
1466
|
+
return { status: entry.status, body };
|
|
1467
|
+
} catch (error) {
|
|
1468
|
+
lastError = error;
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
throw lastError;
|
|
1472
|
+
}
|
|
1473
|
+
async handleThrow(err) {
|
|
1474
|
+
if (!(err instanceof import_common2.HttpException)) throw err;
|
|
1475
|
+
const matched = this.throwsEntries.find((entry) => entry.status === err.getStatus());
|
|
1476
|
+
if (!matched) throw err;
|
|
1477
|
+
await matched.serialize(err.getResponse());
|
|
1478
|
+
throw err;
|
|
1479
|
+
}
|
|
1480
|
+
};
|
|
1481
|
+
ZodResponseInterceptor = __decorateClass([
|
|
1482
|
+
(0, import_common2.Injectable)()
|
|
1483
|
+
], ZodResponseInterceptor);
|
|
1484
|
+
function ZodResponse(arg, operation) {
|
|
1485
|
+
return (target, methodName, descriptor) => {
|
|
1486
|
+
const specs = Array.isArray(arg) ? arg : [arg === void 0 || isSchemaLike(arg) ? { schema: arg } : arg];
|
|
1487
|
+
if (specs.length === 0) throw new Error(`@ZodResponse on ${target.constructor.name}.${String(methodName)}: array argument must not be an empty list`);
|
|
1488
|
+
const innerSchemas = /* @__PURE__ */ new Set();
|
|
1489
|
+
const returnEntries = [];
|
|
1490
|
+
const throwsEntries = [];
|
|
1491
|
+
for (const {
|
|
1492
|
+
throws,
|
|
1493
|
+
schema = throws === void 0 ? resolveDesignReturnType(target, methodName, "@ZodResponse") : void 0,
|
|
1494
|
+
status = 200,
|
|
1495
|
+
description,
|
|
1496
|
+
...toDtoOpts
|
|
1497
|
+
} of specs) {
|
|
1498
|
+
toDtoOpts.errorClass ??= ZodDtoSerializationError;
|
|
1499
|
+
const serializeWith = import_zod_dto4.toDto.with(toDtoOpts);
|
|
1500
|
+
if (schema !== void 0) returnEntries.push({ status, serialize: async (value) => serializeWith.async(schema, value) });
|
|
1501
|
+
if (throws !== void 0) throwsEntries.push({ status, serialize: async (value) => serializeWith.async(throws, value) });
|
|
1502
|
+
const declared = [...new Set([schema, throws].filter(Boolean))].map(applySwaggerDecorators);
|
|
1503
|
+
declared.forEach(({ innerSchemas: iS }) => iS.forEach((inner) => innerSchemas.add(inner)));
|
|
1504
|
+
const variants = declared.map(({ so }) => unwrapSimpleOneOf(so));
|
|
1505
|
+
const schemaForApi = variants.length === 1 ? variants[0] : { oneOf: variants };
|
|
1506
|
+
(0, import_swagger2.ApiResponse)({ status, description, schema: schemaForApi })(target, methodName, descriptor);
|
|
1507
|
+
}
|
|
1508
|
+
if (innerSchemas.size > 0) {
|
|
1509
|
+
const classTarget = typeof target === "function" ? target : target.constructor;
|
|
1510
|
+
(0, import_swagger2.ApiExtraModels)(...innerSchemas)(classTarget);
|
|
1511
|
+
}
|
|
1512
|
+
if (operation) (0, import_swagger2.ApiOperation)(operation)(target, methodName, descriptor);
|
|
1513
|
+
(0, import_common2.UseInterceptors)(new ZodResponseInterceptor(returnEntries, throwsEntries))(target, methodName, descriptor);
|
|
1435
1514
|
};
|
|
1436
1515
|
}
|
|
1437
1516
|
|
|
1438
1517
|
// src/index.ts
|
|
1439
|
-
(0,
|
|
1518
|
+
(0, import_zod_dto5.registerOnCreate)((dto) => void Promise.resolve().then(() => applySwaggerDecorators(dto)));
|
|
1440
1519
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1441
1520
|
0 && (module.exports = {
|
|
1442
1521
|
ZodDtoSerializationError,
|
|
1443
1522
|
ZodResponse,
|
|
1523
|
+
ZodResponseInterceptor,
|
|
1444
1524
|
ZodSerialize,
|
|
1445
1525
|
ZodValidationPipe,
|
|
1446
1526
|
applySwaggerDecorators
|