Construyendo una API en Rust con Rocket.rs y Diesel.rs (Arquitectura Limpia)
En esta guía, lo guiaré a través del proceso de creación de una API CRUD simple desde cero en Rust usando Rocket.rs. Le mostraré cómo crear migraciones y acceder a una base de datos PostgreSQL usando Diesel.rs y conectar todo hasta un front-end React + Typescript. Cuando construyamos el proyecto seguiremos la arquitectura limpia, aunque no voy a hablar demasiado sobre lo que es, ya que no es el enfoque de esta guía.
Esta guía asume:
- Ya tiene una configuración de base de datos PostgreSQL
- Tienes la última versión de Rust (esta guía usa v1.65.0)
- Tiene una comprensión de básica a decente de los conceptos de Rust y la sintaxis del lenguaje.
Construyendo la arquitectura del proyecto.
El primer paso es configurar la arquitectura de la aplicación. Comience por crear un proyecto general de Rust:
cargo new rust-blog
cd rust-blog
- API Layer manejará las solicitudes de API y actuará como nuestro controlador de ruta.
- La capa de aplicación manejará la lógica detrás de las solicitudes de API.
- La capa de dominio contendrá nuestros modelos y esquemas de base de datos.
- La capa de infraestructura sostendrá nuestras migraciones y conexiones de bases de datos.
- La capa compartida contendrá cualquier otro modelo que necesite nuestro proyecto, como estructuras de respuesta.
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",
]
Dado que estamos utilizando Diesel.rs como administrador de la base de datos, necesitaremos instalar la herramienta CLI . Diesel CLI tiene algunas dependencias que deben instalarse de antemano según la base de datos que planee usar:
libpq
para PostgresSQLlibmysqlclient
para mysqllibsqlite3
para SQLite
Con libpq
instalado, ahora podemos ejecutar el siguiente comando para instalar Diesel CLI:
cargo install diesel_cli --no-default-features --features postgres
echo DATABASE_URL=postgres://username:password@localhost/blog > .env
diesel setup
- Una carpeta de migraciones utilizada para almacenar todas las migraciones
- Una migración vacía que podemos usar para administrar nuestro esquema de base de datos.
diesel migration generate create_posts
Ahora, avancemos y escribamos algo de SQL para las migraciones.
-- 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
Crear una conexión
Con nuestro primer conjunto de migraciones terminado y la arquitectura de nuestro proyecto diseñada, finalmente escribamos algo de código Rust para conectar nuestra aplicación a la base de datos.
# 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 y Esquemas
Comience por navegar domain
y agregar los siguientes módulos a 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,
}
}
Además de definir modelos de base de datos, creemos un modelo para estructurar cómo se formatearán nuestras respuestas API. Navegue shared/src
y cree un nuevo archivo 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,
}
Configuración de Rocket.rs
¡Guau! Esa fue una gran cantidad de configuración solo para nuestra base de datos, solo para que todos estemos actualizados, así es como debería verse la estructura del proyecto actualmente:
.
├── 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 las siguientes dependencias:
# 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,
])
}
Cree un nuevo archivo llamado post_handler.rs
y src
escriba el siguiente código de plantilla:
// 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 enumerar todas las publicaciones)
- GET /api/post/<post_id> (usado para enumerar una publicación por id)
Con los controladores de solicitudes en plantilla, escribamos la lógica necesaria para las rutas. Dentro de application
, crea una nueva carpeta llamada post
. Esta carpeta contendrá un archivo para cada una de nuestras rutas 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);
}
}
}
}
Con la lógica de nuestra ruta escrita, regresemos a nuestro controlador de publicación para finalizar nuestras dos rutas 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())
}
Cambiemos eso.
Creación de publicaciones
Como antes, comenzaremos con la plantilla del controlador de ruta. Esta será una solicitud POST que aceptará datos 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!
])
}
Prueba CR__
Con dos de nuestras cuatro letras implementadas, hagamos una pequeña prueba. Vuelva al directorio raíz y ejecute la aplicación.
cargo run


Las dos letras finales
Las últimas dos operaciones que necesitamos son actualizar y eliminar. Implementaremos la actualización mediante la "publicación" de una publicación y la eliminación, bueno... la eliminación de una publicación.
Al igual que con las dos letras anteriores, creemos nuestros controladores.
// 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!
])
}
Su proyecto ahora debería parecerse a lo siguiente:
.
├── 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
Hay algunas cosas que podrían mejorarse al mirar la aplicación como un todo.
En primer lugar, siempre que queramos usar la base de datos, abrimos una nueva conexión. Esto puede volverse costoso e intensivo en recursos cuando se realiza a mayor escala. Una forma en que esto podría solucionarse es mediante el uso de un grupo de conexiones, Rocket.rs incluye soporte integrado para R2D2 , un controlador de grupo de conexiones para Rust.
En segundo lugar, Diesel.rs no es asíncrono; esto no es un gran problema en esta escala. Sin embargo, puede convertirse en un problema mayor para aplicaciones más grandes. Al momento de escribir, no hay una implementación asincrónica del equipo oficial detrás de Diesel.rs. Como alternativa, hay disponible una caja externa para proporcionar esta funcionalidad.
Finalmente, se podría crear una interfaz de usuario de front-end junto con la API de Rust. Dentro del directorio raíz, crearía un nuevo proyecto llamado web_ui
utilizando el idioma de interfaz de su elección. Todo lo que necesita hacer es ejecutar ambos proyectos por separado, llamando a la API de Rust desde su cliente front-end. Aquí está mi implementación de un front-end para inspirarte:

Conclusión
¡Uf! ¡Qué viaje! No solo hemos aprendido a usar Rocket.rs y Diesel.rs, sino que también hemos aprendido a usarlos juntos para crear una API de blogs en Rust. Junto con eso, creamos un front-end para él y lo empaquetamos todo junto en un solo archivo de proyecto siguiendo Clean Architecture.
Todo el código junto con mi implementación del front-end se puede encontrar aquí:https://github.com/BrookJeynes/blog-rust
¡Espero que hayan aprendido mucho hoy, y prueben el proceso ustedes mismos y creen algo nuevo! Asegúrese de destacar el repositorio de Github y déjeme saber qué debo cubrir a continuación o cualquier comentario que tenga.
Gracias por leer,
- Brook ❤
Referencias
Cajas:
- Diesel.rs
- Cohete.rs
- Serde-rs/serde
- Serde-rs/json
- Diesel.rs — Primeros pasos
- Rocket.rs — Primeros pasos
- A Simple Crud on Rust (Con Rocket.rs y Diesel.rs) (Usa paquetes en desuso para la versión actual de rust (v1.65.0) )
- Demasiadas publicaciones de Stackoverflow para mencionar