英语 · 法语
@ddd-ts-kit /验证
一个 Schema 在边界处进行输入验证的契约,带有用于zod的适配器,
类验证器、TypeBox和任何标准模式库。路由,dto和OpenAPI生成器依赖
在契约上,这样验证库可以在不触及它们的情况下进行更改。
业务规则不属于这里:它们存在于域中,从不导入验证库。
模式检查 形状 输入的内容:类型、必填字段、格式、长度。什么 业务决定(过去的插槽,库存以上的数量)由域检查, 无论进入点是什么:
import { z } from 'zod';
import { DomainRefusal } from '@ddd-ts-kit/kernel';
import { zodSchema } from '@ddd-ts-kit/validation/zod';
// Instead of: a business rule hidden in the schema of one route, which a job or a consumer never meets.
export const bookWithARule = zodSchema(
z.object({ startsAt: z.iso.datetime() }).refine((body) => Date.parse(body.startsAt) > Date.now(), 'The slot is in the past.'),
);
// Write: the shape in the schema...
export const book = zodSchema(z.object({ startsAt: z.iso.datetime() }));
// ...and the rule in the domain, with its own refusal code.
export const SchedulingCodes = { SlotInThePast: 'SLOT_IN_THE_PAST' } as const;
export class SlotInThePast extends DomainRefusal {
readonly code = SchedulingCodes.SlotInThePast;
static atStart(startsAt: Date) {
return SlotInThePast.refusal(`The slot at ${startsAt.toISOString()} is in the past.`, {
startsAt: startsAt.toISOString(),
});
}
}
export function startingAt(startsAt: Date, now: Date): Date {
if (startsAt <= now) {
throw SlotInThePast.atStart(startsAt);
}
return startsAt;
}
合同
import { parseOrThrow, type Infer, type Schema } from '@ddd-ts-kit/validation';
interface Schema<T> {
parse(input: unknown): Result<T, ValidationIssue[]>;
toJsonSchema(): JsonSchema;
}
type ValidationIssue = { path: string; code: string; message: string }; // path: "items.0.sku"
模式是一个值:每个输入一个值,传递给使用它进行验证的路由或事件。 它从未绑定到容器中,所以它是一个接口,而不是一个可注入端口(抽象) 类):在组合根中没有什么需要声明的。
parse永远不会为无效输入抛出异常:它会返回err(issues).parseOrThrow(schema, input)返回值或抛出ValidationError,一个DomainError使用代码VALIDATION_FAILED和issues.Infer<typeof schema>给出解析后的类型。customSchema(parse, jsonSchema?)从普通函数构建模式。sharedSchema(name, schema)记录一个模式一次,在name(见 共享模式)。
import { err, ok } from '@ddd-ts-kit/kernel';
import { ValidationError, customSchema, parseOrThrow, type Infer } from '@ddd-ts-kit/validation';
// A schema from a plain function: a check written by hand, or a double in a test.
const quantity = customSchema(
(input: unknown) =>
typeof input === 'number' && Number.isInteger(input) && input > 0
? ok(input)
: err([{ path: '', code: 'not_a_quantity', message: 'A positive whole number is expected.' }]),
{ type: 'integer', minimum: 1 },
);
type Quantity = Infer<typeof quantity>; // number
export const refused = quantity.parse('3'); // { ok: false, error: [{ path: '', code: 'not_a_quantity', ... }] }
export const three: Quantity = parseOrThrow(quantity, 3); // 3
try {
parseOrThrow(quantity, 0);
} catch (error) {
if (error instanceof ValidationError) {
console.log(error.code, error.issues); // VALIDATION_FAILED [{ path: '', code: 'not_a_quantity', ... }]
}
}
根条目不导入第三方包。每个适配器都存在于它自己的子路径和它的 库是可选的对等依赖项:只安装您使用的库。
在路线上
的路线 @ddd-ts-kit/http 为它所读取的请求的每个部分命名一个模式。每一个部分
在处理程序运行之前解析,所有部分的问题一起得到回答,并且每个
Issue path从它的部分开始:
import { z } from 'zod';
import { defineRoute, executeRoute, type RawRequest } from '@ddd-ts-kit/http';
import { zodSchema } from '@ddd-ts-kit/validation/zod';
const placeOrder = defineRoute(
{
method: 'POST',
path: '/orders',
operationId: 'placeOrder',
summary: 'Place an order',
request: {
body: zodSchema(z.object({ items: z.array(z.object({ sku: z.string().min(1), quantity: z.number().int().positive() })).min(1) })),
},
responses: { 201: { description: 'The order is placed.', body: zodSchema(z.object({ lines: z.number() }), { io: 'output' }) } },
security: false,
},
async ({ body }) => ({ status: 201, body: { lines: body.items.length } }), // body is typed: { items: { sku, quantity }[] }
);
const request = (body: unknown): RawRequest => ({ params: {}, query: {}, headers: {}, body, raw: undefined });
export const placed = await executeRoute(placeOrder, request({ items: [{ sku: 'A-1', quantity: 2 }] })); // 201, { lines: 1 }
export const refused = await executeRoute(placeOrder, request({ items: [{ sku: 'A-1', quantity: 0 }] }));
// 422, a problem with the code VALIDATION_FAILED and
// issues: [{ path: 'body.items.0.quantity', code: 'too_small', message: 'Too small: expected number to be >0' }]
处理程序永远不会看到无效请求,路由的OpenAPI文档描述了
同一个身体,来自 toJsonSchema().
萨德
npm i zod
import { z } from 'zod';
import { zodSchema } from '@ddd-ts-kit/validation/zod';
declare const input: unknown; // what the request carried
const placeOrder = zodSchema(
z.object({
customerId: z.string().uuid(),
items: z.array(z.object({ sku: z.string().min(1), quantity: z.number().int().positive() })).min(1),
}),
);
const result = placeOrder.parse(input);
if (!result.ok) {
console.log(result.error); // [{ path: 'items.0.sku', code: 'too_small', message: '...' }]
}
JSON模式来自于zod4 z.toJSONSchema (草案2020-12,可在OpenAPI中使用
3.1)。默认情况下,它描述输入端;通过 { io: 'output' } 用于响应体。一个
类型JSON模式不能表示(a) Date,一个 bigint)写为 {} 而不是
辜负了这一代人。
class-validator
npm i class-validator class-transformer class-validator-jsonschema reflect-metadata
import { Type } from 'class-transformer';
import { ArrayMinSize, IsInt, IsString, IsUUID, Min, ValidateNested } from 'class-validator';
import { classSchema } from '@ddd-ts-kit/validation/class-validator';
class OrderItemDto {
@IsString() declare sku: string;
@IsInt() @Min(1) declare quantity: number;
}
class PlaceOrderDto {
@IsUUID() declare customerId: string;
@ValidateNested({ each: true })
@Type(() => OrderItemDto)
@ArrayMinSize(1)
declare items: OrderItemDto[];
}
export const placeOrder = classSchema(PlaceOrderDto);
parse 返回类的实例。 whitelist 和 forbidNonWhitelisted 都在附近
默认值:未声明的属性是一个问题。覆盖与
classSchema(Dto, { validator: { forbidNonWhitelisted: false }, transform: { ... } }).
嵌套错误被扁平化(items.0.quantity),每个失败的约束有一个问题,即
约束名称为 code. JSON模式是2020-12年的草案,包含嵌套类
可从下面的类访问 $defs.
需要 experimentalDecorators 在项目的 tsconfig.json.
每个字段都是 declareD,没有初值。类转换器保留a的初始值
输入缺少的字段: customerId = '' 下 @IsString() 在没有顾客的情况下会接受订单吗
Pass,好像它的名字是空的一样。可选字段是 @IsOptional() 与
declare note?: string;. classSchema 拒绝其新实例持有带有
DeclarationError 命名它的字段和修复。
声明一次的DTO类在整个(classSchema(SlotDto, { shared: true }),或嵌套
与 @ValidateNested() 和 @Type, @IsDefined() 当需要时:嵌套验证
允许丢失的对象通过),扩展(class BookBody extends SlotDto { ... })或in
部分是通过复制约束、嵌套类型和转换的三个帮助程序完成的
它们所保留的属性:
import { IsEmail, IsIn, IsISO8601, IsString, IsUUID } from 'class-validator';
import { omitClass, partialClass, pickClass } from '@ddd-ts-kit/validation/class-validator';
class AppointmentDto {
@IsUUID() declare patientId: string;
@IsISO8601() declare startsAt: string;
@IsISO8601() declare endsAt: string;
@IsIn(['booked', 'cancelled']) declare status: 'booked' | 'cancelled';
}
class PatientDto {
@IsString() declare name: string;
@IsEmail() declare email: string;
}
export class RescheduleBody extends pickClass(AppointmentDto, ['startsAt', 'endsAt']) {}
export class BookBody extends omitClass(AppointmentDto, ['status']) {}
export class EditPatientBody extends partialClass(PatientDto) {} // every property optional, checked when present
扩展帮助器的类在契约中命名形状。选择一个携带的属性
没有约束被拒绝:验证将把它作为未知剥离。中嵌套的派生类
另一个通过 @Type 声明至少一个自己的属性:class-validator-jsonschema
只从类自己的装饰器记录类。
TypeBox
npm i typebox
import Type from 'typebox';
import { typeboxSchema } from '@ddd-ts-kit/validation/typebox';
export const slot = Type.Object({ startsAt: Type.String({ format: 'date-time' }), minutes: Type.Integer({ default: 30 }) }, { $id: 'Slot' });
export const book = typeboxSchema(Type.Object({ patientId: Type.String({ minLength: 1 }), slot }));
export const page = typeboxSchema(Type.Object({ limit: Type.Integer() }), { coerce: true }); // query strings
架构编译一次(typebox/compile)。 parse 填充默认值并删除
模式没有声明的属性(如zod),然后检查;问题代码是JSON
失败的架构关键字(required, format, minLength……)。 { coerce: true } 转换
首先将文本转换为声明的类型,用于路径参数和查询字符串。TypeBox模式是
JSON模式已经存在: toJsonSchema() 对象构建的嵌套模式 $id 移动
下 $defs 并且被引用,所以它会在合同中出现;具有自己的模式 $id 是
共享。在其路径上报告丢失的属性(slot.startsAt),正如zod所报道的那样
两个不同的模式在一个模式下 $id 是拒绝了。 Type.Pick, Type.Omit 和 Type.Partial
重用模式的一部分,作为一个新的形状; Type.Intersect([slot, Type.Object({...})]) 扩展了它
并将共享部分作为参考(allOf: [{ $ref: Slot }, ...]),而
Type.Interface([slot], {...}) 写入一个新的平面形状。(Type.Composite 属于
@sinclair/typebox 0.34, TypeBox 1中不存在。)
为…而写 typebox 的后续版本 @sinclair/typebox)。
标准模式(validbot, arktype,…)
import * as v from 'valibot';
import { standardSchema } from '@ddd-ts-kit/validation/standard-schema';
export const placeOrder = standardSchema(
v.object({ customerId: v.pipe(v.string(), v.uuid()) }),
// Optional: the JSON Schema of the contract, which this adapter does not derive.
{ type: 'object', required: ['customerId'], properties: { customerId: { type: 'string', format: 'uuid' } } },
);
适配器验证 ~standard.validate 标准模式v1。的JSON模式
合同是第二个参数,当它是手写的,或由出口商
你的图书馆);否则就是库在实现同伴时自己转换的对象
标准JSON模式v1 (~standard.jsonSchema;通过zod 4验证,而arktype
并且有效通过 toStandardJsonSchema 的 @valibot/to-json-schema 宣布它,但不是
输入端默认为和 standardSchema(schema, undefined, { io: 'output' })
得到回应;否则 {}, OpenAPI文档将其显示为“任意值”。图式
库不能以JSON模式编写(zod拒绝) z.date(), z.coerce.date(), z.custom)是
记录 {} 也:传递它的JSON模式作为第二个参数。该规范没有错误代码,所以每个问题
有代码吗? invalid. 异步验证的模式
parse 在边界处不支持Throw: async模式。
共享模式
多个路由使用的模式声明一次,记录一次:的OpenAPI生成器
@ddd-ts-kit/http 写在里面 components.schemas 然后用 $ref 从每一个
路线,整个或嵌套。当模式的JSON模式是对它自己的JSON模式的引用时,模式是共享的
定义({ $ref: '#/$defs/Slot', $defs: { Slot: ... } }),每个库达到它的形式
自己的方式:
import Type from 'typebox';
import * as v from 'valibot';
import { z } from 'zod';
import { sharedSchema } from '@ddd-ts-kit/validation';
import { classSchema } from '@ddd-ts-kit/validation/class-validator';
import { standardSchema } from '@ddd-ts-kit/validation/standard-schema';
import { typeboxSchema } from '@ddd-ts-kit/validation/typebox';
import { zodSchema } from '@ddd-ts-kit/validation/zod';
declare class PatientDto {
name: string;
email: string;
}
// zod: an id in its metadata, kept when the schema is nested in another one
const slot = z.object({ startsAt: z.iso.datetime(), endsAt: z.iso.datetime() }).meta({ id: 'Slot' });
export const reschedule = zodSchema(slot);
export const book = zodSchema(z.object({ patientId: z.uuid(), slot }));
// class-validator: the name of the class
export const patient = classSchema(PatientDto, { shared: true });
// TypeBox: an $id in its options, kept when the schema is nested in another one
const money = Type.Object({ cents: Type.Integer(), currency: Type.String() }, { $id: 'Money' });
export const price = typeboxSchema(Type.Object({ amount: money }));
// any schema, the Standard Schema ones included
const amountSchema = v.object({ cents: v.pipe(v.number(), v.integer()) });
export const amount = sharedSchema('Amount', standardSchema(amountSchema));
sharedNameOf(jsonSchema) 回答JSON模式共享的名称,如果有的话:
import { customSchema, sharedNameOf, sharedSchema } from '@ddd-ts-kit/validation';
const amount = sharedSchema('Amount', customSchema((input) => ({ ok: true, value: input }), { type: 'integer' }));
export const jsonSchema = amount.toJsonSchema(); // { $ref: '#/$defs/Amount', $defs: { Amount: { type: 'integer' } } }
export const name = sharedNameOf(jsonSchema); // 'Amount'
schemaBehind.of(schema) 回答模式 sharedSchema 是给定的,那么另一个又是什么呢
附加到它的包(字段) withFieldAccess 的 @ddd-ts-kit/http 储备)用于
也是共享的。
表示层的共享模式位于 generate schema 写在下面
presentation/http/schemas/ 上下文的,上下文下的 presentation/ 共享内核的
有几种情况可以回答这个问题;从来没有在这个领域。