@voznov/zod-dto-nestjs 0.3.0 → 0.3.2

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
@@ -59,13 +59,15 @@ Importing this package side-effect-registers an `onCreate` hook that decorates e
59
59
 
60
60
  Supported shapes: scalars, objects (nested), arrays, tuples, records, enums, literals, unions (including discriminated), intersections, optional/nullable/default wrappers, and nested DTO references (via `oneOf` + `ApiExtraModels`).
61
61
 
62
+ `z.discriminatedUnion(key, [...])` of DTO classes emits a proper OpenAPI `discriminator: { propertyName, mapping }` alongside `oneOf`, so codegen tools (`openapi-typescript`, `openapi-generator`) generate tagged unions instead of structural ones. Falls back to plain `oneOf` if any variant can't be mapped (non-DTO class or non-literal discriminator field).
63
+
62
64
  `.default(value)` is forwarded to the OpenAPI `default` keyword.
63
65
 
64
66
  > ⚠️ **Lazy defaults are frozen at decoration time.** For `.default(() => ...)`, the thunk is invoked **once** when the swagger metadata is generated, and the resolved value is baked into the spec. Anything non-stable — `Date.now()`, `randomUUID()`, `new Date()` — will freeze at the value the server happened to produce on startup, and every endpoint's example in your docs will show that one stale value. Use a stable thunk, or a literal default.
65
67
 
66
68
  `.describe(text)` is forwarded to the OpenAPI `description` keyword. Works on the wrapper or on the inner type — `z.string().describe('Login email').optional()` and `z.string().optional().describe('Login email')` both end up with `description: 'Login email'` in the spec.
67
69
 
68
- `.refine(...)` validators run at request-validation time 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.
70
+ `.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.
69
71
 
70
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.
71
73
 
@@ -124,9 +126,33 @@ Method decorators that parse the return value of a controller (or any class) met
124
126
  - **`@ZodSerialize`** — runtime parsing only. Use on services, repositories, internal methods.
125
127
  - **`@ZodResponse`** — `@ZodSerialize` + auto-emit `@ApiResponse` Swagger metadata (and register inner DTOs via `@ApiExtraModels`). Use on controller routes.
126
128
 
127
- Both come in two overloads:
129
+ `ZodDtoSerializationError extends ZodDtoValidationError` wire up one exception filter to split client errors (400) from server bugs (500):
130
+
131
+ ```ts
132
+ import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus } from '@nestjs/common';
133
+ import { ZodDtoValidationError } from '@voznov/zod-dto';
134
+ import { ZodDtoSerializationError } from '@voznov/zod-dto-nestjs';
135
+
136
+ @Catch(ZodDtoValidationError)
137
+ export class ZodExceptionFilter implements ExceptionFilter {
138
+ catch(error: ZodDtoValidationError, host: ArgumentsHost) {
139
+ const isServerBug = error instanceof ZodDtoSerializationError;
140
+ 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
+ });
146
+ }
147
+ }
148
+
149
+ // main.ts
150
+ app.useGlobalFilters(new ZodExceptionFilter());
151
+ ```
152
+
153
+ Both decorators come in two overloads:
128
154
 
129
- - **Strict** — schema passed explicitly. The method's return type is constrained at compile time to match the schema's output; `tsc` errors on mismatch.
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>'`).
130
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.
131
157
 
132
158
  ```ts
@@ -184,21 +210,15 @@ Both decorators accept the full `ToDtoOptions` bag (`preprocessors`, `observers`
184
210
  async create(): Promise<NoteDto> { /* ... */ }
185
211
  ```
186
212
 
187
- ### Differentiating client errors from server errors
213
+ ### Async refines on the response schema
188
214
 
189
- `ZodDtoSerializationError extends ZodDtoValidationError`, so a single exception filter can split request-validation failures (client 400) from response-validation failures (server 500):
215
+ 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.
190
216
 
191
217
  ```ts
192
- import { ZodDtoValidationError } from '@voznov/zod-dto';
193
- import { ZodDtoSerializationError } from '@voznov/zod-dto-nestjs';
194
-
195
- @Catch(ZodDtoValidationError)
196
- export class ZodExceptionFilter implements ExceptionFilter {
197
- catch(error: ZodDtoValidationError, host: ArgumentsHost) {
198
- const isServerBug = error instanceof ZodDtoSerializationError;
199
- const status = isServerBug ? 500 : 400;
200
- // log, format, respond...
201
- }
218
+ class Repo {
219
+ // Promise return safeParseAsync. Works with async refines on the schema.
220
+ @ZodSerialize(SchemaWithAsyncRefine)
221
+ async findOne(id: string): Promise<...> { /* ... */ }
202
222
  }
203
223
  ```
204
224
 
package/dist/index.cjs CHANGED
@@ -1311,6 +1311,21 @@ var applyDecoratorsImpl = (schema) => {
1311
1311
  innerSchemas_.forEach((innerSchema) => innerSchemas.add(innerSchema));
1312
1312
  return so.oneOf?.length === 1 ? so.oneOf[0] : so;
1313
1313
  });
1314
+ if (schema instanceof import_zod.z.ZodDiscriminatedUnion) {
1315
+ const propertyName = schema._zod.def.discriminator;
1316
+ const mapping = {};
1317
+ const allMappable = schema.options.every((option, i) => {
1318
+ if (!(option instanceof import_zod.z.ZodObject)) return false;
1319
+ const field = option.shape[propertyName];
1320
+ if (!(field instanceof import_zod.z.ZodLiteral)) return false;
1321
+ if (!("$ref" in oneOf[i])) return false;
1322
+ for (const literalValue of field.values) mapping[String(literalValue)] = oneOf[i].$ref;
1323
+ return true;
1324
+ });
1325
+ if (allMappable) {
1326
+ return { so: { oneOf, discriminator: { propertyName, mapping } }, selfRequired: true, innerSchemas };
1327
+ }
1328
+ }
1314
1329
  return { so: { oneOf }, selfRequired: true, innerSchemas };
1315
1330
  }
1316
1331
  if (schema instanceof import_zod.z.ZodIntersection) {
@@ -1362,6 +1377,9 @@ var import_zod2 = require("zod");
1362
1377
  var ZodDtoSerializationError = class extends import_zod_dto3.ZodDtoValidationError {
1363
1378
  };
1364
1379
  var resolveSchema = (schema, target, propertyKey, decoratorName) => {
1380
+ if (schema !== void 0 && !(schema instanceof import_zod2.z.ZodType) && !(0, import_zod_dto3.isZodDtoClass)(schema)) {
1381
+ throw new Error(`${decoratorName} on ${target.constructor.name}.${String(propertyKey)}: schema argument must be a Zod type or DTO class (got ${typeof schema}).`);
1382
+ }
1365
1383
  if (schema) return schema;
1366
1384
  const rt = Reflect.getMetadata("design:returntype", target, propertyKey);
1367
1385
  if (rt instanceof import_zod2.z.ZodType) return rt;
@@ -1381,7 +1399,7 @@ var wrapMethod = (schema, options, decoratorName) => (target, methodName, descri
1381
1399
  [methodName](...args) {
1382
1400
  const out = originalMethod.apply(this, args);
1383
1401
  if (out instanceof Promise) {
1384
- return out.then((v) => serialize(resolved, v));
1402
+ return out.then(async (v) => serialize.async(resolved, v));
1385
1403
  }
1386
1404
  return serialize(resolved, out);
1387
1405
  }
@@ -1391,7 +1409,8 @@ var wrapMethod = (schema, options, decoratorName) => (target, methodName, descri
1391
1409
  var wrapApiResponse = (schema, options) => (target, propertyKey, descriptor) => {
1392
1410
  const resolved = resolveSchema(schema, target, propertyKey, "@ZodResponse");
1393
1411
  const { so, innerSchemas } = applySwaggerDecorators(resolved);
1394
- (0, import_swagger2.ApiResponse)({ status: options?.status ?? 200, description: options?.description, schema: so })(target, propertyKey, descriptor);
1412
+ const schemaForApi = Object.keys(so).length === 1 && Array.isArray(so.oneOf) && so.oneOf.length === 1 ? so.oneOf[0] : so;
1413
+ (0, import_swagger2.ApiResponse)({ status: options?.status ?? 200, description: options?.description, schema: schemaForApi })(target, propertyKey, descriptor);
1395
1414
  if (innerSchemas.size > 0) {
1396
1415
  const classTarget = typeof target === "function" ? target : target.constructor;
1397
1416
  (0, import_swagger2.ApiExtraModels)(...innerSchemas)(classTarget);