@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 +35 -15
- package/dist/index.cjs +21 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +22 -3
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
|
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
|
-
|
|
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
|
-
###
|
|
213
|
+
### Async refines on the response schema
|
|
188
214
|
|
|
189
|
-
`
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
(
|
|
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);
|