diff options
| author | tv <tv@krebsco.de> | 2026-10-30 20:34:47 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-10-30 20:34:47 +0100 |
| commit | a29a38db0ad7fc8d42a533145c8a489d97702fb5 (patch) | |
| tree | ae1689804484d2aceabf106bb82cd4f0fcf97b36 | |
| parent | caf212f916ce8fb31d7dba8bbe6d537092d324d5 (diff) | |
add support to insert values at list index
| -rw-r--r-- | docs/openapi.yaml | 48 | ||||
| -rw-r--r-- | src/api/mod.rs | 2 | ||||
| -rw-r--r-- | src/api/store.rs | 76 | ||||
| -rw-r--r-- | src/document/store.rs | 77 |
4 files changed, 188 insertions, 15 deletions
diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 5ccafb8..09d0bc6 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -129,6 +129,54 @@ paths: description: Writing failed. '503': description: Node is not ready. + post: + tags: [Store] + summary: Insert into a list + description: | + Inserts the value (as `PUT` takes it) into the list at the path: at `index`, + the items from there on moving up one, or at the end without one (or with + `-1`). One request is one Automerge change. To add a key to a map, `PUT` it. + parameters: + - name: index + in: query + required: false + description: Where, from 0 to the list's length (that's the end); `-1` is the end too. + schema: + type: integer + minimum: -1 + requestBody: + required: true + content: + application/json: + schema: {} + text/plain: + schema: + type: string + responses: + '201': + description: Inserted, and where. + content: + application/json: + schema: + type: object + required: [index] + properties: + index: + type: integer + '400': + description: Invalid path, index, malformed JSON or invalid UTF-8. + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: As for `PUT`. + '404': + description: Nothing at this path. + '409': + description: Not a list there, or the index is past its end (the message says which, and the length). + '415': + description: Content-Type is neither `application/json` nor `text/plain` (absent means JSON). + '503': + description: Node is not ready. delete: tags: [Store] summary: Delete a value 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 @@ -39,6 +39,13 @@ pub(crate) struct ReadQuery { } #[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<i64>, +} + +#[derive(Debug, Deserialize)] pub(crate) struct DeleteQuery { #[serde(default)] prune: bool, @@ -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<JsonValue, StatusCode> { 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::<JsonValue>(&body).map_err(|_| StatusCode::BAD_REQUEST)? + serde_json::from_slice::<JsonValue>(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<AppState>, + Extension(user): Extension<AuthenticatedUser>, + Path(key): Path<String>, + Query(query): Query<InsertQuery>, + headers: HeaderMap, + body: Bytes, +) -> Result<Response, StatusCode> { + 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<usize>, + value: &JsonValue, +) -> Result<usize, StoreError> { + 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<Vec<ObjId>, StoreError> { let mut maps = vec![automerge::ROOT]; @@ -806,3 +835,51 @@ fn nested<T>( } out } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn path(at: &str) -> Vec<String> { + 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) + )); + } +} |
