From 694661d7477e4bd1e8d7ec96dcc37742d1b5ca3f Mon Sep 17 00:00:00 2001 From: tv Date: Mon, 2 Nov 2026 11:04:15 +0100 Subject: api: add type casts --- src/api/mod.rs | 3 ++- src/api/store.rs | 50 +++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 47 insertions(+), 6 deletions(-) (limited to 'src/api') diff --git a/src/api/mod.rs b/src/api/mod.rs index ede1cfa..9bb2bed 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -212,7 +212,8 @@ pub(crate) fn store_status(error: StoreError, context: &str) -> ApiError { | StoreError::NotAList | StoreError::PastTheEnd { .. } | StoreError::NotACounter - | StoreError::Retyping(_) => StatusCode::CONFLICT, + | StoreError::Retyping(_) + | StoreError::NotCastable { .. } => StatusCode::CONFLICT, StoreError::NotFound | StoreError::UnknownHeads => StatusCode::NOT_FOUND, StoreError::Rejoining => StatusCode::SERVICE_UNAVAILABLE, other => { diff --git a/src/api/store.rs b/src/api/store.rs index e6921cd..48bdafc 100644 --- a/src/api/store.rs +++ b/src/api/store.rs @@ -83,6 +83,12 @@ pub(crate) struct PostQuery { /// Adds this to the counter at the path instead of inserting the body /// (`store::increment_path`). increment: Option, + /// Casts what is at the path to this type instead of inserting the body + /// (`store::cast_path`; `typed::Kind::name` names it). + cast: Option, + /// With `cast`, how timestamps are read from and written as strings (`rfc3339` by + /// default; `typed::TimestampFormat`). + format: Option, } #[derive(Debug, Deserialize)] @@ -342,7 +348,11 @@ pub(crate) async fn put_store_entry( .await?; let read = read.ok_or(ApiError(StatusCode::INTERNAL_SERVER_ERROR, None))?; - let mut response = with_heads(read.respond()?, &heads); + Ok(with_retyped(with_heads(read.respond()?, &heads), &retyped)) +} + +/// `response` saying where a write changed types (`RETYPED_HEADER`), if it did. +fn with_retyped(mut response: Response, retyped: &[store::Retyped]) -> Response { if !retyped.is_empty() { let mut listed: Vec = retyped .iter() @@ -356,7 +366,7 @@ pub(crate) async fn put_store_entry( response.headers_mut().insert(RETYPED_HEADER, value); } } - Ok(response) + response } fn etag_for(bytes: &[u8]) -> String { @@ -619,7 +629,9 @@ fn body_value(headers: &HeaderMap, body: &Bytes) -> Result /// appends); `201` with where it went (`{"index": n}`). Not a list there, or an index /// past its end: `409`, saying which. With `?increment=n`, adds `n` to the counter at /// `key` instead (`store::increment_path`), answering with its value then; not a -/// counter there: `409`. +/// counter there: `409`. With `?cast=`, casts what is at `key` to that type +/// instead (`store::cast_path`), answering with it then, as `PUT` does; no cast +/// possible: `409`. pub(crate) async fn post_store_entry( State(state): State, Extension(user): Extension, @@ -633,8 +645,15 @@ pub(crate) async fn post_store_entry( return Err(StatusCode::SERVICE_UNAVAILABLE.into()); } let path = parse_write_path(&state, &user, &key, "POST")?; - // An increment names an item (`-1`: the last); an insert where it goes (`-1`: the end). - let insertion = query.increment.is_none(); + if query.increment.is_some() && query.cast.is_some() { + return Err(ApiError( + StatusCode::BAD_REQUEST, + Some("increment or cast, not both".to_owned()), + )); + } + // An increment or a cast names an item (`-1`: the last); an insert where it goes + // (`-1`: the end). + let insertion = query.increment.is_none() && query.cast.is_none(); if let Some(redirect) = to_positive(&state, &path, insertion, None, raw_query.as_deref()).await? { @@ -663,6 +682,27 @@ pub(crate) async fn post_store_entry( &heads, )); } + if let Some(name) = &query.cast { + let bad = |message: String| ApiError(StatusCode::BAD_REQUEST, Some(message)); + let to = Kind::named(name).ok_or_else(|| bad(format!("no type {name:?}")))?; + let timestamps = match &query.format { + None => TimestampFormat::default(), + Some(format) => TimestampFormat::named(format) + .ok_or_else(|| bad(format!("no timestamp format {format:?}")))?, + }; + let accept = headers.get(ACCEPT).cloned(); + // A value that can't be shown as asked is refused, and the cast with it. + let ((retyped, read), heads) = state + .mutate_doc_for(Some(user.0.clone()), move |doc| { + let retyped = store::cast_path(doc, &path, to, timestamps) + .map_err(|e| store_status(e, "Failed to cast a store value"))?; + let read = read_shown(&*doc, &path, accept.as_ref(), false)?; + Ok::<_, ApiError>((retyped, read)) + }) + .await?; + let read = read.ok_or(ApiError(StatusCode::INTERNAL_SERVER_ERROR, None))?; + return Ok(with_retyped(with_heads(read.respond()?, &heads), &retyped)); + } let index = path.last().and_then(|leaf| store::list_index(leaf)); let value = body_value(&headers, &body)?; let inserted = state -- cgit v1.3.1