Pular para o conteúdo principal

Projeto FastAPI Minimo

Este artigo mostra como começar mesmo o menor projeto FastAPI da melhor maneira possível, com uma estrutura adequada, usando ambiente virtual, linting de código, integração contínua (GitHub Actions), controle de versão e testes automatizados. A partir daí, é possível expandir o projeto de acordo com as necessidades, usando-o para serverless, ciência de dados, REST API, ensino de programação, base para novos gabaritos e outros fins.

A principal diferença deste projeto em relação a outros modelos é que ele possui apenas o conjunto mínimo de funcionalidades e dependências para formar uma base sólida para outros projetos.

Introdução

A melhor maneira de começar um novo projeto é usar um gabarito (template). Isso economiza tempo e evita erros comuns de configuração já que o gabarito contém soluções testadas e aprovadas, baseadas na experiência de outros desenvolvedores.

Diferentes modelos de projeto estão disponíveis para FastAPI, alguns dos quais são listados na documentação do projeto. No entanto, pode ser difícil encontrar um modelo que atenda exatamente às suas necessidades. A situação mais comum é escolher o que mais se aproxima e então ajustá-lo, mas isso pode ser demorado e frustrante porque é complicado remover ou substituir parte das decisões de implementação pré-definidas.

Em vez de criar mais um modelo repleto de funcionalidades e dependências, este tutorial constrói um projeto mínimo em FastAPI que servirá como uma base inicial sólida para projetos mais complexos e específicos. Começaremos com uma aplicação "Hello World" elementar, que será aprimorada incrementalmente até atingir o estado ideal que servirá como modelo para projetos mínimos em FastAPI.

Aplicação Elementar

O projeto elementar de Hello World em FastAPI é composto por um arquivo principal (main.py) e um arquivo de teste (test_hello_world.py).

Hello World Elementar
=====================

hello_world
├── main.py
└── test_hello_world.py

O arquivo main.py contém:

from fastapi import FastAPI

app = FastAPI()


@app.get('/')
def say_hello() -> dict[str, str]:
    return {'message': 'Hello World'}

Como não existe um comando específico em Python ou do FastAPI para executar a aplicação, é necessário usar um servidor web ASGI tal como o Hypercorn ou o Uvicorn. A instalação do FastAPI e do hypercorn em um ambiente virtual e a execução do comando do hypercorn são apresentadas abaixo:

$ python -m venv .venv
$ source .venv/bin/activate
(.venv) $ pip install fastapi hypercorn
...
(.venv) $ hypercorn main:app
[...] [INFO] Running on http://127.0.0.1:8000 (CTRL + C to quit)

em que main:app (linha 5) especifica o uso da variável app dentro do módulo main.py.

Acessando http://localhost:8000 através do httpie, temos o seguinte resultado:

$ http :8000
HTTP/1.1 200
content-length: 25
content-type: application/json
date: ...
server: hypercorn-h11

{
    "message": "Hello World"
}

Você pode usar o curl no lugar do httpie, se preferir:

$ curl http://localhost:8000
{"message":"Hello World"}

O arquivo de teste test_hello_world.py contém:

from fastapi.testclient import TestClient
from main import app

client = TestClient(app)


def test_say_hello() -> None:
    response = client.get('/')
    assert response.status_code == 200
    assert response.json() == {'message': 'Hello World'}

Para executar os testes, você também precisa que pytest e httpx estejam instalados:

(.venv) $ pip install pytest httpx
...

Em seguida, execute o comando:

(.venv) $ pytest
================================= test session starts ==================================
platform linux -- Python 3.11.4, pytest-7.4.0, pluggy-1.2.0
rootdir: /tmp/hello_world
plugins: anyio-3.7.1
collected 1 item

test_hello.py .                                                                  [100%]

================================== 1 passed in 0.74s ===================================

Esses dois arquivos são suficientes para um exemplo ilustrativo, mas não formam um projeto que pode ser usado em produção. Para isso, é necessário adicionar boas práticas de engenharia de software, o que será feito nas próximas seções.

Projeto Python Perfeito + FastAPI

O artigo Como começar um projeto Python perfeito apresenta a criação de uma base inicial para ser usada com qualquer projeto Python. Como todo projeto FastAPI também é um projeto Python, podemos aplicar no projeto FastAPI mínimo essa mesma estrutura e configuração. Assim, incorporamos automaticamente as seguintes características:

  1. Ambiente virtual para o projeto.
  2. Estrutura de diretórios com separação em diferentes módulos e arquivos.
  3. Linting de código usando ferramentas como ruff e mypy.
  4. Testes automatizados usando frameworks como pytest, integrados ao controle de versão e ao GitHub Actions para garantir que o código esteja funcionando corretamente.

A listagem abaixo apresenta lado a lado a estrutura do projeto Hello, World antes (lado esquerdo) e depois (lado direito) da aplicação do gabarito do projeto Python perfeito:

Hello World Elementar          Hello World + Perfect Python Project
=====================          ====================================

hello_world                    hello_world
│                              ├── .github
│                              │   └── workflows
│                              │       └── continuous_integration.yml
│                              ├── .hgignore
│                              ├── hello_world
│                              │   ├── __init__.py
├── main.py                    │   └── main.py
│                              ├── Makefile
│                              ├── poetry.lock
│                              ├── pyproject.toml
│                              ├── README.rst
│                              ├── scripts
│                              │   └── install_hooks.sh
│                              └── tests
└── test_hello_world.py            └── test_hello_world.py

Esta ainda não é a estrutura nem a configuração finais porque alguns ajustes relacionadas ao FastAPI ainda são necessárias:

  1. Instalação de dependências específicas de uma aplicação FastAPI.
  2. Reorganização do arquivo main.py.
  3. Configuração da aplicação.
  4. Reorganização dos testes.

Vamos abordar cada uma dessas etapas nas seções seguintes.

Instalação das Dependências

As dependências que vieram do gabarito do projeto Python perfeito são relacionadas apenas a teste e linting. Ainda é necessário instalar dependências específicas que usaramos no projeto FastAPI mínimo:

$ poetry add fastapi=="*" hypercorn=="*" loguru=="*" python-dotenv=="*" uvloop=="*"
$ poetry add --group=dev asgi-lifespan=="*" alt-pytest-asyncio=="*" httpx=="*"

O primeiro comando instala bibliotecas que são diretamente necessárias para o funcionamento da aplicação. O segundo instala as bibliotecas necessárias apenas para o desenvolvimento e teste da aplicação, mas que não são necessáriam nem devem ser instaladas em produção.

Bibliotecas principais:

  • FastAPI
  • Hypercorn é um servidor web ASGI
  • Loguru é uma biblioteca que visa tornar o logging mais agradável
  • python-dotenv lê pares nome=valor de um arquivo .env e define variáveis de ambiente correspondente
  • uvloop é uma implementação de alta eficiência que substitui a solução padrão usada no asyncio

Bibliotecas de desenvolvimento:

  • alt-pytest-asyncio é um plugin para pytest que possibilita o uso de fixtures e teste assíncronos
  • asgi-lifespan permite a execução de eventos de iniciação e término da aplicação (durante os testes), sem a necessidade de um servidor web ASGI
  • httpx é uma biblioteca síncrona e assíncrona de HTTP

Reorganizando o Arquivo Principal main.py

O arquivo principal original contém apenas a declaração da única rota do projeto. Porém, o número de rotas tende a crescer com o tempo e o arquivo principal acabará se tornando ingerenciável.

É importante preparar o projeto para que possa evoluir de maneira organizada. Para isso, é recomendado declarar rotas, modelos, esquemas etc. em diretórios e arquivos específicos de cada abstração.

A estrutura de diretórios pode ser organizada por função ou entidade. A organização por função de um projeto em FastAPI contendo uma entidade user ficaria:

.
├── models
│   ├── __init__.py
│   └── user.py
├── routers
│   ├── __init__.py
│   └── user.py
└── schemas
    ├── __init__.py
    └── user.py

A outra opção é agrupar por entidade ao invés de função. Nesse caso, modelos, rotas e esquemas ficam dentro do diretório user:

user
├── __init__.py
├── models.py
├── routers.py
└── schemas.py

Usar uma ou outra estrutura é uma questão de preferência apenas. Eu prefiro usar a estrutura de agrupamento por função e não por entidade porque é mais fácil agrupar as importações em Python dessa forma.

Voltando ao projeto FastAPI mínimo, como só temos uma rota para hello world e não há modelos ou esquemas a estrutura resultante de diretórios é:

routers
├── __init__.py
└── hello.py

O arquivo hello.py contém apenas a declaração da rota para /hello que foi extraída de main.py:

from fastapi import APIRouter
from loguru import logger

router = APIRouter()


@router.get('/hello')
async def hello_world() -> dict[str, str]:
    logger.info('Hello world!')
    return {'message': 'Hello World'}

main.py

Apesar de reduzido, o arquivo main.py continua existindo na nova organização do projeto, mas com funcionalidade reduzida. Sua finalidade é apenas coordenar a criação da aplicação, o que engloba importar as rotas e incluir a função que controla os eventos de iniciação e término da aplicação (lifespan):

from fastapi import FastAPI
from . import config
from .resources import lifespan
from .routers import hello
app = FastAPI(
    title='Hello World',
    lifespan=lifespan,
)
routers = (
    hello.router,
)
for router in routers:
    app.include_router(router)

Os roteadores são importados (linha 5), agrupados (linhas 11 a 13) e depois incluídos na aplicação (linhas 14 e 15).

A linha 4 importa lifespan do módulo resources e que depois é usado na criação da aplicação (linha 9). lifespan é um async context manager que coordena os eventos de início e término do ciclo de vida da aplicação. Os detalhes sobre esse assunto serão tratados na próxima seção.

resources.py

Uma aplicação mais complexa precisará de recursos adicionais tais como conexões com banco de dados, cache, filas, etc. que precisam ser iniciados e encerrados de maneira adequada para que a aplicação funcione corretamente. Ainda que o projeto FastAPI mínimo não faça uso de nenhum recurso adicional, é importante que o código esteja preparado para quando for necessário no futuro. Assim, as funções que lidam com recursos adicionais ficarão concentradas no módulo resources.py:

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from loguru import logger
from . import config
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator:  # noqa: ARG001
    await startup()
    try:
        yield
    finally:
        await shutdown()
async def startup() -> None:
    show_config()
    # connect to the database
    logger.info('started...')
async def shutdown() -> None:
    # disconnect from the database
    logger.info('...shutdown')
def show_config() -> None:
    config_vars = {key: getattr(config, key) for key in sorted(dir(config)) if key.isupper()}
    logger.debug(config_vars)

A função lifespan (linha 10) é um async context manager que coordena as chamadas às funções para início (startup) e término (shutdown) da aplicação. A função startup é chamada antes da aplicação iniciar e a função shutdown é chamada depois que a aplicação termina. Estes são os momentos ideais para iniciar/encerrar conexões com outros serviços e alocar/desalocar recursos. Como o projeto FastAPI mínimo não faz uso de nenhum recurso adicional, as funções startup e shutdown basicamente contém marcações para as chamadas futuras (linhas 21 e 26).

A função startup ainda faz uma chamada à função show_config (linha 19) que exibe as variáveis de configuração em caso de DEBUG (linhas 9, 19-26). Essa exibição é útil na depuração e testes.

Configuração

A configuração garante que a aplicação funcione corretamente em diferentes ambientes, como desenvolvimento, teste e produção. A fim de evitar que informações sensíveis tais como endereços e credenciais de acesso fiquem expostas no código-fonte do projeto, é recomendado que a configuração seja definida por meio de variáveis de ambiente.

Apesar dessa recomendação, é comum utilizar um arquivo chamado .env para armazenar as configurações locais de ambientes de desenvolvimento e teste. Este arquivo evita a necessidade de redefinir manualmente as variáveis de ambiente em cada terminal, IDE ou após reiniciar o computador. Existem bibliotecas que identificam automaticamente o arquivo .env e carregam as variáveis de ambiente definidas nele ao iniciar a execução do projeto. Porém, é importante configurar o controle de versão para que o arquivo .env não seja rastreado.

config.py

O módulo config.py é responsável por extrair as variáveis de ambiente e fazer verificações e outros ajustes necessários na configuração:

import os
from dotenv import load_dotenv
load_dotenv()
ENV: str = os.getenv('ENV', 'production').lower()
if ENV not in ('production', 'development', 'testing'):
    raise ValueError(
        f'ENV={ENV} is not valid. '
        "It should be 'production', 'development' or 'testing'"
    )
DEBUG: bool = ENV != 'production'
TESTING: bool = ENV == 'testing'
LOG_LEVEL: str = os.getenv('LOG_LEVEL') or (DEBUG and 'DEBUG') or 'INFO'
os.environ['LOGURU_DEBUG_COLOR'] = '<fg #777>'

Na linha 5, load_dotenv carrega configurações do arquivo .env, se existir. Por padrão, load_dotenv não sobrescreve variáveis de ambiente existentes.

Na linha 7, ENV indica o tipo do ambiente em que o projeto está executando. Pode ser production, development ou testing. Se nenhum valor for definido, o valor padrão é production.

Na linha 13, DEBUG indica se o projeto está em modo de desenvolvimento. De modo análogo, TESTING indica se o projeto está em modo de teste (linha 14). DEBUG costuma ser usado para influenciar o nível de detalhes de informações apresentadas durante a execução do projeto (LOG_LEVEL), enquanto TESTING costuma sinalizar quando executar algumas ações tais como criar uma massa de testes ou desfazer transações de banco de dados ao final de cada teste.

Na linha 17, LOG_LEVEL indica o nível de log do projeto. Se não for definido na variável de ambiente, ou não estiver no modo de desenvolvimento, então o valor padrão é INFO.

Na linha 18, os.environ['LOGURU_DEBUG_COLOR'] ajusta a cor de mensagens de log de nível DEBUG que será usado pelo loguru. É uma questão de preferência estética apenas. Não é essencial.

Testes

Os testes síncronos, como o usado no arquivo test_hello_world.py, limitam significativamente a capecidade de testar aplicações baseadas em processamento assíncrono. Por exemplo, pode ser necessário realizar chamadas assíncronas durante os testes para confirmar se determinadas informações foram gravadas corretamente em um banco de dados após uma chamada de API.

Embora seja possível realizar chamadas assíncronas em testes ou funções síncronas, isso requer alguns malabarismos de programação ou o uso de bibliotecas adicionais. Por outro lado, esses problemas inexistem em testes assíncronos, já que chamar uma função síncrona em um contexto assíncrono é trivial.

Para adotar testes assíncronos, é necessário:

  1. instalar um plugin de testes assíncronos no pytest. Há três opções: pytest-asyncio, alt-pytest-asyncio e anyio. Vamos usar alt-pytest-asyncio neste projeto porque resolve o problema e não requer nenhuma configuração adicional para ser usado. Nem mesmo é necessário marcar os testes com pytest.mark.asyncio.
  2. substituir TestClient por httpx.AsyncClient como classe base para os testes

O teste assíncrono equivalente a test_hello_world.py é:

from httpx import AsyncClient
from hello_world.main import app


async def test_say_hello() -> None:
    async with AsyncClient(app=app, base_url='http://test') as client:
        response = await client.get('/')
    assert response.status_code == 200
    assert response.json() == {'message': 'Hello World'}

Como uma instância configurada de um AsyncClient vai ser usada com frequência, vamos manter a sua criação centralizada em uma fixture no conftest.py e recebê-la como parâmetro em todos os testes em que for necessário.

Com a fixture, test_hello_world.py fica:

from httpx import AsyncClient


async def test_say_hello(client: AsyncClient) -> None:
    response = await client.get('/')
    assert response.status_code == 200
    assert response.json() == {'message': 'Hello World'}

Com a reorganização da estrutura do projeto, test_hello_world.py passa a ser tests/routes/test_hello.py já que a estrutura de diretórios de testes reflete a estrutura de diretórios da aplicação.

conftest.py

O arquivo conftest.py contém as fixture usadas nos testes:

import os
from collections.abc import AsyncIterable
from asgi_lifespan import LifespanManager
from fastapi import FastAPI
from httpx import AsyncClient
from pytest import fixture
os.environ['ENV'] = 'testing'
from hello_world.main import app as _app  # noqa: E402
@fixture
async def app() -> AsyncIterable[FastAPI]:
    async with LifespanManager(_app):
        yield _app
@fixture
async def client(app: FastAPI) -> AsyncIterable[AsyncClient]:
    async with AsyncClient(app=app, base_url='http://test') as client:
        yield client

A linha 9 garante que o projeto será executado no modo de teste. Note que essa linha deve estar antes da importação da aplicação (linha 11) para que outros módulos sejam configurados corretamente.

As linhas 14 à 17 definem a fixture app que tem o objetivo de disparar os eventos de iniciação e término da aplicação. Esse disparo não acontece automaticamente de outra forma durante os testes, nem mesmo pelo context manager criado na fixture client (linhas 20 a 23). Precisamos da biblioteca asgi-lifespan e da classe LifespanManager (linha 16).

Tarefas Automatizadas Adicionais

Além das tarefas test, lint, format e install_hooks herdadas do gabarito do projeto Python perfeito, vamos adicionar uma nova ação no arquivo Makefile que torne mais fácil rodar a aplicação sem ter de lembrar os parâmetros do hypercorn:

run_dev:
    ENV=development hypercorn --reload --config=hypercorn.toml 'hello_world.main:app'

Para a linha de comando não ficar tão longa, parte dos parâmetros do hypercorn fica em um arquivo de configuração de nome hypercorn.toml:

worker_class = "uvloop"
bind = "0.0.0.0:5000"
accesslog = "-"
errorlog = "-"
access_log_format = "%(t)s %(h)s %(S)s %(r)s %(s)s"

Estrutura Final da Aplicação

A aplicação elementar inicial evoluiu primeiro absorvendo a estrutura do projeto Python perfeito e depois ainda foi alterada para ter uma estrutura mais adequada a uma aplicação FastAPI. A diferença entre a estrutura de diretórios anterior e a final é apresentada na listagem abaixo:

Hello World + Projeto Python Perfeito          Projeto FastaAPI Mínimo
=====================================          =======================

hello_world                                   hello_world
├── .github                                   ├── .github
│   └── workflows                             │   └── workflows
│       └── continuous_integration.yml        │       └── continuous_integration.yml
├── .hgignore                                 ├── .hgignore
├── hello_world                               ├── hello_world
│   ├── __init__.py                           │   ├── __init__.py
│   │                                         │   ├── config.py
│   └── main.py                               │   ├── main.py
│                                             │   ├── resources.py
│                                             │   └── routers
│                                             │       ├── __init__.py
│                                             │       └── hello.py
│                                             ├── hypercorn.toml
├── Makefile                                  ├── Makefile
├── poetry.lock                               ├── poetry.lock
├── pyproject.toml                            ├── pyproject.toml
├── README.rst                                ├── README.rst
├── scripts                                   ├── scripts
│   └── install_hooks.sh                      │   └── install_hooks.sh
└── tests                                     └── tests
    ├── __init__.py                               ├── __init__.py
    └── test_hello_world.py                       │
                                                  ├── conftest.py
                                                  └── routers
                                                      ├── __init__.py
                                                      └── test_hello.py

Considerações Finais

Durante a criação do projeto mínimo em FastAPI, algumas escolhas foram feitas baseadas nas minhas preferências pessoais. Por exemplo, a adoção de uvloop para otimização e alt-pytest-asyncio para permitir testes assíncronos. Mas como são poucas e pontuais, não comprometem nem o objetivo nem a extensibilidade do projeto mínimo.

O projeto mínimo em FastAPI, como o próprio nome indica, tem o objetivo de fornecer uma base para novos projetos, sejam eles serverless, de ciência de dados, que usam diferentes tipos de bancos de dados, para construir REST APIs e até para outros gabaritos.

Ao invés de digitar manualmente todo o código apresentado, use o gabarito disponibilizado no GitHub.

Para instanciar um novo projeto, é necessário usar o cookiecutter. Recomendo combiná-lo com pipx:

$ pipx run cookiecutter gh:andredias/perfect_python_project \
       -c fastapi-minimum

Próximo artigo: Empacotamento e Distribuição do Projeto FastAPI Mínimo

Artigo anterior: Como Começar um Projeto Python Perfeito

Comentários

Comments powered by Disqus