Schemas: validação, serialização e tipos
Os schemas da rota (params, query, headers, body e response) são escritos em TypeBox (JSON Schema) e podem ser declarados de duas formas equivalentes:
@Get('/:id', byId) // enxuta: no próprio verbo
getUser(ctx: Context<typeof byId>) {}
@Get('/:id') // verbosa: em um decorator separado
@Schema(byId)
getUser(ctx: Context<typeof byId>) {}As duas formas podem conviver no mesmo método quando declaram partes diferentes (por exemplo, params no verbo e response no @Schema). Declarar a mesma parte nos dois lugares gera erro.
const list = {
query: Type.Object({
limit: Type.Integer({ minimum: 1, maximum: 100, default: 20 }),
tag: Type.Optional(Type.Array(Type.String())),
}),
response: { 200: Type.Array(User) },
} satisfies RouteSchemas;
@Get('/', list)
list(ctx: Context<typeof list>) {
ctx.query.limit; // number (já convertido de "20" e com default)
ctx.query.tag; // string[] | undefined (?tag=a&tag=b ou ?tag=a)
}Path e params conferidos
- Em compilação: com schema de
paramsno verbo, todo parâmetro do path precisa estar no schema.@Get('/:userId', { params: Type.Object({ id: Type.String() }) })é erro de tipo (Missing in params schema: "userId"). - No registro: o
app.mount()confere path x schema nos dois sentidos, já com o prefixo do@Router/mount(que pode ter parâmetros, como'/orgs/:org'), também para@Schemaseparado e rotas manuais. Também recusa path malformado:/:, nome repetido, regex sem fechamento,*fora do fim. - Sem schema:
Context<'/:org/users/:id'>tipactx.paramspelo path ({ org: string; id: string }). O utilitárioPathParams<'/:a/:b'>dá'a' | 'b'.
Validação
Tipagem: com
Context<typeof schemas>,ctx.params,ctx.queryectx.bodysão tipados a partir do schema. Sem o genérico,Contextusa tipos genéricos.Coerção: params, query e headers chegam como texto e são convertidos para o tipo declarado. O body não sofre coerção:
"30"não vira30.Defaults: valores
defaultsão aplicados.Ordem: headers → params → query → body. O primeiro erro gera um 400 com os detalhes:
json{ "type": "about:blank", "title": "Bad Request", "status": 400, "detail": "Invalid body", "code": "VALIDATION_ERROR", "details": [{ "path": "/email", "message": "must match format \"email\"" }] }Campos extras: use
additionalProperties: falsenos bodies para rejeitar campos não previstos. É a proteção contra mass assignment.
Resposta
- Filtro: o schema de
responseserializa a saída comfast-json-stringify, e campos não declarados não saem, por exemplopassword. - Status padrão: se só existe um 2xx declarado (como
201), ele é o status padrão; não é preciso chamarctx.status(201). - Status sem schema: se a rota declara schemas de resposta e o handler devolve um status sem schema, a resposta é 500, com o motivo no log, e não o objeto sem filtro.
- Sem corpo: retornar
undefined(por exemplo, num 204) dispensa schema.
Entrada e saída são schemas diferentes
O contrato de uma rota não obriga a resposta a espelhar a entrada. params, query, headers e body descrevem o que chega; response descreve, para cada status, o que sai. Um POST que recebe um pedido inteiro e responde só com uma mensagem:
const Order = Type.Object({ product: Type.String(), quantity: Type.Integer({ minimum: 1 }) }, { additionalProperties: false });
const submit = {
body: Order, // entrada: o pedido inteiro
response: { 202: Type.Object({ message: Type.String() }) }, // saída: só a mensagem
} satisfies RouteSchemas;
@Post('/', submit)
submit(ctx: Context<typeof submit>) {
queue.push(ctx.body);
return { message: 'Pedido recebido' }; // 202: o único status de sucesso declarado
}- Só o status, sem corpo:
response: { 204: Type.Null() }, e o handler não retorna nada. - Proteção: se o handler devolver mais do que o schema da saída declara (o pedido salvo, com
ide um custo interno), só os campos declarados saem. - Mais de um desfecho de sucesso (
201quando cria,200quando já existia): declare os dois e escolha comctx.status(). Ele só aceita os status declarados, e o corpo precisa corresponder ao schema escolhido. - Na saída, o schema serializa e filtra, mas não é uma validação completa: a garantia é que nada além do declarado sai. A validação rigorosa (
minLength,format…) é a da entrada.
Para não repetir campos, derive um schema do outro com o TypeBox:
const Task = Type.Object({ id: Type.Integer(), title: Type.String(), done: Type.Boolean() });
const CreateTask = Type.Omit(Task, ['id', 'done']); // POST: só o que o cliente envia
const UpdateTask = Type.Partial(Type.Omit(Task, ['id'])); // PATCH: tudo opcional, sem o idModo estrito. createApp({ requireSchemas: true }) falha na inicialização se alguma rota não tiver schema de response, ou se um POST/PUT/PATCH não tiver schema de body.
Zod, Valibot e ArkType: o motor dos contratos
O motor padrão do FlowingTS é o contracts.native(), para schemas TypeBox. Para escrever os contratos em Zod, Valibot ou ArkType (qualquer biblioteca que implemente o Standard Schema), declare o motor no app:
import { z } from 'zod';
import { type Context, contracts, createApp, Post, Router, type RouteSchemas } from 'flowingts';
const app = createApp({
contracts: contracts.standard({ toJSONSchema: (schema) => z.toJSONSchema(schema as z.ZodType) }),
});
const criar = {
body: z.object({ nome: z.string().trim().min(2), email: z.email(), ativo: z.boolean().default(true) }),
response: { 201: z.object({ id: z.number(), nome: z.string() }) },
} satisfies RouteSchemas;
@Router('/membros')
class MembroRoutes {
@Post('/', criar)
criar(ctx: Context<typeof criar>) {
return salvar(ctx.body); // ctx.body tem o tipo do schema Zod, já com o trim e o default aplicados
}
}- Um motor por app. Sem a opção, vale o
contracts.native(). Comcontracts.standard(), todo schema precisa ser Standard Schema: um schema TypeBox numa rota (ou um Zod num app nativo) falha no registro, com a dica de configuração. Não há mistura no mesmo app. - Valor transformado: o que o schema devolve (com
trim,default,transform) substituictx.body,ctx.queryectx.params. Os headers são validados, masctx.req.headersnão é trocado. - Sem coerção automática: params e query chegam como texto. Use os conversores da biblioteca:
z.coerce.number(),z.coerce.boolean(). - Validação assíncrona (
refinecomasync) funciona na entrada. Na resposta, o schema precisa validar de forma síncrona. - Resposta: o schema do status valida e filtra a saída (o
z.objectremove o que não foi declarado;.passthrough()desliga isso) e o valor vai comJSON.stringify. Uma saída que não corresponde ao schema vira 500, sem enviar o objeto. - Erros no mesmo formato do motor nativo: 400
VALIDATION_ERROR, com os caminhos em JSON Pointer (/email). - OpenAPI: com
toJSONSchema, os schemas entram no documento; sem, as rotas aparecem com schemas vazios. - Path: no motor nativo, os parâmetros do path são conferidos contra o schema de
paramsno registro; com Standard Schema, essa conferência não é feita (a validação na requisição continua).