Pular para o conteúdo principal

Projeto Mínimo em FastAPI

Este artigo mostra a criação de uma projeto mínimo em FastAPI, basicamente um "Hello World" bem robusto. A ideia é 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í, você pode expandir o projeto do jeito que achar melhor, usando-o para serverless, ciência de dados, REST API, ensino de programação, outros gabaritos etc.

A principal diferença em relação a outros gabaritos é que este possui apenas o menor conjunto possível de funcionalidades de acordo com boas práticas de engenharia de software.

Introdução

vários gabaritos disponíveis para FastAPI e alguns são listados na própria documentação do projeto. Cada um deles representa uma opinião diferente de como é o projeto ideal e essa opinião se reflete nas funcionalidades oferecidas e na combinação de bibliotecas e frameworks adotados. Isso pode ser bom se o projeto que você quer criar se encaixa com o que o gabarito oferece. Por outro lado, é difícil encontrar um par perfeito entre suas necessidades e os gabaritos disponíveis. Mesmo encontrando um parecido, ainda é muito difícil customizá-lo removendo ou substituindo parte das suas decisões de implementação.

Ao invés de construir mais um gabarito repleto de preferências por determinadas funcionalidades e bibliotecas, vamos construir um projeto mínimo em FastAPI para servir de base para aplicações mais complexas e específicas. Para não haver distrações com funcionalidades, bibliotecas e frameworks adicionais e desnecessários, usamos uma aplicação "Hello World" como ponto de partida porque é a menor aplicação funcional possível em uma linguagem ou framework. Em seguida, faremos melhorias incrementais até chegar no estado que servirá de base para o gabarito de um projeto mínimo 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 rodar 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.10.7, pytest-7.2.0, pluggy-1.0.0
rootdir: /tmp/hello_world
plugins: anyio-3.6.2
collected 1 item

test_hello.py .                                                                  [100%]

================================== 1 passed in 0.23s ===================================

Esses dois arquivos são suficientes para um exemplo ilustrativo, mas não formam um projeto que pode ser usado em produção. Vamos aprimorar o projeto nas próximas seções.

Projeto Python Perfeito + FastAPI

Como todo projeto FastAPI também é um projeto Python, dá para aproveitar toda a estrutura e configuração montada no artigo Como começar um projeto Python perfeito e aplicar no Hello World em FastAPI. Isso resolve automaticamente os problemas de:

  1. Ambiente virtual
  2. Controle de versão
  3. Linting
  4. Testes automatizados
  5. Integração contínua

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. Outras mudanças ainda são necessárias para um projeto baseado em FastAPI:

  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

Instalação das Dependências

As dependências que vieram do gabarito 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=="*" orjson=="*" python-dotenv=="*" uvloop=="*"
$ poetry add --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
  • orjson é uma biblioteca JSON de alta eficiência
  • 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 crescer 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.

Como só temos uma rota para hello world e não há modelos ou esquemas a estrutura resultante é:

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 com funcionalidade reduzida. Sua finalidade é coordenar a configuração da aplicação, o que engloba importar as rotas, ajustar algumas otimizações e incluir funções associadas aos eventos de iniciação e término da aplicação (startup e shutdown):

from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
from . import config
from .resources import shutdown, startup
from .routers import hello
routers = [
    hello.router,
]
app = FastAPI(
    title='Hello World',
    debug=config.DEBUG,
    default_response_class=ORJSONResponse,
    on_startup=(startup,),
    on_shutdown=(shutdown,),
)
for router in routers:
    app.include_router(router)

A classe ORJSONResponse importada na linha 2 é uma subclasse de Response. Ela fornece uma otimização na conversão de respostas no formato JSON por ser baseada na biblioteca orjson. A linha 14 define ORJSONResponse como a classe padrão de resposta.

Os roteadores são importados (linha 6), agrupados (linhas 8 a 10) e depois incluídos na aplicação (linhas 18 e 19). À medida que novos roteadores são criados, basta manter o padrão de importação e agrupamento na lista routers.

A principal diferença em relação ao arquivo principal é a configuração dos eventos de início e término (linhas 15 e 16), relacionados ao ciclo de vida de uma aplicação ASGI (Lifespan Protocol). Esses eventos são acionados pelo servidor ASGI ao instanciar e encerrar a aplicação e são os pontos ideais para criar/encerrar conexões com bancos de dados e outros serviços.

As funções startup e shutdown serão implementadas no módulo resources.py para manter main.py o mais enxuto possível.

resources.py

Este módulo concentra as funções que lidam com os eventos de iniciação e encerramento da execução da aplicação. Como este é um projeto mínimo FastAPI, ainda não temos conexões com bancos de dados e outros serviços mas os pontos de inserção das chamadas estão indicados no código:

from string import ascii_uppercase
from loguru import logger
from . import config
async def startup() -> None:
    show_config()
    # connect to database
    logger.info('started...')
async def shutdown() -> None:
    # disconnect from database
    logger.info('...shutdown')
def show_config() -> None:
    config_vars = {
        key: getattr(config, key)
        for key in sorted(dir(config))
        if key[0] in ascii_uppercase
    }
    logger.debug(config_vars)
    return

A exibição das variáveis de configuração em caso de DEBUG (linhas 9, 19-26) não é fundamental, mas é algo que costumo usar para ajudar na depuração e testes.

Configuração

Configuração é tudo o que precisa ser ajustado para que a aplicação funcione em um determinado ambiente (desenvolvimento, teste, produção etc.), tal como endereços e credenciais para acessar outros serviços. Segundo as recomendações do The Twelve-Factor App, a configuração deve ser obtida a partir de variáveis de ambiente ao invés de ser mantida diretamente no código-fonte do projeto.

Arquivo .env

Apesar da recomendação de não manter configuração em arquivos, é muito comum o uso de um arquivo .env em ambientes locais de desenvolvimento e teste porque manter variáveis de ambiente não é muito prático: cada vez que você muda de terminal, fecha a IDE ou reinicia o computador, tem de definir todas as variáveis de ambiente novamente. Por outro lado, é muito cômodo manter as configurações locais no arquivo .env e carregá-lo automaticamente durante a iniciação do projeto.

Esse padrão não viola os princípios do 12-factor-app desde que o arquivo .env não seja rastreado pelo controle de versão e, portanto, não faça parte do projeto oficial.

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

O maior problema de testes síncronos tal como test_hello_world.py é que isso limita e dificulta testar uma aplicação baseada em processamento assíncrono. Por exemplo, se sua aplicação usa alguma biblioteca assíncrona de banco de dados tal como o Encode/Databases ou aioredis, você pode precisar fazer uma chamada assíncrona no teste para confirmar se uma determinada informação foi gravada corretamente no banco de dados depois de uma chamada a uma API.

Esses problemas simplesmente desaparecem se trocarmos testes sincronos por assíncronos. Afinal, fazer uma chamada síncrona em uma função assíncrona não requer nenhum esforço adicional.

Para a adoção de testes assíncronos será 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 freqê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 que torne mais fácil rodar a aplicação sem ter de lembrar os parâmetros do hypercorn:

run:
    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 + Perfect Python Project          Minimum FastaAPI Project
====================================          ========================

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 orjson e 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

Comentários

Comments powered by Disqus