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(())
}