Implement the http variant of the bank server.

During that process, many shortcomings with the socket server and the
bank lib were fixed.

I am aware a massive commit like this is not ideal.
This commit is contained in:
Sebastian Hugentobler 2022-03-18 19:35:34 +01:00
parent c69654a924
commit dac95b7dae
Signed by: shu
GPG key ID: BB32CF3CA052C2F0
34 changed files with 1797 additions and 140 deletions

16
http-server/Cargo.toml Normal file
View file

@ -0,0 +1,16 @@
[package]
name = "http-server"
version = "0.1.0"
edition = "2021"
[dependencies]
bank = { path = "../bank" }
actix-web = "4.0.1"
anyhow = "1.0.55"
thiserror = "1.0.30"
log = "0.4.14"
pretty_env_logger = "0.4.0"
serde = { version = "1.0.136", features = ["derive"] }
[dev-dependencies]
serde_json = "1.0.79"

View file

@ -0,0 +1,29 @@
use actix_web::{post, web, HttpResponse, Responder, Result};
use bank::account::AccountError;
use bank::bank::Bank;
use crate::handlers::error::HttpAccountError;
use crate::AppState;
#[post("/{nr}/close")]
pub async fn route(info: web::Path<String>, data: web::Data<AppState>) -> Result<impl Responder> {
let nr = info.into_inner();
info!("closing account {}...", nr);
let bank = data.bank.read().unwrap();
match Bank::account_action(bank, &nr, |account| {
// TODO: make the error handling part of the passivate method
if account.balance > 0_f64 {
Err(AccountError::AccountNotZero)
} else if account.passivate() {
Ok(0_f64)
} else {
Err(AccountError::Inactive)
}
}) {
Err(e) => Err(HttpAccountError(e).into()),
Ok(_) => Ok(HttpResponse::Ok().finish()),
}
}

View file

@ -0,0 +1,29 @@
use actix_web::{post, web, Responder, Result};
use serde::{Deserialize, Serialize};
use crate::AppState;
#[derive(Serialize, Deserialize)]
pub struct OwnerData {
pub(crate) owner: String,
}
#[derive(Serialize)]
struct AccountCreated {
nr: String,
}
#[post("")]
pub async fn route(
form: web::Form<OwnerData>,
data: web::Data<AppState>,
) -> Result<impl Responder> {
let owner = &form.owner;
info!("creating new account with owner {}...", owner);
let mut bank = data.bank.write().unwrap();
let nr = bank.create_account(owner.clone());
info!("created account {}", nr);
Ok(web::Json(AccountCreated { nr }))
}

View file

@ -0,0 +1,31 @@
use actix_web::{post, web, HttpResponse, Responder, Result};
use serde::Serialize;
use bank::bank::Bank;
use crate::handlers::error::HttpAccountError;
use crate::handlers::AmountData;
use crate::AppState;
#[derive(Serialize)]
struct AccountCreated {
nr: String,
}
#[post("/{nr}/deposit")]
pub async fn route(
info: web::Path<String>,
form: web::Form<AmountData>,
data: web::Data<AppState>,
) -> Result<impl Responder> {
let nr = info.into_inner();
let amount = form.amount;
info!("depositing {} into account {}...", amount, nr);
let bank = data.bank.read().unwrap();
match Bank::account_action(bank, &nr, |account| account.deposit(amount)) {
Err(e) => Err(HttpAccountError(e).into()),
Ok(_) => Ok(HttpResponse::Ok().finish()),
}
}

View file

@ -0,0 +1,53 @@
use std::fmt::{Display, Formatter};
use std::ops::Deref;
use actix_web::http::StatusCode;
use actix_web::{HttpResponse, ResponseError};
use serde::Serialize;
use thiserror::Error;
use bank::account::AccountError;
#[derive(Serialize)]
struct ErrorResponse {
code: u16,
message: String,
}
#[derive(Debug, Error)]
pub struct HttpAccountError(pub AccountError);
impl Display for HttpAccountError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.deref())
}
}
impl Deref for HttpAccountError {
type Target = AccountError;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl ResponseError for HttpAccountError {
fn status_code(&self) -> StatusCode {
match self.deref() {
AccountError::NotFound => StatusCode::NOT_FOUND,
AccountError::InvalidAmount => StatusCode::BAD_REQUEST,
AccountError::Inactive => StatusCode::BAD_REQUEST,
AccountError::Overdraw => StatusCode::BAD_REQUEST,
AccountError::AccountNotZero => StatusCode::EXPECTATION_FAILED,
}
}
fn error_response(&self) -> HttpResponse {
let status_code = self.status_code();
let error_response = ErrorResponse {
code: status_code.as_u16(),
message: self.to_string(),
};
HttpResponse::build(status_code).json(error_response)
}
}

View file

@ -0,0 +1,44 @@
use std::ops::Deref;
use actix_web::{get, web, Responder, Result};
use serde::Serialize;
use bank::account::{Account, AccountError};
use crate::handlers::error::HttpAccountError;
use crate::AppState;
#[derive(Serialize)]
pub struct JsonAccount {
pub number: String,
pub owner: String,
pub balance: f64,
pub is_active: bool,
}
impl From<&Account> for JsonAccount {
fn from(a: &Account) -> Self {
JsonAccount {
number: a.number.clone(),
owner: a.owner.clone(),
balance: a.balance,
is_active: a.is_active,
}
}
}
#[get("/{nr}")]
pub async fn route(info: web::Path<String>, data: web::Data<AppState>) -> Result<impl Responder> {
let nr = info.into_inner();
info!("getting account {}...", nr);
let bank = data.bank.read().unwrap();
match bank.accounts.get(&nr) {
None => Err(HttpAccountError(AccountError::NotFound).into()),
Some(account) => {
let account = account.read().unwrap();
let account: JsonAccount = account.deref().into();
Ok(web::Json(account))
}
}
}

View file

@ -0,0 +1,11 @@
use actix_web::{get, web, Responder, Result};
use crate::AppState;
#[get("")]
pub async fn route(data: web::Data<AppState>) -> Result<impl Responder> {
info!("getting account numbers...");
let bank = data.bank.read().unwrap();
Ok(web::Json(bank.account_numbers()))
}

View file

@ -0,0 +1,16 @@
use serde::{Deserialize, Serialize};
pub mod close_account;
pub mod create_account;
pub mod deposit;
pub mod error;
pub mod get_account;
pub mod get_account_nrs;
pub mod pong;
pub mod transfer;
pub mod withdraw;
#[derive(Deserialize, Serialize)]
pub struct AmountData {
pub(crate) amount: f64,
}

View file

@ -0,0 +1,6 @@
use actix_web::{get, HttpResponse};
#[get("/ping")]
pub async fn route() -> HttpResponse {
HttpResponse::Ok().finish()
}

View file

@ -0,0 +1,44 @@
use actix_web::{post, web, HttpResponse, Responder, Result};
use serde::{Deserialize, Serialize};
use bank::account::AccountError;
use crate::handlers::error::HttpAccountError;
use crate::AppState;
#[derive(Deserialize, Serialize)]
pub struct TransferData {
pub(crate) from: String,
pub(crate) to: String,
pub(crate) amount: f64,
}
#[post("/transfer")]
pub async fn route(
form: web::Form<TransferData>,
data: web::Data<AppState>,
) -> Result<impl Responder> {
let from = form.from.clone();
let to = form.to.clone();
let amount = form.amount;
info!("transfering {} from {} to {}...", from, to, amount);
let bank = data.bank.read().unwrap();
let from_acc = bank
.accounts
.get(&from)
.ok_or(HttpAccountError(AccountError::NotFound))?;
let mut from_acc = from_acc.write().unwrap();
let to_acc = bank
.accounts
.get(&to)
.ok_or(HttpAccountError(AccountError::NotFound))?;
let mut to_acc = to_acc.write().unwrap();
match bank.transfer(&mut from_acc, &mut to_acc, amount) {
Err(e) => Err(HttpAccountError(e).into()),
Ok(_) => Ok(HttpResponse::Ok().finish()),
}
}

View file

@ -0,0 +1,31 @@
use actix_web::{post, web, HttpResponse, Responder, Result};
use serde::Serialize;
use bank::bank::Bank;
use crate::handlers::error::HttpAccountError;
use crate::handlers::AmountData;
use crate::AppState;
#[derive(Serialize)]
struct AccountCreated {
nr: String,
}
#[post("/{nr}/withdraw")]
pub async fn route(
info: web::Path<String>,
form: web::Form<AmountData>,
data: web::Data<AppState>,
) -> Result<impl Responder> {
let nr = info.into_inner();
let amount = form.amount;
info!("withdrawing {} from account {}...", amount, nr);
let bank = data.bank.read().unwrap();
match Bank::account_action(bank, &nr, |account| account.withdraw(amount)) {
Err(e) => Err(HttpAccountError(e).into()),
Ok(_) => Ok(HttpResponse::Ok().finish()),
}
}

204
http-server/src/main.rs Normal file
View file

@ -0,0 +1,204 @@
#[macro_use]
extern crate log;
use std::sync::RwLock;
use actix_web::middleware::TrailingSlash;
use actix_web::web::{Data, ServiceConfig};
use actix_web::{middleware, web, App, HttpServer};
use bank::bank::Bank;
use crate::handlers::close_account;
use crate::handlers::create_account;
use crate::handlers::deposit;
use crate::handlers::get_account;
use crate::handlers::get_account_nrs;
use crate::handlers::pong;
use crate::handlers::transfer;
use crate::handlers::withdraw;
pub struct AppState {
bank: RwLock<Bank>,
}
mod handlers;
fn config_app(app_data: Data<AppState>) -> Box<dyn Fn(&mut ServiceConfig)> {
Box::new(move |cfg: &mut ServiceConfig| {
cfg.app_data(app_data.clone()).service(pong::route).service(
web::scope("/accounts")
.service(create_account::route)
.service(get_account::route)
.service(deposit::route)
.service(close_account::route)
.service(get_account_nrs::route)
.service(withdraw::route)
.service(transfer::route),
);
})
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
pretty_env_logger::init();
let app_data = web::Data::new(AppState {
bank: RwLock::new(Bank::new()),
});
HttpServer::new(move || {
App::new()
.wrap(middleware::NormalizePath::new(TrailingSlash::Trim))
.configure(config_app(app_data.clone()))
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}
#[cfg(test)]
mod tests {
use actix_web::body::to_bytes;
use actix_web::{test, App};
use serde_json::Value;
use crate::create_account::OwnerData;
use crate::handlers::AmountData;
use crate::transfer::TransferData;
use super::*;
#[actix_web::test]
async fn test_routes() {
// TODO: split up into separate tests
let app_data = web::Data::new(AppState {
bank: RwLock::new(Bank::new()),
});
let app = test::init_service(
App::new()
.wrap(middleware::NormalizePath::new(TrailingSlash::Trim))
.configure(config_app(app_data)),
)
.await;
let payload = OwnerData {
owner: "aaa".to_string(),
};
let req = test::TestRequest::post()
.uri("/accounts")
.set_form(&payload)
.to_request();
let resp = test::call_service(&app, req).await;
assert!(resp.status().is_success());
let body_bytes = to_bytes(resp.into_body()).await.unwrap();
let response_json: Value = serde_json::from_slice(&body_bytes).unwrap();
let nr = response_json["nr"].as_str().unwrap();
assert!(!nr.is_empty());
let payload = AmountData { amount: 10_f64 };
let req = test::TestRequest::post()
.uri(&format!("/accounts/{}/withdraw", nr))
.set_form(&payload)
.to_request();
let resp = test::call_service(&app, req).await;
assert!(!resp.status().is_success());
let req = test::TestRequest::post()
.uri(&format!("/accounts/{}/deposit", nr))
.set_form(&payload)
.to_request();
let resp = test::call_service(&app, req).await;
assert!(resp.status().is_success());
let req = test::TestRequest::get()
.uri(&format!("/accounts/{}", nr))
.to_request();
let resp = test::call_service(&app, req).await;
assert!(resp.status().is_success());
let body_bytes = to_bytes(resp.into_body()).await.unwrap();
let response_json: Value = serde_json::from_slice(&body_bytes).unwrap();
let balance = response_json["balance"].as_f64().unwrap();
assert_eq!(10_f64, balance);
let payload = OwnerData {
owner: "bbb".to_string(),
};
let req = test::TestRequest::post()
.uri("/accounts")
.set_form(&payload)
.to_request();
let resp = test::call_service(&app, req).await;
assert!(resp.status().is_success());
let body_bytes = to_bytes(resp.into_body()).await.unwrap();
let response_json: Value = serde_json::from_slice(&body_bytes).unwrap();
let nr2 = response_json["nr"].as_str().unwrap();
let payload = TransferData {
from: nr.to_string(),
to: nr2.to_string(),
amount: 5_f64,
};
let req = test::TestRequest::post()
.uri("/accounts/transfer")
.set_form(&payload)
.to_request();
let resp = test::call_service(&app, req).await;
assert!(resp.status().is_success());
let req = test::TestRequest::get()
.uri(&format!("/accounts/{}", nr2))
.to_request();
let resp = test::call_service(&app, req).await;
assert!(resp.status().is_success());
let body_bytes = to_bytes(resp.into_body()).await.unwrap();
let response_json: Value = serde_json::from_slice(&body_bytes).unwrap();
let balance = response_json["balance"].as_f64().unwrap();
assert_eq!(5_f64, balance);
let req = test::TestRequest::post()
.uri(&format!("/accounts/{}/close", nr))
.to_request();
let resp = test::call_service(&app, req).await;
assert!(!resp.status().is_success());
let payload = AmountData { amount: 5_f64 };
let req = test::TestRequest::post()
.uri(&format!("/accounts/{}/withdraw", nr))
.set_form(&payload)
.to_request();
let resp = test::call_service(&app, req).await;
assert!(resp.status().is_success());
let req = test::TestRequest::get().uri("/accounts").to_request();
let resp = test::call_service(&app, req).await;
assert!(resp.status().is_success());
let body_bytes = to_bytes(resp.into_body()).await.unwrap();
let response_json: Value = serde_json::from_slice(&body_bytes).unwrap();
let nrs = response_json.as_array().unwrap();
assert_eq!(2, nrs.len());
let req = test::TestRequest::post()
.uri(&format!("/accounts/{}/close", nr))
.to_request();
let resp = test::call_service(&app, req).await;
assert!(resp.status().is_success());
let req = test::TestRequest::get().uri("/accounts").to_request();
let resp = test::call_service(&app, req).await;
assert!(resp.status().is_success());
let body_bytes = to_bytes(resp.into_body()).await.unwrap();
let response_json: Value = serde_json::from_slice(&body_bytes).unwrap();
let nrs = response_json.as_array().unwrap();
assert_eq!(1, nrs.len());
}
}