transaction: add CSV deserialization
It is unfortunate that both [1] and [2] conspire to make this code way worse than it could otherwise be with a saner (de)serialization format. We both need to introduce `TransactionRecord` due to tagged enums not being powerful enough in CSV, and make its `amount` field optional to deal with the varying number of fields for each kind of transaction. [1]: https://github.com/BurntSushi/rust-csv/issues/211 [2]: https://github.com/BurntSushi/rust-csv/issues/172
This commit is contained in:
parent
c7e64692e6
commit
ffd6a20a30
4 changed files with 621 additions and 5 deletions
19
src/core.rs
19
src/core.rs
|
|
@ -1,16 +1,27 @@
|
|||
//! Core types used in the processing of payments.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::{serde_as, DisplayFromStr};
|
||||
|
||||
/// Clients are anonymous, identified by globally unique ids. "16-bit ought to be enough for
|
||||
/// anyone".
|
||||
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[derive(
|
||||
Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize,
|
||||
)]
|
||||
#[serde(transparent)]
|
||||
pub struct ClientId(pub u16);
|
||||
|
||||
/// Transactions are identified by a globally unique id. 32 bit is sufficient for our puposes.
|
||||
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[derive(
|
||||
Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize,
|
||||
)]
|
||||
#[serde(transparent)]
|
||||
pub struct TxId(pub u32);
|
||||
|
||||
/// Amounts are represented as exact decimals, up to four places past the decimal.
|
||||
/// For ease of implementation, make use of [fpdec::Decimal] instead of implementing a custom
|
||||
/// fixed-point number.
|
||||
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct TxAmount(pub fpdec::Decimal);
|
||||
#[serde_as]
|
||||
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct TxAmount(#[serde_as(as = "DisplayFromStr")] pub fpdec::Decimal);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
//! Define all supported transactions.
|
||||
use crate::core::{ClientId, TxAmount, TxId};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// A generic [Transaction].
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
|
||||
#[serde(try_from = "TransactionRecord")]
|
||||
pub enum Transaction {
|
||||
Deposit(Deposit),
|
||||
Withdrawal(Withdrawal),
|
||||
|
|
@ -11,6 +14,61 @@ pub enum Transaction {
|
|||
Chargeback(Chargeback),
|
||||
}
|
||||
|
||||
impl Transaction {
|
||||
/// Build a [csv::ReaderBuilder] configured to read a CSV formatted [Transaction] stream.
|
||||
pub fn configured_csv_reader_builder() -> csv::ReaderBuilder {
|
||||
let mut builder = csv::ReaderBuilder::new();
|
||||
builder
|
||||
// Expect header input
|
||||
.has_headers(true)
|
||||
// Allow whitespace
|
||||
.trim(csv::Trim::All)
|
||||
// Allow trailing fields to be omitted
|
||||
.flexible(true);
|
||||
builder
|
||||
}
|
||||
}
|
||||
|
||||
// A type used to deserialize [Transaction] from an input CSV stream.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
|
||||
struct TransactionRecord<'a> {
|
||||
#[serde(rename = "type")]
|
||||
type_: &'a str,
|
||||
client: ClientId,
|
||||
tx: TxId,
|
||||
amount: Option<TxAmount>,
|
||||
}
|
||||
|
||||
impl TryFrom<TransactionRecord<'_>> for Transaction {
|
||||
// FIXME: use an actual error type.
|
||||
type Error = String;
|
||||
|
||||
fn try_from(value: TransactionRecord<'_>) -> Result<Self, Self::Error> {
|
||||
let TransactionRecord {
|
||||
type_,
|
||||
client,
|
||||
tx,
|
||||
amount,
|
||||
} = value;
|
||||
|
||||
let transaction = match type_ {
|
||||
"deposit" => {
|
||||
let amount = amount.ok_or("Missing amount for transaction")?;
|
||||
Transaction::Deposit(Deposit { client, tx, amount })
|
||||
}
|
||||
"withdrawal" => {
|
||||
let amount = amount.ok_or("Missing amount for transaction")?;
|
||||
Transaction::Withdrawal(Withdrawal { client, tx, amount })
|
||||
}
|
||||
"dispute" => Transaction::Dispute(Dispute { client, tx }),
|
||||
"resolve" => Transaction::Resolve(Resolve { client, tx }),
|
||||
"chargeback" => Transaction::Chargeback(Chargeback { client, tx }),
|
||||
_ => return Err(format!("Unkown transaction type '{}'", type_)),
|
||||
};
|
||||
Ok(transaction)
|
||||
}
|
||||
}
|
||||
|
||||
/// Deposit funds into an account, i.e: increase its balance by the amount given.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct Deposit {
|
||||
|
|
@ -55,3 +113,117 @@ pub struct Chargeback {
|
|||
pub client: ClientId,
|
||||
pub tx: TxId,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use fpdec::{Dec, Decimal};
|
||||
|
||||
fn parse_transaction(input: &str) -> Transaction {
|
||||
let rdr = Transaction::configured_csv_reader_builder().from_reader(input.as_bytes());
|
||||
rdr.into_deserialize().next().unwrap().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_deposit() {
|
||||
let data = "type,client,tx,amount\ndeposit,1,2,3.0";
|
||||
assert_eq!(
|
||||
parse_transaction(data),
|
||||
Transaction::Deposit(Deposit {
|
||||
client: ClientId(1),
|
||||
tx: TxId(2),
|
||||
amount: TxAmount(Dec!(3.0))
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_withdrawal() {
|
||||
let data = "type,client,tx,amount\nwithdrawal,1,2,3.0";
|
||||
assert_eq!(
|
||||
parse_transaction(data),
|
||||
Transaction::Withdrawal(Withdrawal {
|
||||
client: ClientId(1),
|
||||
tx: TxId(2),
|
||||
amount: TxAmount(Dec!(3.0))
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_dispute() {
|
||||
let data = "type,client,tx,amount\ndispute,1,2";
|
||||
assert_eq!(
|
||||
parse_transaction(data),
|
||||
Transaction::Dispute(Dispute {
|
||||
client: ClientId(1),
|
||||
tx: TxId(2),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_resolve() {
|
||||
let data = "type,client,tx,amount\nresolve,1,2";
|
||||
assert_eq!(
|
||||
parse_transaction(data),
|
||||
Transaction::Resolve(Resolve {
|
||||
client: ClientId(1),
|
||||
tx: TxId(2),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_chargeback() {
|
||||
let data = "type,client,tx,amount\nchargeback,1,2";
|
||||
assert_eq!(
|
||||
parse_transaction(data),
|
||||
Transaction::Chargeback(Chargeback {
|
||||
client: ClientId(1),
|
||||
tx: TxId(2),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_transactions() {
|
||||
let data = concat!(
|
||||
"type,client,tx,amount\n",
|
||||
"deposit, 1, 2, 12.0000\n",
|
||||
"withdrawal , 3,4, 42.27 \n",
|
||||
"dispute, 5 , 6, \n",
|
||||
" resolve,7,8,\n",
|
||||
"chargeback,9,10",
|
||||
);
|
||||
let rdr = Transaction::configured_csv_reader_builder().from_reader(data.as_bytes());
|
||||
let transactions: Result<Vec<Transaction>, _> = rdr.into_deserialize().collect();
|
||||
assert_eq!(
|
||||
transactions.unwrap(),
|
||||
vec![
|
||||
Transaction::Deposit(Deposit {
|
||||
client: ClientId(1),
|
||||
tx: TxId(2),
|
||||
amount: TxAmount(Dec!(12.0000)),
|
||||
}),
|
||||
Transaction::Withdrawal(Withdrawal {
|
||||
client: ClientId(3),
|
||||
tx: TxId(4),
|
||||
amount: TxAmount(Dec!(42.27)),
|
||||
}),
|
||||
Transaction::Dispute(Dispute {
|
||||
client: ClientId(5),
|
||||
tx: TxId(6),
|
||||
}),
|
||||
Transaction::Resolve(Resolve {
|
||||
client: ClientId(7),
|
||||
tx: TxId(8),
|
||||
}),
|
||||
Transaction::Chargeback(Chargeback {
|
||||
client: ClientId(9),
|
||||
tx: TxId(10),
|
||||
}),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue