Une API REST CRUD en rust
2021-06-17
Ou: apprentissage par la documentation
Table des matières
Sans vouloir refaire la présentation officielle, Rust est un langage intéressant pour plusieurs raisons:
- fortement typé;
- la plupart des problèmes sont détectés au moment de la compilation (sauf usage d’
unsafe); - ça créé des binaires sans dépendances, donc déployables partout;
- un package manager (
crate) absolument au top.
Voici un exemple de code rust:
struct Dog {
name: String,
age: u8
}
impl Dog {
fn new(name: &str, age: u8) {
Dog {
name,
age
}
}
}
fn main() {
let pluto = Dog::new("Pluto", 16);
println!("{} est un bon chien de {} ans", pluto.name, pluto.age);
println!("{}", match pluto.age > 13 {
True => "Il faut profiter de la présence de Pluto tant qu'il est encore en vie",
False => "Pluto est en excellente santé!"
});
}
Si vous avez jugé le bout de code précédent intéressant, je vous invite à aller lire le rust book et revenir sur cet article après en avoir assimilé le contenu (et bienvenue parmis nous!). Sinon, continuons.
Introduction🔗
Dans cet article, nous allons construire ensemble une API REST permettant du CRUD sur une collection de tâches.
Nous aurons besoin de warp (framework de routage / serveur web), tokio (ici, gestionnaire async), chrono (types concrets de dates), serde (serialisation) et serde_json (serialisation json). Voici un extrait-exemple de Cargo.toml correspondant à ces dépendances:
[dependencies]
chrono = {version="0.4", features=["serde"]}
serde = { version="1", features=["derive"]}
serde_json = "1"
warp = "0.3.1"
tokio = {version="1.5.0", features=["full"]}
Les fondations🔗
Après un cargo new tasks-api; cd tasks-api, nous allons commencer par nous intéresser au modèle de données. Nous allons créer un module models qui contiendra nos définitions.
Commençons par inclure src/models.rs dans notre arbre de compilation:
// src/main.rs
mod models;
fn main(){
println!("Hello World!");
}
Vient ensuite l’écriture proprement dite:
// src/models.rs
use serde::{Deserialize, Serialize};
use chrono::serde::{ts_milliseconds, ts_milliseconds_option};
use chrono::{DateTime, Utc};
#[derive(Deserialize, Serialize, Debug, Clone)]
pub enum Column {
TODO,
DOING,
BLOCKED,
DONE,
ARCHIVED,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct Task {
/// a unique id
pub uid: u16,
/// title of a task
pub title: String,
/// description of a task (markdown?)
pub description: String,
#[serde(with = "ts_milliseconds")]
pub created: DateTime<Utc>,
#[serde(with = "ts_milliseconds_option")]
pub due: Option<DateTime<Utc>>,
#[serde(with = "ts_milliseconds_option")]
pub completion_date: Option<DateTime<Utc>>,
/// Column: see the Column enum
pub column: Column,
}
impl Default for Task {
fn default() -> Self {
Task {
uid: 0,
title: String::new(),
description: String::new(),
created: Utc::now(),
due: None,
completion_date: None,
column: Column::TODO,
}
}
}
L’usage du Data Driven Development (DDD) permet une vue claire des flux d’échanges et transformations des données, ce qui en fait selon moi une méthode tout à fait adaptée au développement de micro-services.
L’usage de serde nous permet de facilement convertir du json vers Task et inversement:
fn main() {
let t = Task {
uid: 0,
title: String::from("faire une lessive"),
description: String::from("Les vêtements noirs commencent à s'accumuler. Lancer à température plutôt basse"),
created: chrono::Utc::now(),
due: None,
completion_date: None,
column: Column::TODO
};
}
Ceci donnera par exemple le json suivant:
{
"uid": 0,
"title": "faire une lessive",
"description": "Les vêtements noirs commencent à s'accumuler. Lancer à température plutôt basse",
"created": 1624116967000,
"due": null,
"completion_date": null,
"column": "TODO"
}
Nous n’allons pas utiliser une base de données réelles, mais simplement une abstraction sur du stockage mémoire JSON, avec persistence sur disque. Pour ce faire, nous allons créer le type Db, qui nous permettra de facilement changer d’avis plus tard:
// src/main.rs
mod db;
mod models;
fn main(){
//...
}
Nous pouvons maintenant écrire notre interface de base de données:
// src/db.rs
use crate::models::Task; // notre modèle
use serde_json::from_reader;
use std::fs::File;
use std::io::Write;
use std::ops::Deref;
use std::sync::Arc; // Atomic Reference Counter
use tokio::sync::Mutex; // Mutex compatible async
pub type Db = Arc<Mutex<Vec<Task>>>; // Db est donc un vecteur de Tasks protégé inter-thread
/// Tries to load a `tasks.json` file in current dir, hopefully containing valid json
pub fn init_db() -> Db {
match File::open("tasks.json") { // on tente d'ouvrir notre fichier de persistence...
Ok(json) => { // si ça réussit, on charge notre json,
// le met derrière un Arc<Mutex>, et on le renvoit
let tasks = from_reader(json).expect("Could not un-serialize tasks.json");
Arc::new(Mutex::new(tasks))
}
Err(_) => Arc::new(Mutex::new(Vec::new())), // sinon, on en créé un nouveau \o/
}
}
/// saves the given Db to a file named `tasks.json` in current directory
pub async fn save_db(db: Db) {
match File::open("tasks.json") {
Ok(mut f) => {
let tasks = db.lock().await;
f.write_all(
serde_json::to_string_pretty(&tasks.deref())
.expect("malformed internal data")
.as_bytes(),
)
.expect("Could not save task file");
}
Err(_) => {}
};
}
Gérer le HTTP🔗
Maintenant que nous avons de bonnes fondations, nous pouvons nous intéresser à configurer l’interface HTTP qui nous permettra de requêter la base de données. Pour ce faire, nous allons utiliser warp . warp est un framework http basé sur hyper , la librairie HTTP pour Rust. warp est à Rust ce que Flask est à Python.
Puisque warp utilise le système de Result<_, Error> de rust, il est normal que la plupart de ce qu’attend warp en retour de Request soit des Result<impl warp::Reply, Error>. Étant donné qu’au cœur du routing de warp se trouve le système des Filter, on peut aussi s’attendre à devoir composer avec.
Nous avons 3 concepts à implémenter:
- la manipulation de la DB;
- le routing
- le
main, aka «comment lancer tout ce bordel?!»
Manipulation de la DB🔗
Nous avons besoins de créer des fonctions permettant d’effectuer le CRUD sur la DB. Nous allons donc créer un module handlers pour ce faire.
// src/main.rs
mod db;
mod handlers;
mod models;
fn main(){
//...
}
Nous allons commencer par créer la fonction permettant de lister toutes les Tasks disponibles et de retourner une réponse. C’est une fonction assez simple, qui ne peut retourner de 404: si la liste est vide, renvoyons une liste JSON vide. Le type de la réponse ne changera pas, nous pouvons donc utiliser impl. impl Trait, quand utilisé dans une signature de fonction, est un raccourci signifiant «un type concret implémentant le trait Trait», au compile-time.
Ainsi, notre fonction list_tasks ressemblera à ceci:
// src/handlers.rs
use std::convert::Infallible;
use warp;
use crate::db::Db;
use crate::models::Task;
use warp::reply::json;
pub async fn list_tasks(db: Db) -> Result<impl warp::Reply, Infallible> {
let tasks = db.lock().await; // récupérons le lock sur db
// puisque notre type Db est un simple Vec<Task>, il suffit de le cloner
let tasks: Vec<Task> = tasks.clone();
// puis d'appeller warp::reply::json qui nous fera une réponse avec
// `Content-Type: application/json`, et appellera la serialisation
// serde pour nous
Ok(json(&tasks))
}
Pour la fonction qui retourne une seule Task (GET /tasks/:uid), il est possible d’avoir une erreur HTTP 404, en cas de uid non-valide. Nous allons donc devoir utiliser un Box<dyn warp::Reply>, puisque le type concret n’est pas connu au compile-time. L’usage de dyn ajoute un overhead non-négligeable au runtime, il est donc préférable d’utiliser impl Trait autant que possible. Quant à Box, c’est un type permettant d’encapsuler une zone mémoire.
Nous allons donc rajouter la fonction get_task(uid: u16, db: Db) -> Result<Box<dyn warp::Reply>, Infallible> à notre module handlers:
// src/handlers.rs
use std::convert::Infallible;
use warp;
use crate::db::Db;
use crate::models::Task;
use warp::http::StatusCode;
use warp::reply::json;
pub async fn list_tasks(db: Db) -> Result<impl warp::Reply, Infallible> {
// --8<-- snip snip --8<--
}
pub async fn get_task(uid: u16, db: Db) -> Result<Box<dyn warp::Reply>, Infallible> {
let tasks = db.lock().await;
for task in tasks.iter() { // recherche de l'uid
if task.uid == uid {
//retour d'une réponse json dans une Box<dyn warp::Reply>
return Ok(Box::new(json(&task)));
}
}
// si on trouve rien, on renvoie une Reply correspondant à un HTTP 404
Ok(Box::new(StatusCode::NOT_FOUND))
}
Maintenant que nous avons vu les 2 grands cas, il ne nous reste plus qu’à écrire les autres fonctions du CRUD:
// src/handlers.rs
use std::convert::Infallible;
use warp;
use crate::db::{save_db, Db};
use crate::models::Task;
use warp::http::StatusCode;
use warp::reply::json;
pub async fn list_tasks(db: Db) -> Result<impl warp::Reply, Infallible> {
let tasks = db.lock().await; // récupérons le lock sur db
// puisque notre type Db est un simple Vec<Task>, il suffit de le cloner
let tasks: Vec<Task> = tasks.clone();
// puis d'appeller warp::reply::json qui nous fera une réponse avec
// `Content-Type: application/json`, et appellera la serialisation
// serde pour nous
Ok(json(&tasks))
}
// new_task contient les données POST désérialisées
pub async fn create_task(new_task: Task, db: Db) -> Result<impl warp::Reply, Infallible> {
let mut tasks = db.lock().await;
for task in tasks.iter() {
if task.uid == new_task.uid {
return Ok(StatusCode::BAD_REQUEST);
}
}
tasks.push(new_task);
save_db(db.clone()); // on appelle save_db, puisque c'est une modification.
Ok(StatusCode::CREATED)
}
pub async fn get_task(uid: u16, db: Db) -> Result<Box<dyn warp::Reply>, Infallible> {
let tasks = db.lock().await;
for task in tasks.iter() { // recherche de l'uid
if task.uid == uid {
//retour d'une réponse json dans une Box<dyn warp::Reply>
return Ok(Box::new(json(&task)));
}
}
// si on trouve rien, on renvoie une Reply correspondant à un HTTP 404
Ok(Box::new(StatusCode::NOT_FOUND))
}
// updated_task contient les données du POST désérialisées
pub async fn update_task(
uid: u16,
updated_task: Task,
db: Db,
) -> Result<impl warp::Reply, Infallible> {
let mut tasks = db.lock().await;
for task in tasks.iter_mut() {
if task.uid == uid {
*task = updated_task;
save_db(db.clone());
return Ok(StatusCode::OK);
}
}
Ok(StatusCode::NOT_FOUND)
}
pub async fn delete_task(uid: u16, db: Db) -> Result<impl warp::Reply, Infallible> {
let mut tasks = db.lock().await;
let tasks_len = tasks.len();
tasks.retain(|t| t.uid != uid);
if tasks.len() != tasks_len {
save_db(db.clone());
Ok(StatusCode::NO_CONTENT)
} else {
Ok(StatusCode::NOT_FOUND)
}
}
Et voilà! Nous avons implémenté la manipulation de la base de données, et la gestion des Reply au passage. Nous pouvons passer à la partie suivante…
Le routing🔗
Ici, nous allons devoir donner à warp les instructions pour diriger les requêtes vers les bonnes fonctions et leur renseigner les paramètres. Nous allons faire ça avec une implémentation de Filter. La lecture de leur documentation est fortement conseillée avant de continuer, surtout les parties sur l’extraction de tuples!
Si vous avez lu la documentation de Filter, vous aurez compris que c’est une implémentation de middleware qui permet à la fois d’établir des conditions de routage et de rajouter des informations. Toutes nos fonction de manipulation de DB écrites dans la sous-section précédente on besoin… d’une référence vers la DB! Surprenant, hein? 😄 Bref, on va commencer par écrire ça.
Nous allons créer encore un nouveau module, routes:
// src/main.rs
mod db;
mod handlers;
mod models;
mod routes;
fn main(){
//...
}
// src/routes.rs
use crate::db::Db;
use std::convert::Infallible;
use warp::Filter;
fn with_db(db: Db) -> impl Filter<Extract = (Db,), Error = Infallible> + Clone {
warp::any() // Nous utilisons any() pour avoir un template de filter acceptant tout
.map(move || db.clone()) // on clone l'Arc de la DB pour pouvoir le passer aux fonctions de handlers.rs
}
Maintenant que nous avons un moyen de passer une DB aux fonctions de manipulation, créons la route pour GET /tasks, list_tasks:
// src/routes.rs
use warp;
use crate::db::Db;
use crate::handlers;
use std::convert::Infallible;
use warp::Filter;
fn with_db(db: Db) -> impl Filter<Extract = (Db,), Error = Infallible> + Clone {
warp::any() // Nous utilisons any() pour avoir un template de filter acceptant tout
.map(move || db.clone()) // on clone l'Arc de la DB pour pouvoir le passer aux fonctions de handlers.rs
}
/// GET /tasks
fn list_tasks(db: Db) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
warp::path("tasks") // /tasks
.and(warp::get()) // la requête est avec le verbe GET
.and(with_db(db)) // on ajoute la référence vers la DB
.and_then(handlers::list_tasks) // enfin, on indique la fonction à exécuter
}
Dans la foulée, nous pouvons déjà écrire get_task et delete_task, qui fonctionnent de manière semblable:
// src/routes.rs
// --8<-- snip --8<--
/// GET /tasks/:uid
fn get_task(db: Db) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
// notons l'usage de la macro path! qui gère les paramètres d'URL,
// ici un u16 correspondant à :uid
warp::path!("tasks" / u16)
.and(warp::get())
.and(with_db(db))
.and_then(handlers::get_task)
}
/// DELETE /tasks/:uid
fn delete_task(db: Db) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
warp::path!("tasks" / u16)
.and(warp::delete())
.and(with_db(db))
.and_then(handlers::delete_task)
}
Il nous reste à gérer les fonctions demandant un body JSON. Nous devons refuser les fonctions n’en ayant pas un. C’est le boulot d’un autre Filter!
// src/routes.rs
// --8<-- snip --8<--
fn json_body() -> impl Filter<Extract = (Task,), Error = warp::Rejection> + Clone {
warp::body::content_length_limit(1024 * 16).and(warp::body::json())
}
// --8<-- snip --8<--
Avec cet outil, nous pouvons écrire les fonctions create_task et update_task:
// src/routes.rs
// --8<-- snip --8<--
/// POST /tasks
fn create_task(db: Db) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
warp::path("tasks")
.and(warp::post())
.and(json_body())
.and(with_db(db))
.and_then(handlers::create_task)
}
/// PUT /tasks/:uid
fn update_task(db: Db) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
warp::path!("tasks" / u16)
.and(warp::put())
.and(json_body())
.and(with_db(db))
.and_then(handlers::update_task)
}
Enfin, il ne rest plus qu’à rajouter la fonction se chargeant de faire le routage entre toutes ces fonctions de routage, ce sera celle appelée pour toute requête entrante:
// src/routes.rs
// --8<-- snip --8<--
pub fn task_routes(
db: Db,
) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
// ici, l'ordre va être important. il faut aller de la route la plus spécifique à la moins spécifique. get_task doit être avant list_tasks, par exemple
get_task(db.clone())
.or(update_task(db.clone()))
.or(delete_task(db.clone()))
.or(list_tasks(db.clone()))
.or(create_task(db.clone()))
}
En parlant de requêtes entrantes, il est maintenant temps de s’attaquer au serveur proprement dit.
« Comment lancer tout ce bordel ?! »🔗
Après tout ce que nous avons fait avant, c’est un jeu d’enfant. Il nous faut utiliser tokio pour gérer un main async, créer une instance de Db et enfin lancer le serveur warp en lui passant nos routes.
use warp;
mod db;
mod handlers;
mod models;
mod routes;
#[tokio::main]
async fn main() {
let db = db::init_db();
let task_routes = routes::task_routes(db.clone());
warp::serve(task_routes).run(
([127, 0, 0, 1], 3000)
).await;
}
Conclusion🔗
Après un petit cargo run et une compilation bien plus longue, nous pouvons tenter un petit curl:
$ curl -v http://localhost:3000/tasks
* Trying ::1:3000...
* connect to ::1 port 3000 failed: Connexion refusée
* Trying 127.0.0.1:3000...
* Connected to localhost (127.0.0.1) port 3000 (#0)
> GET /tasks HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.77.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< content-type: application/json
< content-length: 121
< date: Sat, 19 Jun 2021 16:49:04 GMT
<
* Connection #0 to host localhost left intact
[{"uid":0,"title":"default1","description":"","created":1624121342463,"due":null,"completion_date":null,"column":"TODO"}]⏎
Ça fonctionne!
Nous avons donc une API REST fonctionnelle, dans un binaire sans dépendances au runtime, prêt à être envoyé en prod (non)!
Comme nous avons vu, il est important de bien réfléchir son architecture et connaître ses outils. Il ne m’aurait pas été possible d’écrire ce programme sans lire attentivement la documentation de warp, ni sans les exemples fournis.
Bien entendu, il est un peu bateau de faire une API REST pour des collections de tâches, ça n’a de sens qu’au sein d’un contexte plus large, mais notre structure Task est substituable par à peu près n’importe quel modèle de données. Nos handlers sont également très basiques, mais il est aisé de rajouter ce qu’on souhaite dedans, comme des requêtes à d’autres services, de la gestion d’état…
Au-delà de la pertinence du modèle de données choisi, il manque plusieurs choses à notre programme, comme des log , des test unitaires et d’intégration, une formalisation du contrat d’interface et de la documentation de manière plus générale.
J’espère vous avoir été utile, et vous dit à la prochaine.
sujets: [ dev | rust | informatique ]