@voznov/zod-dto-nestjs 0.3.2 → 0.4.0

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
@@ -150,10 +155,9 @@ export class ZodExceptionFilter implements ExceptionFilter {
150
155
  app.useGlobalFilters(new ZodExceptionFilter());
151
156
  ```
152
157
 
153
- Both decorators come in two overloads:
158
+ `@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
159
 
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.
160
+ 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
161
 
158
162
  ```ts
159
163
  import { Body, Controller, Get, Param, Post } from '@nestjs/common';
@@ -173,10 +177,24 @@ export class NotesController {
173
177
  @ZodResponse(z.array(NoteDto))
174
178
  async list(): Promise<NoteDto[]> { /* ... */ }
175
179
 
176
- // Override status (default 200) + description for the OpenAPI operation.
180
+ // Override status (default 200) + description via the spec object form. Second argument
181
+ // is the ApiOperation metadata (summary / tags / deprecated / operationId / description).
177
182
  @Post()
178
- @ZodResponse(NoteDto, { status: 201, description: 'note created' })
183
+ @ZodResponse({ schema: NoteDto, status: 201, description: 'note created' }, { summary: 'Create a note' })
179
184
  create(@Body() body: CreateNoteDto): NoteDto { /* ... */ }
185
+
186
+ // Multi-response: 200 returns the note, 404 throws `HttpException(notFoundBody, 404)` and we
187
+ // validate that the thrown body matches `NotFoundDto` (shape drift surfaces as ZodDtoSerializationError).
188
+ // `throws:` schemas are NOT in the method's return-type union — the method just returns `NoteDto`.
189
+ @Get('many/:id')
190
+ @ZodResponse(
191
+ [
192
+ { schema: NoteDto, status: 200, description: 'Found' },
193
+ { throws: NotFoundDto, status: 404, description: 'Not found' },
194
+ ],
195
+ { summary: 'Get a note' },
196
+ )
197
+ findOneOrThrow(@Param() p: NoteIdParam): NoteDto { /* throws HttpException(..., 404) on miss */ }
180
198
  }
181
199
  ```
182
200
 
@@ -198,18 +216,51 @@ class NotesService {
198
216
 
199
217
  ### Options
200
218
 
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).
219
+ 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
220
 
203
- `@ZodResponse` extends the bag with two Swagger-only fields:
221
+ `@ZodResponse` takes two arguments:
204
222
 
205
- - `status: number`HTTP status for the OpenAPI response object. Default `200`.
206
- - `description: string`description on the OpenAPI response object.
223
+ 1. **Response spec**what shape the route returns / throws. Accepts one of:
224
+ - a Zod schema or DTO class simple form, defaults `status` to 200;
225
+ - a `ResponseSpec` object — `{ schema?, throws?, status?, description? }` plus `ToDtoOptions` (`preprocessors`, `observers`, `errorClass`);
226
+ - 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);
227
+ - `undefined` (loose form) — resolves the schema from `design:returntype`.
228
+ 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
229
 
208
230
  ```ts
209
- @ZodResponse(NoteDto, { status: 201, observers: [(note) => metrics.recordCreate(note)] })
231
+ @ZodResponse({ schema: NoteDto, status: 201, observers: [(note) => metrics.recordCreate(note)] }, { summary: 'Create a note', tags: ['notes'] })
210
232
  async create(): Promise<NoteDto> { /* ... */ }
211
233
  ```
212
234
 
235
+ > 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.
236
+
237
+ ### Throws-marked specs — validate `HttpException` body against a schema
238
+
239
+ 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.
240
+
241
+ ```ts
242
+ @Get(':id')
243
+ @ZodResponse([
244
+ { schema: NoteDto, status: 200 },
245
+ { throws: NotFoundError, status: 404 }, // method throws HttpException({code, message}, 404) on miss
246
+ ])
247
+ findOne(@Param() p: NoteIdParam): NoteDto { // return-type union is just NoteDto — throws-spec doesn't widen it
248
+ const note = this.svc.find(p.id);
249
+ if (!note) throw new HttpException({ code: 'NOT_FOUND', message: `note ${p.id} missing` }, 404);
250
+ return note;
251
+ }
252
+ ```
253
+
254
+ Key properties:
255
+
256
+ - **`throws:` schemas are excluded from the method's return-type constraint** — `findOne(): NoteDto` compiles even when the array also declares `{throws: NotFoundError, status: 404}`.
257
+ - **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.
258
+ - **Non-HttpException throws pass through unchanged** — vanilla `Error` doesn't get validated.
259
+
260
+ ### Behind the scenes
261
+
262
+ `@ZodResponse` doesn't wrap the method; it auto-registers a per-method `ZodResponseInterceptor` via `UseInterceptors`. The interceptor takes over response writing: after a successful return-side parse it calls `res.status(spec.status).json(parsedBody)` directly, then emits `EMPTY` so NestJS's default rendering doesn't run a second `res.status(verbDefault)` over it. This is the only reliable way to dispatch a non-default status (`@Get` defaults 200, `@Post` 201, etc. — Nest writes those after interceptors). Trade-off: upstream interceptors receive `EMPTY` rather than the parsed body, and custom body-renderer chains (msgpack/yaml/...) are bypassed. For typical JSON APIs it's the right default; if you need composability with response transformations, hold off on `@ZodResponse` for those routes.
263
+
213
264
  ### Async refines on the response schema
214
265
 
215
266
  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.
@@ -229,9 +280,10 @@ class Repo {
229
280
  | `ZodValidationPipe` | `PipeTransform` for `@Body()` / `@Param()` / `@Query()`. Accepts `{ createError?: (issues: string[]) => Error }`. |
230
281
  | `ZodValidationPipeOptions` | Options type for `ZodValidationPipe`. |
231
282
  | `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. |
283
+ | `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`. |
284
+ | `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. |
285
+ | `ZodResponseInterceptor` | Per-method `NestInterceptor` auto-registered by `@ZodResponse`. Exposed for manual `app.useGlobalInterceptors(...)` use if needed. |
286
+ | `ZodDtoSerializationError` | Subclass of `ZodDtoValidationError` thrown when a method's return value or a throws-spec's `HttpException` body fails validation. |
235
287
  | `applySwaggerDecorators(schema)` | Low-level: apply `@ApiProperty` metadata to a schema. Auto-invoked via `registerOnCreate`; export is for manual/edge-case use. |
236
288
 
237
289
  ## License