📚 Pré-requisitos Teóricos: este projeto aplica conceitos ensinados em Especialização em Backend com Python e FastAPI. Recomendado revisar antes de começar.
v1.1
Trilha de Aprendizado — Projeto 1 de 4 (Trilha Python)
- ➡️ 01 (este): CRUD básico de jogos · SQLite · Bootstrap 5 · FastAPI · Deploy Docker + Render + Neon
- 02: Refatoração SOLID/DRY — camada de Service, DTOs Pydantic dedicados, Validação de entrada
- 03: Categorias (relacionamento relacional 1:N), tema claro/escuro, paginação e filtros
- 04: Levantamento de Requisitos · Backlog & Sprints · Git Flow · Testes Automatizados · ADRs
🎓 Nível profissional simulado: Estagiário / Júnior. Primeiro contato com Python 3.11+, FastAPI, SQLAlchemy 2.0 e Jinja2 + Bootstrap 5: uma única entidade (
Jogo), endpoints web com formulário e redirecionamentos, e endpoints REST JSON documentados interativamente pelo Swagger UI (/docs). O objetivo é dominar o ciclo de vida de uma aplicação web completa, da persistência ao deploy. (Inclui testes automatizados compyteste Swagger interativo).🗺️ Mapa da trilha: veja a tabela comparativa na listagem principal de projetos.
Construir uma aplicação web e API para gerenciar o catálogo da sua coleção pessoal de jogos: cadastrar novos títulos, listar a biblioteca com badges de status (“Zerado” / “Jogando”), alternar status e apagar jogos.
flowchart TD
subgraph Browser ["🌐 Navegador do Usuário"]
UI["💻 Interface Web (Bootstrap 5)"]
SW["📖 Swagger UI (/docs)"]
end
subgraph App ["⚡ Aplicação FastAPI (Docker)"]
Routes["Rotas Web & REST (routes.py)"]
DBLayer["Sessão SQLAlchemy (database.py)"]
Model["Entidade Jogo (models.py)"]
end
subgraph Database ["🗄️ Banco de Dados"]
DB[("SQLite (Dev) / PostgreSQL Neon (Prod)")]
end
UI -->|HTML Forms / POST| Routes
SW -->|HTTP / JSON| Routes
Routes --> DBLayer
DBLayer --> Model
Model --- DB
| Papel | Stack Java (javaweb_jogos_01_crud) |
Stack Python (bibliotecajogos_python_01) |
|---|---|---|
| Linguagem & Runtime | Java 21 (LTS) | Python 3.11+ |
| Framework Web | Spring Boot 3.x / 4.x | FastAPI + Uvicorn |
| ORM / Mapeamento | Spring Data JPA / Hibernate | SQLAlchemy 2.0 (Mapped, mapped_column) |
| Template Engine | Thymeleaf (th:each, th:if) |
Jinja2 ({% for %}, {% if %}) |
| Frontend UI | Bootstrap 5 | Bootstrap 5 |
| Banco Local (Dev) | H2 Database (em memória) | SQLite (sqlite:///./bibliotecajogos.db) |
| Banco Remoto (Prod) | PostgreSQL (Neon) | PostgreSQL (Neon) via psycopg |
| Documentação da API | SpringDoc / Swagger | Swagger UI nativo em /docs |
| Testes Automatizados | JUnit 5 | pytest + httpx (TestClient) |
| Deploy | Docker + Render | Docker + Render |
erDiagram
JOGOS {
INT id PK "Auto-incremento"
VARCHAR titulo "Título do jogo"
VARCHAR plataforma "Plataforma (PC, PS5, Xbox, Switch)"
BOOLEAN concluido "Status (True: Zerado / False: Jogando)"
}
bibliotecajogos_python_01/
├── app/
│ ├── __init__.py
│ ├── config.py # Configurações com pydantic-settings (.env / prod)
│ ├── database.py # Engine SQLAlchemy 2.0 e SessionLocal
│ ├── models.py # Entidade Jogo (tabela jogos)
│ ├── schemas.py # Schemas Pydantic para validação e Swagger
│ ├── routes.py # Rotas Web (Bootstrap 5) e Endpoints REST
│ ├── main.py # Instância FastAPI e lifespan
│ └── templates/
│ └── jogos.html # Interface visual responsiva em Bootstrap 5
├── tests/
│ ├── __init__.py
│ ├── conftest.py # Fixture com banco SQLite em memória isolado
│ └── test_jogos.py # Testes de integração Web e REST
├── .dockerignore
├── .env.example
├── .gitignore
├── Dockerfile # Imagem baseada em python:3.11-slim
├── README.md # Instruções de execução e Swagger
└── requirements.txt # Dependências do projeto
mkdir bibliotecajogos-py
cd bibliotecajogos-py
git init
git branch -M main
# Criar ambiente virtual Python 3.11+
python -m venv .venv
# Ativar ambiente virtual:
# Windows (PowerShell):
.venv\Scripts\Activate.ps1
# Linux/macOS:
source .venv/bin/activate
requirements.txt:fastapi>=0.115.0
uvicorn[standard]>=0.30.0
jinja2>=3.1.4
python-multipart>=0.0.9
sqlalchemy>=2.0.35
pydantic>=2.9.0
pydantic-settings>=2.5.0
psycopg[binary]>=3.2.0
pytest>=8.3.0
httpx>=0.27.0
Instale as dependências:
pip install -r requirements.txt
Arquivo: app/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
APP_NAME: str = "Biblioteca de Jogos"
DATABASE_URL: str = "sqlite:///./bibliotecajogos.db"
ENVIRONMENT: str = "dev"
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore"
)
settings = Settings()
Arquivo: app/database.py
from typing import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker, Session
from app.config import settings
db_url = settings.DATABASE_URL
if db_url.startswith("postgres://"):
db_url = db_url.replace("postgres://", "postgresql+psycopg://", 1)
elif db_url.startswith("postgresql://") and not db_url.startswith("postgresql+"):
db_url = db_url.replace("postgresql://", "postgresql+psycopg://", 1)
connect_args = {"check_same_thread": False} if db_url.startswith("sqlite") else {}
engine = create_engine(db_url, connect_args=connect_args)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
Arquivo: app/models.py
from sqlalchemy import String, Boolean
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class Jogo(Base):
__tablename__ = "jogos"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
titulo: Mapped[str] = mapped_column(String(255), nullable=False)
plataforma: Mapped[str] = mapped_column(String(100), nullable=False)
concluido: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
Arquivo: app/schemas.py
from pydantic import BaseModel, ConfigDict, Field
class JogoBase(BaseModel):
titulo: str = Field(..., min_length=1, max_length=255, description="Título do jogo")
plataforma: str = Field(..., min_length=1, max_length=100, description="Plataforma (ex: PC, PS5, Xbox, Switch)")
concluido: bool = Field(default=False, description="Indica se o jogo foi concluído/zerado")
class JogoCreate(JogoBase):
pass
class JogoUpdate(BaseModel):
titulo: str | None = Field(default=None, min_length=1, max_length=255)
plataforma: str | None = Field(default=None, min_length=1, max_length=100)
concluido: bool | None = Field(default=None)
class JogoResponse(JogoBase):
id: int
model_config = ConfigDict(from_attributes=True)
Arquivo: app/routes.py
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, Form, Request, HTTPException, status
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import Jogo
from app.schemas import JogoCreate, JogoUpdate, JogoResponse
router = APIRouter()
TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
# ==========================================
# Rotas Web (Jinja2 + Bootstrap 5)
# ==========================================
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
def listar_web(request: Request, db: Session = Depends(get_db)):
jogos = db.query(Jogo).order_by(Jogo.id.asc()).all()
return templates.TemplateResponse(request=request, name="jogos.html", context={"jogos": jogos})
@router.post("/jogos", response_class=RedirectResponse, status_code=status.HTTP_303_SEE_OTHER, tags=["Web"], summary="Adicionar jogo via Formulário Web")
def adicionar_web(
titulo: Annotated[str, Form(description="Título do jogo")],
plataforma: Annotated[str, Form(description="Plataforma do jogo")],
db: Session = Depends(get_db),
):
novo_jogo = Jogo(titulo=titulo, plataforma=plataforma, concluido=False)
db.add(novo_jogo)
db.commit()
return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/jogos/{id}/concluir", response_class=RedirectResponse, status_code=status.HTTP_303_SEE_OTHER, tags=["Web"], summary="Alternar status do jogo via Formulário Web")
def alternar_status_web(id: int, db: Session = Depends(get_db)):
jogo = db.query(Jogo).filter(Jogo.id == id).first()
if jogo:
jogo.concluido = not jogo.concluido
db.commit()
return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/jogos/{id}/apagar", response_class=RedirectResponse, status_code=status.HTTP_303_SEE_OTHER, tags=["Web"], summary="Apagar jogo via Formulário Web")
def apagar_web(id: int, db: Session = Depends(get_db)):
jogo = db.query(Jogo).filter(Jogo.id == id).first()
if jogo:
db.delete(jogo)
db.commit()
return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
# ==========================================
# Endpoints REST API (Swagger / JSON)
# ==========================================
@router.get("/api/jogos", response_model=list[JogoResponse], tags=["API REST - Jogos"], summary="Listar todos os jogos")
def api_listar_jogos(db: Session = Depends(get_db)):
return db.query(Jogo).order_by(Jogo.id.asc()).all()
@router.get("/api/jogos/{id}", response_model=JogoResponse, tags=["API REST - Jogos"], summary="Buscar jogo por ID")
def api_buscar_jogo(id: int, db: Session = Depends(get_db)):
jogo = db.query(Jogo).filter(Jogo.id == id).first()
if not jogo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Jogo com ID {id} não encontrado",
)
return jogo
@router.post("/api/jogos", response_model=JogoResponse, status_code=status.HTTP_201_CREATED, tags=["API REST - Jogos"], summary="Cadastrar novo jogo")
def api_criar_jogo(jogo_in: JogoCreate, db: Session = Depends(get_db)):
novo_jogo = Jogo(
titulo=jogo_in.titulo,
plataforma=jogo_in.plataforma,
concluido=jogo_in.concluido,
)
db.add(novo_jogo)
db.commit()
db.refresh(novo_jogo)
return novo_jogo
@router.put("/api/jogos/{id}", response_model=JogoResponse, tags=["API REST - Jogos"], summary="Atualizar jogo por ID")
def api_atualizar_jogo(id: int, jogo_in: JogoUpdate, db: Session = Depends(get_db)):
jogo = db.query(Jogo).filter(Jogo.id == id).first()
if not jogo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Jogo com ID {id} não encontrado",
)
if jogo_in.titulo is not None:
jogo.titulo = jogo_in.titulo
if jogo_in.plataforma is not None:
jogo.plataforma = jogo_in.plataforma
if jogo_in.concluido is not None:
jogo.concluido = jogo_in.concluido
db.commit()
db.refresh(jogo)
return jogo
@router.patch("/api/jogos/{id}/concluir", response_model=JogoResponse, tags=["API REST - Jogos"], summary="Alternar status zerado/jogando")
def api_alternar_status(id: int, db: Session = Depends(get_db)):
jogo = db.query(Jogo).filter(Jogo.id == id).first()
if not jogo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Jogo com ID {id} não encontrado",
)
jogo.concluido = not jogo.concluido
db.commit()
db.refresh(jogo)
return jogo
@router.delete("/api/jogos/{id}", status_code=status.HTTP_204_NO_CONTENT, tags=["API REST - Jogos"], summary="Excluir jogo por ID")
def api_deletar_jogo(id: int, db: Session = Depends(get_db)):
jogo = db.query(Jogo).filter(Jogo.id == id).first()
if not jogo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Jogo com ID {id} não encontrado",
)
db.delete(jogo)
db.commit()
Arquivo: app/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.config import settings
from app.database import Base, engine
from app.routes import router
@asynccontextmanager
async def lifespan(app: FastAPI):
Base.metadata.create_all(bind=engine)
yield
app = FastAPI(
title="Biblioteca de Jogos API",
description="Aplicação Web e API REST de Catálogo de Jogos em Python 3.11+, FastAPI e SQLAlchemy 2.0",
version="1.0.0",
lifespan=lifespan,
)
app.include_router(router)
Arquivo: app/templates/jogos.html
<!DOCTYPE html>
<html lang="pt-br">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Biblioteca de Jogos</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container py-5" style="max-width: 900px;">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h2 mb-0">🎮 Minha Biblioteca de Jogos</h1>
<a href="/docs" target="_blank" class="btn btn-outline-dark btn-sm">📖 Swagger UI</a>
</div>
<!-- Formulário de Adicionar Jogo -->
<div class="card shadow-sm mb-4">
<div class="card-body">
<form action="/jogos" method="post" class="row g-2">
<div class="col-md-5">
<input type="text" name="titulo" class="form-control" placeholder="Título do jogo (ex: Elden Ring)" required>
</div>
<div class="col-md-4">
<input type="text" name="plataforma" class="form-control" placeholder="Plataforma (PC, PS5, Xbox, Switch...)" required>
</div>
<div class="col-md-3 d-grid">
<button type="submit" class="btn btn-primary">Adicionar</button>
</div>
</form>
</div>
</div>
<!-- Tabela de Jogos -->
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0 bg-white">
<thead class="table-light">
<tr>
<th>Título</th>
<th>Plataforma</th>
<th>Status</th>
<th class="text-end">Ações</th>
</tr>
</thead>
<tbody>
{% for jogo in jogos %}
<tr>
<td class="fw-semibold">{{ jogo.titulo }}</td>
<td><span class="badge bg-light text-dark border">{{ jogo.plataforma }}</span></td>
<td>
{% if jogo.concluido %}
<span class="badge bg-success">Zerado</span>
{% else %}
<span class="badge bg-secondary">Jogando</span>
{% endif %}
</td>
<td class="text-end">
<form action="/jogos/{{ jogo.id }}/concluir" method="post" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-success">Alternar</button>
</form>
<form action="/jogos/{{ jogo.id }}/apagar" method="post" class="d-inline ms-1">
<button type="submit" class="btn btn-sm btn-outline-danger" onclick="return confirm('Deseja realmente apagar este jogo?')">Apagar</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="text-center text-muted py-4">Nenhum jogo cadastrado na sua biblioteca.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>
pytestArquivo: tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.main import app
from app.database import Base, get_db
SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@pytest.fixture(scope="function")
def db_session():
Base.metadata.create_all(bind=engine)
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
Base.metadata.drop_all(bind=engine)
@pytest.fixture(scope="function")
def client(db_session):
def override_get_db():
try:
yield db_session
finally:
pass
app.dependency_overrides[get_db] = override_get_db
with TestClient(app, follow_redirects=True) as test_client:
yield test_client
app.dependency_overrides.clear()
Arquivo: tests/test_jogos.py
def test_web_listar_jogos_vazio(client):
response = client.get("/")
assert response.status_code == 200
assert "Nenhum jogo cadastrado" in response.text
def test_web_fluxo_completo(client):
# 1. Adicionar jogo via form
response = client.post("/jogos", data={"titulo": "Super Mario Odyssey", "plataforma": "Nintendo Switch"})
assert response.status_code == 200
assert "Super Mario Odyssey" in response.text
assert "Jogando" in response.text
# 2. Alternar para Zerado
response_concluir = client.post("/jogos/1/concluir")
assert response_concluir.status_code == 200
assert "Zerado" in response_concluir.text
# 3. Apagar jogo
response_apagar = client.post("/jogos/1/apagar")
assert response_apagar.status_code == 200
assert "Super Mario Odyssey" not in response_apagar.text
def test_api_rest_completa(client):
# 1. Listar vazio
resp = client.get("/api/jogos")
assert resp.status_code == 200
assert resp.json() == []
# 2. Criar via POST JSON
post_resp = client.post("/api/jogos", json={"titulo": "God of War Ragnarok", "plataforma": "PS5", "concluido": False})
assert post_resp.status_code == 201
jogo_id = post_resp.json()["id"]
# 3. Buscar por ID
get_resp = client.get(f"/api/jogos/{jogo_id}")
assert get_resp.status_code == 200
assert get_resp.json()["titulo"] == "God of War Ragnarok"
# 4. Atualizar via PUT
put_resp = client.put(f"/api/jogos/{jogo_id}", json={"titulo": "God of War (2018)", "plataforma": "PC", "concluido": True})
assert put_resp.status_code == 200
assert put_resp.json()["titulo"] == "God of War (2018)"
# 5. Alternar status via PATCH
patch_resp = client.patch(f"/api/jogos/{jogo_id}/concluir")
assert patch_resp.status_code == 200
assert patch_resp.json()["concluido"] is False
# 6. Deletar via DELETE
del_resp = client.delete(f"/api/jogos/{jogo_id}")
assert del_resp.status_code == 204
# 7. Buscar deletado (404)
resp_404 = client.get(f"/api/jogos/{jogo_id}")
assert resp_404.status_code == 404
def test_api_nao_encontrado_404(client):
id_inexistente = 99999
assert client.get(f"/api/jogos/{id_inexistente}").status_code == 404
assert client.put(f"/api/jogos/{id_inexistente}", json={"titulo": "X"}).status_code == 404
assert client.patch(f"/api/jogos/{id_inexistente}/concluir").status_code == 404
assert client.delete(f"/api/jogos/{id_inexistente}").status_code == 404
def test_api_validacao_campos(client):
# Título vazio não deve ser aceito (min_length=1)
resp = client.post("/api/jogos", json={"titulo": "", "plataforma": "PC"})
assert resp.status_code == 422
# Plataforma ausente não deve ser aceita
resp = client.post("/api/jogos", json={"titulo": "Zelda"})
assert resp.status_code == 422
def test_api_atualizacao_parcial(client):
post_resp = client.post("/api/jogos", json={"titulo": "Hollow Knight", "plataforma": "PC", "concluido": False})
jogo_id = post_resp.json()["id"]
# Atualiza apenas a plataforma
put_resp = client.put(f"/api/jogos/{jogo_id}", json={"plataforma": "Switch"})
assert put_resp.status_code == 200
assert put_resp.json()["titulo"] == "Hollow Knight"
assert put_resp.json()["plataforma"] == "Switch"
def test_web_acoes_jogo_inexistente(client):
resp_concluir = client.post("/jogos/99999/concluir")
assert resp_concluir.status_code == 200
resp_apagar = client.post("/jogos/99999/apagar")
assert resp_apagar.status_code == 200
Execute os testes automatizados:
pytest -v
Inicie o servidor de desenvolvimento:
uvicorn app.main:app --reload --port 8000
O FastAPI disponibiliza documentação interativa automática para explorar os endpoints:
http://127.0.0.1:8000/docs.POST /api/jogos):
POST /api/jogos → Clique em “Try it out”.{
"titulo": "The Witcher 3: Wild Hunt",
"plataforma": "PC",
"concluido": true
}
201 Created e o id retornado.GET /api/jogos):
GET /api/jogos → “Try it out” → “Execute”.PATCH /api/jogos/{id}/concluir):
PATCH /api/jogos/{id}/concluir → “Try it out”.id do jogo e clique em “Execute”. O status concluido será invertido de true para false (ou vice-versa).DELETE /api/jogos/{id}):
DELETE /api/jogos/{id} → “Try it out” → Informe o id → “Execute” (Retorno: 204 No Content).Arquivo: Dockerfile
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV PORT=8000
EXPOSE 8000
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"]
postgresql+psycopg://).| Variável | Valor |
|---|---|
DATABASE_URL |
postgresql+psycopg://neondb_owner:SENHA@ep-xxx.neon.tech/neondb?sslmode=require |
ENVIRONMENT |
prod |
Você implementou e publicou a aplicação Biblioteca de Jogos em Python 3.11+, FastAPI, SQLAlchemy 2.0, Bootstrap 5 e documentação interativa Swagger UI, com total equivalência ao projeto original em Spring Boot.
Próximo projeto da trilha → Versão 02:
No seu editor/IDE, abra a pasta deste projeto (File > Open Folder) ou navegue via terminal:
cd python_web_jogos_01_fastapi
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000
# testes automatizados: pytest -v
[!TIP] Dica para execução a partir da raiz do repositório: Se você abriu o repositório completo no VS Code, basta navegar até a pasta antes de executar: cd proj_aplicacoes_full_stack/projetos/python_web_jogos_01_fastapi