cargo new rocket_example
Rocket per sviluppatori Spring Boot
Dimostrazione di utilizzo di Rocket, web framework di Rust
Introduzione
Cos’è Rocket?
Rocket è uno dei più conosciuti e apprezzati web framework di Rust per lo sviluppo di API che fanno delle performance il loro punto chiave.
È caratterizzato da una grande praticità d’uso, mettendo a disposizione diversi strumenti per semplificare molto l’esperienza di scrittura Rust.
Diverse funzionalità di questo framework sono riprese da altri framework, come Spring Boot, perciò quest’articolo avrà dei paragoni diretti con quest’ultimo per rendere più semplice la comprensione per chi sa già come creare una web-api con Spring Boot e vorrebbe iniziare ad utilizzare Rocket.
Prerequisiti
Per la prosecuzione di questo articolo è consigliata la conoscenza base di Rust, del suo module system e del suo package manager “Cargo”.
Cercherò di utilizzare pattern, architetture e denominazioni quanto più simili a quelle di Spring Boot, per far sì che possiate averne un paragone più diretto.
Dimostrazione
Setup del progetto
Innanzitutto creiamo il nostro progetto Rust:
Poi possiamo aggiungere Rocket:
cargo add rocket
Modifichiamo il main in questo modo:
#[launch] fn rocket() -> _ { rocket::build() }
“launch” è una macro che compila la funzione a cui è assegnata in modo che diventi il main della nostra applicazione.
All’interno di questa funzione deve esserci necessariamente un’istanza dell’oggetto Rocket, creabile e configurabile tramite builder pattern.
Successivamente creiamo il file “lib.rs” e inseriamo al suo interno i moduli principali che verranno usati nell’esempio:
pub mod controllers; pub mod models; pub mod connections; pub mod repositories; pub mod dto;
Gli endpoint
Per testare il funzionamento basilare di Rocket creiamo subito un sottomodulo “test_controller” all’interno del modulo “controller”.
Spring Boot permette di creare dei REST controller attraverso la notation @RestController, da applicare ad una classe che conterrà poi i vari endpoint.
Rocket d’altro canto non permette di annotare un “gruppo di endpoint”, perdendo semplicità d’uso ma fornendo flessibilità aggiuntiva in quanto non costretti da una specifica architettura.
Creiamo quindi il nostro primo endpoint:
#[get("/")] pub fn hello_rocket() -> String { String::from("Hello from Rocket!") }
Il funzionamento è molto semplice: si annota la funzione con la macro relativa al metodo della richiesta, in maniera molto similare a Spring Boot, Rust si occuperà del resto.
Spring Boot ha un routing di default che applica agli endpoint, Rocket non ne fornisce, quindi abbiamo la necessità di definirli.
Per usare questo endpoint all’applicazione perciò dobbiamo “montarlo” ad una route tramite l’oggetto Rocket definito nel main:
#[launch] fn rocket() -> _ { rocket::build().mount("/", routes![hello_rocket]) }
La funzione “mount” appartenente a Rocket prende in input due valori:
Una route di base;
Una lista di routes da applicare a quella di base;
In particolare la lista di routes è gestita dalla macro “routes!” che accetta come suoi elementi solo funzioni annotate con le endpoint-macro.
Per avviare l’applicazione eseguire il comando “cargo run” da terminale.
È possibile gestire anche dei percorsi dinamici tramite l’utilizzo di path params, guardate quest’esempio:
#[get("/<name>")] pub fn hello(name: &str) -> String { format!("Hello, {}!", name) }
Semplice no? Non ci resta che inserire il metodo all’interno delle routes e il gioco è fatto.
I modelli
Iniziamo quindi col creare il modello della nostra API, aggiungiamo il sottomodulo “user” in “models” e scriviamo quanto segue:
pub struct User { pub id: Option<i32>, pub name: String, pub email: String, pub password: String, pub phone: Option<String>, }
L’entità è basilare, un id numerico opzionale (in quanto, nel nostro esempio, assegnazione e serialità verranno gestiti da Postgres), un nome, una mail, una password e un numero di telefono opzionale.
Andiamo ora a creare un endpoint per testare le response di Rocket.
Creiamo un sottomodulo “user_controller” e scriviamo questo:
#[get("/test")] pub fn test_user() -> User { User { id: Some(1), name: String::from("Test"), email: String::from("prova@gmail.com"), password: String::from("123456"), phone: Some(String::from("+39 1234567890")), } }
Notiamo subito che il compilatore non è molto felice, in quanto “User” non implementa il trait “Responder”.
Questo perché come output di un endpoint ci deve essere per forza di cose un oggetto che veicoli al suo interno uno statusHTTP, degli headers e un body, similmente al “ResponseEntity” di Spring Boot (in Rocket però è obbligatorio).
Lo scopo del trait “Responder” è quello di generare una risposta HTTP in base alla struttura dati che lo implementa.
Per risolvere questo errore quindi abbiamo due possibilità:
Implementare il trait “Responder” su un nuovo struct che andrà poi a incapsulare “User”;
Incapsulare “User” in un “Responder” fornito da Rocket;
Nel nostro caso andrà più che bene la seconda opzione.
Rocket implementa il trait “Responder” per alcuni tipi della libreria standard come &str, String, Option e Result.
Inoltre fornisce dei “Responder” custom per le varie evenienze e casi d’uso, come ad esempio “Json”, “Template”, “Redirect”, ecc…
Andremo quindi a incapsulare “User” all’interno del “Responder” “Json”, per serializzare automaticamente l’utente, ma prima di fare ciò dobbiamo importare la feature tramite cargo e rendere serializzabile e deserializzabile il nostro struct:
cargo add rocket --features json
annotiamo lo struct “User” in questo modo:
#[derive(Serialize, Deserialize)] #[serde(crate = "rocket::serde")] pub struct User { pub id: Option<i32>, pub name: String, pub email: String, pub password: String, pub phone: Option<String>, }
Ora cambiamo leggermente il nostro endpoint come segue:
#[get("/test")] pub fn test_user() -> Json<User> { User { id: Some(1), name: String::from("Test"), email: String::from("prova@gmail.com"), password: String::from("123456"), phone: Some(String::from("+39 1234567890")), } .into() }
In poche parole ora il nostro metodo restituisce l‘“User” in formato Json (viene convertito con il metodo “into”).
Ora scriviamo un metodo post che prenda come body della richiesta il Json di un “User” e restituisca una stringa con delle informazioni:
#[post("/print", data = "<user>")] pub fn print_user(user: Json<User>) -> String { format!("Hi, I'm {}, my mail is: {}", user.name, user.email) }
Qui vige una regola molto simile a quella del “Responder”, l’oggetto che viene passato nel body deve implementare “FromData” (“RequestBody” in Spring Boot), nel nostro caso “Json” va ancora bene.
Aggiungiamo quindi entrambi i metodi alla route “user”:
#[launch] fn rocket() -> _ { rocket::build() .mount("/", routes![hello_rocket, hello]) .mount("/user", routes![test_user, print_user]) }
Ora potete testare entrambi gli endpoint.
Il database
Iniziamo ora a vedere come comunicare con un database, Postgres nel nostro caso.
Rocket out of the box non fornisce un ORM altamente tipizzato come Spring Boot con Hibernate, l’ecosistema di Rust però ne vanta alcuni veramente validi come ad esempio “https://www.sea-ql.org/SeaORM/[SeaORM]” e “https://diesel.rs/[Diesel]”, tuttavia questi non verranno utilizzati ai fini di questo esempio.
Rocket ci fornisce dei driver per permetterci di utilizzare una pool di connessioni per eseguire comandi tramite SQL al database, come Spring Boot con le “native query”.
Vediamo come fare.
Creiamo innanzitutto la table “users” su un database postgres:
CREATE TABLE users ( id SERIAL PRIMARY KEY, name VARCHAR(50) NOT NULL, email VARCHAR(50) NOT NULL, password VARCHAR(50) NOT NULL, phone VARCHAR(20) )
Ora importiamo i driver necessari (potete trovarli a questa pagina) aggiungendo quanto segue al vostro file “Cargo.toml”:
[dependencies.rocket_db_pools] version = "0.2.0" features = ["deadpool_postgres"]
La feature cambierà a seconda del database che vogliamo utilizzare, in questo caso stiamo usando la feature relativa a postgres.
Successivamente sullo stesso livello del “Cargo.toml” creiamo il file “Rocket.toml” (file di configurazione di Rocket) e scriviamo questo:
[default.databases.utenti] url = "postgresql://postgres:postgres@localhost:5432/users"
Come avrete capito qui va inserito l’url per la connessione al db, ma attenzione: nelle parentesi quadre l’ultima parola è a vostra scelta e farà riferimento a questo url di connessione.
Ora creiamo un sottomodulo “postgres” nel modulo “connection”.
Qui andrà creata la struttura dati che gestirà la connessione:
#[derive(Database)] #[database("utenti")] pub struct Utenti(deadpool_postgres::Pool);
Lo struct va ad implementare il trait “Database” e bisogna annotarlo con il nome del database di riferimento (quello messo nel file “Rocket.toml”).
Per poter usare la nostra connessione all’interno degli endpoint, questa va inizializzata in rocket:
#[launch] fn rocket() -> _ { rocket::build() .mount("/", routes![hello_rocket, hello]) .mount("/user", routes![test_user, print_user]) .attach(Utenti::init()) }
Le funzioni di “attach” gestiscono delle operazioni di middleware, in questo caso l’inizializzazione della connessione al db.
Ora testiamo il funzionamento con un endpoint.
Aggiungiamo ai controller dell’utente questo:
#[post("/user_example")] pub async fn user_example(conn: Connection<Utenti>) { conn.execute( "INSERT INTO users (name, email, password) VALUES ('Test', 'test@gmail.com', '123456')", &[], ) .await .unwrap(); }
“Connection” è un wrapper che viene popolato automaticamente quando l’endpoint viene chiamato, iniettando così la connessione al db.
È tramite la variabile “conn” che possiamo eseguire comandi SQL, in questo endopint usiamo la funzione “execute” che restituisce un “Result” con il numero di righe coinvolte nel comando (lo slice vuoto è un “contenitore” di dipendenze e verrà utilizzato più avanti).
Aggiungiamo a rocket e testiamo l’endpoint:
#[launch] fn rocket() -> _ { rocket::build() .mount("/", routes![hello_rocket, hello]) .mount("/user", routes![ test_user, print_user, user_example ]) .attach(Utenti::init()) }
Le repositories
Per un’architettura più ordinata, creiamo un sottomodulo “postgres_user_repository” nel modulo “repositories”.
Qui andremo ad implementare una struttura dati che fornisca delle CRUD usando il wrapper “Connection”:
pub struct PostgresUserRepository; impl PostgresUserRepository { pub async fn create_user(&self, user: &User, conn: Connection<Utenti>) -> Result<u64, Error> { conn.execute( "INSERT INTO users (name, email, password, phone) VALUES ($1, $2, $3, $4)", &[&user.name, &user.email, &user.password, &user.phone], ) .await } }
Qui abbiamo implementato solo la CREATE, come potete vedere nello slice possono essere inserite delle dipendenze per rendere “dinamico” lo statement sql.
Aggiungiamo anche READ, UPDATE e DELETE:
pub struct PostgresUserRepository; impl PostgresUserRepository { pub async fn create_user(&self, user: &User, conn: Connection<Utenti>) -> Result<u64, Error> { conn.execute( "INSERT INTO users (name, email, password, phone) VALUES ($1, $2, $3, $4)", &[&user.name, &user.email, &user.password, &user.phone], ) .await } pub async fn find_user_by_id( &self, id: i32, conn: Connection<Utenti>, ) -> Result<Option<User>, Error> { match conn .query("SELECT * FROM users WHERE id = $1", &[&id]) .await { Err(e) => Err(e), Ok(rows) => { if rows.is_empty() { Ok(None) } else { Ok(Some(User { id: rows[0].get("id"), name: rows[0].get("name"), email: rows[0].get("email"), password: rows[0].get("password"), phone: rows[0].get("phone"), })) } } } } pub async fn update_user(&self, user: &User, conn: Connection<Utenti>) -> Result<u64, Error> { conn.execute( "UPDATE users SET name = $1, email = $2, password = $3, phone = $4 WHERE id = $5", &[ &user.name, &user.email, &user.password, &user.phone, &user.id, ], ) .await } pub async fn delete_user(&self, id: i32, conn: Connection<Utenti>) -> Result<u64, Error> { conn.execute("DELETE FROM users WHERE id = $1", &[&id]) .await } }
La funzione “query” usata nel metodo “find_user_by_id” restituisce un vettore di “Row”, degli oggetti rappresentativi delle righe della tabella, dal quale possiamo estrapolare i valori delle colonne tramite la funzione “get”, che vuole come input un indice testuale o numerico.
Grazie a rocket possiamo far iniettare questo struct in ogni endpoint dove ne abbiamo necessità, in maniera simile alla connessione al db:
#[launch] fn rocket() -> _ { rocket::build() .mount("/", routes![hello_rocket, hello]) .mount("/user", routes![ test_user, print_user, user_example ]) .attach(Utenti::init()) .manage(PostgresUserRepository) }
I DTO
Prima di scrivere gli endpoint però potrebbe essere utile avere dei DTO, andiamo a implementarli nei sottomoduli “input_user_dto” e “output_user_dto” che possiamo creare nel modulo “dto”.
Rispettivamente, input:
#[derive(Deserialize)] #[serde(crate = "rocket::serde")] pub struct InputUserDto { pub name: String, pub email: String, pub password: String, pub phone: Option<String>, } impl From<InputUserDto> for User { fn from(value: InputUserDto) -> Self { let InputUserDto { name, email, password, phone, } = value; Self { id: None, name, email, password, phone, } } }
e output:
#[derive(Serialize)] #[serde(crate = "rocket::serde")] pub struct OutputUserDto { pub name: String, pub email: String, pub phone: Option<String>, } impl From<User> for OutputUserDto { fn from(value: User) -> Self { let User { id: _, name, email, password: _, phone, } = value; Self { name, email, phone } } }
Entrambi i DTO implementano il trait “From” per la conversione con lo struct “User”.
CRUD endpoints
Creiamo quindi ora gli endpoint necessari:
#[post("/", data = "<user>")] pub async fn create_user( user: Json<InputUserDto>, repo: &State<PostgresUserRepository>, conn: Connection<Utenti>, ) -> Result<Json<OutputUserDto>, BadRequest<String>> { let user: User = user.into_inner().into(); match repo.create_user(&user, conn).await { Ok(_) => Ok(Json(user.into())), Err(e) => Err(BadRequest(format!("Error: {}", e))), } } #[get("/<id>")] pub async fn find_user_by_id( id: i32, repo: &State<PostgresUserRepository>, conn: Connection<Utenti>, ) -> Result<Json<Option<OutputUserDto>>, BadRequest<String>> { match repo.find_user_by_id(id, conn).await { Ok(user) => Ok(Json(user.map(|user| user.into()))), Err(e) => Err(BadRequest(format!("Error: {}", e))), } } #[put("/", data = "<user>")] pub async fn update_user( user: Json<User>, repo: &State<PostgresUserRepository>, conn: Connection<Utenti>, ) -> Result<Json<OutputUserDto>, BadRequest<String>> { let user: User = user.into_inner(); match repo.update_user(&user, conn).await { Ok(_) => Ok(Json(user.into())), Err(e) => Err(BadRequest(format!("Error: {}", e))), } } #[delete("/<id>")] pub async fn delete_user( id: i32, repo: &State<PostgresUserRepository>, conn: Connection<Utenti>, ) -> Result<Json<u64>, BadRequest<String>> { match repo.delete_user(id, conn).await { Ok(n) => Ok(n.into()), Err(e) => Err(BadRequest(format!("Error: {}", e))), } }
Grazie al wrapper “State” possiamo utilizzare il nostro repo per eseguire i metodi CRUD.
Questi wrapper (“State” e “Connection”) hanno un nome specifico in Rocket e vengono chiamati “Request Guards”.
Avrete notato che nel “Result” restituito dalle funzioni viene usato il tipo “BadRequest”.
Questo è un “Responder” che va a modificare lo status della risposta su 400 e restituisce l’oggetto incapsulato.
Esistono “Responder” e “Request Guards” di vario tipo e il loro approfondimento può portare a tanti vantaggi per le vostre applicazioni Rocket.
Ora non ci manca altro che inserire gli endpoint in rocket e testarli:
#[launch] fn rocket() -> _ { rocket::build() .mount("/", routes![hello_rocket, hello]) .mount("/user", routes![ test_user, print_user, user_example, create_user, find_user_by_id, update_user, delete_user ]) .attach(Utenti::init()) .manage(PostgresUserRepository) }
Conclusioni
Questa era una semplice dimostrazione di utilizzo di Rocket, un framework in grado di semplificare veramente lo sviluppo di API Rust senza sacrificarne le performance.
Ci sono tanti argomenti che non ho potuto approfondire in questo articolo (ad esempio i Fairings e la parte reattiva) e invito i più curiosi a farlo in autonomia tramite la documentazione ufficiale.
Grazie della lettura.
References
Articoli correlati
Link utili
I nostri contatti
Pec: besmartbeopen@pec.it
P.IVA e C.F.: 02137570509