use ruststream::memory::prelude::*;
use ruststream::schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::notifications::{Notice, Notices};
use crate::payments::{INTERNAL, PaymentRequest};
use crate::state::Shop;
pub(crate) const MAX_LINES: usize = 100;
pub(crate) const MAX_QUANTITY: u32 = 1_000;
pub(crate) const MAX_PRICE_CENTS: u64 = 1_000_000;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
pub(crate) struct Line {
pub(crate) sku: String,
pub(crate) quantity: u32,
pub(crate) price_cents: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema, Outgoing)]
#[outgoing(name = "orders.placed", headers = RequestMeta)]
pub(crate) struct Order {
pub(crate) id: u64,
pub(crate) customer: String,
pub(crate) lines: Vec<Line>,
}
pub(crate) fn within_bounds(lines: &[Line]) -> bool {
lines.len() <= MAX_LINES
&& lines
.iter()
.all(|line| line.quantity <= MAX_QUANTITY && line.price_cents <= MAX_PRICE_CENTS)
}
pub(crate) fn total_cents(lines: &[Line]) -> Option<u64> {
if !within_bounds(lines) {
return None;
}
lines.iter().try_fold(0_u64, |total, line| {
total.checked_add(u64::from(line.quantity).checked_mul(line.price_cents)?)
})
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
pub(crate) struct RequestMeta {
pub(crate) request_id: String,
}
#[subscriber("orders.placed")]
async fn audit(order: &Order, Headers(meta): Headers<RequestMeta>) -> HandlerOutcome {
tracing::info!(
request = %meta.request_id,
order = order.id,
total_cents = total_cents(&order.lines),
"order placed",
);
HandlerOutcome::ack()
}
#[subscriber("orders.placed", reply)]
async fn accept(
order: &Order,
Headers(meta): Headers<RequestMeta>,
Out(notices): Out<impl Publisher, Notices>,
) -> Result<PaymentRequest, HandlerOutcome> {
if order.lines.is_empty() {
tracing::warn!(order = order.id, "an order with no lines, dropped");
return Err(HandlerOutcome::drop());
}
let Some(amount_cents) = total_cents(&order.lines) else {
tracing::warn!(order = order.id, "an order past the shop's bounds, dropped");
return Err(HandlerOutcome::drop());
};
let notice = Notice {
customer: order.customer.clone(),
text: format!("We have your order {}.", order.id),
};
if notices.message(¬ice).publish().await.is_err() {
return Err(HandlerOutcome::retry());
}
Ok(PaymentRequest {
order: order.id,
customer: order.customer.clone(),
lines: order.lines.clone(),
amount_cents,
request_id: meta.request_id,
})
}
pub(crate) fn routes() -> impl RouterDef<MemoryBroker, Shop> {
Router::new()
.include(audit)
.include(accept)
.out_reply(Publish)
.codec(INTERNAL)
.out(Notices, Publish)
.build()
}
#[cfg(test)]
mod tests {
use std::error::Error;
use ruststream::memory::prelude::*;
use ruststream::testing::TestApp;
use super::{Line, MAX_QUANTITY, Order, RequestMeta};
use crate::notifications::Notice;
use crate::payments::PaymentRequest;
use crate::service::{shop, test_broker};
fn order(id: u64, quantity: u32, price_cents: u64) -> Order {
Order {
id,
customer: "ada@example.com".to_owned(),
lines: vec![Line {
sku: "mug".to_owned(),
quantity,
price_cents,
}],
}
}
#[tokio::test]
async fn an_order_past_the_bounds_is_not_charged() -> Result<(), Box<dyn Error>> {
let tb = TestApp::start(shop(test_broker())).await?;
for placed in [order(10, MAX_QUANTITY + 1, 0), order(11, 2, 1 << 63)] {
let meta = RequestMeta {
request_id: format!("req-{}", placed.id),
};
tb.message(&placed).with_headers(&meta).publish().await?;
}
let broker = tb.broker::<MemoryBroker>();
broker
.published::<PaymentRequest>("payments.requested")
.assert_not_called();
broker.published::<Notice>("notices").assert_not_called();
Ok(())
}
}