Préparation

Avoir le hands on en local

cargo install mdbook
mdbook serve

Lancer le service ratings

cd ratings
PORT=7878 RUST_LOG=info cargo run

Vérifier qu'il marche:

curl localhost:7878/ratings/0
curl localhost:7878/ratings/0 -d '{"reviewer":"me","rating":3}'
curl localhost:7878/ratings/0

Lancer le service avec rebuild automatique

cargo install cargo-watch
cd reviews
RUST_LOG=warn RATINGS_URL=http://127.0.0.1:7878 cargo watch --ignore db.sqlite -x 'run'

Serveur HTTP

Démarrer une application et un système d'acteur

Dans lib.rs

extern crate actix;
extern crate actix_web;
extern crate futures;

extern crate serde;
#[macro_use]
extern crate serde_derive;

use actix_web::middleware::Logger;
use actix_web::{server, App};

pub struct AppState {
}

Dans la fonction run

let sys = actix::System::new("reviews");

server::new(move || {
    App::with_state(AppState {
    }).middleware(Logger::default())
}).bind(addr)
    .unwrap()
    .start();

let _ = sys.run();

Résultat

Un serveur http qui écoute, mais ne répond que des 404

curl localhost:9080 -i
HTTP/1.1 404 Not Found
content-length: 0
date: Thu, 19 Apr 2018 21:36:42 GMT

Router les requêtes

Définir les méthodes retournant une réponse

Dans reviews.rs

use std::collections::HashMap;

use actix_web::{error, AsyncResponder, HttpResponse, Json, Path, State};
use futures::Future;
use futures;

Définir comment extraire le product_id du path

#[derive(Deserialize)]
pub struct ProductId {
    product_id: i32,
}

GET des reviews

pub fn reviews(
    product_id: Path<ProductId>,
    state: State<super::AppState>,
) -> Box<Future<Item = HttpResponse, Error = error::Error>> {
    let product_id = product_id.product_id;

    unimplemented!()
}

POST d'une review

#[derive(Debug, Deserialize, Serialize)]
pub struct NewReview {}

pub fn create_review(
    product_id: Path<ProductId>,
    review: Json<NewReview>,
    state: State<super::AppState>,
) -> Box<Future<Item = HttpResponse, Error = error::Error>> {
    let product_id = product_id.product_id;

    unimplemented!()
}

Définir les routes

Dans lib.rs

use actix_web::http;

mod reviews;
server::new(move || {
    App::with_state(AppState {
    }).middleware(Logger::default())
        .resource("/reviews/{product_id}", |r| {
            r.method(http::Method::GET).with2(reviews::reviews);
            r.method(http::Method::POST).with3(reviews::create_review);
        })
}).bind(addr)
    .unwrap()
    .start();

Résultat

Les routes définies ne répondent pas, et un log apparaît quand on les appelle

curl localhost:9080/reviews/0 -i
curl: (52) Empty reply from server
thread 'arbiter:"85373381-235b-47af-bde8-4d85e5b778d8":"actor"' panicked at 'not yet implemented', src/reviews.rs:18:5

Renvoyer des données

Définir le modèle

Dans models.rs

#[derive(Serialize, Debug)]
pub struct Review {
    pub product_id: i32,
    pub reviewer: String,
    pub review: String,
}

Dans lib.rs

mod models;

Traduire avec les entrées / sorties

Dans reviews.rs

use models;

Définir la structure JSON de sortie

#[derive(Debug, Serialize)]
pub struct Product {
    pub id: i32,
    pub reviews: Vec<Review>,
}

#[derive(Debug, Serialize)]
pub struct Review {
    pub reviewer: String,
    pub text: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub rating: Option<Rating>,
}

#[derive(Debug, Serialize)]
pub struct Rating {
    pub stars: i32,
    pub color: Color,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Color {
    Blue,
    Yellow,
    Red,
}

Convertir le modèle en sortie

pub fn rating_nb_to_rating(rating: i32) -> Rating {
    Rating {
        stars: rating,
        color: match rating {
            1 => Color::Red,
            2 | 3 => Color::Yellow,
            _ => Color::Blue,
        },
    }
}

pub fn reviews_with_ratings(
    reviews: Vec<models::Review>,
    ratings: HashMap<String, i32>,
) -> Vec<Review> {
    reviews
        .iter()
        .map(|review| {
            let reviewer = review.reviewer.clone();
            Review {
                rating: ratings.get(&reviewer).map(|&r| rating_nb_to_rating(r)),
                reviewer: reviewer,
                text: review.review.clone(),
            }
        })
        .collect()
}

Renvoyer la réponse

let ratings = HashMap::new();
let reviews = vec![];

let build_response_from_ratings = move |ratings| {
    futures::future::result({
        // build the response
        let product = Product {
            id: product_id,
            reviews: reviews_with_ratings(reviews, ratings),
        };
        // return a 200 response with the reviews as json
        Ok(HttpResponse::Ok().json(product))
    })
};

build_response_from_ratings(ratings).responder()

Définir la structure JSON en entrée

#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct NewReview {
    reviewer: String,
    text: String,
    rating: i32,
}

Convertir l'entrée en modèle

let review_to_save = models::Review {
    product_id: product_id as i32,
    reviewer: review.reviewer.clone(),
    review: review.text.clone(),
};

Renvoyer la réponse

futures::future::result({
    Ok(HttpResponse::Ok().json(review.clone()))
}).responder()

Résultat

Récupérer les reviews d'un product donne une liste vide

curl localhost:9080/reviews/0 -i
HTTP/1.1 200 OK
content-length: 21
content-type: application/json
date: Thu, 19 Apr 2018 21:59:21 GMT

{"id":0,"reviews":[]}

Poster une review la renvoie telle quelle

curl localhost:9080/reviews/0 -i -H 'Content-Type: application/json' -d '{"reviewer":"moi","rating":3,"text":"mon avis"}'
HTTP/1.1 200 OK
content-length: 47
content-type: application/json
date: Thu, 19 Apr 2018 22:00:57 GMT

{"reviewer":"moi","text":"mon avis","rating":3}

Poster un JSON invalide répond une erreur 400 et un message dans les logs

curl localhost:9080/reviews/0 -i -H 'Content-Type: application/json' -d '{"reviewer":"moi","rating":3}'
HTTP/1.1 400 Bad Request
content-length: 0
date: Thu, 19 Apr 2018 22:01:54 GMT
{"msg":"Error occured during request handling: Json deserialize error: missing field `text` at line 1 column 29","level":"WARN","ts":"2018-04-20T00:01:55.287447+02:00","logger":"app"}

Acteur DB

Définir l'acteur

Dans db.rs

use actix::prelude::*;

pub struct DbExecutor();

impl Actor for DbExecutor {
    type Context = SyncContext<Self>;
}

Définir le message GetReviews

use models;

#[derive(Debug)]
pub struct GetReviews {
    pub product_id: i32,
}

impl Message for GetReviews {
    type Result = Result<Vec<models::Review>, ()>;
}

impl Handler<GetReviews> for DbExecutor {
    type Result = Result<Vec<models::Review>, ()>;

    fn handle(&mut self, msg: GetReviews, _: &mut Self::Context) -> Self::Result {
        warn!("getting reviews for product {}", msg.product_id);

        Ok(vec![
            models::Review {
                product_id: msg.product_id,
                review: "great!".to_string(),
                reviewer: "user1".to_string(),
            },
        ])
    }
}

Définir le message SaveReview

#[derive(Debug)]
pub struct SaveReview {
    pub review: models::Review,
}

impl Message for SaveReview {
    type Result = Result<models::Review, ()>;
}

impl Handler<SaveReview> for DbExecutor {
    type Result = Result<models::Review, ()>;

    fn handle(&mut self, msg: SaveReview, _: &mut Self::Context) -> Self::Result {
        warn!("saving review {:?}", msg.review);

        Ok(msg.review)
    }
}

Ajouter l'acteur à l'AppState

Dans lib.rs

use actix::prelude::*;

mod db;

pub struct AppState {
    db: Addr<Syn, db::DbExecutor>,
}
let db_addr = SyncArbiter::start(3, move || db::DbExecutor());

server::new(move || {
    App::with_state(AppState {
        db: db_addr.clone(),
    }).middleware(Logger::default())

Appeller l'acteur pendant les requêtes

Dans reviews.rs

use db;

Get des reviews

Enlever

let reviews = vec![];

Et remplacer

futures::future::result({

par:

state
    .db
    .send(db::GetReviews {
        product_id: product_id,
    })
    .map(|result| match result {
        Ok(reviews) => reviews,
        Err(err) => {
            error!("{:?}", err);
            vec![]
        }
    })
    .from_err()
    .and_then(move |reviews| {

Save d'une review

Remplacer

futures::future::result({ Ok(HttpResponse::Ok().json(review.clone())) }).responder()

par:

state
    .db
    .send(db::SaveReview {
        review: review_to_save,
    })
    .from_err()
    .and_then(move |_| { Ok(HttpResponse::Ok().json(review.clone())) }).responder()

Résultat

Notre avis s'affiche !

curl localhost:9080/reviews/0 -i
HTTP/1.1 200 OK
content-length: 57
content-type: application/json
date: Thu, 19 Apr 2018 22:13:43 GMT

{"id":0,"reviews":[{"reviewer":"user1","text":"great!"}]}

À la sauvegarde d'un avis, un log s'affiche:

curl localhost:9080/reviews/0 -i -H 'Content-Type: application/json' -d '{"reviewer":"moi","rating":3,"text":"mon avis"}'
HTTP/1.1 200 OK
content-length: 47
content-type: application/json
date: Thu, 19 Apr 2018 22:23:06 GMT

{"reviewer":"moi","text":"mon avis","rating":3}
{"msg":"saving review Review { product_id: 0, reviewer: \"moi\", review: \"mon avis\" }","level":"WARN","ts":"2018-04-20T00:23:06.973686+02:00","logger":"app"}

Configuration

module config.rs

use std::env;

#[derive(Debug)]
pub struct Config {
    pub host: String,
    pub port: String,
    pub database_url: String,
    pub ratings_url: String,
}

impl Config {
    pub fn new() -> Self {
        Self {
            host: env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()),
            port: env::var("PORT").unwrap_or_else(|_| "9081".to_string()),
            database_url: env::var("DATABASE_URL").unwrap_or_else(|_| "db.sqlite".to_string()),
            ratings_url: env::var("RATINGS_URL")
                .unwrap_or_else(|_| "http://ratings:9080".to_string()),
        }
    }
}

Ecouter sur l'adresse de la configuration

Dans bin.rs

mod config;

Remplacer l'assignation existante d'addr par:

let config = config::Config::new();
let addr = format!("{}:{}", config.host, config.port);

Rendre la configuration accessible

Dans lib.rs

#[macro_use]
extern crate lazy_static;

mod config;

lazy_static! {
    static ref CONFIG: config::Config = config::Config::new();
}

Résultat

Notre service écoute maintenant sur le port 9081

curl localhost:9081/reviews/0 -i
HTTP/1.1 200 OK
content-length: 57
content-type: application/json
date: Thu, 19 Apr 2018 22:27:26 GMT

{"id":0,"reviews":[{"reviewer":"user1","text":"great!"}]}

Utilisation de diesel

Nouvelles dépendances

Dans lib.rs

#[macro_use]
extern crate diesel;
extern crate r2d2;

use diesel::prelude::*;
use diesel::r2d2::ConnectionManager;

Migrations et schémas

Dans lib.rs

mod schema;

Ajout d'attributs au model

Dans model.rs

use super::schema::reviews;

#[derive(Serialize, Debug, Queryable, Insertable)]
#[table_name = "reviews"]
pub struct Review {
    pub product_id: i32,
    pub reviewer: String,
    pub review: String,
}

Configurer le pool de connection

Dans lib.rs, à la place de l'assignation à db_addr:

let manager = ConnectionManager::<SqliteConnection>::new(CONFIG.database_url.clone());
let pool = r2d2::Pool::builder().build(manager).expect("Failed to create pool.");
let db_addr = SyncArbiter::start(3, move || db::DbExecutor(pool.clone()));

Acteur db db.rs

Importer les nouvelles dépendances

use diesel;
use diesel::prelude::*;
use diesel::r2d2::{ConnectionManager, Pool};

use schema;

Ajouter le pool de connection

Changer la définition de DbExecutor par:

pub struct DbExecutor(pub Pool<ConnectionManager<SqliteConnection>>);

Récupérer les reviews

Changer le message GetReviews par:

#[derive(Debug)]
pub struct GetReviews {
    pub product_id: i32,
}

impl Message for GetReviews {
    type Result = Result<Vec<models::Review>, diesel::result::Error>;
}

impl Handler<GetReviews> for DbExecutor {
    type Result = Result<Vec<models::Review>, diesel::result::Error>;

    fn handle(&mut self, msg: GetReviews, _: &mut Self::Context) -> Self::Result {
        warn!("getting reviews for product {}", msg.product_id);

        use self::schema::reviews::dsl::*;

        let conn: &SqliteConnection = &self.0.get().unwrap();

        let items = reviews
            .filter(product_id.eq(msg.product_id))
            .load::<models::Review>(conn)?;

        Ok(items)
    }
}

Sauvegarder une review

Changer le message SaveReview par:

#[derive(Debug)]
pub struct SaveReview {
    pub review: models::Review,
}

impl Message for SaveReview {
    type Result = Result<models::Review, diesel::result::Error>;
}

impl Handler<SaveReview> for DbExecutor {
    type Result = Result<models::Review, diesel::result::Error>;

    fn handle(&mut self, msg: SaveReview, _: &mut Self::Context) -> Self::Result {
        warn!("saving review {:?}", msg.review);

        use self::schema::reviews::dsl::*;

        let conn: &SqliteConnection = &self.0.get().unwrap();

        diesel::insert_into(reviews)
            .values(&msg.review)
            .execute(conn)?;

        Ok(msg.review)
    }
}

Résultat

On récupère les reviews de la base de données:

curl localhost:9081/reviews/0
{
   "reviews" : [
      {
         "text" : "An extremely entertaining play by Shakespeare. The slapstick humour is refreshing!",
         "reviewer" : "Reviewer1"
      },
      {
         "reviewer" : "Reviewer2",
         "text" : "Absolutely fun and entertaining. The play lacks thematic depth when compared to other plays by Shakespeare."
      }
   ],
   "id" : 0
}

Les avis envoyés sont sauvegardés:

curl localhost:9081/reviews/1 -H 'Content-Type: application/json' -d '{"reviewer":"moi","rating":3,"text":"mon avis"}'
{"reviewer":"moi","text":"mon avis","rating":3}

curl localhost:9081/reviews/1
{
   "id" : 1,
   "reviews" : [
      {
         "text" : "mon avis",
         "reviewer" : "moi"
      }
   ]
}

Communiquer avec le service ratings

Dans reviews.rs

Nouveaux imports

use actix_web::client::ClientRequest;
use actix_web::HttpMessage;

Récupérer les ratings

Définir la structure du json

#[derive(Debug, Deserialize)]
pub struct RatingsResponse {
    id: i32,
    pub ratings: HashMap<String, i32>,
}

Appeler le service

Remplacer l'assignation de ratings par:

let ratings = ClientRequest::get(&format!("{}/ratings/{}", ::CONFIG.ratings_url, product_id))
    .finish()
    .unwrap()
    .send()
    .map_err(error::Error::from)
    .and_then(move |resp| {
        resp.json()
            .from_err()
            .and_then(|ratings: RatingsResponse| Ok(ratings.ratings))
    })
    .or_else(|err| {
        // in case of error, log it and continue with an empty list of ratings
        error!("{:?}", err);
        Ok(HashMap::new())
    });

Et remplacer

build_response_from_ratings(ratings).responder()

par

ratings.and_then(build_response_from_ratings).responder()

Sauvegarder un nouveau rating

Appeler le service

Remplacer

state
    .db
    .send(db::SaveReview {
        review: review_to_save,
    })
    .from_err()
    .and_then(move |_| Ok(HttpResponse::Ok().json(review.clone())))
    .responder()

par:

ClientRequest::post(&format!("{}/ratings/{}", ::CONFIG.ratings_url, product_id))
    .json(review.clone())
    .unwrap()
    .send()
    .map(|_| ())
    .or_else(|err| {
        // in case of error, log it and ignore it
        error!("{:?}", err);
        Ok(())
    })
    .and_then(move |_| {
        state
            .db
            .send(db::SaveReview {
                review: review_to_save,
            })
            .from_err()
            .and_then(move |_| Ok(HttpResponse::Ok().json(review.clone())))
    })
    .responder()

Résultat

Les ratings sont maintenant dispo quand on demande des avis, et l'application ratings a loggé qu'elle a été appellée

curl localhost:9081/reviews/0
{
   "reviews" : [
      {
         "rating" : {
            "stars" : 5,
            "color" : "blue"
         },
         "text" : "An extremely entertaining play by Shakespeare. The slapstick humour is refreshing!",
         "reviewer" : "Reviewer1"
      },
      {
         "reviewer" : "Reviewer2",
         "rating" : {
            "color" : "blue",
            "stars" : 4
         },
         "text" : "Absolutely fun and entertaining. The play lacks thematic depth when compared to other plays by Shakespeare."
      }
   ],
   "id" : 0
}
{"msg":"requesting ratings for product 0","level":"INFO","ts":"2018-04-20T00:52:35.348631+02:00","logger":"app"}
{"msg":"[RESPONSE][d65d721f-b0d4-4689-a1fd-7b455fedd76d][HTTP/1.1][200 OK][755µs]","level":"INFO","ts":"2018-04-20T00:52:35.348900+02:00","logger":"app"}

À la sauvegarde d'un avis, la note est sauvegardée dans l'application ratings

curl localhost:9081/reviews/3 -H 'Content-Type: application/json' -d '{"reviewer":"moi","rating":3,"text":"mon avis"}'
{"reviewer":"moi","text":"mon avis","rating":3}

curl localhost:9081/reviews/3
{
   "reviews" : [
      {
         "reviewer" : "moi",
         "text" : "mon avis",
         "rating" : {
            "color" : "yellow",
            "stars" : 3
         }
      }
   ],
   "id" : 3
}
{"msg":"saving new rating NewRating { reviewer: \"moi\", rating: 3 } for product 3","level":"INFO","ts":"2018-04-20T01:00:22.008725+02:00","logger":"app"}
{"msg":"[RESPONSE][4c0951b3-f302-4759-9458-506a6b1a9f97][HTTP/1.1][200 OK][822µs]","level":"INFO","ts":"2018-04-20T01:00:22.009032+02:00","logger":"app"}

Ajouter un healthcheck

Nouvelle dépendance

Dans lib.rs

extern crate time;

Module health

Dans health.rs

Ajouter les imports

use actix_web::{HttpRequest, HttpResponse};

use time;

Déclarer les données retournées

#[derive(Serialize)]
pub struct Healthcheck {
    now: i64,
    version: &'static str,
    status: &'static str,
}

Retourner les données

pub fn healthcheck(_: HttpRequest<super::AppState>) -> HttpResponse {
    HttpResponse::Ok().json(Healthcheck {
        now: time::now_utc().to_timespec().sec,
        version: env!("CARGO_PKG_VERSION"),
        status: "Reviews is healthy",
    })
}

Ajouter la route pour /GET healthcheck

Dans lib.rs

mod health;
    .resource("/health", |r| {
        r.method(http::Method::GET).f(health::healthcheck)
    })

Résultat

curl localhost:9081/health
{
   "status" : "Reviews is healthy",
   "now" : 1524179124,
   "version" : "0.1.0"
}