Escrevendo uma API com MongoDB no Rust
Avalie esse Tutorial
Você escolheu escrever uma API que aproveita o MongoDB no Rust — gerenciamento de memória, tempo de vida, pool de banco de dados e muito mais! Você fez uma ótima decisão e estou ansioso para mostrar os fundamentos, o interesse e a facilidade com que você pode criar a API. Presumi que você sabe criar um projeto Rust com
cargo
e que está familiarizado com Cargo.toml
e uso de dependência no Rust.A caixa do MongoDB e sua documentação vêm totalmente detalhadas e ricas em recursos, prontas para uso (ou caixa)! A caixa do MongoDB está localizada em crates.io.
Para adicionar isso ao seu projeto, Go
cargo add mongodb
.Você notará uma entrada adicionada a
Cargo.toml
que se parece um pouco com isto:1 [dependencies] 2 mongodb = "2.8.1"
Depois que sua dependência do MongoDB for declarada, devemos extrair a documentação para mantê-la ao nosso lado enquanto iteramos esta API.
Ao usar o driver MongoDB para Rust, você precisa tomar uma decisão sobre qual tempo de execução usar. Isso se resume a duas opções: sync ou async.
Se você estiver desenvolvendo uma API, provavelmente desejará um tempo de execução assíncrono, que é o que usaremos neste tutorial. Por padrão, o driver Rust do MongoDB é fornecido com
tokio
como tempo de execução, mas ele pode ser configurado para usar async-std
.Existem casos de uso em que um tempo de execução de bloqueio (síncrono) pode ser útil, e o driver MongoDB Rust também fornece isso.
Embora estejamos usando o tempo de execução assíncrono, você pode escolher o tempo de execução de sincronização definindo-o na entrada
Cargo.toml
:1 mongodb = {version = "2.8.1", features = ["sync"]}
Antes de pularmos para o restante das dependências do nosso projeto, vamos configurar a dependência mais importante, se você ainda não tiver feito isso: uma instância do MongoDB! Para nossa sorte, podemos aproveitar o Atlas do MongoDB para criar nossa própria instância do MongoDB.
Vamos começar criando um novo projeto no MongoDB para essa API e o tutorial.
Depois de criar o projeto, vamos criar um sistema para hospedar nossa instância real do MongoDB.
Com uma implantação, podemos selecionar o nível e o provedor de hospedagem que gostaríamos de usar. O MongoDB oferece a camada M0 , que é perfeita para nosso uso de aprendizado e desenvolvimento inicial.
Depois de criar tudo isso e passar pelas etapas de segurança, vamos adicionar alguns dados à nossa instância do MongoDB. Forneci alguns dados de amostra no repositório do GitHub. O nome dele é
bread_data.json
.Podemos aproveitar o recurso
Add Data
do MongoDB Atlas para fazer isso: Em seguida, Go para
Import File
. A partir daqui, aproveitaremos o MongoDB Compass. Usando Connect to Compass
, o Atlas apresentará a você uma connection string parecida com esta: mongodb+srv://<user>:<password>@<db_name>.<identifier>.mongodb.net/
. Usaremos isso para nos conectar ao Compass. Para se conectar à sua instância do MongoDB no Compass, insira a connection string quando solicitado:
Depois de conectado no Compass, clique em + para adicionar um banco de dados à nossa instância:
Nomeei meu banco de dados
bread
e a coleção recipes
. Você verá que o Compass reconhece que não temos dados em nosso banco de dados e coleção recém-criados.
Vamos importar alguns dados! Clique em
Import Data
e selecione o bread_data.json
para carregar. Você verá uma pequena solicitação para que você saiba que a operação foi bem-sucedida: Agora podemos girar de volta para o Atlas e ver nossos dados.
Agora que temos nossa instância do MongoDB Atlas configurada e preenchida com dados, de volta às coisas do Rust!
Existem algumas estruturas web diferentes disponíveis no ecossistema Rust. Há actix-web, axum, foguete, warp e muito mais. À primeira vista, você pode pensar que todos são muito parecidos, e você está certo! Pode haver diferenças de desempenho entre eles ao comparar certas operações e similares, mas para o meu caso de uso (uma API de pequena a média escala para uso interno), poderíamos escolher qualquer uma delas e elas serviriam perfeitamente ao trabalho.
Creia em construir rapidamente e iterar muito em meus projetos. Sei que podemos ficar presos a milissegundos (ou até microssegundos) de diferença de desempenho, mas acho que, para a maioria dos aplicativos, você pode escolher a estrutura ou o projeto que mais lhe interessa e Go daí.
Para mim, a caixa que mais se destacou para mim foi Rocket. Meu principal motivo para escolher o Rocket foi a documentação, simplicidade e uma ótima experiência de desenvolvedor. Isso não quer dizer que as caixas mencionadas anteriormente (ou não mencionadas) não forneçam isso também, mas fui atraído por Rocket. Além disso, quando comecei no Rust, o Rocket foi uma das primeiras caixas que usei para criar uma API.
Se você estiver interessado em criar uma API de alto desempenho com facilidade e rapidez, o Rocket nos permite fazer exatamente isso. Ele fornece exemplos de início rápido em seu repositório e, após algumas olhadas, pode-se começar a ter uma ideia de como a API é distribuída.
Go os fundamentos da configuração de uma API no Foguete.
Primeiro, vamos executar o
cargo add -F json rocket
para adicionar o Foguete às dependências do projeto. Isso resultará em algo como isto sendo adicionado em [dependencies]
:1 rocket = {version = "0.5.0", features = ["json"]}
Enquanto adicionamos coisas à nossa lista de dependências, devemos adicionar uma caixa desenvolvida pela Rocket que nos permite fazer com que a Rocket use um wrapper para gerenciar um pool de collection para as conexões assíncronas feitas sem o cliente MongoDB e a caixa. Isso nos permitirá parametrizar nosso MongoDB database e collection e fazer com que cada função receba sua própria conexão para uso.
Para adicionar isso às nossas dependências, executamos
cargo add -F mongodb rocket_db_pools
, o que resultará em algo como isso sendo adicionado a [dependencies]
:1 rocket_db_pools = { version = "0.1.0", features = ["mongodb"] }
Para configurar o Rocket para usar o nosso MongoDB database que criamos anteriormente no MongoDB Atlas, vá em frente e crie um novo arquivo de configuração e nomeie-o
Rocket.toml
. Esse é um arquivo de configuração que o Rocket lerá por padrão para pegar determinados itens de configuração. Para nossos propósitos, vamos definir nossa string de conexão do MongoDB como um campo chamado url
. Para fazer isso, o conteúdo deRocket.toml
deve ter um formato como este:1 [default.databases.<db_name>] 2 url = "<MongoDB Connection String>"
Seguindo esse formato, meu
Rocket.toml
tem a seguinte aparência:1 [default.databases.db] 2 url = "mongodb+srv://<username>:<password>@tutorial.xzpnmhw.mongodb.net/?retryWrites=true&w=majority&appName=tutorial"
Chamei o nome do meu pão porque todos os dados de teste que usei para essa API são receitas de pão. Eu os gerei aleatoriamente, então não há garantias de que sejam comestíveis! ;) Se quiser ler mais sobre o que é possível fazer com oRocket.toml, você pode encontrá-lo listado na documentação.
Antes de mergulharmos no código em si e começarmos a escrever a API, gostaria de analisar a estrutura da API em
.rs
arquivos.Diagramação de cada arquivo e seu uso abaixo:
1 . 2 ├── Cargo.lock # dependency info 3 ├── Cargo.toml # project and dependency info 4 ├── Rocket.toml # rocket config info 5 └── src # directory where our rust code lives 6 ├── db.rs # file used to establish db 7 ├── main.rs # file used to start the API 8 ├── models.rs # file used for organization of data 9 └── routes.rs # file used for API routes
Esse arquivo (
db.rs
) é usado para instanciar a conexão com nossa instância do MongoDB.Você pode nomear a estrutura o que quiser. A parte importante aqui é usar o mesmo
<db_name>
que você definiu no Rocket.toml
acima.1 use rocket_db_pools::{mongodb::Client, Database}; 2 3 4 5 pub struct MainDatabase(Client);
Na minha API, é definido assim. Observe o uso do nome “db, o mesmo que no meu
Rocket.toml
:1 use rocket_db_pools::{mongodb::Client, Database}; 2 3 4 5 pub struct MainDatabase(Client);
Há mais uma parte importante para inicializar a conexão com o MongoDB: Precisamos anexar a estrutura do banco de dados à nossa instância do Rocket. Em
main.rs
, precisamos inicializar o banco de dados e anexá-lo da forma abaixo:1 mod db; 2 mod models; 3 mod routes; 4 5 use rocket::{launch, routes}; 6 use rocket_db_pools::Database; 7 8 9 fn rocket() -> _ { 10 rocket::build().attach(db::MainDatabase::init()).mount() 11 }
Por enquanto,
rust-analyzer
pode relatar a falta de argumentos para mount()
. Tudo bem — adicionaremos as rotas e tal mais tarde.Definir estruturas (modelos) consistentes e úteis para representar os dados que vão e vêm em nossa API é especialmente útil. Ele permite fazer suposições sobre o que os dados possuem, como podemos usá-los e assim por diante.
No meu arquivo
models.rs
, defini uma estrutura que representa uma receita de pão e a nomeei, chocantemente, Recipe
. Podemos habilitar as características de serialização e desserialização para nossa estrutura de e para JSON para facilitar a interatividade ao recuperar e enviar dados por meio da API. Isso é feito com a macro derivada: #[derive(Debug, Serialize, Deserialize)]
. Também incluí a característicaDebug
nele para que eu possa depurar e ver facilmente o conteúdo de uma entidade, se necessário.1 use mongodb::bson::oid::ObjectId; 2 use rocket::serde::{Deserialize, Serialize}; 3 4 5 6 pub struct Recipe { 7 8 pub id: Option<ObjectId>, 9 pub title: String, 10 pub ingredients: Vec<String>, 11 pub temperature: u32, 12 pub bake_time: u32, 13 }
O roteamento é uma parte incrivelmente importante de uma API. O roteamento permite que o programa direcione a solicitação para o endpoint adequado para servir ou receber os dados. O arquivo
routes.rs
contém todas as rotas definidas na API. Abaixo está um exemplo de uma rota, mas o routes.rs
real precisará incluir suas importações e outros códigos.1 2 pub async fn get_recipes(db: Connection<MainDatabase>) -> Json<Vec<Recipe>> { 3 let recipes: Cursor<Recipe> = db 4 .database("bread") 5 .collection("recipes") 6 .find(None, None) 7 .await 8 .expect("Failed to retrieve recipes"); 9 10 Json(recipes.try_collect().await.unwrap()) 11 }
Não se esqueça de que precisamos adicionar as rotas à função de lançamento principal do Rocket.
Em
main.rs
, as rotas devem ser adicionadas da seguinte forma:1 mod db; 2 mod models; 3 mod routes; 4 5 use rocket::{launch, routes}; 6 use rocket_db_pools::Database; 7 8 9 fn rocket() -> _ { 10 rocket::build().attach(db::MainDatabase::init()).mount( 11 "/", 12 routes![ 13 routes::index, 14 routes::get_recipes, 15 routes::create_recipe, 16 routes::get_recipe 17 ], 18 ) 19 }
Agora que começamos a definir rotas para nossa API, também precisamos nos concentrar no tratamento de erros e nas respostas personalizadas, pois as coisas nem sempre saem conforme o planejado ao consultar, acessar ou criar dados!
Vamos modificar a rota
get_recipes()
simples mostrada acima para evitar pânico e retornar códigos de status HTTP personalizados.Para fazer isso, podemos aproveitar as estruturas
status::Custom
doRocket, que nos permitem especificar o código de status HTTP a ser retornado, bem como nossos dados. Para começar, modifique o tipo de retorno para status::Custom<Json<Value>>
. Isso permite que o compilador do Rust saiba que retornaremos a estrutura de resposta HTTP personalizada doRocket, que inclui um código de status HTTP e um valor serde que podem ser serializados em JSON.Iremos aproveitar a estrutura de resposta HTTP personalizada para cada uma das nossas rotas. Dessa forma, lidamos com quaisquer erros ou pânicos que possam surgir.
Em nossa rota
create_recipe()
, só temos duas possibilidades, supondo que nossa instância do MongoDB esteja funcionando corretamente: a receita é criada com sucesso (retorno HTTP 201 [Criado]) ou a receita não pôde ser criada, indicando uma solicitação inválida (retornar HTTP 400 [Solicitação Inválida]).1 2 pub async fn create_recipe( 3 db: Connection<MainDatabase>, 4 data: Json<Recipe>, 5 ) -> status::Custom<Json<Value>> { 6 if let Ok(res) = db 7 .database("bread") 8 .collection::<Recipe>("recipes") 9 .insert_one(data.into_inner(), None) 10 .await 11 { 12 if let Some(id) = res.inserted_id.as_object_id() { 13 return status::Custom( 14 Status::Created, 15 Json( 16 json!({"status": "success", "message": format!("Recipe ({}) created successfully", id.to_string())}), 17 ), 18 ); 19 } 20 } 21 22 status::Custom( 23 Status::BadRequest, 24 Json(json!({"status": "error", "message":"Recipe could not be created"})), 25 ) 26 }
A rota
get_recipes()
também tem apenas duas possibilidades realistas: retornar o vetor de receitas encontrado na instância do MongoDB ou retornar um vetor vazio, que não contém receitas (seja porque não há receitas na coleção ou porque a consulta falhou). Devido a esses resultados esperados, retornarei um tipoJson<Vec<Recipe>>
, pois sempre retornaremos um vetor, mesmo que ele esteja vazio, com um status HTTP 200.1 2 pub async fn get_recipes(db: Connection<MainDatabase>) -> Json<Vec<Recipe>> { 3 let recipes = db 4 .database("bread") 5 .collection("recipes") 6 .find(None, None) 7 .await; 8 9 if let Ok(r) = recipes { 10 if let Ok(collected) = r.try_collect::<Vec<Recipe>>().await { 11 return Json(collected); 12 } 13 } 14 15 return Json(vec![]); 16 }
O
get_recipe()
é um pouco mais complicado, pois podemos encontrar alguns erros ou áreas diferentes que podem entrar em pânico. Precisamos levar em conta o seguinte: um document_id
incorreto fornecido (retorno HTTP 400 [Solicitação incorreta]), uma receita encontrada por meio do document_id
(retorno HTTP 200 [Ok]) ou uma receita que não está sendo encontrado por meio do document_id
(retornar HTTP 404 [NotFound]).Como temos inumeras possibilidades, estou retornando um tipo de
status::Custom<Json<Value>>
. Fornecerei um status de sucesso para a própria query no campo de status, uma mensagem se os dados não forem encontrados/atualizados e um campo de dados contendo os dados se eles forem encontrados.1 2 pub async fn get_recipe(db: Connection<MainDatabase>, id: &str) -> status::Custom<Json<Value>> { 3 let b_id = ObjectId::parse_str(id); 4 5 if b_id.is_err() { 6 return status::Custom( 7 Status::BadRequest, 8 Json(json!({"status": "error", "message":"Recipe ID is invalid"})), 9 ); 10 } 11 12 if let Ok(Some(recipe)) = db 13 .database("bread") 14 .collection::<Recipe>("recipes") 15 .find_one(doc! {"_id": b_id.unwrap()}, None) 16 .await 17 { 18 return status::Custom( 19 Status::Ok, 20 Json(json!({"status": "success", "data": recipe})), 21 ); 22 } 23 24 return status::Custom( 25 Status::NotFound, 26 Json(json!({"status": "success", "message": "Recipe not found"})), 27 ); 28 }
Existem algumas maneiras de abordar a atualização de registros por meio da API. Por meio do PUT, o usuário fornece o identificador e todo o documento de substituição.
1 2 pub async fn update_recipe( 3 db: Connection<MainDatabase>, 4 data: Json<Recipe>, 5 id: &str, 6 ) -> status::Custom<Json<Value>> { 7 let b_id = ObjectId::parse_str(id); 8 9 if b_id.is_err() { 10 return status::Custom( 11 Status::BadRequest, 12 Json(json!({"status": "error", "message":"Recipe ID is invalid"})), 13 ); 14 } 15 16 if let Ok(_) = db 17 .database("bread") 18 .collection::<Recipe>("recipes") 19 .update_one( 20 doc! {"_id": b_id.as_ref().unwrap()}, 21 doc! {"$set": mongodb::bson::to_document(&data.into_inner()).unwrap()}, 22 None, 23 ) 24 .await 25 { 26 return status::Custom( 27 Status::Created, 28 Json( 29 json!({"status": "success", "message": format!("Recipe ({}) updated successfully", b_id.unwrap())}), 30 ), 31 ); 32 }; 33 34 status::Custom( 35 Status::BadRequest, 36 Json( 37 json!({"status": "success", "message": format!("Recipe ({}) could not be updated successfully", b_id.unwrap())}), 38 ), 39 ) 40 }
Nem toda API será usada para o método DELETE e para excluir entidades do banco de dados, mas eu queria incluí-la para a posteridade e mostrar como implementá-la com o Rocket. Essa implementação pressupõe que se conheça o identificador BSON do objeto, mas você pode criar a rota e a função para excluir como achar melhor.
1 2 pub async fn delete_recipe(db: Connection<MainDatabase>, id: &str) -> status::Custom<Json<Value>> { 3 let b_id = ObjectId::parse_str(id); 4 5 if b_id.is_err() { 6 return status::Custom( 7 Status::BadRequest, 8 Json(json!({"status": "error", "message":"Recipe ID is invalid"})), 9 ); 10 } 11 12 if db 13 .database("bread") 14 .collection::<Recipe>("recipes") 15 .delete_one(doc! {"_id": b_id.as_ref().unwrap()}, None) 16 .await 17 .is_err() 18 { 19 return status::Custom( 20 Status::BadRequest, 21 Json( 22 json!({"status": "error", "message":format!("Recipe ({}) could not be deleted", b_id.unwrap())}), 23 ), 24 ); 25 }; 26 27 status::Custom( 28 Status::Accepted, 29 Json( 30 json!({"status": "", "message": format!("Recipe ({}) successfully deleted", b_id.unwrap())}), 31 ), 32 ) 33 }
Se você quiser testar os endpoints por meio de curl ou wget, estou incluindo os seguintes comandos de curl para mostrar a funcionalidade da API. Analisaremos os testes completos da API em uma futura postagem do blog:).
1 curl -v --header "Content-Type: application/json" --request POST --data '{"title":"simple bread recipe","ingredients":["water, flour"], "temperature": 250, "bake_time": 120}' http://localhost:8000/recipes
1 curl -v --header "Content-Type: application/json" \ 2 --request PUT --data '{"title":"new, updated title!","ingredients":["water", "flour", "salt", "sugar"], "temperature": 440, "bake_time": 60}' \ 3 http://localhost:8000/recipes/<_id>
1 curl -v --header "Content-Type: application/json" --header "Accept: application/json" http://localhost:8000/recipes/ 2 3 curl -v --header "Content-Type: application/json" --header "Accept: application/json" http://localhost:8000/recipes/<_id>
1 curl -v --header "Content-Type: application/json" --header "Accept: application/json" --request DELETE http://localhost:8000/recipes/<_id>
Se você estiver interessado em ver o resultado completo e o repositório de todo o código no total, poderá encontrar isso no meu GitHub. E se você tiver alguma dúvida ou quiser compartilhar seu trabalho, junta-se a nós na Comunidade de desenvolvedores do MongoDB.