aboutsummaryrefslogtreecommitdiffstats
path: root/src/api/store.rs
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-02 10:26:51 +0100
committertv <tv@krebsco.de>2026-11-02 10:26:51 +0100
commit58d574166fef1246f367009546358703e16d7f7e (patch)
treea307cd78560058c45e32f18631191e0fd15c9ae1 /src/api/store.rs
parent74ced20c6013b7ced9ae14a41033f60239790f6a (diff)
api: add application/vnd.continuum.typed+json
Diffstat (limited to 'src/api/store.rs')
-rw-r--r--src/api/store.rs126
1 files changed, 68 insertions, 58 deletions
diff --git a/src/api/store.rs b/src/api/store.rs
index 13f5012..47aade1 100644
--- a/src/api/store.rs
+++ b/src/api/store.rs
@@ -23,7 +23,7 @@ use super::{ApiError, store_status};
use crate::document::ChangeHash;
use crate::document::meta;
use crate::document::store::{self, StoreError, paths_related};
-use crate::document::typed::{self, JsonForm, Kind, TimestampFormat};
+use crate::document::typed::{self, JsonForm, Kind, TimestampFormat, Val};
use crate::state::{AppState, ReadSource};
use crate::transport::Hex;
@@ -310,13 +310,12 @@ pub(crate) async fn put_store_entry(
}
let value = body_value(&headers, &body)?;
// Answered with the value written, shown as asked.
- let (held, json) = value.held();
- let shown = shown(headers.get(ACCEPT), held.kind)?;
+ let shown = shown(headers.get(ACCEPT), value.kind())?;
+ let (held, json) = written(&value, shown);
let ((), heads) = state
- .mutate_doc_for(Some(user.0.clone()), move |doc| match &value {
- Written::Json(value) => store::write_path(doc, &path, value),
- Written::Scalar(scalar) => store::write_scalar_path(doc, &path, scalar),
+ .mutate_doc_for(Some(user.0.clone()), move |doc| {
+ store::write_val_path(doc, &path, &value)
})
.await
.map_err(|e| store_status(e, "Failed to write store value"))?;
@@ -378,8 +377,15 @@ enum Shown {
/// a timestamp format there is none of.
fn json_form(accept: Option<&HeaderValue>) -> Result<JsonForm, ApiError> {
for (media, params) in accepted(accept) {
- if media == "application/json" {
- return json_form_of(params);
+ match media.as_str() {
+ "application/json" => return json_form_of(params),
+ typed::TYPED_JSON => {
+ return Ok(JsonForm {
+ typed: true,
+ ..json_form_of(params)?
+ });
+ }
+ _ => {}
}
}
Ok(JsonForm::default())
@@ -395,7 +401,10 @@ fn json_form_of(params: &str) -> Result<JsonForm, ApiError> {
)
})?,
};
- Ok(JsonForm { timestamps })
+ Ok(JsonForm {
+ timestamps,
+ typed: false,
+ })
}
/// The media types of `accept`, in its order, each with its parameters (q-values are not
@@ -425,6 +434,12 @@ fn shown(accept: Option<&HeaderValue>, kind: Kind) -> Result<Shown, ApiError> {
for (media, params) in &items {
match media.as_str() {
"application/json" => return Ok(Shown::Json(json_form_of(params)?)),
+ typed::TYPED_JSON => {
+ return Ok(Shown::Json(JsonForm {
+ typed: true,
+ ..json_form_of(params)?
+ }));
+ }
typed::TEXT => return Ok(Shown::Text),
"*/*" | "application/*" => return Ok(Shown::Json(JsonForm::default())),
media if Some(media) == native => {
@@ -442,7 +457,7 @@ fn shown(accept: Option<&HeaderValue>, kind: Kind) -> Result<Shown, ApiError> {
_ => {}
}
}
- let mut offered = vec!["application/json", typed::TEXT];
+ let mut offered = vec!["application/json", typed::TYPED_JSON, typed::TEXT];
if let Some(native) = native.filter(|native| *native != typed::TEXT) {
offered.push(native);
}
@@ -461,7 +476,14 @@ fn shown(accept: Option<&HeaderValue>, kind: Kind) -> Result<Shown, ApiError> {
fn show(held: &store::Held, shown: Shown, json: Option<&JsonValue>) -> Result<Response, ApiError> {
let json = || json.ok_or(ApiError(StatusCode::INTERNAL_SERVER_ERROR, None));
match shown {
- Shown::Json(_) => respond(Some(held.kind), "application/json", json_bytes(json()?)?),
+ Shown::Json(form) => {
+ let media = if form.typed {
+ typed::TYPED_JSON
+ } else {
+ "application/json"
+ };
+ respond(Some(held.kind), media, json_bytes(json()?)?)
+ }
Shown::Text => {
let body = match json()? {
JsonValue::String(value) => value.clone().into_bytes(),
@@ -508,64 +530,53 @@ fn respond(kind: Option<Kind>, content_type: &str, body: Vec<u8>) -> Result<Resp
/// Header naming what a value read or written is stored as (`typed::Kind::name`).
const TYPE_HEADER: &str = "x-continuum-type";
-/// A write's body: JSON (the default), text as a string, or a value of a type of its
-/// own (`typed::from_native`).
-enum Written {
- Json(JsonValue),
- Scalar(automerge::ScalarValue),
-}
-
-impl Written {
- /// What it is stored as, and its JSON, to answer the write with.
- fn held(&self) -> (store::Held, Option<JsonValue>) {
- match self {
- Written::Json(json) => {
- let kind = match json {
- JsonValue::Object(_) => Kind::Map,
- JsonValue::Array(_) => Kind::List,
- JsonValue::String(_) => Kind::String,
- JsonValue::Bool(_) => Kind::Boolean,
- JsonValue::Null => Kind::Null,
- JsonValue::Number(number) if number.is_i64() => Kind::Int,
- JsonValue::Number(number) if number.is_u64() => Kind::Uint,
- JsonValue::Number(_) => Kind::Float,
- };
- let value = store::json_scalar(json).ok().flatten();
- (store::Held { kind, value }, Some(json.clone()))
- }
- Written::Scalar(scalar) => (
- store::Held {
- kind: Kind::of(scalar),
- value: Some(scalar.clone()),
- },
- typed::scalar_to_json(scalar, JsonForm::default()),
- ),
- }
- }
-}
-
-fn body_value(headers: &HeaderMap, body: &Bytes) -> Result<Written, ApiError> {
+/// A write's body as a value to write (`typed::Val`): JSON (the default), typed JSON,
+/// text as a string, or a scalar of a type of its own (`typed::from_native`).
+fn body_value(headers: &HeaderMap, body: &Bytes) -> Result<Val, ApiError> {
let content_type = headers
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("application/json");
let (media, params) = content_type.split_once(';').unwrap_or((content_type, ""));
let media = media.trim().to_ascii_lowercase();
+ let bad = |error: String| ApiError(StatusCode::BAD_REQUEST, Some(error));
+ let json = || {
+ serde_json::from_slice::<JsonValue>(body).map_err(|error| bad(format!("not JSON: {error}")))
+ };
match media.as_str() {
- "application/json" => serde_json::from_slice::<JsonValue>(body)
- .map(Written::Json)
- .map_err(|_| StatusCode::BAD_REQUEST.into()),
+ "application/json" => Val::from_json(&json()?).map_err(bad),
+ typed::TYPED_JSON => {
+ let form = json_form_of(params)?;
+ Val::from_typed_json(&json()?, form.timestamps).map_err(bad)
+ }
typed::TEXT => String::from_utf8(body.to_vec())
- .map(|text| Written::Json(JsonValue::String(text)))
- .map_err(|_| StatusCode::BAD_REQUEST.into()),
+ .map(|text| Val::Scalar(automerge::ScalarValue::Str(text.into())))
+ .map_err(|_| bad("not UTF-8".to_owned())),
media => match typed::from_native(media, params, body) {
- Some(Ok(scalar)) => Ok(Written::Scalar(scalar)),
- Some(Err(error)) => Err(ApiError(StatusCode::BAD_REQUEST, Some(error))),
+ Some(Ok(scalar)) => Ok(Val::Scalar(scalar)),
+ Some(Err(error)) => Err(bad(error)),
None => Err(StatusCode::UNSUPPORTED_MEDIA_TYPE.into()),
},
}
}
+/// What `value` is stored as, and its JSON of `shown`'s form, to answer its write with.
+fn written(value: &Val, shown: Shown) -> (store::Held, Option<JsonValue>) {
+ let held = store::Held {
+ kind: value.kind(),
+ value: match value {
+ Val::Scalar(scalar) => Some(scalar.clone()),
+ Val::Text(text) => Some(automerge::ScalarValue::Str(text.as_str().into())),
+ Val::Map(_) | Val::List(_) => None,
+ },
+ };
+ let form = match shown {
+ Shown::Json(form) => form,
+ Shown::Text | Shown::Native(_) => JsonForm::default(),
+ };
+ (held, value.to_json(form))
+}
+
/// `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
@@ -618,9 +629,8 @@ pub(crate) async fn post_store_entry(
let index = path.last().and_then(|leaf| store::list_index(leaf));
let value = body_value(&headers, &body)?;
let inserted = state
- .mutate_doc_for(Some(user.0.clone()), move |doc| match &value {
- Written::Json(value) => store::insert_path(doc, &path, value),
- Written::Scalar(scalar) => store::insert_scalar_path(doc, &path, scalar),
+ .mutate_doc_for(Some(user.0.clone()), move |doc| {
+ store::insert_val_path(doc, &path, &value)
})
.await;
match inserted {