Crie uma API RESTful com Flask, MongoDB e Python
Avalie esse Tutorial
Esta é a primeira parte de uma pequena série de publicações no blog chamada "Rewrite it in Rust (RiiR)" (Reescreva em Rust, em português). É um título irônico para algumas postagens que investigarão as semelhanças e diferenças entre o mesmo serviço escrito em Python com Flask, e Rust com Actix-Web.
Esta publicação mostra como criei uma API RESTful para uma coleção de receitas de coquetéis que, por acaso, tenho aqui comigo. O objetivo é mostrar um servidor de API com alguma complexidade, portanto, embora seja um exemplo pequeno, ele abordará fatores importantes como:
- Transformação de dados entre o banco de dados e uma representação JSON.
- Validação de dados.
- Paginação.
- Tratamento de erros.
- Python 3.8 ou superior
- Um MongoDB Atlas cluster. Siga o guia "Introdução ao Atlas" para criar sua conta e o MongoDB cluster. Anote o nome de usuário, a senha e string de conexão do banco de dados, pois você precisará deles mais tarde.
Este é um guia avançado, então ele abordará um grupo de bibliotecas diferentes que podem ser reunidas para criar um servidor declarativo de API Restful sobre o MongoDB. Não cobrirá padrões repetidos na base de código, então se você quiser construir a coisa toda, recomendo verificar o código fonte, que está todo no GitHub.
Ele não abordará os conceitos básicos de Python, Flask ou MongoDB, então, se é isso que você está procurando, recomendo conferir os seguintes recursos antes de começar esta publicação:
- actix-cocktail-api: você pode ignorar isso por enquanto.
- data: contém uma exportação dos dados do meu coquetel. Você importará esses dados para seu cluster em breve.
- flask-cocktail-api: o código para esta publicação no blog.
- test_scripts: alguns scripts de shell que usam curl para testar a interface HTTP do servidor da API.
Há mais detalhes no repositório do GitHub, mas o básico é: instale o projeto com seu virtualenv ativo:
1 pip install -e .
Em seguida, você deve importar os dados para o seu cluster. Defina a variável de ambiente
$MONGO_URI
como seu URI de cluster. Essa variável de ambiente será usada em breve para importar seus dados e também pelo aplicativo Flask. Eu uso direnv
para configurar isso e coloco a seguinte linha no meu arquivo .envrc
no diretório do meu projeto:1 export MONGO_URI="mongodb+srv://USERNAME:PASSW0RD@cluster0-abcde.azure.mongodb.net/cocktails?retryWrites=true&w=majority"
Observe que seu banco de dados deve se chamar "cocktails," e a importação criará uma coleção chamada "recipes." Depois de verificar se
$MONGO_URI
está definido corretamente, execute o seguinte comando:1 mongoimport --uri "$MONGO_URI" --file ./recipes.json
Agora você poderá executar o aplicativo Flask no diretório
flask-cocktail-api
:1 FLASK_DEBUG=true FLASK_APP=cocktailapi flask run
(Você pode executar
make run
se preferir).Verifique a saída para garantir que ela esteja de acordo com a configuração e, em seguida, em uma janela de terminal diferente, execute o script
list_cocktails.sh
no diretório test_scripts
. Deve imprimir algo assim:1 { 2 "_links": { 3 "last": { 4 "href": "http://localhost:5000/cocktails/?page=5" 5 }, 6 "next": { 7 "href": "http://localhost:5000/cocktails/?page=5" 8 }, 9 "prev": { 10 "href": "http://localhost:5000/cocktails/?page=3" 11 }, 12 "self": { 13 "href": "http://localhost:5000/cocktails/?page=4" 14 } 15 }, 16 "recipes": [ 17 { 18 "_id": "5f7daa198ec9dfb536781b0d", 19 "date_added": null, 20 "date_updated": null, 21 "ingredients": [ 22 { 23 "name": "Light rum", 24 "quantity": { 25 "unit": "oz", 26 } 27 }, 28 { 29 "name": "Grapefruit juice", 30 "quantity": { 31 "unit": "oz", 32 } 33 }, 34 { 35 "name": "Bitters", 36 "quantity": { 37 "unit": "dash", 38 } 39 } 40 ], 41 "instructions": [ 42 "Pour all of the ingredients into an old-fashioned glass almost filled with ice cubes", 43 "Stir well." 44 ], 45 "name": "Monkey Wrench", 46 "slug": "monkey-wrench" 47 }, 48 ] 49 ...
O código é dividido em três submódulos.
__init__.py
contém todo o código de configuração do Flask e define todas as rotas HTTP.model.py
contém todas as definições do modelo Pydantic.objectid.py
contém uma definição de campo Pydantic que roubei do mapeador de dados de objetos para MongoDB Beanie.
Mencionei anteriormente que este código faz uso de várias bibliotecas:
- O PyMongo e o Flask-PyMongo lidam com a conexão com o banco de dados. O Flask-PyMongo envolve especificamente o objeto da coleção do banco de dados para fornecer um método conveniente
find_one_or_404
. - Pydantic gerencia a validação de dados e alguns aspectos da transformação de dados entre o banco de dados e representações JSON.
Ao criar uma API robusta, é importante validar todos os dados que passam para o sistema. Seria possível fazer isso usando uma pilha de declarações
if/else
, mas é muito mais eficaz definir um esquema declarativamente e permitir que isso valide programaticamente os dados que estão sendo inseridos.Usei uma técnica que aprendi com o Beanie, um ODM novo e bacana que, infelizmente, não pude usar na prática neste projeto, porque o Beanie é assíncrono e o Flask é um framework de bloqueio.
O Beanie usa Pydantic para definir um esquema e adiciona um tipo de campo personalizado para ObjectId.
1 # model.py 2 3 class Cocktail(BaseModel): 4 id: Optional[PydanticObjectId] = Field(None, alias="_id") 5 slug: str 6 name: str 7 ingredients: List[Ingredient] 8 instructions: List[str] 9 date_added: Optional[datetime] 10 date_updated: Optional[datetime] 11 12 def to_json(self): 13 return jsonable_encoder(self, exclude_none=True) 14 15 def to_bson(self): 16 data = self.dict(by_alias=True, exclude_none=True) 17 if data["_id"] is None: 18 data.pop("_id") 19 return data
Este esquema
Cocktail
define a estrutura de uma instância Cocktail
, que será validada pelo Pydantic quando as instâncias forem criadas. Ele inclui outro esquema incorporado para Ingredient
, que é definido de maneira semelhante.Adicionei funções de conveniência para exportar os dados na instância
Cocktail
para um dict
compatível com JSON ou um dict
compatível com BSON. As diferenças são sucessivas, mas o BSON aceita tipos nativos ObjectId
e datetime
, por exemplo, enquanto ao codificar como JSON, é necessário codificar instâncias ObjectId de outra maneira (prefiro uma string contendo o valor hexadecimal do id), e objetos de data e hora são codificados como strings ISO8601.O método
to_json
usa uma função importada do FastAPI, que recorre aos dados da instância, codificando todos os valores em um formato compatível com JSON. Ele já lida com instâncias datetime
corretamente, mas para conseguir lidar com valores de ObjectId, extraí alguns códigos de campo personalizados do Beanie, que podem ser encontrados em objectid.py
.O método
to_bson
não precisa passar os dados dict
por jsonable_encoder
. Todos os tipos usados no esquema podem ser salvos diretamente com o PyMongo. É importante definir by_alias
como True
, de modo que a chave para _id
seja apenas isso, o _id
, e não o id
do esquema sem um sublinhado.1 # objectid.py 2 3 class PydanticObjectId(ObjectId): 4 """ 5 ObjectId field. Compatible with Pydantic. 6 """ 7 8 9 def __get_validators__(cls): 10 yield cls.validate 11 12 13 def validate(cls, v): 14 return PydanticObjectId(v) 15 16 17 def __modify_schema__(cls, field_schema: dict): 18 field_schema.update( 19 type="string", 20 examples=["5eb7cf5a86d9755df3a6c593", "5eb7cfb05e32e07750a1756a"], 21 ) 22 23 ENCODERS_BY_TYPE[PydanticObjectId] = str
Essa abordagem é perfeita para esse caso de uso específico, mas sinto que ela seria limitante em um sistema mais complexo. Existem muitos padrões para armazenar dados no MongoDB. Isso geralmente resulta no armazenamento de dados em um formato ideal para gravações ou leituras, mas não necessariamente a representação que você gostaria de exportar em uma API.
O que é um Slug?
Vendo o esquema acima, você deve ter se perguntado o que é "slug".
Um slug é um mnemônico exclusivo e seguro para URLs usado para identificar um documento. Aprendi a terminologia como desenvolvedor Django, em que esse termo faz parte do framework. Um slug geralmente é derivado de outro campo. Nesse caso, o slug é derivado do nome do coquetel, portanto, se um coquetel fosse chamado de "Rye Whiskey Old-Fashioned", o slug seria "rye-whiskey-old-fashioned".
Nessa API, esse cocktail pode ser acessado enviando uma solicitação
GET
para o endpoint /cocktails/rye-whiskey-old-fashioned
.Mantive o campo
slug
exclusivo separado do campo _id
atribuído automaticamente , mas forneci ambos porque o slug poderia mudar se o nome do coquetel fosse ajustado e, nesse caso, o valor _id
forneceria um identificador constante para procurar um documento exato.Na versão Rust deste código, fui encorajado a usar uma abordagem diferente. É um pouco mais prolixa, mas, no final, fiquei convencido de que seria mais eficiente e flexível à medida que o sistema crescesse.
Agora, mostrarei como seria um único ponto de extremidade, primeiro focando no ponto de extremidade "Criar", que lida com uma solicitação POST para
/cocktails
e cria um novo documento na coleção "receitas". Em seguida, ele retorna o documento que foi armazenado, incluindo o novo ID único que o MongoDB atribuiu como _id
, porque essa é uma API RESTful e é isso que as APIs RESTful fazem.1 2 def new_cocktail(): 3 raw_cocktail = request.get_json() 4 raw_cocktail["date_added"] = datetime.utcnow() 5 6 cocktail = Cocktail(**raw_cocktail) 7 insert_result = recipes.insert_one(cocktail.to_bson()) 8 cocktail.id = PydanticObjectId(str(insert_result.inserted_id)) 9 print(cocktail) 10 11 return cocktail.to_json()
Esse endpoint modifica diretamente o JSON de entrada para adicionar um item
date_added
com a hora atual. Em seguida, ele passa para o construtor do nosso esquema Pydantic. Neste ponto, se o esquema falhasse ao validar os dados, uma exceção seria gerada e exibida para o usuário.Após validar os dados,
to_bson()
é chamado no Cocktail
para convertê-lo em um dicionário compatível com BSON, e isso é passado diretamente para o método insert_one
do PyMongo. Não há como fazer com que o PyMongo retorne o documento que acabou de ser inserido em uma única operação (embora um upsert usando find_one_and_update
seja semelhante a isso).Depois de inserir os dados, o código atualiza o objeto local com o
id
recém-atribuído e o retorna ao cliente.Graças a
Flask-PyMongo
, o endpoint para procurar um único coquetel é ainda mais simples:1 2 def get_cocktail(slug): 3 recipe = recipes.find_one_or_404({"slug": slug}) 4 return Cocktail(**recipe).to_json()
Esse endpoint será abortado com um 404 se o slug não puder ser encontrado na coleção. Caso contrário, ele simplesmente instancia um Cocktail com o documento do banco de dados e chama
to_json
para convertê-lo em um dicionário que o Flask codificará automaticamente corretamente como JSON.Esse ponto de extremidade é um monstro, e isso se deve à paginação e aos links para paginação. Nos dados de amostra acima, você provavelmente notou a seção
_links
:1 "_links": { 2 "last": { 3 "href": "http://localhost:5000/cocktails/?page=5" 4 }, 5 "next": { 6 "href": "http://localhost:5000/cocktails/?page=5" 7 }, 8 "prev": { 9 "href": "http://localhost:5000/cocktails/?page=3" 10 }, 11 "self": { 12 "href": "http://localhost:5000/cocktails/?page=4" 13 } 14 },
Esta seção
_links
é especificada como parte da especificação HAL (Hypertext Application Language). É uma boa ideia seguir um padrão para dados de paginação, e eu não estava com vontade de criar algo sozinho!E aqui está o código para gerar tudo isso. Não entre em pânico.
1 2 def list_cocktails(): 3 """ 4 GET a list of cocktail recipes. 5 6 The results are paginated using the `page` parameter. 7 """ 8 9 page = int(request.args.get("page", 1)) 10 per_page = 10 # A const value. 11 12 # For pagination, it's necessary to sort by name, 13 # then skip the number of docs that earlier pages would have displayed, 14 # and then to limit to the fixed page size, ``per_page``. 15 cursor = recipes.find().sort("name").skip(per_page * (page - 1)).limit(per_page) 16 17 cocktail_count = recipes.count_documents({}) 18 19 links = { 20 "self": {"href": url_for(".list_cocktails", page=page, _external=True)}, 21 "last": { 22 "href": url_for( 23 ".list_cocktails", page=(cocktail_count // per_page) + 1, _external=True 24 ) 25 }, 26 } 27 # Add a 'prev' link if it's not on the first page: 28 if page > 1: 29 links["prev"] = { 30 "href": url_for(".list_cocktails", page=page - 1, _external=True) 31 } 32 # Add a 'next' link if it's not on the last page: 33 if page - 1 < cocktail_count // per_page: 34 links["next"] = { 35 "href": url_for(".list_cocktails", page=page + 1, _external=True) 36 } 37 38 return { 39 "recipes": [Cocktail(**doc).to_json() for doc in cursor], 40 "_links": links, 41 }
Embora haja muito código, ele não é tão complexo quanto pode parecer à primeira vista. Duas solicitações são feitas ao MongoDB: uma para uma página de receitas de coquetéis e a outra para o número total de coquetéis na coleção. Vários cálculos são feitos para verificar quantos documentos devem ser ignorados e quantas páginas de coquetéis existem. Por fim, alguns links são adicionados para as páginas "anterior" e "próxima", se apropriado (ou seja, a página atual não é a primeira nem a última). A serialização dos documentos do coquetéis é feita da mesma forma que o endpoint anterior, mas desta vez em um loop.
Os endpoints atualizar e excluir são principalmente repetições do código que já incluí, então não vou incluí-los aqui. Confira-os no repositório GitHub se quiser ver como eles funcionam.
Nada me irrita mais do que usar uma API JSON que retorna HTML quando ocorre um erro. Então, eu queria evitar que isso acontecesse.
Após o código de configuração do Flask e antes das definições de ponto de extremidade, o código registra dois manipuladores de erros:
1 2 def resource_not_found(e): 3 """ 4 An error-handler to ensure that 404 errors are returned as JSON. 5 """ 6 return jsonify(error=str(e)), 404 7 8 9 10 def resource_not_found(e): 11 """ 12 An error-handler to ensure that MongoDB duplicate key errors are returned as JSON. 13 """ 14 return jsonify(error=f"Duplicate key error."), 400
O primeiro manipulador de erros intercepta qualquer endpoint que falhe com um código de status 404 e garante que o erro seja retornado como um ditado JSON.
O segundo manipulador de erros intercepta um
DuplicateKeyError
gerado por qualquer ponto de extremidade e faz a mesma coisa que o primeiro manipulador de erros, mas define o código de status HTTP como "400 Solicitação inválida."Enquanto escrevia esta publicação, percebi que tinha deixado passar um manipulador de erros para lidar com dados de Cocktail inválidos. Deixarei a implementação disso como um exercício para o leitor! De fato, essa é uma das dificuldades de escrever aplicativos Python robustos: como as exceções podem aparecer do fundo de sua pilha de dependências, é muito difícil prever de forma abrangente quais exceções seu aplicativo pode apresentar em diferentes circunstâncias.
Isso é algo muito diferente no Rust e, embora, como você verá, o tratamento de erros no Rust possa ser extenso e desafiador, comecei a adorá-lo por sua insistência na correção.
Quando comecei a escrever esta publicação, achei que seria relativamente simples. Como adicionei o requisito de que o código não deve ser apenas um exemplo de brinquedo, alguns dos desafios inerentes à criação de uma API robusta em qualquer banco de dados se tornaram aparentes.
Nesse caso, o Flask pode não ser a ferramenta certa para o trabalho. Escrevi recentemente uma publicação no blog sobre a criação de uma API com Beanie. Beanie e FastAPI são uma combinação perfeita para esse tipo de aplicativo e lidam com validação, transformação e paginação com muito menos código. Além disso, eles são autodocumentados e podem fornecer o esquema de dados em formatos abertos, incluindo OpenAPI spec e JSON schema!
Se você está prestes a criar uma API do zero, recomendo que você as verifique. Você pode ler as publicações de Aaron Bassett sobre a pilha FARM (FastAPI, React, MongoDB).
Publicarei em breve a segunda publicação desta série, Crie uma Cocktail API com Atix-Web, MongoDB e Rust, e depois concluirei com uma terceira publicação, Reescrevi em Rust – Como ficou?, na qual avaliarei os pontos fortes e fracos dos dois experimentos.
Obrigado por ler. Fique atento às próximas publicações!
Se tiver dúvidas, acesse o site da nossa comunidade de desenvolvedores, no qual os engenheiros e a comunidade do MongoDB ajudarão você a desenvolver sua próxima grande ideia com o MongoDB.