Criando uma API Pronta para Produção com FastAPI - PT.5

Posted by Luciano on Sunday, August 1, 2021

Table of Contents

Nos Capítulos Anteriores …

Como dito anteriormente, hoje vamos configurar o SQLAlchemy!!

Essa é a parte 5 do nosso projeto do EconoWallet e se você quiser verificar o que já fizemos até o momento, acesse os links abaixo:

Sem enrolação, vamos logo ao que interessa!!!

I’m ready when you are

Reorganizando o projeto

Antes de configurar o ORM, vamos ajustar algumas coisas no nosso diretório. Como estamos seguindo para um estágio mais maduro da aplicação e que você já compreende o básico de configuração do FastAPI, precisaremos agora separar melhor as competências dentro do projeto.

mkdir project/app/api      
mkdir project/app/database           
mkdir project/app/models  
touch project/app/api/__init__.py           
touch project/app/database/__init__.py         
touch project/app/models/__init__.py        
touch project/app/api/status.py     

Veja que agora nós temos alguns diretórios novos✨ que irão comportar diferentes lógicas no nosso projeto:

  • api: todas as nossas rotas da api.
  • database: toda a lógica de configuração do SQLAlchemy.
  • models: toda a especificação de como deverão ser nossas tabelas.

Nós também adicionamos um arquivo chamado status.py, esse irá conter a rota de status da nossa aplicação, que antes estava no nosso arquivo main.py. Veja como está nosso main.py e nosso status.py:

# main.py
import logging

from fastapi import FastAPI

from app.api import status

log = logging.getLogger("uvicorn")


def create_application() -> FastAPI:
    application = FastAPI()
    application.include_router(
        status.router,
        tags=["Hello"],
        prefix="/api/v1"
    )
    return application


app = create_application()


@app.on_event("startup")
async def startup_event():
    log.info("Starting up...")

Aqui nós não configuramos mais diretamente as rotas no main.py, elas agora ficam em diretórios separados, e sempre na inicialização da aplicação é carregado todas as rotas ao chamar a função create_application. Dentro dessa função nós adicionamos um include_router que faz algumas coisas interessantes:

  • inclui todas as rotas de um determinado arquivo.
  • adiciona uma tag para separar melhor no contexto de exibição no swager.
  • adiciona um prefixo à rota: eu normalmente utilizo /api/v1 pelo motivo de que fica fácil alterar para uma versão 2 caso a mesma venha a existir.

Um decorator muito legal do FastAPI é @app.on_event, onde você pode configurar métodos que irão rodar sempre que a aplicação inicializar ou finalizar. Iremos adicionar mais coisas nesse event, mas por enquanto apenas estamos printando um log no console.

# status.py
import os

from fastapi import APIRouter, Depends

from app.config import Settings, get_settings

router = APIRouter()
APP_VERSION = os.getenv("APP_VERSION", "1.0.0")


@router.get("/status")
async def ping(settings: Settings = Depends(get_settings)):
    return {
        "ping": "pong!",
        "version": APP_VERSION,
        "environment": settings.environment,
        "testing": settings.testing
    }

Veja que aqui nós temos uma modificação da rota /ping para /status e ela é definida por @router.get e não mais @app.get. Eu adicionei também uma variável chamada APP_VERSION que é responsável por manter atualizado o registro da verssão da aplicação.

Executamos assim nosso workflow de teste, para saber se tudo está funcionando:

docker-compose down -v
docker-compose up --build -d

Ao acessar http://127.0.0.1:8004/docs/ você deverá ver uma tela igual a de baixo:

status

E nossa árvore de diretórios deve estar como no seguinte snippet:

.
├── docker-compose.yml
├── Dockerfile
├── Pipfile
├── Pipfile.lock
├── project
│   └── app
│       ├── api
│       │   ├── __init__.py
│       │   └── status.py
│       ├── config.py
│       ├── database
│       │   └── __init__.py
│       ├── __init__.py
│       ├── main.py
│       └── models
│           └── __init__.py
└── README.md

Configurando o SQLAlchemy no Projeto

No intuito de ter uma tabela inicial bem simples no nosso banco de dados, apenas para iniciar a configuração do SQLAlchemy, vamos iniciar o processo criando um model. Como dito no post anterior, estamos no que estou chamando de primeiro cenário do SQLAlchemy, onde precisamos da classe declarative_base antes de iniciar a configuração do model.

Antes de tudo, primeiro iremos instalar o SQLAlchemy: pipenv install sqlalchemy==1.4.20. Agora, vamos criar dois novos arquivos:

touch project/app/database/modelbase.py
touch project/app/models/register.py

O modelbase.py é onde eu normalmente coloco a declarative_base, sendo assim, é desse arquivo que nossos models irão buscar a classe mãe.

# modelbase.py
from sqlalchemy.ext import declarative

Base = declarative.declarative_base()

Podemos agora criar o model. Um model é uma abstração de tudo que você faria se estivesse criando uma tabela diretamente no banco, com a diferença de que aqui a gente tem uma abordagem python-like. Cada tipo de ORM tem uma sintaxe própria, e no SQLAlchemy a gente tem a configuração como no arquivo abaixo:

# register.py
from datetime import date

from sqlalchemy import Column, Date, BigInteger, String

from app.database.modelbase import Base


class Register(Base):
    __tablename__ = "registers"

    id: int = Column(BigInteger, primary_key=True, autoincrement=True)
    products: str = Column(String(20), nullable=False)
    created_at: date = Column(Date, nullable=False, index=True)
    expire_at: date = Column(Date, nullable=False, index=True)

Pronto, nosso primeiro model está criado. Como você pode ver, é basicamente uma classe que herda da declarative_base, e nosso dever é configurar como vai ser cada coluna da tabela. Diferentemente de outros ORMs como o django, aqui nós precisamos explicitamente determinar a coluna de id.

Para as outras colunas, nós temos formato de string e data. Sendo que nenhuma delas podem ser atribuídos valores nulos, ou seja, são campos obrigatórios da nossa tabela. Veja que eu já estou adicionando alguns indexes, que basicamente permitem que a consulta à essas colunas seja feita de forma mais rápida.

Até agora nós já temos alguns elementos do primeiro cenário do SQLAlchemy, porém, para criar as tabelas no banco e expor uma session, nós iremos precisar criar um novo arquivo: touch project/app/database/database_session.py.

# database_session.py
import logging
import os
from typing import Callable, Optional

import sqlalchemy
from sqlalchemy.orm import Session

from app.database.modelbase import Base

__factory: Optional[Callable[[], Session]] = None
log = logging.getLogger("uvicorn")


def get_db() -> Session:
    db = create_session()
    try:
        yield db
    finally:
        db.close()


def global_init() -> None:
    global __factory

    if __factory:
        return

    conn_str = str(
        os.environ.get("DATABASE_URL", "sqlite:///project/db/local_database.db")
    )
    log.info("Connecting to the database...")
    engine = sqlalchemy.create_engine(conn_str, echo=False)
    __factory = sqlalchemy.orm.sessionmaker(bind=engine)

    from app.models.register import Register

    Base.metadata.create_all(engine)


def create_session() -> Session:
    global __factory

    if not __factory:
        raise Exception("You must call global_init() before using this method")

    session: Session = __factory()
    session.expire_on_commit = False

    return session

what?

Calma, vou traduzir o que está acontecendo aqui:

  1. Criamos uma função para inicializar uma variável global chamada __factory, essa é responsável por expor uma conexão com o banco para toda a aplicação.
  2. Por meio do create_session() essa variável é acessada e a session é enfim estabelecida.
  3. Setamos uma forma padrão de acessar essa session por meio da função get_db(), que expõe a session e garante que a mesma vai ser finalizada (finaly), aconteça o que acontecer.

Eu ainda não comentei sobre variáveis de ambiente, mas vai ser algo que precisaremos fazer aqui. Uma variável de ambiente é basicamente um valor atribuído dinamicamente que pode afetar o modo como alguns processos irão se comportar em seu projeto.

Em python, normalmente acessamos essas variáveis utilizando a lib os, builtin da linguagem, por meio do comando: os.environ.get("NOME_DA_VARIAVEL").

Alerta Boas Práticas 🚨

Gostaria de pontuar aqui a importância de utilizarmos variáveis de ambiente em nossos projetos. Normalmente projetos reais possuem informações sensíveis que pessoas externas ao projeto não podem ter acesso, ou informações que precisam ser alteradas dinamicamente, como:

  • Senha do banco
  • Token de um bucket
  • Token de alguma API de terceiro
  • Senha de login em outro serviço …

É extremamente indicado não hard coded essas informações diretamente no código. Ao invés disso, que coloquemos as mesmas como variáveis de ambiente em um arquivo separado e oculto do repositório onde você está versionando o seu projeto. Vamos ver então como fazer isso!!

Configurando Variáveis de Ambiente

Graças ao Docker, é simples configurar variáveis de ambiente para nosso projeto. Primeiro, vamos criar alguns arquivos:

mkdir env
touch env/.dev

Dentro do .dev é onde você vai colocar todas as suas variáveis de ambiente:

ENVIRONMENT=dev
TESTING=0
DATABASE_URL=mysql://user:password@db:3306/econowallet

SQL_DATABASE=econowallet
SQL_USER=user
SQL_PASSWORD=password
SQL_HOST=db
SQL_PORT=3306

E agora, atualizamos nosso .gitignore:

# .gitignore
__pycache__
# new
env

Show! Agora, localmente teremos como testar nossa aplicação, e quando a mesma for funcionar em um ambiente de produção, essas variáveis de ambiente serão substituídas dinamicamente para funcionar com as configurações de produção 😍.

Por fim, dentro do docker-compose.yml nós iremos apontar onde o arquivo de variáveis de ambiente está localizado:

version: '3.8'

services:
  web:
    build: .
    volumes:
      - .project:/usr/src/app
    ports:
      - 8004:8000
    # new
    env_file:
      - env/.dev
    environment:
      - ENVIRONMENT=dev
      - TESTING=0
    depends_on:
      - db

  db:
    image: mysql:8.0
    command: --default-authentication-plugin=mysql_native_password
    restart: always
    volumes:
      - mysql_data:/var/lib/mysql_data/data/
    environment:
      MYSQL_DATABASE: econowallet
      MYSQL_USER: user
      MYSQL_PASSWORD: password
      MYSQL_ROOT_PASSWORD: password
    ports:
      - 3320:3306

volumes:
  mysql_data:

Inicializando em Conjunto com o SQLAlchemy

Lembrando que precisamos inicializar uma sessão global por meio do global_init() que vimos anteriormente, vamos então fazer uso de uma feature do FastAPI que vimos anteriormente, @app.on_event():

import logging

from fastapi import FastAPI

from app.api import status
from app.database.database_session import global_init

log = logging.getLogger("uvicorn")


def create_application() -> FastAPI:
    application = FastAPI()
    application.include_router(
        status.router,
        tags=["Hello"],
        prefix="/api/v1"
    )
    return application


app = create_application()

# updated
@app.on_event("startup")
async def startup_event():
    log.info("Starting up...")
    global_init()

Sendo assim, sempre que o app inicializar, vamos iniciar as configurações do SQLAlchemy em conjunto!!

Estamos quaseee lá!

Bug do MySQL 🐛

Se você tentar buildar o projeto, provavelmente irá se deparar com um problema onde o serviço web não irá conseguir inicializar pois não vai ter encontrado um banco MySQL disponível. Eu já busquei explicação do por que disso acontecer, mesmo especificando o depends_on: db no docker-compose.yml o serviço web tenta inicializar primeiro, e como não encontra um banco online, temos uma mensagem de erro.

Para driblar isso, vamos utilizar um pouquinho de conhecimento de bash e adicionar o que chamamos de entrypoint para garantir que nosso serviço web apenas siga em frente após conectar com o banco.

Crie um arquivo chamado local-entrypoint.sh e copie o seguinte código:

#!/bin/bash
# if any of the commands in your code fails for any reason, the entire script fails
set -o errexit
# fail exit if one of your pipe command fails
set -o pipefail
# exits if any of your variables is not set
set -o nounset

mysql_ready() {
python << END
import sys

import mysql.connector
from mysql.connector import Error

try:
    connection = mysql.connector.connect(host="${SQL_HOST}",
                                         database="${SQL_DATABASE}",
                                         user="${SQL_USER}",
                                         password="${SQL_PASSWORD}")
except:
    sys.exit(-1)
sys.exit(0)

END
}
until mysql_ready; do
  >&2 echo 'Waiting for Mysql to become available...'
  sleep 1
done
>&2 echo 'Mysql is available'

uvicorn project.app.main:app --reload --workers 1 --host 0.0.0.0 --port 8000

Nesse script é executado um loop que finaliza apenas quando o MySQL estiver disponível. Precisamos agora apontar nosso Dockerfile para esse entrypoint.

# Dockerfile
FROM python:3.9.0-slim-buster

WORKDIR /usr/src/app

ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

RUN apt-get update \
  && apt-get -y install netcat gcc \
  # mysql dependencies
  && apt-get -y install default-libmysqlclient-dev build-essential \
  && apt-get clean

RUN pip install --upgrade pip \
  && pip install pipenv
COPY ./Pipfile .
COPY ./Pipfile.lock .
RUN pipenv install --deploy --system

COPY . .

# entrypoint
COPY ./local-entrypoint.sh /usr/src/app/local-entrypoint.sh
RUN chmod +x /usr/src/app/local-entrypoint.sh

CMD ["/bin/bash", "-c", "./local-entrypoint.sh"]

Nosso docker-compose.yml também precisará ser alterado:

# docker-compose.yml
version: '3.8'

services:
  # updated
  web:
    build: .
    volumes:
      - .:/usr/src/app
    ports:
      - 8004:8000
    env_file:
      - env/.dev
    environment:
      - ENVIRONMENT=dev
      - TESTING=0
    depends_on:
      - db

  db:
    image: mysql:8.0
    command: --default-authentication-plugin=mysql_native_password
    restart: always
    volumes:
      - mysql_data:/var/lib/mysql_data/data/
    environment:
      MYSQL_DATABASE: econowallet
      MYSQL_USER: user
      MYSQL_PASSWORD: password
      MYSQL_ROOT_PASSWORD: password
    ports:
      - 3320:3306

volumes:
  mysql_data:

Aqui tivemos duas mudanças:

  1. nosso volume não é referente ao .project e sim a pasta root . (caso contrário o local-entrypoint.sh não será encontrado).
  2. removemos o command que estava inicializando app, e jogamos para dentro do entrypoint.

Por fim, garanta a instalação do drive do MySQL e do pacote mysql-connector-python:

pipenv install mysqlclient==2.0.3
pipenv install mysql-connector-python==8.0.25

Refatoração 🔨

No início do projeto nós fizemos uma configuração no PyCharm para considerar o diretório project como root. Porém, teremos que retornar para o diretório anterior (efetuando os mesmos passos link de como fazer.

E além disso, iremos alterar todas as referências nos nossos arquivos de volta para project.app… Os arquivos que precisarão sofrer essa refatoração são:

  • status.py
  • database_session.py
  • modelbase.py
  • register.py
  • main.py

Sorry!

Agora sim, vamos rebuildar a aplicação e observar no DBeaver se veremos uma nova tabela (como especificado nosso model).

docker-compose down -v
docker-compose up --build -d
docker-compose logs -f

dbeaver-registers

🎊 🎉 Agora temos nossa tabela materializada no banco e podemos iniciar as transações!! 🎊 🎉

Próximo Capítulo…

Na próxima etapa, iremos configurar quais rotas teremos na aplicação. Assim como que as rotas interagem com o banco de dados 👋🏽.

Você pode acompanhar o repositório do projeto no link abaixo: