Utilizando o FastAPI – parte 2

utilizando-fastapi-2_abertura

Dando sequência ao desenvolvimento de uma API usando o FastAPI, na primeira parte foram implementados os métodos HTTP para recuperar — GET — e remover — DELETE — registros dentro de uma simulação de base de dados usando um arquivo JSON (o nosso “Banco de Dados”).

Nesta parte serão criados os métodos HTTP que ficaram faltando, POST e PUT, assim como mais um pouco sobre anotação de tipo e, claro, o pydantic.

Um pouco de pydantic

O pydantic é uma biblioteca que implementa validação de dados e também gerenciamento de configurações utilizando as anotações de tipo do próprio Python. Ela força as anotações de tipos do Python em tempo de execução e provê as respectivas mensagens de erro de sua violação.

Atenção : Não irei me aprofundar muito no pydantic e focarei apenas em seu uso para a validação dos campos da API.

Anotação de tipos

Começando com uma exemplo bem simples, a classe Order dentro do arquivo “./examples/pydantic_model.py”:

from pydantic import BaseModel

class Order(BaseModel):
    id: int
    name: str

A classe é definida como uma estrutura de dados contendo os atributos “id” e “name” que, respectivamente, possuem os tipos inteiro e string, e como o pydantic força o que foi definido pela anotação, a tentativa de instanciá-la com outros tipos diferentes resultará em erro…

order = Order(id=("10",), name=["Giovanni"])
Traceback (most recent call last):
 File "", line 1, in 
 File "pydantic/main.py", line 346, in pydantic.main.BaseModel.__init__
pydantic.error_wrappers.ValidationError: 2 validation errors for Order
id
 value is not a valid integer (type=type_error.integer)
name
 str type expected (type=type_error.str)

Diferente de fazê-lo utilizando os tipos de dados corretos que ele espera receber.

order = Order(id=1, name="Giovanni")

Ah sim, um detalhe importante sobre o pydantic, uma entrada como:

order = Order(id=2.99, name=123)

Será considerada válida por conta de uma decisão deliberada de projeto por conta da conversão de dados mas não irei entrar em maiores detalhes quanto a isto.

Customizando a validação

É possível sofisticar a validação dos campos , por exemplo a classe ValidatedOrder (no mesmo arquivo):

from datetime import datetime
from pydantic import BaseModel, validator
...
class ValidatedOrder(Order):
    value: float
    order_date: datetime

    @validator("order_date")
    def validate_order(cls, v: datetime, **kwargs) -> datetime:
        if v > datetime.now():
            raise ValueError(
                "A data do pedido não pode estar no futuro!"
            )
        return v

    @validator("value")
    def validate_value(cls, v: float, **kwargs) -> float:
        if v <= 0.0:
            raise ValueError(
                "Valor do pedido não pode ser menor ou igual a zero!"
            )
        return v

A validação é feita usando o decorador validator para informar qual o campo, ou campos, o método dentro da classe cuidará de validar (apenas respeitando a ordem dos argumentos)…

order = ValidatedOrder(
    id=1, name="Giovanni", value=-10.3,
    order_date=datetime(2020, 12, 1)
)
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "pydantic/main.py", line 346, in pydantic.main.BaseModel.__init__
pydantic.error_wrappers.ValidationError: 2 validation errors for
ValidatedOrder
value
 Valor do pedido não pode ser menor ou igual a zero! (type=value_error)
order_date
 A data do pedido não pode estar no futuro! (type=value_error)

As regras são simples mas suficientes para mostrar uma validação além da mera verificação do tipo.

Criando o modelo de dados

A ideia é claramente deixar que o pydantic cuide da validação dos dados dos estudantes — principalmente aquilo que a API recebe — e para tal é necessário definir um modelo (na verdade modelos visto que será mais de um) descrevendo estas estruturas.

Para não criar confusão com o “modelo” do banco de dados (quando houver um) vou chamá-los de esquemas e colocá-los no arquivo “schemas.py”:

from pydantic import BaseModel
from enumerators import StatesBrEnum as StatesEnum

class StudentBaseSchema(BaseModel):
    name: str
    address: str
    neighbour: str
    city: str
    postal_code: str

Exceto pelos campos “id” e “state”, aqui estão todos os campos que constam lá no arquivo JSON e definidos com do tipo string.

Enumeração

O campo state está definido como uma enumeração contendo uma relação das siglas e nomes dos estados brasileiros e que por enquanto ficará no mesmo arquivo.

from enum import Enum

class StatesBrEnum(str, Enum):
    ac = "Acre"
    al = "Alagoas"
    ...
    se = "Sergipe"
    to = "Tocantins"

Assim fica bem mais fácil de customizar a aplicação sendo possível remover, adicionar ou mesmo trocar o país com pouca alteração na estrutura do código.

Esquemas

A classe com a definição do esquema serve como referência para aquelas que efetivamente serão utilizados dentro da API:

class CreateStudentSchema(StudentBaseSchema):
    state: StatesEnum

class StudentSchema(StudentBaseSchema):
    id: int
    state: StatesEnum

class UpdateStudentSchema(StudentBaseSchema):
    name: str = ""
    address: str = ""
    neighbour: str = ""
    city: str = ""
    state: str = ""
    postal_code: str = ""

Todas são baseadas em StudentBaseSchema e possuem algumas características relacionadas diretamente ao seu uso:

  • A classe CreateStudent é usada para a inclusão de novos estudantes quando o valor de “id” ainda não está definido;
  • StudentSchema é usada para os registros já existentes, daí possuir o campo “id” dentro dela e
  • Por último, a classe UpdateStudentSchema é usada na atualização, então todos os campos terem um valor vazio predefinido, até mesmo o “state” que aqui é do tipo string.

A razão de “state” estar definido nas classes filhas é para evitar a redefinição do seu tipo necessário (o pydantic reclamou).

Validando os modelos de dados

Optei por deixar apenas duas validações para os campos, uma para o formato do CEP com cinco dígitos no começo, um hífen e três dígitos no final:

import re
...
POSTAL_CODE_REGEX = re.compile("[0-9]{5}\\-[0-9]{3}")

class StudentBaseSchema(BaseModel):
    ...
    @validator("postal_code")
    def validate_postal_code(cls, v: str, **kwargs: int) -> str:
    if not POSTAL_CODE_REGEX.match(postal_code := v.rjust(9, "0")):
        raise ValueError("O CEP informado é inválido!")
    return postal_code

A outra para estado e usada durante a atualização:

class UpdateStudentSchema(StudentBaseSchema):
    ...
    @validator("state")
    def validate_state(cls, v: Any, **kwargs: int) -> str:
        try:
            return v if StatesEnum(v) else ""
        except ValueError:
            raise ValueError(f"O valor '{v}' não é válido!")

E agora podemos finalmente seguir adiante com a API.. 😀

Complementando a API

O primeiro método HTTP a implementar é o POST e responsável pela inserção de novos estudantes. Ele deverá receber os dados do estudante, validar (na verdade esta parte é com o pydantic) e fazer a inserção na “Base de Dados”:

def get_max() -> int:
    max_student = max(students, key=lambda i: i.get("id", 0))
    return max_student.get("id", 0)

@app.post("/students/")
def post_student(student: CreateStudentSchema) -> StudentType:
    students.append(
        new_student := {**{"id": get_max() + 1}, **student.dict()}
    )
    return new_student

A função get_max()¹, serve para procurar no “Banco de Dados” qual o registro de maior id que, incrementado, será o valor de id do novo registro. Como toda a validação dos campos já foi feita pelo pydantic as ações executadas são apenas a inserção no “Banco de Dados” e o envio dos dados de volta.

O teste do método POST (em versão compactada)…

$ curl -X POST "http://127.0.0.1:8000/students/" -H "accept: applicat
ion/json" -H "Content-Type: application/json" -d "{\"name\":\"João Sil
va\",\"address\":\"Praça da Matriz, 50 - Apt.65\",\"neighbour\":\"Vil
a Lemos\",\"city\":\"Nova Santana\",\"state\":\"Goiás\",\"postal_code\
":\"51200-023\"}"
{"name":"João Silva","address":"Praça da Matriz, 50 - Apt.65","neighbo
ur":"Vila Lemos","city":"Nova Santana","postal_code":"51200-023","id":
11,"state":"Goiás"}

O último método HTTP a implementar é o PUT e responsável pelas alterações nos registros já existentes:

@app.put("/students/{student_id}")
def put_student(student_id: int, student: UpdateStudentSchema) -> StudentType:
    if old_student := retrieve_student(student_id):
        updated_student = {
            **old_student,
            **{key: value for key, value in student if value},
        }
    students[students.index(old_student)] = updated_student
    return updated_student

Apesar de usar um esquema diferente de dados, ela é bem parecida com a utilizada na implementação do método POST. A principal diferença está em recuperar o registro atual, usando retrieve_student(), e então mesclá-lo com os dados validados recebidos pela API — usando apenas os campos com valores não vazios. No final o novo registro é armazenado no “Banco de Dados” e enviado de volta.

E apenas para registrat, o teste do PUT

$ curl -X PUIT "http://127.0.0.1:8000/students/11" -H "accept: applica
tion/json" -H "Content-Type: application/json" -d "{\"name\":\"João Sil
va Júnior\"}"
{"name":"João Silva Júnior","address":"Praça da Matriz, 50 - Apt.65","n
eighbour":"Vila Lemos","city":"Nova Santana","postal_code":"51200-023",
"id":11,"state":"Goiás"}

(¹) Tanto a função get_max() quanto a retrieve_student() são temporárias e deixarão de existir quando o “Banco de Dados” for substituído por um banco de dados de verdade.

Finalizando esta parte…

utilizando-fastapi-2_openapi

Agora a API está pronta, todos os métodos foram implementados mas ainda falta algo muito importante a ser feito e na próxima parte o “Banco de Dados” será substituído por um banco de dados SQL e acessado através de uma ORM a partir do SQLAlchemy e assim remover algumas das gambiarras largadas no código.

Ah sim, os arquivos desta parte (tag “parte-2”) já estão disponíveis no respectivo repositório no GitHub.

Até!