@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 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(z.object({
29
- page: z.coerce.number().int().min(1).default(1),
30
- limit: z.coerce.number().int().min(1).max(100).default(20),
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 *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.
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 *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`.
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 *before* the value reaches the client, so server-side bugs are surfaced as 500s instead of leaking malformed payloads or extra fields.
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.switchToHttp().getResponse().status(status).json({
142
- statusCode: status,
143
- message: error.message,
144
- issues: isServerBug ? undefined : error.issues,
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
- Both decorators come in two overloads:
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
- - **Strict** schema passed explicitly. The method's return type is constrained at compile time to match the schema's output; `tsc` errors on mismatch as `TS1241: Unable to resolve signature of method decorator...` — the actual mismatch is on the deepest line of the message (`Type 'X' is not assignable to type 'NoteDto | Promise<NoteDto>'`).
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 for the OpenAPI operation.
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, { status: 201, description: 'note created' })
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 *return* value instead of an input). The default `errorClass` is `ZodDtoSerializationError` (vs `ZodDtoValidationError` for `toDto`), so an exception filter can split client errors from server bugs (see below).
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` extends the bag with two Swagger-only fields:
243
+ `@ZodResponse` takes two arguments:
204
244
 
205
- - `status: number`HTTP status for the OpenAPI response object. Default `200`.
206
- - `description: string`description on the OpenAPI response object.
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, { status: 201, observers: [(note) => metrics.recordCreate(note)] })
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 | Description |
228
- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
229
- | `ZodValidationPipe` | `PipeTransform` for `@Body()` / `@Param()` / `@Query()`. Accepts `{ createError?: (issues: string[]) => Error }`. |
230
- | `ZodValidationPipeOptions` | Options type for `ZodValidationPipe`. |
231
- | `ZodSerialize(schema?, options?)` | Method decorator: runtime-parse the return value through `schema` (or via `design:returntype` if omitted). Throws `ZodDtoSerializationError` on mismatch. |
232
- | `ZodResponse(schema?, options?)` | `ZodSerialize` + auto-emits `@ApiResponse` Swagger metadata (and `@ApiExtraModels` for inner DTOs). |
233
- | `ZodResponseOptions` | Options type for `ZodResponse` (`ToDtoOptions & { status?, description? }`). |
234
- | `ZodDtoSerializationError` | Subclass of `ZodDtoValidationError` thrown by `@ZodSerialize` / `@ZodResponse` when a method returns an invalid shape. |
235
- | `applySwaggerDecorators(schema)` | Low-level: apply `@ApiProperty` metadata to a schema. Auto-invoked via `registerOnCreate`; export is for manual/edge-case use. |
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, from, except, desc) => {
16
- if (from && typeof from === "object" || typeof from === "function") {
17
- for (let key of __getOwnPropNames(from))
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: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
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 import_zod_dto4 = require("@voznov/zod-dto");
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, (so) => so.oneOf?.length === 1 ? so.oneOf[0] : so), required }, selfRequired: true, innerSchemas };
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.oneOf?.length === 1 ? so.oneOf[0] : 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.oneOf?.length === 1 ? so.oneOf[0] : so }, selfRequired: true, innerSchemas };
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.oneOf?.length === 1 ? so.oneOf[0] : 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.oneOf?.length === 1 ? so.oneOf[0] : 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
- const leftSo = left.so.oneOf?.length === 1 ? left.so.oneOf[0] : left.so;
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 resolveSchema = (schema, target, propertyKey, decoratorName) => {
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 instanceof import_zod2.z.ZodType) return 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 wrapMethod = (schema, options, decoratorName) => (target, methodName, descriptor) => {
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
- const resolved = resolveSchema(schema, target, methodName, decoratorName);
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 wrapMethod(schema, options, "@ZodSerialize");
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
- function ZodResponse(schema, options) {
1432
- return (target, propertyKey, descriptor) => {
1433
- wrapMethod(schema, options, "@ZodResponse")(target, propertyKey, descriptor);
1434
- wrapApiResponse(schema, options)(target, propertyKey, descriptor);
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, import_zod_dto4.registerOnCreate)((dto) => void Promise.resolve().then(() => applySwaggerDecorators(dto)));
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