Compare commits

..

10 Commits

13 changed files with 172 additions and 79 deletions

View File

@ -1,40 +0,0 @@
image: rust:1.59-alpine3.15
stages:
- test
- build
test:
stage: test
before_script:
- apk --no-cache add musl-dev
script:
- cargo test
audit:
stage: test
before_script:
- apk --no-cache add musl-dev cargo-audit
script:
- cargo audit
build-statically:
stage: build
before_script:
- apk --no-cache add musl-dev make rustup cargo
- rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl x86_64-pc-windows-gnu wasm32-unknown-unknown
- cargo install trunk
- wget https://musl.cc/aarch64-linux-musl-cross.tgz
- echo "c909817856d6ceda86aa510894fa3527eac7989f0ef6e87b5721c58737a06c38 aarch64-linux-musl-cross.tgz" | sha256sum -c - || exit 1
- tar -zxvf aarch64-linux-musl-cross.tgz -C / --exclude='aarch64-linux-musl-cross/usr' --strip 1
- wget https://musl.cc/x86_64-w64-mingw32-cross.tgz
- echo "3a5c90309209a8b2e7ea1715a34b1029692e34189c5e7ecd77e1f102f82f6a02 x86_64-w64-mingw32-cross.tgz" | sha256sum -c - || exit 1
- tar -zxvf x86_64-w64-mingw32-cross.tgz -C / --exclude='./x86_64-w64-mingw32-cross/usr' --strip 2
- wget https://musl.cc/x86_64-linux-musl-cross.tgz
- echo "c5d410d9f82a4f24c549fe5d24f988f85b2679b452413a9f7e5f7b956f2fe7ea x86_64-linux-musl-cross.tgz" | sha256sum -c - || exit 1
- tar -zxvf x86_64-linux-musl-cross.tgz -C / --exclude='x86_64-linux-musl-cross/usr' --strip 1
script:
- make all
artifacts:
paths:
- release/*

27
.woodpecker.yml Normal file
View File

@ -0,0 +1,27 @@
pipeline:
test:
image: docker.io/rust:1.59-alpine3.15
commands:
- apk --no-cache add musl-dev
- cargo test
audit:
image: docker.io/rust:1.59-alpine3.15
commands:
- apk --no-cache add musl-dev cargo-audit
- cargo audit
build-statically:
image: docker.io/rust:1.59-alpine3.15
commands:
- apk --no-cache add musl-dev make rustup cargo
- rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl x86_64-pc-windows-gnu wasm32-unknown-unknown
- cargo install trunk
- wget https://musl.cc/aarch64-linux-musl-cross.tgz
- echo "c909817856d6ceda86aa510894fa3527eac7989f0ef6e87b5721c58737a06c38 aarch64-linux-musl-cross.tgz" | sha256sum -c - || exit 1
- tar -zxvf aarch64-linux-musl-cross.tgz -C / --exclude='aarch64-linux-musl-cross/usr' --strip 1
- wget https://musl.cc/x86_64-w64-mingw32-cross.tgz
- echo "3a5c90309209a8b2e7ea1715a34b1029692e34189c5e7ecd77e1f102f82f6a02 x86_64-w64-mingw32-cross.tgz" | sha256sum -c - || exit 1
- tar -zxvf x86_64-w64-mingw32-cross.tgz -C / --exclude='./x86_64-w64-mingw32-cross/usr' --strip 2
- wget https://musl.cc/x86_64-linux-musl-cross.tgz
- echo "c5d410d9f82a4f24c549fe5d24f988f85b2679b452413a9f7e5f7b956f2fe7ea x86_64-linux-musl-cross.tgz" | sha256sum -c - || exit 1
- tar -zxvf x86_64-linux-musl-cross.tgz -C / --exclude='x86_64-linux-musl-cross/usr' --strip 1
- make all

View File

@ -1,3 +1,4 @@
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use thiserror::Error; use thiserror::Error;
@ -25,6 +26,23 @@ pub struct Account {
pub is_active: bool, pub is_active: bool,
} }
impl Account {
pub fn hash_string(&self) -> String {
let mut hasher = DefaultHasher::new();
self.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
}
impl Hash for Account {
fn hash<H: Hasher>(&self, state: &mut H) {
self.number.hash(state);
self.owner.hash(state);
self.balance.to_string().hash(state);
self.is_active.hash(state);
}
}
impl Default for Account { impl Default for Account {
fn default() -> Self { fn default() -> Self {
Account { Account {
@ -42,12 +60,6 @@ impl PartialEq for Account {
} }
} }
impl Hash for Account {
fn hash<H: Hasher>(&self, state: &mut H) {
self.number.hash(state);
}
}
impl Account { impl Account {
#[cfg(test)] #[cfg(test)]
pub fn new() -> Self { pub fn new() -> Self {

View File

@ -13,11 +13,11 @@ impl Bank {
Default::default() Default::default()
} }
pub fn account_action<F: Fn(&mut Account) -> Result<f64, AccountError>>( pub fn account_action<F: Fn(&mut Account) -> Result<R, AccountError>, R>(
bank: RwLockReadGuard<'_, Bank>, bank: RwLockReadGuard<'_, Bank>,
nr: &str, nr: &str,
action: F, action: F,
) -> Result<f64, AccountError> { ) -> Result<R, AccountError> {
match bank.accounts.get(nr) { match bank.accounts.get(nr) {
None => Err(AccountError::NotFound), None => Err(AccountError::NotFound),
Some(account) => { Some(account) => {

View File

@ -4,5 +4,6 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Vesys Bank</title> <title>Vesys Bank</title>
<link data-trunk rel="css" type="text/css" href="styles.css"> <link data-trunk rel="css" type="text/css" href="styles.css">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
</head> </head>
</html> </html>

View File

@ -27,8 +27,14 @@ pub async fn fetch_account(host: &str, nr: &str) -> Result<JsonAccount, FetchErr
} }
} }
pub async fn set_balance(host: &str, nr: &str, balance: f64) -> Result<(), FetchError> { pub async fn set_balance(
host: &str,
nr: &str,
balance: f64,
etag: String,
) -> Result<(), FetchError> {
let data = JsValue::from_str(&format!("amount={}", balance)); let data = JsValue::from_str(&format!("amount={}", balance));
client::put(&format! {"{}/accounts/{}", host, nr}, data).await?; client::put(&format! {"{}/accounts/{}", host, nr}, data, Some(etag)).await?;
Ok(()) Ok(())
} }

View File

@ -53,7 +53,7 @@ pub async fn get_json(url: &str) -> Result<Option<String>, FetchError> {
Ok(text.as_string()) Ok(text.as_string())
} }
pub async fn put(url: &str, data: JsValue) -> Result<(), FetchError> { pub async fn put(url: &str, data: JsValue, if_match: Option<String>) -> Result<(), FetchError> {
let mut opts = RequestInit::new(); let mut opts = RequestInit::new();
opts.method("PUT"); opts.method("PUT");
opts.mode(RequestMode::Cors); opts.mode(RequestMode::Cors);
@ -63,6 +63,11 @@ pub async fn put(url: &str, data: JsValue) -> Result<(), FetchError> {
request request
.headers() .headers()
.set("Content-Type", "application/x-www-form-urlencoded")?; .set("Content-Type", "application/x-www-form-urlencoded")?;
if let Some(etag) = if_match {
request.headers().set("If-match", &etag)?;
}
self::send_request(request).await?; self::send_request(request).await?;
Ok(()) Ok(())

View File

@ -159,9 +159,9 @@ impl Component for Account {
</select> </select>
</div> </div>
<button onclick={on_deposit} class={classes!("account__button")} disabled={!self.amount_valid}>{"deposit"}</button> <button onclick={on_deposit} class={classes!("account__button", "account__deposit")} disabled={!self.amount_valid}>{"deposit"}</button>
<button onclick={on_withdraw} class={classes!("account__button")} disabled={!self.amount_valid}>{"withdraw"}</button> <button onclick={on_withdraw} class={classes!("account__button", "account__withdraw")} disabled={!self.amount_valid}>{"withdraw"}</button>
<button onclick={on_transfer} class={classes!("account__button")} disabled={!self.amount_valid || !self.is_transfer_account_valid()}>{"transfer"}</button> <button onclick={on_transfer} class={classes!("account__button", "account__transfer")} disabled={!self.amount_valid || !self.is_transfer_account_valid()}>{"transfer"}</button>
</fieldset> </fieldset>
</section> </section>
</> </>

View File

@ -40,9 +40,13 @@ impl Component for Accounts {
fn view(&self, ctx: &Context<Self>) -> Html { fn view(&self, ctx: &Context<Self>) -> Html {
html! { html! {
<> <>
<ul class={classes!("accounts")}> if ctx.props().account_nrs.is_empty() {
{ for ctx.props().account_nrs.iter().map(|e| self.account_entry(e, &ctx.props().selected_nr, ctx)) } <h2>{"No accounts"}</h2>
</ul> } else {
<ul class={classes!("accounts")}>
{ for ctx.props().account_nrs.iter().map(|e| self.account_entry(e, &ctx.props().selected_nr, ctx)) }
</ul>
}
</> </>
} }
} }

View File

@ -77,8 +77,17 @@ impl Component for Main {
true true
} }
Event::SetBalance(balance, nr) => { Event::SetBalance(balance, nr) => {
let acc = bank::account::Account {
number: nr.clone(),
owner: self.selected_owner.clone(),
balance: self.selected_balance,
is_active: true,
};
let hash = acc.hash_string();
ctx.link().send_future(async move { ctx.link().send_future(async move {
match api::set_balance("http://localhost:8000", &nr, balance).await { match api::set_balance("http://localhost:8000", &nr, balance, hash).await {
Err(e) => Event::ShowError(e.to_string()), Err(e) => Event::ShowError(e.to_string()),
Ok(_) => Event::SelectAccountNr(nr), Ok(_) => Event::SelectAccountNr(nr),
} }
@ -99,8 +108,12 @@ impl Component for Main {
<h1 class={classes!("title")}> <h1 class={classes!("title")}>
{"welcome to your vaults"} {"welcome to your vaults"}
</h1> </h1>
<Accounts account_nrs={self.account_nrs.clone()} selected_nr={self.selected_nr.clone()} /> <div class={classes!("inner-content")}>
<Account balance={self.selected_balance} owner={self.selected_owner.clone()} nr={self.selected_nr.clone()} account_nrs={self.account_nrs.clone()} /> <Accounts account_nrs={self.account_nrs.clone()} selected_nr={self.selected_nr.clone()} />
if !self.account_nrs.is_empty() {
<Account balance={self.selected_balance} owner={self.selected_owner.clone()} nr={self.selected_nr.clone()} account_nrs={self.account_nrs.clone()} />
}
</div>
</main> </main>
</> </>
} }

View File

@ -7,12 +7,19 @@ html, body {
} }
.title { .title {
margin: 0; margin-top: 0;
}
.inner-content {
display: flex;
justify-content: flex-start;
gap: 10px;
} }
.account { .account {
border-style: solid; border-style: solid;
border-color: black; border-color: black;
flex-grow: 1;
} }
.account__grid { .account__grid {
@ -25,6 +32,7 @@ html, body {
background-color: black; background-color: black;
color: white; color: white;
padding: 3px 6px; padding: 3px 6px;
margin-left: 7px;
} }
.account__label { .account__label {
@ -47,6 +55,21 @@ html, body {
flex-direction: column; flex-direction: column;
} }
.account__deposit {
grid-column: 1;
grid-row: 2;
}
.account__withdraw {
grid-column: 2;
grid-row: 2;
}
.account__transfer {
grid-column: 3;
grid-row: 2;
}
.account__button { .account__button {
color: black; color: black;
border-radius: 0; border-radius: 0;
@ -86,6 +109,9 @@ html, body {
.accounts { .accounts {
list-style-type: none; list-style-type: none;
display: inline-block;
min-width: min-content;
margin: 0;
padding: 0.5em; padding: 0.5em;
border-style: solid; border-style: solid;
border-color: black; border-color: black;
@ -114,14 +140,19 @@ html, body {
min-width: 370px; min-width: 370px;
} }
@media (max-width: 605px) { @media (max-width: 1005px) {
.account__grid { .inner-content {
display: grid; flex-direction: column;
grid-template-columns: 1fr; }
} }
.account__accounts { @media (max-width: 615px) {
grid-column: 1; .accounts {
grid-row: 2; display: block;
}
.account__grid {
display: flex;
flex-direction: column;
} }
} }

View File

@ -1,9 +1,7 @@
use actix_web::{put, web, HttpResponse, Responder, Result}; use actix_web::{put, web, HttpRequest, HttpResponse, Responder, Result};
use bank::account::AccountError; use bank::account::AccountError;
use serde::Serialize; use serde::Serialize;
use bank::bank::Bank;
use crate::handlers::error::HttpAccountError; use crate::handlers::error::HttpAccountError;
use crate::handlers::AmountData; use crate::handlers::AmountData;
use crate::AppState; use crate::AppState;
@ -18,6 +16,7 @@ pub async fn route(
info: web::Path<String>, info: web::Path<String>,
form: web::Form<AmountData>, form: web::Form<AmountData>,
data: web::Data<AppState>, data: web::Data<AppState>,
req: HttpRequest,
) -> Result<impl Responder> { ) -> Result<impl Responder> {
let nr = info.into_inner(); let nr = info.into_inner();
let amount = form.amount; let amount = form.amount;
@ -29,11 +28,22 @@ pub async fn route(
info!("updating account {}...", nr); info!("updating account {}...", nr);
let bank = data.bank.read().unwrap(); let bank = data.bank.read().unwrap();
match Bank::account_action(bank, &nr, |account| { match bank.accounts.get(&nr) {
account.balance = amount; None => Err(HttpAccountError(AccountError::NotFound).into()),
Ok(amount) Some(account) => {
}) { let mut account = account.write().unwrap();
Err(e) => Err(HttpAccountError(e).into()), let hash = account.hash_string();
Ok(_) => Ok(HttpResponse::Ok().finish()), if let Some(etag) = req.headers().get("If-Match") {
let etag = etag.to_str().unwrap_or("");
if etag != hash {
Ok(HttpResponse::PreconditionFailed().finish())
} else {
account.balance = amount;
Ok(HttpResponse::Ok().finish())
}
} else {
Ok(HttpResponse::PreconditionRequired().finish())
}
}
} }
} }

View File

@ -155,6 +155,30 @@ mod tests {
.set_form(&payload) .set_form(&payload)
.to_request(); .to_request();
let resp = test::call_service(&app, req).await; let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::PRECONDITION_REQUIRED);
let payload = AmountData { amount: 10_f64 };
let req = test::TestRequest::put()
.uri(&format!("/accounts/{}", nr))
.append_header((actix_web::http::header::IF_MATCH, "I aspire to be an etag"))
.set_form(&payload)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::PRECONDITION_FAILED);
let acc = bank::account::Account {
number: nr.to_string(),
owner: "aaa".to_string(),
balance: 5_f64,
is_active: true,
};
let payload = AmountData { amount: 10_f64 };
let req = test::TestRequest::put()
.uri(&format!("/accounts/{}", nr))
.append_header((actix_web::http::header::IF_MATCH, acc.hash_string()))
.set_form(&payload)
.to_request();
let resp = test::call_service(&app, req).await;
assert!(resp.status().is_success()); assert!(resp.status().is_success());
let req = test::TestRequest::put() let req = test::TestRequest::put()