Skip to content

Commit

Permalink
feat: add front and webserver
Browse files Browse the repository at this point in the history
  • Loading branch information
Net-Mist committed Jan 11, 2024
1 parent d849ffa commit e307038
Show file tree
Hide file tree
Showing 14 changed files with 281 additions and 53 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]}
17 changes: 13 additions & 4 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions front/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Odd computer</title>
<style>
body {
font-family: 'Arial', sans-serif;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
}

#upload-container {
text-align: center;
padding: 20px;
border: 2px dashed #ccc;
border-radius: 10px;
cursor: pointer;
}

#file-input {
display: none;
}

#upload-text {
font-size: 18px;
color: #555;
}

#result-container {
margin-top: 20px;
display: none;
}

#json-output {
white-space: pre-line;
}
</style>
</head>
<body>
<div id="upload-container" onclick="handleClick()">
<input type="file" id="file-input" accept=".json" onchange="handleFile()">
<p id="upload-text">Click or drag and drop a JSON file containing the plans of the Empire here</p>
</div>

<div id="result-container">
<h2>Result from Server:</h2>
<pre id="server-result"></pre>
</div>

<script>
function handleClick() {
document.getElementById('file-input').click();
}

function handleFile() {
const fileInput = document.getElementById('file-input');
const resultContainer = document.getElementById('result-container');

const file = fileInput.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function(e) {
const content = e.target.result;
sendDataToServer(content);
resultContainer.style.display = 'block';
};

reader.readAsText(file);
}
}


function sendDataToServer(data) {
const url = '/proba';
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: data,
})
.then(response => response.text())
.then(result => {
const serverResult = document.getElementById('server-result');
serverResult.textContent = result;
})
.catch(error => {
console.error('Error sending data to server:', error);
});
}
</script>
</body>
</html>
17 changes: 12 additions & 5 deletions src/application_services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self> {
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<Self> {
Expand Down Expand Up @@ -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};
Expand Down
4 changes: 2 additions & 2 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<()> {
Expand Down
4 changes: 2 additions & 2 deletions src/domain_model.rs → src/domain_models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PlanetId, Vec<(PlanetId, u64)>>);

impl Default for GalaxyRoutes {
Expand Down Expand Up @@ -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<String, PlanetId>);

impl Default for PlanetCatalog {
Expand Down
4 changes: 2 additions & 2 deletions src/domain_services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
};

Expand Down
73 changes: 73 additions & 0 deletions src/infrastructure_services/actix.rs
Original file line number Diff line number Diff line change
@@ -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<AppState>, req_body: String) -> std::result::Result<String, Error> {
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<Server> {
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)
}
25 changes: 25 additions & 0 deletions src/infrastructure_services/args.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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",
))
}
}
Loading

0 comments on commit e307038

Please sign in to comment.