mod notifications;
mod observability;
mod orders;
mod payments;
mod service;
mod shipping;
mod state;
use std::collections::VecDeque;
use std::error::Error;
use std::sync::Arc;
use std::time::Duration;
use axum::extract::{Json, State};
use axum::http::{HeaderMap, StatusCode};
use axum::routing::post;
use ruststream::codec::JsonCodec;
use ruststream::memory::prelude::*;
use tokio::net::TcpListener;
use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken;
use crate::orders::{Order, RequestMeta, total_cents};
use crate::service::shop;
#[derive(Default)]
struct Store {
orders: Vec<Order>,
outbox: VecDeque<(RequestMeta, Order)>,
}
async fn place_order(
State(store): State<Arc<Mutex<Store>>>,
headers: HeaderMap,
Json(order): Json<Order>,
) -> (StatusCode, &'static str) {
let request_id = headers
.get("x-request-id")
.and_then(|value| value.to_str().ok())
.unwrap_or("unknown")
.to_owned();
if total_cents(&order.lines).is_none() {
return (
StatusCode::BAD_REQUEST,
"the order is past the shop's bounds\n",
);
}
{
let mut store = store.lock().await;
store.orders.push(order.clone());
store.outbox.push_back((RequestMeta { request_id }, order));
}
(StatusCode::ACCEPTED, "accepted\n")
}
async fn relay_pending(store: &Mutex<Store>, egress: &impl Publisher) {
loop {
let Some((meta, order)) = store.lock().await.outbox.front().cloned() else {
return;
};
let sent = egress
.message(&order)
.with_headers(&meta)
.with_codec(JsonCodec)
.publish()
.await;
if sent.is_err() {
return;
}
store.lock().await.outbox.pop_front();
}
}
async fn relay_outbox(
store: Arc<Mutex<Store>>,
egress: impl Publisher,
stop: impl Future<Output = ()>,
) {
let mut rounds = tokio::time::interval(Duration::from_millis(200));
tokio::pin!(stop);
loop {
tokio::select! {
biased;
() = &mut stop => break,
_ = rounds.tick() => relay_pending(&store, &egress).await,
}
}
relay_pending(&store, &egress).await;
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn Error>> {
ruststream::logging::init()?;
let broker = MemoryBroker::new().bindable();
let egress = broker.bind(Publish);
let running = shop(broker).start().await?;
let egress = running.publisher(egress).await?;
let store = Arc::new(Mutex::new(Store::default()));
let stop_relay = CancellationToken::new();
let relay = tokio::spawn(relay_outbox(
Arc::clone(&store),
egress,
stop_relay.clone().cancelled_owned(),
));
let router = axum::Router::new()
.route("/orders", post(place_order))
.with_state(store);
let listener = TcpListener::bind("127.0.0.1:8080").await?;
tracing::info!("storefront on http://127.0.0.1:8080/orders");
let stopping = running.stopping();
axum::serve(listener, router)
.with_graceful_shutdown(async move {
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
() = stopping => {}
}
})
.await?;
stop_relay.cancel();
relay.await?;
running.shutdown().await?;
Ok(())
}
#[cfg(test)]
mod tests {
use std::error::Error;
use std::future::ready;
use std::sync::Arc;
use axum::extract::{Json, State};
use axum::http::{HeaderMap, StatusCode};
use ruststream::memory::prelude::*;
use ruststream::testing::TestApp;
use tokio::sync::Mutex;
use super::{Store, place_order, relay_outbox, relay_pending};
use crate::orders::{Line, Order};
use crate::service::{shop, test_broker};
fn order(id: u64) -> Order {
Order {
id,
customer: "ada@example.com".to_owned(),
lines: vec![Line {
sku: "mug".to_owned(),
quantity: 2,
price_cents: 1_200,
}],
}
}
async fn place(store: &Arc<Mutex<Store>>, order: Order) -> StatusCode {
let mut headers = HeaderMap::new();
let request_id = format!("req-{}", order.id);
headers.insert("x-request-id", request_id.parse().expect("ascii"));
let (status, _) = place_order(State(Arc::clone(store)), headers, Json(order)).await;
status
}
async fn take(store: &Arc<Mutex<Store>>, id: u64) -> StatusCode {
place(store, order(id)).await
}
#[tokio::test]
async fn an_order_taken_over_http_reaches_the_shop() -> Result<(), Box<dyn Error>> {
let broker = test_broker().bindable();
let egress = broker.bind(Publish);
let tb = TestApp::start(shop(broker)).await?;
let egress = egress.live().await?;
let store = Arc::new(Mutex::new(Store::default()));
assert_eq!(take(&store, 1).await, StatusCode::ACCEPTED);
relay_pending(&store, &egress).await;
tb.settle().await?;
assert!(store.lock().await.outbox.is_empty());
tb.broker::<MemoryBroker>()
.published::<Order>("orders.placed")
.assert_called_once()
.with(&order(1))
.with_header("request_id", "req-1");
tb.broker::<MemoryBroker>()
.subscriber("payments.requested")
.assert_called_once()
.settled(HandlerOutcome::ack());
Ok(())
}
#[tokio::test]
async fn an_event_the_broker_refused_stays_in_the_outbox() -> Result<(), Box<dyn Error>> {
let broker = test_broker().bindable();
let egress = broker.bind(Publish);
let tb = TestApp::start(shop(broker)).await?;
let egress = egress.live().await?;
tb.shutdown().await?;
let store = Arc::new(Mutex::new(Store::default()));
take(&store, 2).await;
relay_pending(&store, &egress).await;
let store = store.lock().await;
assert_eq!(store.orders, [order(2)]);
assert_eq!(store.outbox.len(), 1);
Ok(())
}
#[tokio::test]
async fn an_order_past_the_bounds_is_refused() {
let store = Arc::new(Mutex::new(Store::default()));
for (quantity, price_cents) in [(2, 1 << 63), (u32::MAX, 0)] {
let line = Line {
sku: "mug".to_owned(),
quantity,
price_cents,
};
let order = Order {
lines: vec![line],
..order(5)
};
assert_eq!(place(&store, order).await, StatusCode::BAD_REQUEST);
}
assert_eq!(store.lock().await.orders, []);
assert!(store.lock().await.outbox.is_empty());
}
#[tokio::test]
async fn the_relay_hands_pending_rows_over_when_it_stops() -> Result<(), Box<dyn Error>> {
let broker = test_broker().bindable();
let egress = broker.bind(Publish);
let tb = TestApp::start(shop(broker)).await?;
let egress = egress.live().await?;
let store = Arc::new(Mutex::new(Store::default()));
take(&store, 3).await;
relay_outbox(Arc::clone(&store), egress, ready(())).await;
tb.settle().await?;
assert!(store.lock().await.outbox.is_empty());
tb.broker::<MemoryBroker>()
.published::<Order>("orders.placed")
.assert_called_once()
.with(&order(3));
Ok(())
}
}