From a29a38db0ad7fc8d42a533145c8a489d97702fb5 Mon Sep 17 00:00:00 2001 From: tv Date: Fri, 30 Oct 2026 20:34:47 +0100 Subject: add support to insert values at list index --- src/api/mod.rs | 2 ++ src/api/store.rs | 76 ++++++++++++++++++++++++++++++++++++++++---------- src/document/store.rs | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 15 deletions(-) (limited to 'src') diff --git a/src/api/mod.rs b/src/api/mod.rs index 3f60cd6..1f178e7 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -49,6 +49,7 @@ pub(crate) fn router( "/store/{*key}", get(store::get_store_entry) .put(store::put_store_entry) + .post(store::post_store_entry) .delete(store::delete_store_entry), ) .route("/stream", get(store::stream_store_root)) @@ -197,6 +198,7 @@ pub(crate) fn store_status(error: StoreError, context: &str) -> StatusCode { match error { StoreError::Conflict => StatusCode::CONFLICT, StoreError::NotFound => StatusCode::NOT_FOUND, + StoreError::NotAList | StoreError::PastTheEnd { .. } => StatusCode::CONFLICT, other => { error!(error = %format!("{other:#}"), "{context}"); StatusCode::INTERNAL_SERVER_ERROR diff --git a/src/api/store.rs b/src/api/store.rs index 7e8caed..89123e2 100644 --- a/src/api/store.rs +++ b/src/api/store.rs @@ -38,6 +38,13 @@ pub(crate) struct ReadQuery { shallow: bool, } +#[derive(Debug, Deserialize)] +pub(crate) struct InsertQuery { + /// Where in the list: the items from there on move up one. At the end without one, + /// or with `-1`. + index: Option, +} + #[derive(Debug, Deserialize)] pub(crate) struct DeleteQuery { #[serde(default)] @@ -202,7 +209,19 @@ pub(crate) async fn put_store_entry( } let path = parse_write_path(&state, &user, &key, "PUT")?; + let value = body_value(&headers, &body)?; + + let written = value.clone(); + state + .mutate_doc(move |doc| store::write_path(doc, &path, &written)) + .await + .map_err(|e| store_status(e, "Failed to write store value"))?; + response_for_value(&value, headers.get(ACCEPT)) +} + +/// A write's body as a value: JSON (the default), or text as a string. +fn body_value(headers: &HeaderMap, body: &Bytes) -> Result { let content_type = headers .get(CONTENT_TYPE) .and_then(|value| value.to_str().ok()) @@ -211,24 +230,51 @@ pub(crate) async fn put_store_entry( .next() .unwrap_or("") .trim(); - - let value = match content_type { + match content_type { "application/json" => { - serde_json::from_slice::(&body).map_err(|_| StatusCode::BAD_REQUEST)? + serde_json::from_slice::(body).map_err(|_| StatusCode::BAD_REQUEST) } - "text/plain" => JsonValue::String( - String::from_utf8(body.to_vec()).map_err(|_| StatusCode::BAD_REQUEST)?, - ), - _ => return Err(StatusCode::UNSUPPORTED_MEDIA_TYPE), - }; - - let written = value.clone(); - state - .mutate_doc(move |doc| store::write_path(doc, &path, &written)) - .await - .map_err(|e| store_status(e, "Failed to write store value"))?; + "text/plain" => String::from_utf8(body.to_vec()) + .map(JsonValue::String) + .map_err(|_| StatusCode::BAD_REQUEST), + _ => Err(StatusCode::UNSUPPORTED_MEDIA_TYPE), + } +} - response_for_value(&value, headers.get(ACCEPT)) +/// `POST /store/{key}`: inserts the body into the list at `key` (`store::insert_path`), +/// at `?index=` or at the end; `201` with where it went (`{"index": n}`). No list there, +/// or an index past its end: `409`, saying which. +pub(crate) async fn post_store_entry( + State(state): State, + Extension(user): Extension, + Path(key): Path, + Query(query): Query, + headers: HeaderMap, + body: Bytes, +) -> Result { + if !state.is_ready().await { + return Err(StatusCode::SERVICE_UNAVAILABLE); + } + let path = parse_write_path(&state, &user, &key, "POST")?; + let index = match query.index { + None | Some(-1) => None, + Some(index) => Some(usize::try_from(index).map_err(|_| StatusCode::BAD_REQUEST)?), + }; + let value = body_value(&headers, &body)?; + let inserted = state + .mutate_doc(move |doc| store::insert_path(doc, &path, index, &value)) + .await; + match inserted { + Ok(index) => Ok(( + StatusCode::CREATED, + axum::Json(serde_json::json!({ "index": index })), + ) + .into_response()), + Err(error @ (StoreError::NotAList | StoreError::PastTheEnd { .. })) => { + Ok((StatusCode::CONFLICT, error.to_string()).into_response()) + } + Err(error) => Err(store_status(error, "Failed to insert into a list")), + } } /// `DELETE /store/{key}`. diff --git a/src/document/store.rs b/src/document/store.rs index 89058fb..13a3fc3 100644 --- a/src/document/store.rs +++ b/src/document/store.rs @@ -15,6 +15,12 @@ pub(crate) enum StoreError { Conflict, #[error("value does not exist")] NotFound, + /// An insert where there is no list. + #[error("not a list")] + NotAList, + /// An insert past a list's end. + #[error("index {index} is past the list's end ({length} items)")] + PastTheEnd { index: usize, length: usize }, #[error(transparent)] Doc(#[from] automerge::AutomergeError), #[error(transparent)] @@ -357,6 +363,29 @@ pub(crate) fn write_path( Ok(()) } +/// Inserts `value` into the list at `path`: at `index` (the items from there on move +/// up one), or at the end without one. Returns where it went. An index past the end is +/// an error (`PastTheEnd`), as is a path without a list (`NotAList`, `NotFound`). +pub(crate) fn insert_path( + doc: &mut AutoCommit, + path: &[String], + index: Option, + value: &JsonValue, +) -> Result { + let list = match resolve_at(doc, path)? { + None => return Err(StoreError::NotFound), + Some(Resolved::Other(Value::Object(ObjType::List), id)) => id, + Some(_) => return Err(StoreError::NotAList), + }; + let length = doc.length(&list); + let index = index.unwrap_or(length); + if index > length { + return Err(StoreError::PastTheEnd { index, length }); + } + write_value(doc, &list, Slot::Index(index), value)?; + Ok(index) +} + /// The merged maps at `path`, creating missing maps on the way. fn ensure_maps(doc: &mut AutoCommit, path: &[String]) -> Result, StoreError> { let mut maps = vec![automerge::ROOT]; @@ -806,3 +835,51 @@ fn nested( } out } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn path(at: &str) -> Vec { + at.split('/').map(str::to_owned).collect() + } + + #[test] + fn inserts_into_lists() { + let mut doc = AutoCommit::new(); + write_path(&mut doc, &path("list"), &json!(["b", "d"])).unwrap(); + assert_eq!( + insert_path(&mut doc, &path("list"), Some(0), &json!("a")).unwrap(), + 0 + ); + assert_eq!( + insert_path(&mut doc, &path("list"), Some(2), &json!("c")).unwrap(), + 2 + ); + assert_eq!( + insert_path(&mut doc, &path("list"), None, &json!({"e": 5})).unwrap(), + 4 + ); + assert_eq!( + read_path(&doc, &path("list")).unwrap(), + Some(json!(["a", "b", "c", "d", {"e": 5}])) + ); + assert!(matches!( + insert_path(&mut doc, &path("list"), Some(6), &json!(1)), + Err(StoreError::PastTheEnd { + index: 6, + length: 5 + }) + )); + write_path(&mut doc, &path("map"), &json!({"k": 1})).unwrap(); + assert!(matches!( + insert_path(&mut doc, &path("map"), None, &json!(1)), + Err(StoreError::NotAList) + )); + assert!(matches!( + insert_path(&mut doc, &path("nothing"), None, &json!(1)), + Err(StoreError::NotFound) + )); + } +} -- cgit v1.3.1