Skip to content
This page has been auto-translated and may contain errors.View in English

Middleware de validação

A verificação pertence à frente do handler. Construí-la significa escrever uma única função que funcione para qualquer rota, seja qual for o schema que essa rota precise.

O middleware do Express é uma função chamada a caminho de um handler, com três argumentos: a requisição, a resposta e next, que passa o controle adiante. Então o que precisamos é de uma função que produza um desses, já com um schema embutido.

text
validate(registerSchema)  ->  middleware que valida contra registerSchema
validate(loginSchema)     ->  middleware que valida contra loginSchema

Uma função que retorna outra função é uma função de ordem superior, e aqui ela funciona como uma fábrica: chamada uma vez quando as rotas são configuradas, e o middleware que ela produz roda a cada requisição.

O formato da fábrica

Comece pelo esboço, em back-end/middleware/validate.ts:

ts
import type { Request, Response, NextFunction } from 'express'
import * as z from 'zod'

export function validate(schema: z.ZodType) {
  return (req: Request, res: Response, next: NextFunction): void => {
    // Valida req.body contra o schema.
  }
}

Duas camadas, fazendo dois trabalhos em dois momentos diferentes.

A validate externa roda uma única vez, enquanto o app está iniciando, e seu único argumento é o schema. z.ZodType é a classe base da qual todo schema do Zod herda, então o parâmetro aceita um schema de objeto, um schema de string, qualquer um.

A função interna é a que o Express guarda e chama a cada requisição. Ela retorna void porque não devolve nenhum valor; ou ela responde à requisição, ou chama next.

JunoO formato da fábrica Ter duas funções empilhadas é a parte que exige um momento de atenção. Ajuda ler as duas como dois momentos separados no tempo.

A externa roda uma única vez, quando o app inicia, e seu trabalho é lembrar o schema. A interna roda a cada requisição, uma por uma, e seu trabalho é fazer a verificação.

JunoO formato da fábrica O motivo de precisar ser uma fábrica é que o Express decide a assinatura do middleware. Ele vai chamar sua função com request, response e next, e não existe um quarto espaço para passar um schema.

Fechar sobre o schema (o famoso "closure") é como você consegue passar um argumento extra. É o mesmo padrão por trás de quase todo middleware configurável que você vai encontrar, e é por isso que cors() e helmet() são chamados como funções em vez de passados diretamente.

JunoO formato da fábrica Tipar o parâmetro como z.ZodType mantém a fábrica genérica, mas abre mão do tipo específico, então result.data volta com tipagem frouxa. Tornar a fábrica genérica em relação ao schema, <T extends z.ZodType>, carrega z.infer<T> até o que você anexa na requisição, e o handler recebe tipos reais sem precisar de asserção.

Vale a pena fazer isso quando você já tem mais do que umas poucas rotas, e vale a pena pular enquanto você está aprendendo o padrão, porque a versão genérica é mais difícil de ler justamente no momento em que você está tentando entender a estrutura de duas camadas.

Rejeitando uma requisição inválida

Dentro da função interna, rode o schema sobre o corpo da requisição:

ts
const result = schema.safeParse(req.body)

safeParse é o método certo para usar aqui, porque uma falha é algo a ser reportado, não uma exceção a ser tratada.

Quando ela falha, a resposta precisa dizer qual campo e o que estava errado:

ts
if (!result.success) {
  const errors = result.error.issues.map((issue) => ({
    field: issue.path[0],
    message: issue.message,
  }))

  res.status(400).json({ success: false, errors })
  return
}

Três coisas acontecem. Os issues viram pares de campo e mensagem, a resposta volta como um 400 carregando eles, e return interrompe a função.

Esse return é toda a segurança disso. res.json() envia uma resposta e continua executando, então sem ele a função segue até next() e o handler roda em cima de dados que falharam um instante antes.

JunoRejeitando uma requisição inválida Enviar uma resposta e interromper a função são duas ações separadas, o que é surpreendente na primeira vez que isso te pega.

res.json() só faz a primeira. O return faz a segunda, e deixar isso de fora é a versão clássica desse bug.

JunoRejeitando uma requisição inválida A etapa de mapeamento é o que transforma a saída do Zod em um contrato de API. Os issues brutos carregam o tipo esperado e a restrição que falhou, que descrevem seu schema para quem está perguntando.

Decidir o formato aqui também significa que toda rota responde da mesma forma, então o front-end escreve uma única função para renderizar erros e nunca precisa se importar com qual endpoint os produziu.

JunoRejeitando uma requisição inválidaissue.path[0] funciona bem para um corpo simples e falha assim que algo é aninhado, porque todo campo sob um mesmo pai colapsa para a mesma chave e as mensagens acabam apontando para o input errado. issue.path.join('.') permanece único e não custa nada escrever desde já.

Dois detalhes para mais tarde. Índices de array chegam no path como números, então uma falha dentro de uma lista dá items.2.quantity, enquanto a maioria das bibliotecas de formulário espera items[2].quantity. E validar só req.body deixa params e query strings sem checagem, que é justamente onde costuma estar um id usado em uma consulta ao banco de dados. Estender a fábrica para receber { body, params, query } é uma mudança pequena que fecha uma lacuna real.

Passando adiante os dados válidos

Tudo depois desse return só roda quando a validação passou:

ts
;(req as any).validatedData = result.data
next()

Os dados já validados são anexados à requisição, e então next() passa o controle para o handler da rota, que lê req.validatedData e nunca toca em req.body.

result.data em vez de req.body faz diferença aqui. O Zod pode ter convertido uma string em número, removido espaços em branco, colocado um e-mail em minúsculas ou descartado uma chave que o schema não descrevia. O valor validado carrega tudo isso. O corpo bruto não carrega nada disso.

Aqui está o arquivo completo:

ts
import type { Request, Response, NextFunction } from 'express'
import * as z from 'zod'

export function validate(schema: z.ZodType) {
  return (req: Request, res: Response, next: NextFunction): void => {
    const result = schema.safeParse(req.body)

    if (!result.success) {
      const errors = result.error.issues.map((issue) => ({
        field: issue.path[0],
        message: issue.message,
      }))

      res.status(400).json({ success: false, errors })
      return
    }

    ;(req as any).validatedData = result.data
    next()
  }
}
JunoPassando adiante os dados válidosnext() é a passagem de bastão. Até ele ser chamado, a requisição fica parada nessa função e não vai adiante.

É isso que faz o esquema funcionar: uma requisição que falha nunca é passada adiante, então o código que vem depois só vê dados que passaram na validação.

JunoPassando adiante os dados válidos Esquecer o next() deixa você com uma requisição pendurada, sem resposta e sem erro, até o cliente atingir o timeout. É uma falha confusa justamente porque nada aparece nos logs.

O as any está ali porque o tipo Request do Express não tem uma propriedade validatedData. Funciona, mas desliga a checagem de tipos para essa atribuição, então um erro de digitação no nome da propriedade compila normalmente e o handler lê undefined.

JunoPassando adiante os dados válidos A alternativa tipada ao as any é a fusão de declarações (declaration merging): estender a interface Request do Express em um arquivo .d.ts para que validatedData seja uma propriedade real em todo lugar. Custa algumas linhas uma única vez e remove a asserção de todo middleware que você escrever.

Sobrescrever req.body com o valor validado é a outra abordagem, e é tentadora porque os handlers continuam lendo a mesma propriedade de sempre. Mas também significa que um middleware posterior não consegue distinguir dado validado de dado bruto, e o tipo ainda afirma ser o que quer que seu body parser tenha dito. Uma propriedade separada vale o nome extra.

Vale saber que next(err) com um argumento pula todos os handlers restantes e vai direto para o middleware de erro. Se seu app tem um handler de erro central que já formata as respostas, passar o ZodError para ele mantém a formatação em um único lugar em vez de espalhada em cada middleware.

Coloque em prática

Esse middleware tem três bugs. Dois impedem que funcione; um é silencioso e pior.

ts
export function validate(schema: z.ZodType) {
  return (req: Request, res: Response, next: NextFunction): void => {
    const result = schema.safeParse(req.body)

    if (!result.success) {
      res.status(400).json({ success: false, errors: result.error.issues })
    }

    ;(req as any).validatedData = req.body
    next()
  }
}
Compare suas respostas

1. Falta o return depois do 400. A resposta é enviada, a execução continua, next() roda, e o handler processa uma requisição que falhou na validação. O cliente vê uma rejeição e o trabalho acontece do mesmo jeito.

2. req.body no lugar onde deveria estar result.data. Mesmo depois de adicionar o return, o handler recebe o corpo bruto. Toda conversão, remoção de espaços e minúsculas feitas pelo schema são descartadas, e qualquer chave que o schema removeria continua anexada.

3. result.error.issues bruto na resposta. Funciona, então é o silencioso. Os objetos de issue do Zod descrevem seu schema para o cliente, incluindo tipos esperados e restrições que falharam, e não estão formatados para uma pessoa ler. Mapeie-os para pares de campo e mensagem.

O bug 2 é o que vale a pena refletir com calma. O middleware parece funcionar: requisições válidas passam, inválidas são rejeitadas, e os testes ficam verdes. O que desaparece silenciosamente é cada transformação que o schema estava fazendo, e isso só aparece muito mais tarde, como dados inconsistentes no banco de dados.

Para onde isso vai a seguir

O middleware está pronto e valida contra qualquer schema que receba. O que ele ainda não recebeu é um schema, nem uma rota para ficar na frente.

Construindo um endpoint validado escreve os dois, depois faz o schema crescer campo por campo até que todo input do formulário de cadastro esteja sendo checado.