Construindo uma API em Rust com Rocket.rs e Diesel.rs (Clean Architecture)

Dec 02 2022
Construindo um aplicativo de blog simples usando Rust + Rocket.rs + Diesel.rs seguindo a Clean Architecture.
Neste guia, vou orientá-lo no processo de criação de uma API CRUD simples do zero em Rust usando Rocket.rs.

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:

  • libpqpara PostgreSQL
  • libmysqlclientpara Mysql
  • libsqlite3para SQlite

Com libpqinstalado, 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 domaine 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/srce 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 apie 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.rso srcseguinte 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!()
}

  1. GET /api/ (usado para listar todos os posts)
  2. 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

Figura: GET / trabalhando como pretendido
Figura: POST /new_post funcionando como pretendido

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_uiusando 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:

Figura: minha implementação da interface do usuário front-end

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