Pular para conteúdo

Refatorando a Estrutura do Projeto


Objetivos da Aula:

  • Mover coisas de autenticação para um arquivo chamado fast_zero/auth.py
  • Reestruturar o projeto para facilitar sua manutenção
  • Deixando em fast_zero/security.py somente as validações de senha
  • Remover constantes usados em código (SECRET_KEY, ALGORITHM e ACCESS_TOKEN_EXPIRE_MINUTES) usando a classe Settings do arquivo fast_zero/settings.py que já temos e movendo para variáveis de ambiente no arquivo .env
  • Criar routers específicos para rotas que tratam das funcionalidades de usuários e para as rotas de autenticação
Caso prefira ver a aula em vídeo

Esse aula ainda não está disponível em formato de vídeo, somente em texto!

Aula Slides Código


Ao longo da evolução de um projeto, é natural que sua estrutura inicial necessite de ajustes para manter a legibilidade, a facilidade de manutenção e a organização do código. Nesta aula, faremos exatamente isso em nosso projeto FastAPI: refatoraremos partes dele para melhorar sua estrutura e, em seguida, ampliar a cobertura de nossos testes para garantir que todos os cenários possíveis sejam tratados corretamente. Vamos começar!

Criando Routers

O FastAPI oferece uma ferramenta poderosa conhecida como routers, que facilita a organização e agrupamento de diferentes rotas em uma aplicação. Pense em um router como um "subaplicativo" do FastAPI que pode ser integrado em uma aplicação principal. Isso não só mantém o código organizado e legível, mas também se mostra especialmente útil à medida que a aplicação se expande e novas rotas são adicionadas.

Esse tipo de organização nos oferece diversos benefícios:

  1. Organização e Legibilidade: Routers ajudam a manter o código organizado e legível, o que é crucial à medida que a aplicação se expande.
  2. Separação de Preocupações: Alinhado ao princípio de SoC, os routers facilitam o entendimento e teste do código.
  3. Escalabilidade: A estruturação com routers permite adicionar novas rotas e funcionalidades de maneira eficiente conforme o projeto cresce.

Estruturação Inicial

Criaremos inicialmente uma nova estrutura de diretórios chamada routers dentro do seu projeto fast_zero. Aqui, teremos subaplicativos dedicados a funções específicas, como gerenciamento de usuários e autenticação.

├── fast_zero
  ├── app.py
  ├── database.py
  ├── models.py
  ├── routers
    ├── auth.py
    └── users.py

Esta organização facilita a expansão do seu projeto e a manutenção de uma estrutura clara.

Implementando um Router para Usuários

No arquivo fast_zero/routers/users.py, implementaremos o recurso APIRouter do FastAPI, a ferramenta chave para criar nosso subaplicativo. O parâmetro prefix que passamos ajuda a agrupar todos os endpoints relacionados aos usuários sob um mesmo teto.

fast_zero/routers/users.py
from fastapi import APIRouter

router = APIRouter(prefix='/users', tags=['users'])

Com essa simples configuração, estamos prontos para definir rotas específicas para usuários neste router, em vez de sobrecarregar o aplicativo principal. Utilizamos @router ao invés de @app para definir estas rotas. O uso da tag 'users' contribui para a organização e documentação automática no swagger.

Desta forma podemos migrar todos os nossos imports e nossas funções de endpoints para o arquivo fast_zero/routers/users.py e os removendo de fast_zero/app.py. Fazendo com que todos esses endpoints estejam no mesmo contexto e isolados da aplicação principal:

fast_zero/routers/users.py
from http import HTTPStatus

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session

from fast_zero.database import get_session
from fast_zero.models import User
from fast_zero.schemas import Message, UserList, UserPublic, UserSchema
from fast_zero.security import (
    get_current_user,
    get_password_hash,
)

router = APIRouter(prefix='/users', tags=['users'])

@router.post('/', status_code=HTTPStatus.CREATED, response_model=UserPublic)
# ...
@router.get('/', response_model=UserList)
# ...
@router.put('/{user_id}', response_model=UserPublic)
# ...
@router.delete('/{user_id}', response_model=Message)
# ...

Com o prefixo definido no router, os paths dos endpoints se tornam mais simples e diretos. Ao invés de '/users/{user_id}', por exemplo, usamos apenas '/{user_id}'.

Exemplo do arquivo fast_zero/routers/users.py completo
fast_zero/routers/users.py
from http import HTTPStatus

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session

from fast_zero.database import get_session
from fast_zero.models import User
from fast_zero.schemas import Message, UserList, UserPublic, UserSchema
from fast_zero.security import (
    get_current_user,
    get_password_hash,
)

router = APIRouter(prefix='/users', tags=['users'])


@router.post('/', status_code=HTTPStatus.CREATED, response_model=UserPublic)
def create_user(user: UserSchema, session: Session = Depends(get_session)):
    db_user = session.scalar(select(User).where(User.email == user.email))
    if db_user:
        raise HTTPException(
            status_code=HTTPStatus.BAD_REQUEST,
            detail='Email already registered'
        )

    hashed_password = get_password_hash(user.password)

    db_user = User(
        email=user.email,
        username=user.username,
        password=hashed_password,
    )
    session.add(db_user)
    session.commit()
    session.refresh(db_user)
    return db_user


@router.get('/', response_model=UserList)
def read_users(
    skip: int = 0, limit: int = 100, session: Session = Depends(get_session)
):
    users = session.scalars(select(User).offset(skip).limit(limit)).all()
    return {'users': users}


@router.put('/{user_id}', response_model=UserPublic)
def update_user(
    user_id: int,
    user: UserSchema,
    session: Session = Depends(get_session),
    current_user: User = Depends(get_current_user),
):
    if current_user.id != user_id:
        raise HTTPException(
            status_code=HTTPStatus.BAD_REQUEST, detail='Not enough permissions'
        )

    current_user.username = user.username
    current_user.password = get_password_hash(user.password)
    current_user.email = user.email
    session.commit()
    session.refresh(current_user)

    return current_user


@router.delete('/{user_id}', response_model=Message)
def delete_user(
    user_id: int,
    session: Session = Depends(get_session),
    current_user: User = Depends(get_current_user),
):
    if current_user.id != user_id:
        raise HTTPException(
            status_code=HTTPStatus.BAD_REQUEST, detail='Not enough permissions'
        )

    session.delete(current_user)
    session.commit()

    return {'message': 'User deleted'}

Por termos criados as tags, isso reflete na organização do swagger

Swagger com tags

Criando um router para Auth

No momento, temos rotas para / e /token ainda no arquivo fast_zero/app.py. Daremos um passo adiante e criar um router separado para lidar com a autenticação. Desta forma, conseguiremos manter nosso arquivo principal (app.py) mais limpo e focado em sua responsabilidade principal que é iniciar nossa aplicação.

O router para autenticação será criado no arquivo fast_zero/routers/auth.py. Veja como fazer:

fast_zero/routers/auth.py
from http import HTTPStatus

from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy import select
from sqlalchemy.orm import Session

from fast_zero.database import get_session
from fast_zero.models import User
from fast_zero.schemas import Token
from fast_zero.security import create_access_token, verify_password

router = APIRouter(prefix='/auth', tags=['auth'])


@router.post('/token', response_model=Token)
def login_for_access_token(
    form_data: OAuth2PasswordRequestForm = Depends(),
    session: Session = Depends(get_session),
):
    user = session.scalar(select(User).where(User.email == form_data.username))

    if not user:
        raise HTTPException(
            status_code=HTTPStatus.BAD_REQUEST,
            detail='Incorrect email or password'
        )

    if not verify_password(form_data.password, user.password):
        raise HTTPException(
            status_code=HTTPStatus.BAD_REQUEST,
            detail='Incorrect email or password'
        )

    access_token = create_access_token(data={'sub': user.email})

    return {'access_token': access_token, 'token_type': 'bearer'}

Neste bloco de código, nós criamos um novo router que lidará exclusivamente com a rota de obtenção de token (/token). O endpoint login_for_access_token é definido exatamente da mesma maneira que antes, mas agora como parte deste router de autenticação.

Alteração da validação de token

É crucial abordar um aspecto relacionado à modificação do router: o uso do parâmetro prefix. Ao introduzir o prefixo, o endereço do endpoint /token, responsável pela validação do bearer token JWT, é alterado para /auth/token. Esse caminho está explicitamente definido no OAuth2PasswordBearer dentro de security.py, resultando em uma referência ao caminho antigo /token, anterior à criação do router.

Esse problema fica evidente ao clicar no botão Authorize no Swagger:

Captura de tela do Swagger indicando endereço incorreto

Percebe-se que o caminho para a autorização está incorreto. Como consequência, ao tentar autenticar através do Swagger, nos deparamos com um erro na interface:

Captura de tela do erro exibido no Swagger

No entanto, o erro não é suficientemente descritivo para identificarmos a origem do problema, retornando apenas uma mensagem genérica de Auth Error. Para compreender melhor o que ocorreu, é necessário verificar o log produzido pelo uvicorn no terminal:

Erro mostrado no terminal
task serve
# ...
INFO:     127.0.0.1:40132 - "POST /token HTTP/1.1" 404 Not Found

A solução para este problema é relativamente simples. Precisamos ajustar o parâmetro tokenUrl na OAuth2PasswordBearer para refletir as mudanças feitas no router, direcionando para /auth/token. Faremos isso no arquivo security.py:

security.py
oauth2_scheme = OAuth2PasswordBearer(tokenUrl='auth/token')

Após essa alteração, ao utilizar o Swagger, a autorização será direcionada corretamente para o endpoint apropriado.

Captura de tela do Swagger direcionando para o endereço correto

Alteração no teste do token

Essa alteração fará com que o teste referente a criação do token também falhe. Pois ele procurará pelo endpoint /token. Devemos fazer a alteração para o novo caminho, que com a criação de router, adiciona o prefixo /auth. Ficando assim:

tests/test_app.py
def test_get_token(client, user):
    response = client.post(
        '/auth/token',#(1)!
        data={'username': user.email, 'password': user.clean_password},
    )
    token = response.json()

    assert response.status_code == HTTPStatus.OK
    assert 'access_token' in token
    assert 'token_type' in token
  1. A única alteração é mesmo o endpoint!

Desta forma o teste específico do token poderá passar corretamente. Mas, existem testes que dependem do token criado pela fixture.

Alteração na fixture de token

A alteração da fixture de token é igual que fizemos em /tests/test_auth.py, precisamos somente corrigir o novo endereço do router no arquivo /tests/conftest.py:

/tests/conftest.py
@pytest.fixture()
def token(client, user):
    response = client.post(
        '/auth/token',
        data={'username': user.email, 'password': user.clean_password},
    )
    return response.json()['access_token']

Fazendo assim com que os testes que dependem dessa fixture passem a funcionar.

Contudo, essas modificações ainda não podem ser executadas, pois precisamos plugar os roteadores no aplicativo antes de executar.

Plugando as rotas em app

O FastAPI oferece uma maneira fácil e direta de incluir routers em nossa aplicação principal. Isso nos permite organizar nossos endpoints de maneira eficiente e manter nosso arquivo app.py focado apenas em suas responsabilidades principais.

Para incluir os routers em nossa aplicação principal, precisamos importá-los e usar a função include_router(). Aqui está como o nosso arquivo app.py fica depois de incluir os routers:

fast_zero/fast_zero/app.py
from http import HTTPStatus

from fastapi import FastAPI

from fast_zero.routers import auth, users
from fast_zero.schemas import Message

app = FastAPI()

app.include_router(users.router)
app.include_router(auth.router)


@app.get('/', status_code=HTTPStatus.OK, response_model=Message)
def read_root():
    return {'message': 'Olá Mundo!'}

Como você pode ver, nosso arquivo app.py é muito mais simples agora. Ele agora delega as rotas para os respectivos routers, mantendo o foco em iniciar nossa aplicação FastAPI.

Executando os testes

Após refatorar nosso código, é crucial verificar se tudo continua funcionando como esperado. Para isso, executamos nossos testes novamente.

$ Execução no terminal!
task test

# ...

tests/test_app.py::test_root_deve_retornar_ok_e_ola_mundo PASSED
tests/test_app.py::test_create_user PASSED
tests/test_app.py::test_read_users PASSED
tests/test_app.py::test_read_users_with_users PASSED
tests/test_app.py::test_update_user PASSED
tests/test_app.py::test_delete_user PASSED
tests/test_app.py::test_get_token PASSED
tests/test_db.py::test_create_user PASSED

Como você pode ver, todos os testes passaram. Isso significa que as alterações que fizemos no nosso código não afetaram o funcionamento do nosso aplicativo. O router manteve todos os endpoints nas mesmas rotas, garantindo a continuidade do comportamento esperado.

Agora, para melhor alinhar nossos testes com a nova estrutura do nosso código, devemos reorganizar os arquivos de teste de acordo. Ou seja, também devemos criar arquivos de teste específicos para cada router, em vez de manter todos os testes no arquivo tests/test_app.py. Essa estrutura facilitará a manutenção e compreensão dos testes à medida que nossa aplicação cresce.

Reestruturando os arquivos de testes

Para acompanhar a nova estrutura routers, podemos desacoplar os testes do módulo test/test_app.py e criar arquivos de teste específicos para cada um dos domínios:

  • /tests/test_app.py: Para testes relacionados ao aplicativo em geral
  • /tests/test_auth.py: Para testes relacionados à autenticação e token
  • /tests/test_users.py: Para testes relacionados às rotas de usuários

Vamos adaptar os testes para se encaixarem nessa nova estrutura.

Ajustando os testes para Auth

Começaremos criando o arquivo /tests/test_auth.py. Esse arquivo será responsável por testar todas as funcionalidades relacionadas à autenticação do usuário.

/tests/test_auth.py
from http import HTTPStatus


def test_get_token(client, user):
    response = client.post(
        '/auth/token',
        data={'username': user.email, 'password': user.clean_password},
    )
    token = response.json()

    assert response.status_code == HTTPStatus.OK
    assert 'access_token' in token
    assert 'token_type' in token

É importante notar que com a criação do router usando prefix='/auth' devemos alterar o endpoint onde o request é feito de '/token' para '/auth/token'. Fazendo com que a requisição seja encaminhada para o lugar certo.

Ajustando os testes para User

Em seguida, moveremos os testes relacionados ao domínio do usuário para o arquivo /tests/test_users.py.

/tests/test_users.py
from http import HTTPStatus

from fast_zero.schemas import UserPublic


def test_create_user(client):
    response = client.post(
        '/users/',
        json={
            'username': 'alice',
            'email': 'alice@example.com',
            'password': 'secret',
        },
    )
    assert response.status_code == HTTPStatus.CREATED
    assert response.json() == {
        'username': 'alice',
        'email': 'alice@example.com',
        'id': 1,
    }


def test_read_users(client):
    response = client.get('/users')
    assert response.status_code == HTTPStatus.OK
    assert response.json() == {'users': []}


def test_read_users_with_users(client, user):
    user_schema = UserPublic.model_validate(user).model_dump()
    response = client.get('/users/')
    assert response.json() == {'users': [user_schema]}


def test_update_user(client, user, token):
    response = client.put(
        f'/users/{user.id}',
        headers={'Authorization': f'Bearer {token}'},
        json={
            'username': 'bob',
            'email': 'bob@example.com',
            'password': 'mynewpassword',
        },
    )
    assert response.status_code == HTTPStatus.OK
    assert response.json() == {
        'username': 'bob',
        'email': 'bob@example.com',
        'id': 1,
    }


def test_delete_user(client, user, token):
    response = client.delete(
        f'/users/{user.id}',
        headers={'Authorization': f'Bearer {token}'},
    )

    assert response.status_code == HTTPStatus.OK
    assert response.json() == {'message': 'User deleted'}

Para a construção desse arquivo, nenhum teste foi modificado. Eles foram somente movidos para o domínio específico do router. Importante, porém, notar que alguns destes testes usam a fixture token para checar a autorização, como o endpoint do token foi alterado, devemos alterar a fixture de token para que esses testes continuem passando.

Executando os testes

Após essa reestruturação, é importante garantir que tudo continua funcionando corretamente. Executaremos os testes novamente para confirmar isso.

$ Execução no terminal!
task test

# ...

tests/test_app.py::test_root_deve_retornar_ok_e_ola_mundo PASSED
tests/test_auth.py::test_get_token PASSED
tests/test_db.py::test_create_user PASSED
tests/test_users.py::test_create_user PASSED
tests/test_users.py::test_read_users PASSED
tests/test_users.py::test_read_users_with_users PASSED
tests/test_users.py::test_update_user PASSED
tests/test_users.py::test_delete_user PASSED

Como podemos ver, todos os testes continuam passando com sucesso, mesmo após terem sido movidos para arquivos diferentes. Isso é uma confirmação de que nossa reestruturação foi bem-sucedida e que nossa aplicação continua funcionando como esperado.

Refinando a Definição de Rotas com Annotated

O FastAPI suporta um recurso fascinante da biblioteca nativa typing, conhecido como Annotated. Esse recurso prova ser especialmente útil quando buscamos simplificar a utilização de dependências.

Ao definir uma anotação de tipo, seguimos a seguinte formatação: nome_do_argumento: Tipo = Depends(o_que_dependemos). Em todos os endpoints, acrescentamos a injeção de dependência da sessão da seguinte forma:

session: Session = Depends(get_session)

O tipo Annotated nos permite combinar um tipo e os metadados associados a ele em uma única definição. Através da aplicação do FastAPI, podemos utilizar o Depends no campo dos metadados. Isso nos permite encapsular o tipo da variável e o Depends em uma única entidade, facilitando a definição dos endpoints.

Veja o exemplo a seguir:

fast_zero/routers/users.py
from typing import Annotated

Session = Annotated[Session, Depends(get_session)]
CurrentUser = Annotated[User, Depends(get_current_user)]

Desse modo, conseguimos refinar a definição dos endpoints para que se tornem mais concisos, sem alterar seu funcionamento:

fast_zero/routers/users.py
@router.post('/', status_code=HTTPStatus.CREATED, response_model=UserPublic)
def create_user(user: UserSchema, session: Session):
# ...

@router.get('/', response_model=UserList)
def read_users(session: Session, skip: int = 0, limit: int = 100):
# ...

@router.put('/{user_id}', response_model=UserPublic)
def update_user(
    user_id: int,
    user: UserSchema,
    session: Session,
    current_user: CurrentUser
):
# ...

@router.delete('/{user_id}', response_model=Message)
def delete_user(user_id: int, session: Session, current_user: CurrentUser):
# ...

Da mesma forma, podemos otimizar o roteador de autenticação:

fast_zero/routers/auth.py
from typing import Annotated

# ...

OAuth2Form = Annotated[OAuth2PasswordRequestForm, Depends()]
Session = Annotated[Session, Depends(get_session)]

@router.post('/token', response_model=Token)
def login_for_access_token(form_data: OAuth2Form, session: Session):
#...

Através do uso de tipos Annotated, conseguimos reutilizar os mesmos consistentemente, reduzindo a repetição de código e aumentando a eficiência do nosso trabalho.

Movendo as constantes para variáveis de ambiente

Conforme mencionamos na aula sobre os 12 fatores, é uma boa prática manter as constantes que podem mudar dependendo do ambiente em variáveis de ambiente. Isso torna o seu projeto mais seguro e modular, pois você pode alterar essas constantes sem ter que modificar o código-fonte.

Por exemplo, temos estas constantes em nosso módulo security.py:

SECRET_KEY = 'your-secret-key'  # Isso é provisório, vamos ajustar!
ALGORITHM = 'HS256'
ACCESS_TOKEN_EXPIRE_MINUTES = 30

Estes valores não devem estar diretamente no código-fonte, então vamos movê-los para nossas variáveis de ambiente e representá-los na nossa classe Settings.

Adicionando as constantes a Settings

Já temos uma classe ideal para fazer isso em fast_zero/settings.py. Alteraremos essa classe para incluir estas constantes.

fast_zero/settings.py
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file='.env', env_file_encoding='utf-8'
    )

    DATABASE_URL: str
    SECRET_KEY: str
    ALGORITHM: str
    ACCESS_TOKEN_EXPIRE_MINUTES: int

Agora, precisamos adicionar estes valores ao nosso arquivo .env.

.env
1
2
3
4
DATABASE_URL="sqlite:///database.db"
SECRET_KEY="your-secret-key"
ALGORITHM="HS256"
ACCESS_TOKEN_EXPIRE_MINUTES=30

Com isso, podemos alterar o nosso código em fast_zero/security.py para ler as constantes a partir da classe Settings.

Removendo as constantes do código

Primeiramente, carregaremos as configurações da classe Settings no início do módulo security.py.

fast_zero/security.py
from fast_zero.settings import Settings

settings = Settings()

Com isso, todos os lugares onde as constantes eram usadas devem ser substituídos por settings.CONSTANTE. Por exemplo, na função create_access_token, alteraremos para usar as constantes da classe Settings:

fast_zero/security.py
def create_access_token(data: dict):
    to_encode = data.copy()
    expire = datetime.utcnow() + timedelta(
        minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
    )
    to_encode.update({'exp': expire})
    encoded_jwt = encode(
        to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
    )
    return encoded_jwt

Desta forma, eliminamos todas as constantes do código-fonte e passamos a usar as configurações a partir da classe Settings. Isso torna nosso código mais seguro, pois as constantes sensíveis, como a chave secreta, estão agora seguras em nosso arquivo .env, e nosso código fica mais modular, pois podemos facilmente alterar estas constantes simplesmente mudando os valores no arquivo .env. Além disso, essa abordagem facilita o gerenciamento de diferentes ambientes (como desenvolvimento, teste e produção) pois cada ambiente pode ter seu próprio arquivo .env com suas configurações específicas.

Precisamos alterar o teste para usar as mesmas variáveis de ambiente do código:

/tests/test_security.py
from jwt import decode

from fast_zero.security import create_access_token, settings


def test_jwt():
    data = {'test': 'test'}
    token = create_access_token(data)

    decoded = decode(
        token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
    )

    assert decoded['test'] == data['test']
    assert decoded['exp']

Testando se tudo funciona

Depois de todas essas mudanças, é muito importante garantir que tudo ainda está funcionando corretamente. Para isso, executaremos todos os testes que temos até agora.

$ Execução no terminal!
task test

# ...

tests/test_app.py::test_root_deve_retornar_ok_e_ola_mundo PASSED
tests/test_auth.py::test_get_token PASSED
tests/test_db.py::test_create_user PASSED
tests/test_users.py::test_create_user PASSED
tests/test_users.py::test_read_users PASSED
tests/test_users.py::test_read_users_with_users PASSED
tests/test_users.py::test_update_user PASSED
tests/test_users.py::test_delete_user PASSED

Se tudo estiver certo, todos os testes devem passar. Lembre-se de que a refatoração não deve alterar a funcionalidade do nosso código - apenas torná-lo mais fácil de ler e manter.

Commit

Para finalizar, criaremos um commit para registrar todas as alterações que fizemos na nossa aplicação. Como essa é uma grande mudança que envolve reestruturar a forma como lidamos com as rotas e mover as constantes para variáveis de ambiente, podemos usar uma mensagem de commit descritiva que explique todas as principais alterações:

$ Execução no terminal!
git add .
git commit -m "Refatorando estrutura do projeto: Criado routers para Users e Auth; movido constantes para variáveis de ambiente."

Conclusão

Nesta aula, vimos como refatorar a estrutura do nosso projeto FastAPI para torná-lo mais manutenível. Organizamos nosso código em diferentes arquivos e usamos o sistema de roteadores do FastAPI para separar diferentes partes da nossa API. Também mudamos algumas constantes para o arquivo de configuração, tornando nosso código mais seguro e flexível. Finalmente, atualizamos nossos testes para refletir a nova estrutura do projeto.

Refatorar é um processo contínuo - sempre há espaço para melhorias. No entanto, com a estrutura que estabelecemos aqui, estamos em uma boa posição para continuar a expandir nossa API no futuro.

Na próxima aula, exploraremos mais sobre autenticação e como gerenciar tokens de acesso e de atualização em nossa API FastAPI.