FastAPI: construir REST API com validação automática

1. Visão geral

Ao escrever uma REST API, grande parte do código “defensivo” fica em verificar entrada: o campo é obrigatório, o número é negativo, o email é válido. FastAPI delega isso ao Pydantic. Você só precisa declarar tipos de dados e restrições, e o framework validará automaticamente a requisição e retornará um erro 422 estruturado.

Este artigo constrói uma API de gerenciamento de pedidos (Order) do zero. Após ler, você saberá como restringir body, path e query. Você também será capaz de escrever validadores personalizados, filtrar dados de resposta, alterar o formato de erro e testar tudo com pytest.

2. Instalação

Você precisa de Python 3.10 ou superior. Crie um virtualenv e instale FastAPI com o grupo de dependência standard:

python -m venv .venv
source .venv/bin/activate
pip install "fastapi[standard]" pytest

O pacote fastapi[standard] já vem com tudo que você precisa neste artigo:

  • uvicorn para executar o servidor.
  • fastapi-cli fornece o comando fastapi dev.
  • httpx usado para TestClient.
  • email-validator usado para o tipo EmailStr.

3. Primeiro endpoint com modelo Pydantic

Crie o arquivo main.py:

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI(title="Order API")


class OrderCreate(BaseModel):
    customer_email: str
    product_sku: str
    quantity: int


@app.post("/orders", status_code=201)
def create_order(order: OrderCreate) -> OrderCreate:
    return order

Execute o servidor em modo dev com recarregamento automático:

fastapi dev main.py

Aqui ainda não há nenhum if. O parâmetro order: OrderCreate diz ao FastAPI três coisas:

  • Ler JSON do corpo da requisição.
  • Fazer parse do JSON em OrderCreate.
  • Retornar 422 se os dados não corresponderem ao tipo.

Tente enviar quantity como uma string que não é um número:

curl -X POST localhost:8000/orders \
  -H "Content-Type: application/json" \
  -d '{"customer_email":"an@example.com","product_sku":"TSHIRT-01","quantity":"abc"}'
{"detail":[{"type":"int_parsing","loc":["body","quantity"],
"msg":"Input should be a valid integer, unable to parse string as an integer",
"input":"abc"}]}

O campo loc aponta a localização exata do erro: está no body, no campo quantity. Além disso, a documentação OpenAPI em /docs é gerada automaticamente deste modelo. O código e a documentação, portanto, nunca ficam desincronizados.

4. Restrições de dados com Field

Verificar tipo é insuficiente. Por exemplo, quantity = -5 ainda é um int válido. Você adiciona restrições usando Field, combinado com Annotated:

from decimal import Decimal
from typing import Annotated

from pydantic import BaseModel, EmailStr, Field

Sku = Annotated[str, Field(min_length=3, max_length=20, pattern=r"^[A-Za-z0-9-]+$")]


class OrderCreate(BaseModel):
    customer_email: EmailStr
    product_sku: Sku
    quantity: Annotated[int, Field(gt=0, le=100)]
    unit_price: Annotated[Decimal, Field(gt=0, max_digits=10, decimal_places=2)]
    discount: Annotated[Decimal, Field(ge=0, decimal_places=2)] = Decimal("0")
    note: Annotated[str | None, Field(max_length=200)] = None

Cada linha faz uma coisa:

  • EmailStr valida o formato real do email, não apenas verifica se há um caractere @.
  • Sku é um tipo reutilizável. Requer 3-20 caracteres contendo apenas letras, números e hífens.
  • quantity deve ser maior que 0 e não exceder 100.
  • unit_price usa Decimal em vez de float para evitar erros de arredondamento ao calcular dinheiro. Máximo 10 dígitos, 2 casas decimais.
  • discount e note têm valores padrão, portanto são campos opcionais. Qualquer campo sem padrão é obrigatório.

Enviar quantity: 0 resultará em:

{"type":"greater_than","loc":["body","quantity"],"msg":"Input should be greater than 0","input":0,"ctx":{"gt":0}}

Nota: Por padrão, Pydantic roda em modo lax. A string "5" será coagida para o número 5 sem relatar erro. Se você quiser rejeitar completamente, adicione model_config = ConfigDict(strict=True) ao modelo, ou use Field(strict=True) para campos individuais.

5. Validar path e query parameter

Não apenas o body, path e query também são validados pelo mesmo mecanismo:

from fastapi import HTTPException, Path, Query

orders_db: dict[int, dict] = {}


@app.get("/orders/{order_id}")
def get_order(order_id: Annotated[int, Path(gt=0)]):
    if order_id not in orders_db:
        raise HTTPException(status_code=404, detail="Order not found")
    return orders_db[order_id]


@app.get("/orders")
def list_orders(
    page: Annotated[int, Query(ge=1)] = 1,
    size: Annotated[int, Query(ge=1, le=50)] = 10,
    sku: Annotated[str | None, Query(min_length=3)] = None,
):
    items = [o for o in orders_db.values() if sku is None or o["product_sku"] == sku]
    start = (page - 1) * size
    return items[start : start + size]

Resultados com diferentes requisições:

  • GET /orders/0 retorna 422, pois order_id deve ser maior que 0.
  • GET /orders?size=500 retorna 422, pois size máximo é 50.
  • GET /orders?page=2&size=5 executa normalmente. Os dois valores page e size foram coagidos para int antes de entrar na função.

Diferencie 422 de 404. Erro 422 significa que a requisição tem formato inválido. Erro 404 é lógica de negócio, então você raise HTTPException.

6. Validadores personalizados

Há regras que não podem ser expressas usando Field. Neste caso, você usa field_validator para um campo, ou model_validator para regras envolvendo múltiplos campos.

from pydantic import field_validator, model_validator

FORBIDDEN_DOMAINS = {"mailinator.com", "tempmail.com"}


class OrderCreate(BaseModel):
    # ... campos como na seção 4 ...

    @field_validator("product_sku")
    @classmethod
    def normalize_sku(cls, value: str) -> str:
        return value.upper()

    @field_validator("customer_email")
    @classmethod
    def reject_disposable_email(cls, value: str) -> str:
        domain = value.split("@")[-1].lower()
        if domain in FORBIDDEN_DOMAINS:
            raise ValueError("Disposable email is not allowed")
        return value

    @model_validator(mode="after")
    def check_discount(self) -> "OrderCreate":
        total = self.unit_price * self.quantity
        if self.discount > total:
            raise ValueError("Discount cannot exceed order total")
        return self

Explicação para cada validador:

  • normalize_sku executa após Field ter verificado o padrão. Ele normaliza o SKU para maiúsculas, então "tshirt-01" é armazenado como "TSHIRT-01". Um validador pode transformar o valor, desde que return o novo valor.
  • reject_disposable_email lança ValueError. Pydantic converte esse erro em um erro de validação, e a mensagem recebe o prefixo "Value error, ".
  • check_discount executa com mode="after", ou seja, quando todos os campos já são válidos e têm o tipo correto. Isso permite multiplicar Decimal com segurança. Lembre-se de fazer return self.

Nota: Não chame banco de dados ou APIs externas dentro de validadores. Por exemplo, verificar “SKU existe no estoque” é lógica de negócio. Coloque isso em um serviço ou dependência. Um validador deve ser uma função pura, rápida e fácil de testar.

7. response_model: controlar dados de saída

Você geralmente armazena mais informações do que deseja retornar ao cliente, como notas internas. Separe modelos de entrada e saída:

class OrderRead(BaseModel):
    id: int
    customer_email: EmailStr
    product_sku: str
    quantity: int
    total: Decimal


@app.post("/orders", status_code=201, response_model=OrderRead)
def create_order(order: OrderCreate):
    order_id = len(orders_db) + 1
    record = {
        "id": order_id,
        **order.model_dump(),
        "total": order.unit_price * order.quantity - order.discount,
        "internal_flag": "manual-review",
    }
    orders_db[order_id] = record
    return record

A função retorna um dict contendo tanto note quanto internal_flag. Porém, response_model=OrderRead filtra a resposta deixando apenas os campos declarados:

{"id":1,"customer_email":"an@example.com","product_sku":"TSHIRT-01","quantity":2,"total":"39.98"}

Dois pontos importantes:

  • Decimal é serializado para a string "39.98" para preservar a precisão. O cliente precisa estar ciente disso.
  • A resposta também é validada. Se você esquecer o campo total, FastAPI lança ResponseValidationError e retorna 500, não 422. Este é um erro do servidor, não do cliente.

8. Personalizar formato de erro 422

O formato padrão é bem detalhado. Muitos times preferem uma estrutura compacta e uniforme para o frontend. Você sobrescreve o handler de RequestValidationError:

from fastapi import Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse


@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
    errors = [
        {
            "field": ".".join(str(part) for part in err["loc"][1:]) or "body",
            "message": err["msg"],
        }
        for err in exc.errors()
    ]
    return JSONResponse(
        status_code=422,
        content={"code": "VALIDATION_FAILED", "errors": errors},
    )

Como o handler processa cada erro:

  • err["loc"][1:] remove o primeiro elemento (body, query, path) e junta o restante com pontos.
  • Erros de model_validator não se vinculam a nenhum campo. Nesse caso, loc contém apenas ("body",), então o handler atribui "body".

Enviar um pedido com quantity: 0 e um email descartável resultará em:

{"code":"VALIDATION_FAILED","errors":[
  {"field":"customer_email","message":"Value error, Disposable email is not allowed"},
  {"field":"quantity","message":"Input should be greater than 0"}]}

Pydantic coleta todos os erros de todos os campos em uma passada, em vez de parar no primeiro erro. O cliente consegue corrigir tudo de uma vez.

9. Testar com pytest

TestClient chama o app diretamente, sem precisar executar o servidor. Crie o arquivo test_main.py ao lado de main.py:

import pytest
from fastapi.testclient import TestClient

from main import app, orders_db

client = TestClient(app)

VALID_ORDER = {
    "customer_email": "an@example.com",
    "product_sku": "tshirt-01",
    "quantity": 2,
    "unit_price": "19.99",
}


@pytest.fixture(autouse=True)
def clear_db():
    orders_db.clear()


def test_create_order_returns_normalized_sku_and_total():
    response = client.post("/orders", json=VALID_ORDER)

    assert response.status_code == 201
    body = response.json()
    assert body["product_sku"] == "TSHIRT-01"
    assert body["total"] == "39.98"
    assert "internal_flag" not in body


def test_invalid_quantity_and_email_are_reported_together():
    payload = {**VALID_ORDER, "quantity": 0, "customer_email": "x@mailinator.com"}

    response = client.post("/orders", json=payload)

    assert response.status_code == 422
    fields = {e["field"] for e in response.json()["errors"]}
    assert fields == {"quantity", "customer_email"}


def test_discount_greater_than_total_is_rejected():
    payload = {**VALID_ORDER, "discount": "100.00"}

    response = client.post("/orders", json=payload)

    assert response.status_code == 422
    assert response.json()["errors"][0] == {
        "field": "body",
        "message": "Value error, Discount cannot exceed order total",
    }


def test_page_size_over_limit_is_rejected():
    assert client.get("/orders?size=500").status_code == 422

Execute os testes:

pytest -q
....                                                    [100%]
4 passed in 0.31s

A fixture clear_db reseta orders_db antes de cada teste, garantindo que os testes sejam independentes uns dos outros. Além disso, você pode testar o modelo isoladamente sem HTTP, usando OrderCreate.model_validate(...) combinado com pytest.raises(ValidationError). Esta abordagem é adequada quando o modelo tem muitas regras complexas.

10. Erros comuns

  • Usar float para dinheiro. 0.1 + 0.2 não é igual a 0.3. Use Decimal com max_digits e decimal_places.
  • Esquecer @classmethod ou ordem de decoradores. @field_validator deve estar acima de @classmethod. Caso contrário, Pydantic v2 gera erro na importação.
  • Esquecer return em validador. Um field_validator sem return atribui None ao campo. Um model_validator(mode="after") deve retornar self.
  • Usar sintaxe Pydantic v1. @validator, class Config, .dict() foram descontinuados. Use @field_validator, model_config, .model_dump().
  • Confundir 422 com 500. Entrada inválida gera 422. Resposta não correspondendo a response_model gera 500. Se ver 500, verifique os dados que você está retornando.
  • Usar o mesmo modelo para entrada e saída. Isso expõe campos internos e força o cliente a enviar campos como id. Separe OrderCreate e OrderRead.

Nota: Validação na camada API não substitui restrições no banco de dados. Restrições como UNIQUE ou NOT NULL ainda são necessárias. Requisições podem entrar no sistema por múltiplos caminhos, como jobs em background ou scripts de migração.

11. Conclusão

FastAPI combinado com Pydantic transforma type hints em um contrato de dados com três benefícios principais:

  • Requisições inválidas são bloqueadas na porta de entrada.
  • Erros são retornados completos e estruturados.
  • Documentação OpenAPI sempre corresponde ao código.

Neste artigo, você usou Field para restrições simples, Path e Query para parâmetros URL, field_validator e model_validator para regras personalizadas. Você também usou response_model para filtrar saída, alterou o formato de erro 422 e testou tudo com TestClient.

Princípio a lembrar: deixe validadores fazerem o trabalho de verificar a forma dos dados, enquanto a lógica de negócio vai na camada de serviço.

pythonfastapipydanticrest-apivalidation