From e3070387e5e1d059437c74632bf235580d9dd52a Mon Sep 17 00:00:00 2001 From: Sebastien Iooss Date: Thu, 11 Jan 2024 15:38:59 +0100 Subject: [PATCH] feat: add front and webserver --- Cargo.lock | 1 + Cargo.toml | 1 + Readme.md | 17 +++- front/index.html | 98 +++++++++++++++++++ src/application_services.rs | 17 +++- src/cli.rs | 4 +- src/{domain_model.rs => domain_models.rs} | 4 +- src/domain_services.rs | 4 +- src/infrastructure_services/actix.rs | 73 ++++++++++++++ src/infrastructure_services/args.rs | 25 +++++ .../mod.rs} | 44 +++------ src/lib.rs | 4 +- src/main.rs | 16 ++- tests/health_check.rs | 26 ++++- 14 files changed, 281 insertions(+), 53 deletions(-) create mode 100644 front/index.html rename src/{domain_model.rs => domain_models.rs} (98%) create mode 100644 src/infrastructure_services/actix.rs create mode 100644 src/infrastructure_services/args.rs rename src/{infrastructure_service.rs => infrastructure_services/mod.rs} (68%) diff --git a/Cargo.lock b/Cargo.lock index b08c0fe..5b233af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1070,6 +1070,7 @@ dependencies = [ "serde", "serde_json", "sqlx", + "thiserror", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index 7fa2002..001c7b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,4 +25,5 @@ reqwest = "0.11.23" serde = {version = "1.0.195", features = ["derive"]} serde_json = "1.0.111" sqlx = {version = "0.7.3", features = ["runtime-tokio", "sqlite"]} +thiserror = "1.0.56" tokio = {version = "1.35.1", features = ["full"]} diff --git a/Readme.md b/Readme.md index 499fa06..17765cf 100644 --- a/Readme.md +++ b/Readme.md @@ -2,6 +2,8 @@ Solution of the [developer-test](https://github.com/lioncowlionant/developer-test). +With a recent version of rust (tested with 1.75.0), you can build the project with `cargo build --release`. Then you can run the cli with `./target/release/give-me-the-odds examples/millennium-falcon.json examples/example2/empire.json` and the webserver with `./target/release/millennium_falcon examples/millennium-falcon.json` + ## Architecture The code follows the onion architecture. More specifically, the code is divided into 4 sections: @@ -29,24 +31,31 @@ Contains code to connect and read from the DB, process the CLI input and definin ## Technology stack -- This code was written in Rust 1.75.0. +This code was written in Rust 1.75.0. Notables dependencies are Actix for the web server, Anyhow for the error handling, SQLX for database connection and Tokio for the async engine. ### CI -- A github action perform a security audit every day. More specifically: +2 GitHub workflows are defined. + +- One that performs a security audit every day. More specifically: - Cargo-deny check for security vulnerabilities, license violation, unmaintained projects and several other things. - Cargo-audit for a second security audit. Seems to be more precise than cargo-deny, and also automatically open issue on security vulnerabilities. -- A second CI run the classic steps: - +- A second that run the classic steps: - format with `cargo-fmt` - lint with `clippy` - build and test in dev mode - build and test again in release mode - build the docker image - push the docker image to dockerhub + - compute and publish code coverage + +## Test + +Unit-tests are defined directly inside the code. Look for the `mod test`. Integration tests are defined in the `tests` folder ## TODO +- logging - bulding the docker image - pushing to dockerhub with a dev tag - release-please diff --git a/front/index.html b/front/index.html new file mode 100644 index 0000000..5e69fc6 --- /dev/null +++ b/front/index.html @@ -0,0 +1,98 @@ + + + + + + Odd computer + + + +
+ +

Click or drag and drop a JSON file containing the plans of the Empire here

+
+ +
+

Result from Server:

+

+    
+ + + + diff --git a/src/application_services.rs b/src/application_services.rs index d6bbbe7..bd72a81 100644 --- a/src/application_services.rs +++ b/src/application_services.rs @@ -5,21 +5,28 @@ use std::{ use anyhow::{Context, Result}; use serde::Deserialize; +use std::path::PathBuf; -use crate::domain_model::{BountyHunterPlanning, GalaxyRoutes, PlanetCatalog}; +use crate::domain_models::{BountyHunterPlanning, GalaxyRoutes, PlanetCatalog}; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Clone)] pub struct MillenniumFalconData { pub autonomy: u64, pub departure: String, pub arrival: String, - pub routes_db: String, + pub routes_db: PathBuf, } impl MillenniumFalconData { pub fn read(path: &str) -> Result { let content = &fs::read_to_string(path).context("Unable to read millennium data file")?; - MillenniumFalconData::parse(content) + let mut data = MillenniumFalconData::parse(content)?; + // fix the routes_db path to be relative to the yaml file + // the unwrap here is safe as path is a file (else the read_to_string can't work) + let mut path = PathBuf::from(path).parent().unwrap().to_path_buf(); + path.push(data.routes_db); + data.routes_db = path; + Ok(data) } pub fn parse(text: &str) -> Result { @@ -96,7 +103,7 @@ mod test { use crate::{ application_services::BountyHunter, - domain_model::{BountyHunterPlanning, GalaxyRoutes, PlanetCatalog}, + domain_models::{BountyHunterPlanning, GalaxyRoutes, PlanetCatalog}, }; use super::{into_galaxy_routes_and_planet_id, EmpireData, Route}; diff --git a/src/cli.rs b/src/cli.rs index cd95f3e..c91be47 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,8 +3,8 @@ use millennium_falcon::application_services::into_galaxy_routes_and_planet_id; use millennium_falcon::application_services::EmpireData; use millennium_falcon::application_services::MillenniumFalconData; use millennium_falcon::domain_services::compute_probability_of_success; -use millennium_falcon::infrastructure_service::get_routes_from_db; -use millennium_falcon::infrastructure_service::parse_cli; +use millennium_falcon::infrastructure_services::args::parse_cli; +use millennium_falcon::infrastructure_services::get_routes_from_db; #[tokio::main] async fn main() -> Result<()> { diff --git a/src/domain_model.rs b/src/domain_models.rs similarity index 98% rename from src/domain_model.rs rename to src/domain_models.rs index 30e80a3..253cd1d 100644 --- a/src/domain_model.rs +++ b/src/domain_models.rs @@ -32,7 +32,7 @@ impl Display for PlanetId { /// This design facilitates quick access to all routes originating from a specific planet. /// It's important to note that planets are identified not by their names but by a `PlanetId`, /// ensuring that the representation of planets remains independent of their names. -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq, Clone)] pub struct GalaxyRoutes(HashMap>); impl Default for GalaxyRoutes { @@ -92,7 +92,7 @@ impl GalaxyRoutes { } /// Structure keeping the relationship between the planet id and its information (for now only name). -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq, Clone)] pub struct PlanetCatalog(HashMap); impl Default for PlanetCatalog { diff --git a/src/domain_services.rs b/src/domain_services.rs index 4eaa741..9dfa9ec 100644 --- a/src/domain_services.rs +++ b/src/domain_services.rs @@ -2,7 +2,7 @@ use std::{cmp::Reverse, collections::BinaryHeap}; use anyhow::Result; -use crate::domain_model::{BountyHunterPlanning, GalaxyRoutes, PlanetCatalog, PlanetId}; +use crate::domain_models::{BountyHunterPlanning, GalaxyRoutes, PlanetCatalog, PlanetId}; /// When exploring all the states to find the best route, we want to /// privilege the one with less bounty hunter, then the one that took the less amont of time. @@ -96,7 +96,7 @@ fn probability_been_captured(n_bounty_hunter: u64) -> f64 { mod test { use crate::{ - domain_model::{BountyHunterPlanning, GalaxyRoutes, PlanetCatalog}, + domain_models::{BountyHunterPlanning, GalaxyRoutes, PlanetCatalog}, domain_services::probability_been_captured, }; diff --git a/src/infrastructure_services/actix.rs b/src/infrastructure_services/actix.rs new file mode 100644 index 0000000..43a764f --- /dev/null +++ b/src/infrastructure_services/actix.rs @@ -0,0 +1,73 @@ +use actix_web::{ + dev::Server, get, post, web, App, HttpResponse, HttpServer, Responder, ResponseError, +}; +use anyhow::Result; + +use crate::{ + application_services::{EmpireData, MillenniumFalconData}, + domain_models::{GalaxyRoutes, PlanetCatalog}, + domain_services::compute_probability_of_success, +}; + +struct AppState { + galaxy_routes: GalaxyRoutes, + planet_catalog: PlanetCatalog, + millennium_falcon_data: MillenniumFalconData, +} + +/// Custom Error type that wrap anyhow::Error and implement actix_web::ResponseError +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("an internal error occurred: {0}")] + InternalError(#[from] anyhow::Error), +} + +impl ResponseError for Error {} + +#[get("/health_check")] +async fn health_check() -> impl Responder { + HttpResponse::Ok() +} + +#[post("/proba")] +async fn proba(data: web::Data, req_body: String) -> std::result::Result { + let empire_data = EmpireData::parse(&req_body)?; + let hunter_planning = empire_data.to_bounty_hunters_planning(&data.planet_catalog); + let proba = compute_probability_of_success( + &hunter_planning, + &data.galaxy_routes, + &data.planet_catalog, + data.millennium_falcon_data.autonomy, + &data.millennium_falcon_data.departure, + &data.millennium_falcon_data.arrival, + empire_data.countdown, + )? * 100.; + Ok(format!("{proba}%")) +} + +#[get("/")] +async fn index() -> impl Responder { + HttpResponse::Ok().body(include_str!("../../front/index.html")) +} + +pub fn run( + address: &str, + galaxy_routes: GalaxyRoutes, + planet_catalog: PlanetCatalog, + millennium_falcon_data: MillenniumFalconData, +) -> Result { + let server = HttpServer::new(move || { + App::new() + .app_data(web::Data::new(AppState { + galaxy_routes: galaxy_routes.clone(), + planet_catalog: planet_catalog.clone(), + millennium_falcon_data: millennium_falcon_data.clone(), + })) + .service(health_check) + .service(proba) + .service(index) + }) + .bind(address)? + .run(); + Ok(server) +} diff --git a/src/infrastructure_services/args.rs b/src/infrastructure_services/args.rs new file mode 100644 index 0000000..2d0926f --- /dev/null +++ b/src/infrastructure_services/args.rs @@ -0,0 +1,25 @@ +use itertools::Itertools; + +use anyhow::anyhow; +use anyhow::Result; +use std::env; + +pub fn parse_cli() -> Result<(String, String)> { + if let Some((millennium_data_path, empire_data_path)) = env::args().skip(1).collect_tuple() { + Ok((millennium_data_path, empire_data_path)) + } else { + Err(anyhow!( + "script should have 2 arguments, millennium_data_path and empire_data_path", + )) + } +} + +pub fn parse_webserver() -> Result { + if let Some((millennium_data_path,)) = env::args().skip(1).collect_tuple() { + Ok(millennium_data_path) + } else { + Err(anyhow!( + "script should have 1 argument, millennium_data_path", + )) + } +} diff --git a/src/infrastructure_service.rs b/src/infrastructure_services/mod.rs similarity index 68% rename from src/infrastructure_service.rs rename to src/infrastructure_services/mod.rs index 4dd3910..c175719 100644 --- a/src/infrastructure_service.rs +++ b/src/infrastructure_services/mod.rs @@ -1,22 +1,14 @@ -use actix_web::{dev::Server, get, App, HttpResponse, HttpServer, Responder}; +use std::path::Path; + use anyhow::anyhow; use anyhow::Context; use anyhow::Result; -use itertools::Itertools; use sqlx::sqlite::SqlitePoolOptions; -use std::env; use crate::application_services::Route; -pub fn parse_cli() -> Result<(String, String)> { - if let Some((millennium_data_path, empire_data_path)) = env::args().skip(1).collect_tuple() { - Ok((millennium_data_path, empire_data_path)) - } else { - Err(anyhow!( - "script should have 2 arguments, millennium_data_path and empire_data_path", - )) - } -} +pub mod actix; +pub mod args; #[derive(Debug)] struct RouteDB { @@ -61,12 +53,18 @@ impl TryFrom for Route { } } -pub async fn get_routes_from_db(db_path: &str) -> Result> { - // db +pub async fn get_routes_from_db(db_path: &Path) -> Result> { + let db_path = db_path + .to_path_buf() + .into_os_string() + .into_string() + .map_err(|e| anyhow!("{e:?}")) + .context("Unable to convert path to string")?; let pool = SqlitePoolOptions::new() - .max_connections(5) - .connect(db_path) - .await?; + .max_connections(1) + .connect(&db_path) + .await + .context(format!("Unable to connect the the database at {db_path}"))?; let routes: Vec = sqlx::query_as!(RouteDB, "SELECT * FROM ROUTES") .fetch_all(&pool) @@ -85,15 +83,3 @@ pub async fn get_routes_from_db(db_path: &str) -> Result> { Ok(routes) } - -#[get("/health_check")] -async fn health_check() -> impl Responder { - HttpResponse::Ok() -} - -pub fn run(address: &str) -> Result { - let server = HttpServer::new(|| App::new().service(health_check)) - .bind(address)? - .run(); - Ok(server) -} diff --git a/src/lib.rs b/src/lib.rs index b86d787..2edb079 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ pub mod application_services; -pub mod domain_model; +pub mod domain_models; pub mod domain_services; -pub mod infrastructure_service; +pub mod infrastructure_services; diff --git a/src/main.rs b/src/main.rs index 248c254..973f796 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,22 @@ +use actix_web::dev::Server; +use anyhow::Result; use core::panic; +use millennium_falcon::{ + application_services::{into_galaxy_routes_and_planet_id, MillenniumFalconData}, + infrastructure_services::{actix::run, args::parse_webserver, get_routes_from_db}, +}; -use millennium_falcon::infrastructure_service::run; +pub async fn setup_webserver(address: &str) -> Result { + let millennium_falcon_data_path = parse_webserver()?; + let millennium_falcon_data = MillenniumFalconData::read(&millennium_falcon_data_path)?; + let routes = get_routes_from_db(&millennium_falcon_data.routes_db).await?; + let (galaxy_routes, planet_ids) = into_galaxy_routes_and_planet_id(routes); + run(address, galaxy_routes, planet_ids, millennium_falcon_data) +} #[actix_web::main] async fn main() -> std::io::Result<()> { - match run("127.0.0.1:8000") { + match setup_webserver("127.0.0.1:8000").await { Ok(server) => server.await, Err(e) => { let e = e.context("unable to start the server"); diff --git a/tests/health_check.rs b/tests/health_check.rs index b8e102c..7bc07ab 100644 --- a/tests/health_check.rs +++ b/tests/health_check.rs @@ -1,6 +1,12 @@ +use anyhow::Result; +use millennium_falcon::{ + application_services::{into_galaxy_routes_and_planet_id, MillenniumFalconData}, + infrastructure_services::{actix::run, get_routes_from_db}, +}; + #[tokio::test] async fn test_health_check() { - spawn_app(); + spawn_app().await.unwrap(); let client = reqwest::Client::new(); let response = client @@ -15,8 +21,18 @@ async fn test_health_check() { assert_eq!(response.content_length(), Some(0)); } -fn spawn_app() { - let server = millennium_falcon::infrastructure_service::run("127.0.0.1:8080") - .expect("failed to run server"); - let _ = tokio::spawn(server); +async fn spawn_app() -> Result<()> { + let millennium_falcon_data_path = "examples/millennium-falcon.json"; + let millennium_falcon_data = MillenniumFalconData::read(millennium_falcon_data_path)?; + let routes = get_routes_from_db(&millennium_falcon_data.routes_db).await?; + let (galaxy_routes, planet_ids) = into_galaxy_routes_and_planet_id(routes); + let server = run( + "127.0.0.1:8080", + galaxy_routes, + planet_ids, + millennium_falcon_data, + )?; + + tokio::spawn(server); + Ok(()) }