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:
uvicornpara executar o servidor.fastapi-clifornece o comandofastapi dev.httpxusado paraTestClient.email-validatorusado para o tipoEmailStr.
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
422se 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:
EmailStrvalida 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.quantitydeve ser maior que 0 e não exceder 100.unit_priceusaDecimalem vez defloatpara evitar erros de arredondamento ao calcular dinheiro. Máximo 10 dígitos, 2 casas decimais.discountenotetê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úmero5sem relatar erro. Se você quiser rejeitar completamente, adicionemodel_config = ConfigDict(strict=True)ao modelo, ou useField(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/0retorna422, poisorder_iddeve ser maior que 0.GET /orders?size=500retorna422, poissizemáximo é 50.GET /orders?page=2&size=5executa normalmente. Os dois valorespageesizeforam coagidos paraintantes 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_skuexecuta apósFieldter 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 quereturno novo valor.reject_disposable_emaillançaValueError. Pydantic converte esse erro em um erro de validação, e a mensagem recebe o prefixo"Value error, ".check_discountexecuta commode="after", ou seja, quando todos os campos já são válidos e têm o tipo correto. Isso permite multiplicarDecimalcom segurança. Lembre-se de fazerreturn 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çaResponseValidationErrore retorna500, não422. 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_validatornão se vinculam a nenhum campo. Nesse caso,locconté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
floatpara dinheiro.0.1 + 0.2não é igual a0.3. UseDecimalcommax_digitsedecimal_places. - Esquecer
@classmethodou ordem de decoradores.@field_validatordeve estar acima de@classmethod. Caso contrário, Pydantic v2 gera erro na importação. - Esquecer
returnem validador. Umfield_validatorsem return atribuiNoneao campo. Ummodel_validator(mode="after")deve retornarself. - Usar sintaxe Pydantic v1.
@validator,class Config,.dict()foram descontinuados. Use@field_validator,model_config,.model_dump(). - Confundir
422com500. Entrada inválida gera422. Resposta não correspondendo aresponse_modelgera500. Se ver500, 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. SepareOrderCreateeOrderRead.
Nota: Validação na camada API não substitui restrições no banco de dados. Restrições como
UNIQUEouNOT NULLainda 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.