From 6444464b1790e9de9c8a9295445c34d9de09faf7 Mon Sep 17 00:00:00 2001 From: tv Date: Fri, 30 Oct 2026 21:11:06 +0100 Subject: api: support list indexes in path --- src/api/store.rs | 103 +++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 81 insertions(+), 22 deletions(-) (limited to 'src/api') diff --git a/src/api/store.rs b/src/api/store.rs index 89123e2..f60d30d 100644 --- a/src/api/store.rs +++ b/src/api/store.rs @@ -6,10 +6,10 @@ use automerge::ChangeHash; use axum::{ Extension, body::{Body, Bytes}, - extract::{Path, Query, State}, + extract::{Path, Query, RawQuery, State}, http::{ HeaderMap, HeaderValue, StatusCode, - header::{ACCEPT, CONTENT_TYPE}, + header::{ACCEPT, CONTENT_TYPE, LOCATION}, }, response::{IntoResponse, Response}, }; @@ -38,13 +38,6 @@ 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)] @@ -103,8 +96,12 @@ pub(crate) async fn get_store_entry( Path(key): Path, headers: HeaderMap, Query(query): Query, + RawQuery(raw_query): RawQuery, ) -> Result { let path = parse_key_path(&key)?; + if let Some(redirect) = to_positive(&state, &path, false, raw_query.as_deref()).await? { + return Ok(redirect); + } if query.shallow { return shallow(&state, path, false, &query).await; } @@ -201,6 +198,7 @@ pub(crate) async fn put_store_entry( State(state): State, Extension(user): Extension, Path(key): Path, + RawQuery(raw_query): RawQuery, headers: HeaderMap, body: Bytes, ) -> Result { @@ -209,6 +207,9 @@ pub(crate) async fn put_store_entry( } let path = parse_write_path(&state, &user, &key, "PUT")?; + if let Some(redirect) = to_positive(&state, &path, false, raw_query.as_deref()).await? { + return Ok(redirect); + } let value = body_value(&headers, &body)?; let written = value.clone(); @@ -241,14 +242,15 @@ fn body_value(headers: &HeaderMap, body: &Bytes) -> Result, Extension(user): Extension, Path(key): Path, - Query(query): Query, + RawQuery(raw_query): RawQuery, headers: HeaderMap, body: Bytes, ) -> Result { @@ -256,16 +258,16 @@ pub(crate) async fn post_store_entry( 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)?), - }; + if let Some(redirect) = to_positive(&state, &path, true, raw_query.as_deref()).await? { + return Ok(redirect); + } + let index = path.last().and_then(|leaf| store::list_index(leaf)); let value = body_value(&headers, &body)?; let inserted = state - .mutate_doc(move |doc| store::insert_path(doc, &path, index, &value)) + .mutate_doc(move |doc| store::insert_path(doc, &path, &value)) .await; match inserted { - Ok(index) => Ok(( + Ok(()) => Ok(( StatusCode::CREATED, axum::Json(serde_json::json!({ "index": index })), ) @@ -284,7 +286,8 @@ pub(crate) async fn delete_store_entry( Extension(user): Extension, Path(key): Path, Query(query): Query, -) -> Result { + RawQuery(raw_query): RawQuery, +) -> Result { let path = parse_write_path(&state, &user, &key, "DELETE")?; tracing::Span::current().record("path", path.join("/").as_str()); @@ -297,13 +300,69 @@ pub(crate) async fn delete_store_entry( return Err(StatusCode::BAD_REQUEST); } + if let Some(redirect) = to_positive(&state, &path, false, raw_query.as_deref()).await? { + return Ok(redirect); + } + let prune = query.prune; state - .mutate_doc(move |doc| store::delete_path(doc, &path, prune)) + .mutate_doc(move |doc| store::delete_at(doc, &path, prune)) .await .map_err(|e| store_status(e, "Failed to delete store value"))?; - Ok(StatusCode::NO_CONTENT) + Ok(StatusCode::NO_CONTENT.into_response()) +} + +/// A path with negative list indexes (`-1` the last item; for an insert, the end): a +/// `307` to it with them made positive (`store::positive_path`), the query kept, so a +/// client reads, writes or deletes what the index names now. None without any. +async fn to_positive( + state: &AppState, + path: &[String], + insertion: bool, + query: Option<&str>, +) -> Result, StatusCode> { + if !path.iter().any(|segment| segment.starts_with('-')) { + return Ok(None); + } + let positive = { + let replica = state.doc.read().await; + store::positive_path(&replica.doc, path, insertion) + } + .map_err(|e| store_status(e, "Failed to resolve a negative index"))?; + let Some(positive) = positive else { + return Ok(None); + }; + let mut location = format!( + "/store/{}", + positive + .iter() + .map(|segment| encode_segment(segment)) + .collect::>() + .join("/") + ); + if let Some(query) = query.filter(|query| !query.is_empty()) { + location.push('?'); + location.push_str(query); + } + let location = HeaderValue::from_str(&location).map_err(|_| StatusCode::BAD_REQUEST)?; + Ok(Some( + (StatusCode::TEMPORARY_REDIRECT, [(LOCATION, location)]).into_response(), + )) +} + +/// `segment` as a URL path segment: unreserved characters as they are, the rest +/// percent-encoded (UTF-8). +fn encode_segment(segment: &str) -> String { + let mut encoded = String::with_capacity(segment.len()); + for byte in segment.bytes() { + if byte.is_ascii_alphanumeric() || b"-._~".contains(&byte) { + encoded.push(char::from(byte)); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + encoded } // --- streams ----------------------------------------------------------------- -- cgit v1.3.1