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:
parent
c69654a924
commit
dac95b7dae
34 changed files with 1797 additions and 140 deletions
16
http-server/Cargo.toml
Normal file
16
http-server/Cargo.toml
Normal 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"
|
29
http-server/src/handlers/close_account.rs
Normal file
29
http-server/src/handlers/close_account.rs
Normal 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()),
|
||||
}
|
||||
}
|
29
http-server/src/handlers/create_account.rs
Normal file
29
http-server/src/handlers/create_account.rs
Normal 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 }))
|
||||
}
|
31
http-server/src/handlers/deposit.rs
Normal file
31
http-server/src/handlers/deposit.rs
Normal 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()),
|
||||
}
|
||||
}
|
53
http-server/src/handlers/error.rs
Normal file
53
http-server/src/handlers/error.rs
Normal 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)
|
||||
}
|
||||
}
|
44
http-server/src/handlers/get_account.rs
Normal file
44
http-server/src/handlers/get_account.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
11
http-server/src/handlers/get_account_nrs.rs
Normal file
11
http-server/src/handlers/get_account_nrs.rs
Normal 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()))
|
||||
}
|
16
http-server/src/handlers/mod.rs
Normal file
16
http-server/src/handlers/mod.rs
Normal 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,
|
||||
}
|
6
http-server/src/handlers/pong.rs
Normal file
6
http-server/src/handlers/pong.rs
Normal file
|
@ -0,0 +1,6 @@
|
|||
use actix_web::{get, HttpResponse};
|
||||
|
||||
#[get("/ping")]
|
||||
pub async fn route() -> HttpResponse {
|
||||
HttpResponse::Ok().finish()
|
||||
}
|
44
http-server/src/handlers/transfer.rs
Normal file
44
http-server/src/handlers/transfer.rs
Normal 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()),
|
||||
}
|
||||
}
|
31
http-server/src/handlers/withdraw.rs
Normal file
31
http-server/src/handlers/withdraw.rs
Normal 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
204
http-server/src/main.rs
Normal 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());
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue