aboutsummaryrefslogtreecommitdiffstats
path: root/src/api/store.rs
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-10-30 21:11:06 +0100
committertv <tv@krebsco.de>2026-10-30 21:11:06 +0100
commitfefd2e5c302fa99aca135ccec541adcb2d844cc6 (patch)
treea037a4dd503b2cbcb55bdc40de443e8ebb390699 /src/api/store.rs
parentc39a500fe07d755b507599f89df5f409934ba55c (diff)
api: support list indexes in path
Diffstat (limited to 'src/api/store.rs')
-rw-r--r--src/api/store.rs103
1 files changed, 81 insertions, 22 deletions
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},
};
@@ -39,13 +39,6 @@ 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,
@@ -103,8 +96,12 @@ pub(crate) async fn get_store_entry(
Path(key): Path<String>,
headers: HeaderMap,
Query(query): Query<ReadQuery>,
+ RawQuery(raw_query): RawQuery,
) -> Result<Response, StatusCode> {
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<AppState>,
Extension(user): Extension<AuthenticatedUser>,
Path(key): Path<String>,
+ RawQuery(raw_query): RawQuery,
headers: HeaderMap,
body: Bytes,
) -> Result<Response, StatusCode> {
@@ -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<JsonValue, StatusCode
}
}
-/// `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.
+/// `POST /store/{key}`: inserts the body into a list, `key` ending in the index it
+/// goes to (`store::insert_path`: the items from there on move up; the length, or `-1`,
+/// appends); `201` with where it went (`{"index": n}`). Not a 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>,
+ RawQuery(raw_query): RawQuery,
headers: HeaderMap,
body: Bytes,
) -> Result<Response, StatusCode> {
@@ -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<AuthenticatedUser>,
Path(key): Path<String>,
Query(query): Query<DeleteQuery>,
-) -> Result<StatusCode, StatusCode> {
+ RawQuery(raw_query): RawQuery,
+) -> Result<Response, StatusCode> {
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<Option<Response>, 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::<Vec<_>>()
+ .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 -----------------------------------------------------------------