Construindo uma API em Rust com Rocket.rs e Diesel.rs (Clean Architecture)
Neste guia, vou orientá-lo no processo de criação de uma API CRUD simples do zero em Rust usando Rocket.rs. Mostrarei como criar migrações e acessar um banco de dados PostgreSQL usando Diesel.rs e conectar tudo a um front-end React + Typescript. Ao construir o projeto, seguiremos a Arquitetura Limpa, embora não vá falar muito sobre o que é, pois não é o foco deste guia.
Este guia pressupõe:
- Você já tem uma configuração de banco de dados PostgreSQL
- Você tem a versão mais recente do Rust (este guia usa v1.65.0)
- Você tem uma compreensão básica a decente dos conceitos Rust e da sintaxe da linguagem
Construindo a arquitetura do projeto
O primeiro passo é configurar a arquitetura do aplicativo. Comece criando um projeto Rust abrangente:
cargo new rust-blog
cd rust-blog
- A Camada de API manipulará as solicitações de API e atuará como nosso manipulador de rotas.
- A camada de aplicativo lidará com a lógica por trás das solicitações de API.
- A camada de domínio manterá nossos modelos e esquemas de banco de dados.
- A camada de infraestrutura manterá nossas migrações e conexões de banco de dados.
- A camada compartilhada conterá quaisquer outros modelos que nosso projeto precise, como estruturas de resposta.
cargo new api --lib
cargo new application --lib
cargo new domain --lib
cargo new infrastructure --lib
cargo new shared --lib
.
├── Cargo.lock
├── Cargo.toml
├── api
│ ├── Cargo.toml
│ └── src
│ └── lib.rs
├── application
│ ├── Cargo.toml
│ └── src
│ └── lib.rs
├── domain
│ ├── Cargo.toml
│ └── src
│ └── lib.rs
├── infrastructure
│ ├── Cargo.toml
│ └── src
│ └── lib.rs
└── shared
├── Cargo.toml
└── src
└── lib.rs
[workspace]
members = [
"api",
"domain",
"infrastructure",
"application",
"shared",
]
Como estamos usando o Diesel.rs como gerenciador de banco de dados, precisaremos instalar a ferramenta CLI . O Diesel CLI possui algumas dependências que precisam ser instaladas previamente, dependendo do banco de dados que você planeja usar:
libpq
para PostgreSQLlibmysqlclient
para Mysqllibsqlite3
para SQlite
Com libpq
instalado, agora podemos executar o seguinte comando para instalar o Diesel CLI:
cargo install diesel_cli --no-default-features --features postgres
echo DATABASE_URL=postgres://username:password@localhost/blog > .env
diesel setup
- Uma pasta de migrações usada para armazenar todas as migrações
- Uma migração vazia que podemos usar para gerenciar nosso esquema de banco de dados.
diesel migration generate create_posts
Agora, vamos escrever um pouco de SQL para as migrações.
-- up.sql
CREATE TABLE posts (
id SERIAL PRIMARY KEY,
title VARCHAR NOT NULL,
body TEXT NOT NULL,
genre VARCHAR NOT NULL,
published BOOLEAN NOT NULL DEFAULT false
)
-- down.sql
DROP TABLE posts
diesel migration run
Criando uma conexão
Com nosso primeiro conjunto de migrações concluído e nossa arquitetura de projeto definida, vamos finalmente escrever algum código Rust para conectar nosso aplicativo ao banco de dados.
# infrastructure/Cargo.toml
[package]
name = "infrastructure"
version = "0.1.0"
edition = "2021"
[dependencies]
diesel = { version = "2.0.0", features = ["postgres"] }
dotenvy = "0.15"
// infrastructure/src/lib.rs
use diesel::pg::PgConnection;
use diesel::prelude::*;
use dotenvy::dotenv;
use std::env;
pub fn establish_connection() -> PgConnection {
dotenv().ok();
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set.");
PgConnection::establish(&database_url).unwrap_or_else(|_| panic!("Error connecting to {}", database_url))
}
Modelos e Esquemas
Comece navegando domain
e adicionando os seguintes módulos ao arquivo lib.rs
.
// domain/src/lib.rs
pub mod models;
pub mod schema;
# domain/Cargo.toml
[package]
name = "domain"
version = "0.1.0"
edition = "2021"
[dependencies]
rocket = { version = "0.5.0-rc.2", features = ["json"] }
diesel = { version = "2.0.0", features = ["postgres"] }
serde = { version = "1.0.147", features = ["derive"] }
// domain/src/models.rs
use crate::schema::posts;
use diesel::prelude::*;
use rocket::serde::{Deserialize, Serialize};
use std::cmp::{Ord, Eq, PartialOrd, PartialEq};
// Queryable will generate the code needed to load the struct from an SQL statement
#[derive(Queryable, Serialize, Ord, Eq, PartialEq, PartialOrd)]
pub struct Post {
pub id: i32,
pub title: String,
pub body: String,
pub genre: String,
pub published: bool,
}
#[derive(Insertable, Deserialize)]
#[serde(crate = "rocket::serde")]
#[diesel(table_name = posts)]
pub struct NewPost {
pub title: String,
pub body: String,
pub genre: String,
}
// domain/src/schema.rs
// @generated automatically by Diesel CLI.
diesel::table! {
posts (id) {
id -> Int4,
title -> Varchar,
body -> Text,
genre -> Varchar,
published -> Bool,
}
}
Além de definir modelos de banco de dados, vamos criar um modelo para estruturar como nossas respostas de API serão formatadas. Navegue shared/src
e crie um novo arquivo response_models.rs
.
# shared/Cargo.toml
[package]
name = "shared"
version = "0.1.0"
edition = "2021"
[dependencies]
domain = { path = "../domain" }
rocket = { version = "0.5.0-rc.2", features = ["json"] }
serde = { version = "1.0.147", features = ["derive"] }
// shared/src/lib.rs
pub mod response_models;
// shared/src/response_models.rs
use domain::models::Post;
use rocket::serde::Serialize;
#[derive(Serialize)]
pub enum ResponseBody {
Message(String),
Post(Post),
Posts(Vec<Post>)
}
#[derive(Serialize)]
#[serde(crate = "rocket::serde")]
pub struct Response {
pub body: ResponseBody,
}
Configurando o Rocket.rs
Uau! Isso foi muita configuração apenas para nosso banco de dados, apenas para estarmos todos atualizados, aqui está como a estrutura do projeto deve ficar atualmente:
.
├── Cargo.lock
├── Cargo.toml
├── api
│ └── ...
├── application
│ └── ...
├── domain
│ ├── Cargo.toml
│ └── src
│ ├── lib.rs
│ └── models.rs
├── infrastructure
│ ├── Cargo.toml
│ ├── migrations
│ │ └── 2022–11–18–090125_create_posts
│ │ ├── up.sql
│ │ └── down.sql
│ └── src
│ ├── lib.rs
│ └── schema.rs
└── shared
├── Cargo.toml
└── src
├── lib.rs
└── response_models.rs
Navegue api
e importe as seguintes dependências:
# api/Cargo.toml
[package]
name = "api"
version = "0.1.0"
edition = "2021"
[dependencies]
domain = { path = "../domain" }
application = { path = "../application" }
shared = { path = "../shared" }
rocket = { version = "0.5.0-rc.2", features = ["json"] }
serde_json = "1.0.88"
.
└── api
├── Cargo.toml
└── src
├── bin
│ └── main.rs
└── lib.rs
// api/src/lib.rs
pub mod post_handler;
// api/src/bin/main.rs
#[macro_use] extern crate rocket;
use api::post_handler;
#[launch]
fn rocket() -> _ {
rocket::build()
.mount("/api", routes![
post_handler::list_posts_handler,
post_handler::list_post_handler,
])
}
Crie um novo arquivo chamado e escreva post_handler.rs
o src
seguinte código de modelo:
// api/src/post_handler.rs
use shared::response_models::{Response, ResponseBody};
use application::post::{read};
use domain::models::{Post};
use rocket::{get};
use rocket::response::status::{NotFound};
use rocket::serde::json::Json;
#[get("/")]
pub fn list_posts_handler() -> String {
todo!()
}
#[get("/post/<post_id>")]
pub fn list_post_handler(post_id: i32) -> Result<String, NotFound<String>> {
todo!()
}
- GET /api/ (usado para listar todos os posts)
- GET /api/post/<post_id> (usado para listar uma postagem por id)
Com os manipuladores de solicitação modelados, vamos escrever a lógica necessária para as rotas. Dentro de application
, crie uma nova pasta chamada post
. Esta pasta conterá um arquivo para cada uma das nossas rotas lógicas.
# application/Cargo.toml
[package]
name = "application"
version = "0.1.0"
edition = "2021"
[dependencies]
domain = { path = "../domain" }
infrastructure = { path = "../infrastructure" }
shared = { path = "../shared" }
diesel = { version = "2.0.0", features = ["postgres"] }
serde_json = "1.0.88"
rocket = { version = "0.5.0-rc.2", features = ["json"] }
// application/src/lib.rs
pub mod post;
// application/src/post/mod.rs
pub mod read;
// application/src/post/read.rs
use domain::models::Post;
use shared::response_models::{Response, ResponseBody};
use infrastructure::establish_connection;
use diesel::prelude::*;
use rocket::response::status::NotFound;
pub fn list_post(post_id: i32) -> Result<Post, NotFound<String>> {
use domain::schema::posts;
match posts::table.find(post_id).first::<Post>(&mut establish_connection()) {
Ok(post) => Ok(post),
Err(err) => match err {
diesel::result::Error::NotFound => {
let response = Response { body: ResponseBody::Message(format!("Error selecting post with id {} - {}", post_id, err))};
return Err(NotFound(serde_json::to_string(&response).unwrap()));
},
_ => {
panic!("Database error - {}", err);
}
}
}
}
pub fn list_posts() -> Vec<Post> {
use domain::schema::posts;
match posts::table.select(posts::all_columns).load::<Post>(&mut establish_connection()) {
Ok(mut posts) => {
posts.sort();
posts
},
Err(err) => match err {
_ => {
panic!("Database error - {}", err);
}
}
}
}
Com a lógica para nossa rota escrita, vamos retornar ao nosso manipulador de postagem para finalizar nossas duas rotas GET.
// api/src/post_handler.rs
// ...
#[get("/")]
pub fn list_posts_handler() -> String {
// New function body!
let posts: Vec<Post> = read::list_posts();
let response = Response { body: ResponseBody::Posts(posts) };
serde_json::to_string(&response).unwrap()
}
#[get("/post/<post_id>")]
pub fn list_post_handler(post_id: i32) -> Result<String, NotFound<String>> {
// New function body!
let post = read::list_post(post_id)?;
let response = Response { body: ResponseBody::Post(post) };
Ok(serde_json::to_string(&response).unwrap())
}
Vamos mudar isso.
Criando postagens
Como antes, começaremos modelando o manipulador de rotas. Esta será uma solicitação POST que aceitará dados JSON.
// api/src/post_handler.rs
use shared::response_models::{Response, ResponseBody};
use application::post::{create, read}; // New!
use domain::models::{Post, NewPost}; // New!
use rocket::{get, post}; // New!
use rocket::response::status::{NotFound, Created}; // New!
use rocket::serde::json::Json;
// ...
#[post("/new_post", format = "application/json", data = "<post>")]
pub fn create_post_handler(post: Json<NewPost>) -> Created<String> {
create::create_post(post)
}
// application/src/post/mod.rs
pub mod read;
pub mod create; // New!
// application/src/post/create.rs
use domain::models::{Post, NewPost};
use shared::response_models::{Response, ResponseBody};
use infrastructure::establish_connection;
use diesel::prelude::*;
use rocket::response::status::Created;
use rocket::serde::json::Json;
pub fn create_post(post: Json<NewPost>) -> Created<String> {
use domain::schema::posts;
let post = post.into_inner();
match diesel::insert_into(posts::table).values(&post).get_result::<Post>(&mut establish_connection()) {
Ok(post) => {
let response = Response { body: ResponseBody::Post(post) };
Created::new("").tagged_body(serde_json::to_string(&response).unwrap())
},
Err(err) => match err {
_ => {
panic!("Database error - {}", err);
}
}
}
}
// api/src/bin/main.rs
#[macro_use] extern crate rocket;
use api::post_handler;
#[launch]
fn rocket() -> _ {
rocket::build()
.mount("/api", routes![
post_handler::list_posts_handler,
post_handler::list_post_handler,
post_handler::create_post_handler, // New!
])
}
Teste CR__
Com duas das nossas quatro letras implementadas, vamos fazer um pequeno teste. Navegue de volta ao diretório raiz e execute o aplicativo.
cargo run


As duas letras finais
As duas últimas operações de que precisamos são atualização e exclusão. Implementaremos a atualização por meio da “publicação” de uma postagem e a exclusão, bem... da exclusão de uma postagem.
Como nas duas últimas letras, vamos criar nossos manipuladores.
// api/src/post_handler.rs
use shared::response_models::{Response, ResponseBody};
use application::post::{create, read, publish, delete}; // New!
use domain::models::{Post, NewPost};
use rocket::{get, post};
use rocket::response::status::{NotFound, Created};
use rocket::serde::json::Json;
// ...
#[get("/publish/<post_id>")]
pub fn publish_post_handler(post_id: i32) -> Result<String, NotFound<String>> {
let post = publish::publish_post(post_id)?;
let response = Response { body: ResponseBody::Post(post) };
Ok(serde_json::to_string(&response).unwrap())
}
#[get("/delete/<post_id>")]
pub fn delete_post_handler(post_id: i32) -> Result<String, NotFound<String>> {
let posts = delete::delete_post(post_id)?;
let response = Response { body: ResponseBody::Posts(posts) };
Ok(serde_json::to_string(&response).unwrap())
}
// application/src/post/mod.rs
pub mod create;
pub mod read;
pub mod publish; // New!
pub mod delete; // New!
// application/src/post/publish.rs
use domain::models::Post;
use shared::response_models::{Response, ResponseBody};
use infrastructure::establish_connection;
use rocket::response::status::NotFound;
use diesel::prelude::*;
pub fn publish_post(post_id: i32) -> Result<Post, NotFound<String>> {
use domain::schema::posts::dsl::*;
match diesel::update(posts.find(post_id)).set(published.eq(true)).get_result::<Post>(&mut establish_connection()) {
Ok(post) => Ok(post),
Err(err) => match err {
diesel::result::Error::NotFound => {
let response = Response { body: ResponseBody::Message(format!("Error publishing post with id {} - {}", post_id, err))};
return Err(NotFound(serde_json::to_string(&response).unwrap()));
},
_ => {
panic!("Database error - {}", err);
}
}
}
}
// application/src/post/delete.rs
use shared::response_models::{Response, ResponseBody};
use infrastructure::establish_connection;
use diesel::prelude::*;
use rocket::response::status::NotFound;
use domain::models::Post;
pub fn delete_post(post_id: i32) -> Result<Vec<Post>, NotFound<String>> {
use domain::schema::posts::dsl::*;
use domain::schema::posts;
let response: Response;
let num_deleted = match diesel::delete(posts.filter(id.eq(post_id))).execute(&mut establish_connection()) {
Ok(count) => count,
Err(err) => match err {
diesel::result::Error::NotFound => {
let response = Response { body: ResponseBody::Message(format!("Error deleting post with id {} - {}", post_id, err))};
return Err(NotFound(serde_json::to_string(&response).unwrap()));
},
_ => {
panic!("Database error - {}", err);
}
}
};
if num_deleted > 0 {
match posts::table.select(posts::all_columns).load::<Post>(&mut establish_connection()) {
Ok(mut posts_) => {
posts_.sort();
Ok(posts_)
},
Err(err) => match err {
_ => {
panic!("Database error - {}", err);
}
}
}
} else {
response = Response { body: ResponseBody::Message(format!("Error - no post with id {}", post_id))};
Err(NotFound(serde_json::to_string(&response).unwrap()))
}
}
// api/src/bin/main.rs
#[macro_use] extern crate rocket;
use api::post_handler;
#[launch]
fn rocket() -> _ {
rocket::build()
.mount("/api", routes![
post_handler::list_posts_handler,
post_handler::list_post_handler,
post_handler::create_post_handler,
post_handler::publish_post_handler, // New!
post_handler::delete_post_handler, // New!
])
}
Seu projeto agora deve se parecer com o seguinte:
.
├── Cargo.lock
├── Cargo.toml
├── api
│ ├── Cargo.toml
│ └── src
│ ├── bin
│ │ └── main.rs
│ ├── lib.rs
│ └── post_handler.rs
├── application
│ ├── Cargo.toml
│ └── src
│ ├── lib.rs
│ └── post
│ ├── create.rs
│ ├── delete.rs
│ ├── mod.rs
│ ├── publish.rs
│ └── read.rs
├── domain
│ ├── Cargo.toml
│ └── src
│ ├── lib.rs
│ ├── models.rs
│ └── schema.rs
├── infrastructure
│ ├── Cargo.toml
│ ├── migrations
│ │ └── 2022–11–18–090125_create_posts
│ │ ├── up.sql
│ │ └── down.sql
│ └── src
│ └── lib.rs
└── shared
├── Cargo.toml
└── src
├── lib.rs
└── response_models.rs
Existem algumas coisas que podem ser melhoradas quando se olha para o aplicativo como um todo.
Em primeiro lugar, sempre que queremos usar o banco de dados, abrimos uma nova conexão. Isso pode se tornar caro e consumir muitos recursos quando em uma escala maior. Uma maneira de corrigir isso é usando um pool de conexão, o Rocket.rs inclui suporte integrado para R2D2 , um manipulador de pool de conexão para Rust.
Em segundo lugar, o Diesel.rs não é assíncrono - isso não é um grande problema nessa escala. No entanto, pode se tornar um problema maior para aplicativos maiores. No momento, não há implementação assíncrona da equipe oficial por trás do Diesel.rs. Como alternativa, uma caixa externa está disponível para fornecer essa funcionalidade.
Por fim, uma IU de front-end pode ser criada junto com a Rust API. Dentro do diretório raiz, você criaria um novo projeto chamado web_ui
usando a linguagem de front-end de sua escolha. Tudo o que você precisa fazer é executar os dois projetos separadamente, chamando a API Rust do seu cliente front-end. Aqui está minha implementação de um front-end para alguma inspiração:

Conclusão
Ufa! Que jornada. Não apenas aprendemos como usar Rocket.rs e Diesel.rs, mas também como usá-los juntos para criar uma API de blog em Rust. Junto com isso, construímos um front-end para ele e empacotamos tudo junto em um único arquivo de projeto seguindo a Arquitetura Limpa.
Todo o código junto com minha implementação do front-end pode ser encontrado aqui:https://github.com/BrookJeynes/blog-rust
Espero que vocês tenham aprendido muito hoje e experimentem o processo e criem algo novo! Certifique-se de marcar o repositório do Github com estrela e deixe-me saber o que devo abordar a seguir ou qualquer feedback que você tiver.
Obrigado por ler,
- Brook ❤
Referências
Caixotes:
- Diesel.rs
- foguete.rs
- Serde-rs/serde
- Serde-rs/json
- Diesel.rs — Primeiros passos
- Rocket.rs — Primeiros passos
- A Simple Crud on Rust (Com Rocket.rs e Diesel.rs) (Usa pacotes obsoletos para a versão atual do rust (v1.65.0) )
- Muitas postagens do Stackoverflow para mencionar