feat(kanban): acknowledge idempotent server command
req: sync/001\nreq: sync/005\nreq: sync/006\nreq: sync/007\nreq: sync/008\nreq: sync/012\nreq: sync/013
This commit is contained in:
+148
-2
@@ -1,7 +1,9 @@
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use futures_util::{stream, StreamExt};
|
||||
use hemplate::Hemplate;
|
||||
use hemx::{Html, IntoEffect};
|
||||
@@ -12,6 +14,7 @@ use hemx_axum::{
|
||||
use hemx_kanban_example::ui::board::{self as board};
|
||||
use hemx_kanban_example::ui::board_card as card_board;
|
||||
use hemx_kanban_example::ui::{self, board as board_ui};
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::convert::Infallible;
|
||||
use std::net::SocketAddr;
|
||||
@@ -23,6 +26,58 @@ const COLUMNS: [(&str, &str); 3] = [("backlog", "Backlog"), ("doing", "Doing"),
|
||||
#[derive(Default)]
|
||||
struct AppState {
|
||||
board: Mutex<BoardState>,
|
||||
sync: Mutex<SyncState>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SyncState {
|
||||
next_sequence: u64,
|
||||
acknowledgements: BTreeMap<CommandId, SyncAcknowledgement>,
|
||||
reconnects: BTreeMap<String, u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
struct CommandId(String);
|
||||
|
||||
impl CommandId {
|
||||
fn parse(value: Option<&String>) -> Result<Self, SyncRejection> {
|
||||
let value = value.map(String::as_str).unwrap_or_default();
|
||||
if value.is_empty()
|
||||
|| value.len() > 128
|
||||
|| !value.bytes().all(|byte| {
|
||||
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b':' | b'.')
|
||||
})
|
||||
{
|
||||
return Err(SyncRejection::BadRequest("invalid command_id"));
|
||||
}
|
||||
Ok(Self(value.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SyncAcknowledgement {
|
||||
command_id: String,
|
||||
server_sequence: u64,
|
||||
card_id: u64,
|
||||
canonical_column: &'static str,
|
||||
status: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum SyncRejection {
|
||||
BadRequest(&'static str),
|
||||
Conflict(&'static str),
|
||||
}
|
||||
|
||||
impl IntoResponse for SyncRejection {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let (status, error) = match self {
|
||||
Self::BadRequest(error) => (StatusCode::BAD_REQUEST, error),
|
||||
Self::Conflict(error) => (StatusCode::CONFLICT, error),
|
||||
};
|
||||
(status, Json(serde_json::json!({ "error": error }))).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
@@ -114,11 +169,17 @@ async fn main() {
|
||||
},
|
||||
],
|
||||
}),
|
||||
sync: Mutex::new(SyncState {
|
||||
next_sequence: 1,
|
||||
..SyncState::default()
|
||||
}),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(home).post(interact))
|
||||
.route("/events", get(events))
|
||||
.route("/sync/commands", post(sync_command))
|
||||
.route("/sync/acknowledgements", get(sync_acknowledgements))
|
||||
.route(runtime_js_path(), get(runtime))
|
||||
.with_state(state);
|
||||
|
||||
@@ -172,6 +233,91 @@ async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResp
|
||||
sse(batches)
|
||||
}
|
||||
|
||||
// req: sync/001 req: sync/008 req: sync/012
|
||||
async fn sync_command(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<BTreeMap<String, String>>,
|
||||
) -> Result<Json<SyncAcknowledgement>, SyncRejection> {
|
||||
let command_id = CommandId::parse(params.get("command_id"))?;
|
||||
let card_id = params
|
||||
.get("card_id")
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.ok_or(SyncRejection::BadRequest("invalid card_id"))?;
|
||||
|
||||
let mut sync = state.sync.lock().unwrap();
|
||||
if let Some(existing) = sync.acknowledgements.get(&command_id) {
|
||||
if existing.card_id != card_id {
|
||||
return Err(SyncRejection::Conflict(
|
||||
"command_id was already used for a different payload",
|
||||
));
|
||||
}
|
||||
return Ok(Json(existing.clone()));
|
||||
}
|
||||
|
||||
let mut board = state.board.lock().unwrap();
|
||||
let card = board
|
||||
.cards
|
||||
.iter_mut()
|
||||
.find(|card| card.id == card_id)
|
||||
.ok_or(SyncRejection::BadRequest("unknown card_id"))?;
|
||||
card.column = 2;
|
||||
let acknowledgement = SyncAcknowledgement {
|
||||
command_id: command_id.0.clone(),
|
||||
server_sequence: sync.next_sequence,
|
||||
card_id,
|
||||
canonical_column: "done",
|
||||
status: "accepted",
|
||||
};
|
||||
sync.next_sequence += 1;
|
||||
sync.acknowledgements
|
||||
.insert(command_id, acknowledgement.clone());
|
||||
Ok(Json(acknowledgement))
|
||||
}
|
||||
|
||||
// req: sync/005 req: sync/006 req: sync/007 req: sync/013
|
||||
async fn sync_acknowledgements(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<BTreeMap<String, String>>,
|
||||
) -> Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>> {
|
||||
let after = params
|
||||
.get("after")
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or_default();
|
||||
let reconnect_key = params
|
||||
.get("reconnect")
|
||||
.filter(|value| !value.is_empty())
|
||||
.cloned();
|
||||
|
||||
let mut sync = state.sync.lock().unwrap();
|
||||
if let Some(key) = reconnect_key {
|
||||
let attempts = sync.reconnects.entry(key).or_default();
|
||||
*attempts += 1;
|
||||
if *attempts == 1 {
|
||||
return Sse::new(
|
||||
stream::iter([Ok(Event::default()
|
||||
.comment("reconnect")
|
||||
.retry(Duration::from_millis(25)))])
|
||||
.boxed(),
|
||||
)
|
||||
.keep_alive(KeepAlive::default());
|
||||
}
|
||||
}
|
||||
let events = sync
|
||||
.acknowledgements
|
||||
.values()
|
||||
.filter(|acknowledgement| acknowledgement.server_sequence > after)
|
||||
.map(|acknowledgement| {
|
||||
Ok(Event::default()
|
||||
.id(acknowledgement.server_sequence.to_string())
|
||||
.event("acknowledgement")
|
||||
.json_data(acknowledgement)
|
||||
.expect("serializable acknowledgement"))
|
||||
})
|
||||
.collect::<Vec<Result<Event, Infallible>>>();
|
||||
Sse::new(stream::iter(events).boxed()).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
fn registry(state: Arc<AppState>) -> impl DispatchRegistry {
|
||||
interactions(ui::BUILD_FINGERPRINT)
|
||||
.on(board::create_card, {
|
||||
|
||||
Reference in New Issue
Block a user