diff options
| author | tv <tv@krebsco.de> | 2026-11-02 10:26:51 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-02 10:26:51 +0100 |
| commit | d54ac96f6acabb7fd39e0b642bf1cd919a6e82dd (patch) | |
| tree | 8f36f28cd7afb2dc2847d08b30ced9892ac3688d | |
| parent | 9a73a89e5888df59bbdaff1279bd65e51264cd61 (diff) | |
api: add application/vnd.continuum.typed+json
| -rw-r--r-- | docs/openapi.yaml | 10 | ||||
| -rw-r--r-- | src/api/store.rs | 126 | ||||
| -rw-r--r-- | src/document/store.rs | 212 | ||||
| -rw-r--r-- | src/document/typed.rs | 227 |
4 files changed, 383 insertions, 192 deletions
diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 5402389..36c3ddc 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -79,8 +79,9 @@ paths: description: | The value at the path, as JSON, or as `Accept` asks (`api.md`, Types): its type's own media type for a scalar (`application/octet-stream` for bytes, - `application/vnd.continuum.<type>`), or `text/plain` (strings unquoted, - anything else as JSON). `Accept` is taken in its order; `406` if it names + `application/vnd.continuum.<type>`), typed JSON + (`application/vnd.continuum.typed+json`, which tags what plain JSON would show + as another type), or `text/plain` (strings unquoted, anything else as JSON). `Accept` is taken in its order; `406` if it names nothing the value can be shown as. `application/json; timestamp-format=…` shows timestamps as `rfc3339` (the default), `unix`, `unix-ms` or `http-date`. Concurrent writes resolve as follows: concurrent maps merge, anything else @@ -117,7 +118,10 @@ paths: JSON maps and arrays become Automerge maps and lists; numbers become integers when they fit, floats otherwise; strings are plain strings (not text objects). - A scalar of another type is written as its media type (`api.md`, Types): + Typed JSON (`application/vnd.continuum.typed+json`) writes values of every + type, tagged (`{"$timestamp": …}`, `{"$bytes": …}`, `{"$uint": …}`, + `{"$counter": …}`, `{"$float": …}`, `{"$text": …}`, `{"$map": …}`). + A scalar of another type is also written as its media type (`api.md`, Types): `application/octet-stream` (bytes), `application/vnd.continuum.int`, `.uint`, `.float`, `.boolean`, `.counter` (decimal or `true`/`false`), or `application/vnd.continuum.timestamp; format=rfc3339|unix|unix-ms|http-date`; 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 { diff --git a/src/document/store.rs b/src/document/store.rs index 0f1fd58..4dd9d02 100644 --- a/src/document/store.rs +++ b/src/document/store.rs @@ -9,7 +9,7 @@ use serde::Serialize; use serde_json::Value as JsonValue; use std::collections::BTreeSet; -use super::typed::{self, JsonForm, Kind}; +use super::typed::{self, JsonForm, Kind, Val}; /// Store paths touched by one batch of document changes (`AppState::change_events`). #[derive(Debug)] @@ -333,7 +333,7 @@ fn resolved_to_json( } } - Ok(JsonValue::Object(out)) + Ok(typed::map_to_json(out, form)) } fn object_to_json( @@ -357,7 +357,7 @@ fn object_to_json( } Ok(JsonValue::Array(out)) } - ObjType::Text => Ok(JsonValue::String(doc.text(id)?)), + ObjType::Text => Ok(typed::text_to_json(doc.text(id)?, form)), ObjType::Table => Err(anyhow!("Automerge tables are not supported").into()), } } @@ -632,6 +632,16 @@ pub(crate) fn write_path( path: &[String], value: &JsonValue, ) -> Result<(), StoreError> { + write_val_path(doc, path, &from_json(value)?) +} + +/// [`write_path`] for a value of any type (`typed::Val`): from typed JSON, or a scalar +/// of a type JSON doesn't have. +pub(crate) fn write_val_path( + doc: &mut impl Transactable, + path: &[String], + value: &Val, +) -> Result<(), StoreError> { let (leaf, parents) = path.split_last().ok_or(StoreError::NotFound)?; match parent_for_write(doc, parents)? { Parent::Maps(maps) => update_key(doc, &maps, leaf, value)?, @@ -651,67 +661,18 @@ pub(crate) fn write_path( Ok(()) } -/// [`write_path`] for a scalar (`typed::from_native`): one of a type JSON doesn't have, -/// or doesn't tell apart (bytes, a timestamp, an unsigned integer, a counter). -pub(crate) fn write_scalar_path( - doc: &mut impl Transactable, - path: &[String], - scalar: &ScalarValue, -) -> Result<(), StoreError> { - let (leaf, parents) = path.split_last().ok_or(StoreError::NotFound)?; - let same = |holds: &[(Value<'static>, ObjId)]| matches!(holds, [(Value::Scalar(held), _)] if held.as_ref() == scalar); - match parent_for_write(doc, parents)? { - Parent::Maps(maps) => { - if !same(&candidates(doc, &maps, leaf.as_str())?) { - delete_in_all(doc, &maps, leaf)?; - put_scalar(doc, &maps[0], Slot::Key(leaf), scalar.clone())?; - } - } - Parent::List(list) => { - let index = list_index(leaf).ok_or(StoreError::NotFound)?; - let length = doc.length(&list); - if index > length { - return Err(StoreError::NotFound); - } - if index == length { - put_scalar(doc, &list, Slot::Index(index), scalar.clone())?; - } else if !same(&candidates(doc, std::slice::from_ref(&list), index)?) { - put_scalar(doc, &list, Slot::At(index), scalar.clone())?; - } - } - } - Ok(()) +fn from_json(value: &JsonValue) -> Result<Val, StoreError> { + Val::from_json(value).map_err(|error| anyhow!(error).into()) } // A write makes its path read as the value written, changing only what differs: a map // written over a map keeps its keys that stay (and what is inside them), and gets // those it lacks and loses those the value doesn't have; a list written over a list -// does the same item by item, by position; a scalar equal to the one there isn't -// written again. So a write of a whole map, concurrent with one inside it elsewhere, -// keeps the other unless it changes the same key. Where the path holds concurrent -// values (a conflict), or one of another kind, it is written anew: the write ends the -// conflict. - -/// What `value` would be stored as, if it is a scalar. -pub(crate) fn json_scalar(value: &JsonValue) -> Result<Option<ScalarValue>, StoreError> { - Ok(Some(match value { - JsonValue::String(value) => ScalarValue::Str(value.as_str().into()), - JsonValue::Bool(value) => ScalarValue::Boolean(*value), - JsonValue::Number(value) => { - if let Some(value) = value.as_i64() { - ScalarValue::Int(value) - } else if let Some(value) = value.as_u64() { - ScalarValue::Uint(value) - } else if let Some(value) = value.as_f64() { - ScalarValue::F64(value) - } else { - return Err(anyhow!("Unsupported JSON number").into()); - } - } - JsonValue::Null => ScalarValue::Null, - JsonValue::Object(_) | JsonValue::Array(_) => return Ok(None), - })) -} +// does the same item by item, by position; a text written over a text is changed +// where it differs (a diff, as splices); a scalar equal to the one there isn't written +// again. So a write of a whole map, concurrent with one inside it elsewhere, keeps the +// other unless it changes the same key. Where the path holds concurrent values (a +// conflict), or one of another kind, it is written anew: the write ends the conflict. /// What updating a place to a value comes to, given what it holds (all its concurrent /// values). @@ -722,22 +683,23 @@ enum Update { Map(ObjId), /// A list there, the only value: its items updated. List(ObjId), + /// A text there, the only value: changed where it differs. + Text(ObjId), /// Written anew. Anew, } -fn update_of(holds: &[(Value<'static>, ObjId)], value: &JsonValue) -> Result<Update, StoreError> { +fn update_of(holds: &[(Value<'static>, ObjId)], value: &Val) -> Update { let [(held, id)] = holds else { - return Ok(Update::Anew); + return Update::Anew; }; - Ok(match (held, value) { - (Value::Object(ObjType::Map), JsonValue::Object(_)) => Update::Map(id.clone()), - (Value::Object(ObjType::List), JsonValue::Array(_)) => Update::List(id.clone()), - (Value::Scalar(held), _) if json_scalar(value)?.as_ref() == Some(held.as_ref()) => { - Update::Same - } + match (held, value) { + (Value::Object(ObjType::Map), Val::Map(_)) => Update::Map(id.clone()), + (Value::Object(ObjType::List), Val::List(_)) => Update::List(id.clone()), + (Value::Object(ObjType::Text), Val::Text(_)) => Update::Text(id.clone()), + (Value::Scalar(held), Val::Scalar(scalar)) if held.as_ref() == scalar => Update::Same, _ => Update::Anew, - }) + } } /// Updates `key` of the merged maps `maps` to `value`. @@ -745,16 +707,17 @@ fn update_key( doc: &mut impl Transactable, maps: &[ObjId], key: &str, - value: &JsonValue, + value: &Val, ) -> Result<(), StoreError> { - match update_of(&candidates(doc, maps, key)?, value)? { + match update_of(&candidates(doc, maps, key)?, value) { Update::Same => Ok(()), Update::Map(map) => update_map(doc, &map, value), Update::List(list) => update_list(doc, &list, value), + Update::Text(text) => update_text(doc, &text, value), Update::Anew => { // Cleared in every merged map, or an old map would merge into the new value. delete_in_all(doc, maps, key)?; - json_to_automerge(doc, &maps[0], key, value) + write_value(doc, &maps[0], Slot::Key(key), value) } } } @@ -764,44 +727,37 @@ fn update_item( doc: &mut impl Transactable, list: &ObjId, index: usize, - value: &JsonValue, + value: &Val, ) -> Result<(), StoreError> { - match update_of(&candidates(doc, std::slice::from_ref(list), index)?, value)? { + match update_of(&candidates(doc, std::slice::from_ref(list), index)?, value) { Update::Same => Ok(()), Update::Map(map) => update_map(doc, &map, value), Update::List(inner) => update_list(doc, &inner, value), + Update::Text(text) => update_text(doc, &text, value), Update::Anew => write_value(doc, list, Slot::At(index), value), } } -fn update_map( - doc: &mut impl Transactable, - map: &ObjId, - value: &JsonValue, -) -> Result<(), StoreError> { - let JsonValue::Object(object) = value else { - unreachable!("a map is updated to an object"); +fn update_map(doc: &mut impl Transactable, map: &ObjId, value: &Val) -> Result<(), StoreError> { + let Val::Map(entries) = value else { + unreachable!("a map is updated to a map"); }; let maps = std::slice::from_ref(map); // The keys it loses first, then those it keeps or gets, each in key order. for key in doc.keys(map).collect::<Vec<_>>() { - if !object.contains_key(&key) { + if !entries.iter().any(|(kept, _)| *kept == key) { doc.delete(map, key.as_str())?; } } - for (key, value) in object { + for (key, value) in entries { update_key(doc, maps, key, value)?; } Ok(()) } -fn update_list( - doc: &mut impl Transactable, - list: &ObjId, - value: &JsonValue, -) -> Result<(), StoreError> { - let JsonValue::Array(items) = value else { - unreachable!("a list is updated to an array"); +fn update_list(doc: &mut impl Transactable, list: &ObjId, value: &Val) -> Result<(), StoreError> { + let Val::List(items) = value else { + unreachable!("a list is updated to a list"); }; let length = doc.length(list); for (index, item) in items.iter().enumerate() { @@ -817,6 +773,14 @@ fn update_list( Ok(()) } +fn update_text(doc: &mut impl Transactable, text: &ObjId, value: &Val) -> Result<(), StoreError> { + let Val::Text(new) = value else { + unreachable!("a text is updated to a text"); + }; + doc.update_text(text, new)?; + Ok(()) +} + /// What a write's last segment goes into. enum Parent { /// Merged maps (`[0]` takes new keys). @@ -912,23 +876,23 @@ fn counter_at(doc: &impl ReadDoc, parent: &ObjId, prop: Prop) -> Result<i64, Sto /// there on move up one; the list's length appends). An index past the end is /// `PastTheEnd`; a parent that isn't a list `NotAList`; anything but an index there, or /// no parent, `NotFound`. +#[cfg(test)] pub(crate) fn insert_path( doc: &mut impl Transactable, path: &[String], value: &JsonValue, ) -> Result<(), StoreError> { - let (list, index) = insertion(doc, path)?; - write_value(doc, &list, Slot::Index(index), value) + insert_val_path(doc, path, &from_json(value)?) } -/// [`insert_path`] for a scalar (`typed::from_native`). -pub(crate) fn insert_scalar_path( +/// [`insert_path`] for a value of any type (`typed::Val`). +pub(crate) fn insert_val_path( doc: &mut impl Transactable, path: &[String], - scalar: &ScalarValue, + value: &Val, ) -> Result<(), StoreError> { let (list, index) = insertion(doc, path)?; - put_scalar(doc, &list, Slot::Index(index), scalar.clone()) + write_value(doc, &list, Slot::Index(index), value) } /// The list an insert at `path` goes into, and where (`insert_path`). @@ -1216,55 +1180,43 @@ fn write_value( doc: &mut impl Transactable, parent: &ObjId, slot: Slot, - value: &JsonValue, + value: &Val, ) -> Result<(), StoreError> { match value { - JsonValue::Object(_) | JsonValue::Array(_) => { + Val::Scalar(scalar) => put_scalar(doc, parent, slot, scalar.clone()), + tree => { // A new tree in one go, not an operation at a time. - let tree = hydrated(value)?; + let tree = hydrated(tree, doc.text_encoding()); let (prop, insert): (Prop, bool) = match slot { Slot::Key(key) => (key.into(), false), Slot::Index(index) => (index.into(), true), Slot::At(index) => (index.into(), false), }; doc.batch_create_object(parent, prop, &tree, insert)?; - } - scalar => { - let scalar = json_scalar(scalar)?.expect("neither an object nor an array"); - put_scalar(doc, parent, slot, scalar)?; + Ok(()) } } - - Ok(()) } -/// `value` as Automerge holds it: maps, lists and scalars as `write_value` stores them. -fn hydrated(value: &JsonValue) -> Result<automerge::hydrate::Value, StoreError> { +/// `value` as Automerge holds it. +fn hydrated(value: &Val, encoding: automerge::TextEncoding) -> automerge::hydrate::Value { use automerge::hydrate; - Ok(match value { - JsonValue::Object(object) => { - let mut map = std::collections::HashMap::new(); - for (key, value) in object { - map.insert(key.clone(), hydrated(value)?); - } - hydrate::Value::Map(hydrate::Map::from(map)) - } - JsonValue::Array(items) => { - hydrate::Value::from(items.iter().map(hydrated).collect::<Result<Vec<_>, _>>()?) - } - scalar => { - hydrate::Value::from(json_scalar(scalar)?.expect("neither an object nor an array")) - } - }) -} - -fn json_to_automerge( - doc: &mut impl Transactable, - parent: &ObjId, - key: &str, - value: &JsonValue, -) -> Result<(), StoreError> { - write_value(doc, parent, Slot::Key(key), value) + match value { + Val::Map(entries) => hydrate::Value::Map(hydrate::Map::from( + entries + .iter() + .map(|(key, value)| (key.clone(), hydrated(value, encoding))) + .collect::<std::collections::HashMap<_, _>>(), + )), + Val::List(items) => hydrate::Value::from( + items + .iter() + .map(|item| hydrated(item, encoding)) + .collect::<Vec<_>>(), + ), + Val::Text(text) => hydrate::Value::text(encoding, text), + Val::Scalar(scalar) => hydrate::Value::Scalar(scalar.clone()), + } } fn scalar_to_json(scalar: ScalarValue, form: JsonForm) -> Result<JsonValue, StoreError> { diff --git a/src/document/typed.rs b/src/document/typed.rs index bca79b3..a542578 100644 --- a/src/document/typed.rs +++ b/src/document/typed.rs @@ -35,6 +35,8 @@ pub(crate) const FLOAT: &str = "application/vnd.continuum.float"; pub(crate) const BOOLEAN: &str = "application/vnd.continuum.boolean"; pub(crate) const COUNTER: &str = "application/vnd.continuum.counter"; pub(crate) const TIMESTAMP: &str = "application/vnd.continuum.timestamp"; +/// JSON whose scalars keep their types (`Val::from_typed_json`, `scalar_to_json`). +pub(crate) const TYPED_JSON: &str = "application/vnd.continuum.typed+json"; impl Kind { pub(crate) fn of(scalar: &ScalarValue) -> Self { @@ -194,11 +196,66 @@ impl TimestampFormat { #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) struct JsonForm { pub(crate) timestamps: TimestampFormat, + /// Typed JSON (`TYPED_JSON`): what plain JSON doesn't tell apart is tagged, so it + /// reads back as it is stored. + pub(crate) typed: bool, +} + +// Typed JSON is plain JSON but for what plain JSON shows as another type: a single-key +// object whose key is a tag names it. `{"$uint": 5}`, `{"$counter": 5}`, +// `{"$timestamp": "2026-11-01T21:50:20.345Z"}` (in the timestamp format asked), +// `{"$bytes": "<base64>"}`, `{"$float": 2}` (a float without a fraction, which JSON +// numbers don't keep apart from integers in every language), `{"$text": "…"}` (an +// Automerge text). A map whose only key starts with `$` is `{"$map": {…}}`, so that +// nothing else reads as a tag. + +/// A map's single key that is a tag, or would be taken for one. +pub(crate) fn tag_like(map: &serde_json::Map<String, JsonValue>) -> bool { + map.len() == 1 && map.keys().all(|key| key.starts_with('$')) +} + +fn tagged(tag: &str, value: JsonValue) -> JsonValue { + let mut map = serde_json::Map::new(); + map.insert(tag.to_owned(), value); + JsonValue::Object(map) +} + +/// A map in `form` (`$map` around one that would read as a tag in typed JSON). +pub(crate) fn map_to_json(map: serde_json::Map<String, JsonValue>, form: JsonForm) -> JsonValue { + if form.typed && tag_like(&map) { + tagged("$map", JsonValue::Object(map)) + } else { + JsonValue::Object(map) + } +} + +/// A text in `form`. +pub(crate) fn text_to_json(text: String, form: JsonForm) -> JsonValue { + if form.typed { + tagged("$text", JsonValue::String(text)) + } else { + JsonValue::String(text) + } } /// `scalar` in JSON: integers, floats and counters as numbers, bytes as base64, -/// timestamps as `form` says. +/// timestamps as `form` says; in typed JSON, tagged where plain JSON would show it as +/// another type. pub(crate) fn scalar_to_json(scalar: &ScalarValue, form: JsonForm) -> Option<JsonValue> { + if form.typed { + match scalar { + ScalarValue::Uint(u) => return Some(tagged("$uint", (*u).into())), + ScalarValue::Counter(c) => return Some(tagged("$counter", i64::from(c).into())), + ScalarValue::Timestamp(t) => { + return Some(tagged("$timestamp", form.timestamps.json(*t))); + } + ScalarValue::Bytes(b) => return Some(tagged("$bytes", BASE64.encode(b).into())), + ScalarValue::F64(f) if f.fract() == 0.0 => { + return Some(tagged("$float", serde_json::Number::from_f64(*f)?.into())); + } + _ => {} + } + } Some(match scalar { ScalarValue::Null => JsonValue::Null, ScalarValue::Boolean(b) => JsonValue::Bool(*b), @@ -316,3 +373,171 @@ pub(crate) fn param(params: &str, name: &str) -> Option<String> { .then(|| value.trim().trim_matches('"').to_owned()) }) } + +/// A value to write (`store::write_val_path`), as Automerge stores it. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Val { + /// Its keys in order. + Map(Vec<(String, Val)>), + List(Vec<Val>), + /// An Automerge text. + Text(String), + Scalar(ScalarValue), +} + +/// What a plain JSON `value` is stored as, if it is a scalar: an integer where it fits +/// (signed, else unsigned), a float otherwise. +pub(crate) fn json_scalar(value: &JsonValue) -> Result<Option<ScalarValue>, String> { + Ok(Some(match value { + JsonValue::String(value) => ScalarValue::Str(value.as_str().into()), + JsonValue::Bool(value) => ScalarValue::Boolean(*value), + JsonValue::Number(value) => { + if let Some(value) = value.as_i64() { + ScalarValue::Int(value) + } else if let Some(value) = value.as_u64() { + ScalarValue::Uint(value) + } else if let Some(value) = value.as_f64() { + ScalarValue::F64(value) + } else { + return Err(format!("unsupported JSON number {value}")); + } + } + JsonValue::Null => ScalarValue::Null, + JsonValue::Object(_) | JsonValue::Array(_) => return Ok(None), + })) +} + +impl Val { + /// Plain JSON, as it is stored: maps, lists and scalars (`json_scalar`). + pub(crate) fn from_json(value: &JsonValue) -> Result<Val, String> { + Ok(match value { + JsonValue::Object(map) => Val::Map( + map.iter() + .map(|(key, value)| Ok((key.clone(), Val::from_json(value)?))) + .collect::<Result<_, String>>()?, + ), + JsonValue::Array(items) => { + Val::List(items.iter().map(Val::from_json).collect::<Result<_, _>>()?) + } + scalar => Val::Scalar(json_scalar(scalar)?.expect("a scalar")), + }) + } + + /// Typed JSON (`TYPED_JSON`): plain JSON, but tagged values as their tags say, + /// timestamps in `timestamps`. An object of one key that starts with `$` and is no + /// tag is an error. + pub(crate) fn from_typed_json( + value: &JsonValue, + timestamps: TimestampFormat, + ) -> Result<Val, String> { + let JsonValue::Object(map) = value else { + return match value { + JsonValue::Array(items) => Ok(Val::List( + items + .iter() + .map(|item| Val::from_typed_json(item, timestamps)) + .collect::<Result<_, _>>()?, + )), + scalar => Val::from_json(scalar), + }; + }; + if !tag_like(map) { + return Ok(Val::Map( + map.iter() + .map(|(key, value)| Ok((key.clone(), Val::from_typed_json(value, timestamps)?))) + .collect::<Result<_, String>>()?, + )); + } + let (tag, value) = map.iter().next().expect("one key"); + let integer = |value: &JsonValue| { + value + .as_i64() + .ok_or_else(|| format!("{tag}: not an integer: {value}")) + }; + Ok(match tag.as_str() { + "$map" => match value { + JsonValue::Object(map) => Val::Map( + map.iter() + .map(|(key, value)| { + Ok((key.clone(), Val::from_typed_json(value, timestamps)?)) + }) + .collect::<Result<_, String>>()?, + ), + other => return Err(format!("$map: not an object: {other}")), + }, + "$text" => Val::Text( + value + .as_str() + .ok_or_else(|| format!("$text: not a string: {value}"))? + .to_owned(), + ), + "$bytes" => Val::Scalar(ScalarValue::Bytes( + value + .as_str() + .and_then(|text| BASE64.decode(text).ok()) + .ok_or_else(|| format!("$bytes: not base64: {value}"))?, + )), + "$uint" => { + Val::Scalar(ScalarValue::Uint(value.as_u64().ok_or_else(|| { + format!("$uint: not an unsigned integer: {value}") + })?)) + } + "$counter" => Val::Scalar(ScalarValue::counter(integer(value)?)), + "$float" => Val::Scalar(ScalarValue::F64( + value + .as_f64() + .ok_or_else(|| format!("$float: not a number: {value}"))?, + )), + "$timestamp" => { + let text = match value { + JsonValue::String(text) => text.clone(), + JsonValue::Number(number) => number.to_string(), + other => { + return Err(format!( + "$timestamp: neither a string nor a number: {other}" + )); + } + }; + Val::Scalar(ScalarValue::Timestamp(timestamps.read(&text).ok_or_else( + || format!("$timestamp: not a {} timestamp: {text}", timestamps.name()), + )?)) + } + other => { + return Err(format!( + "no tag {other} (a map whose only key starts with $ is written as {{\"$map\": …}})" + )); + } + }) + } + + /// What it is stored as. + pub(crate) fn kind(&self) -> Kind { + match self { + Val::Map(_) => Kind::Map, + Val::List(_) => Kind::List, + Val::Text(_) => Kind::Text, + Val::Scalar(scalar) => Kind::of(scalar), + } + } + + /// In JSON of `form`. + pub(crate) fn to_json(&self, form: JsonForm) -> Option<JsonValue> { + Some(match self { + Val::Map(entries) => map_to_json( + entries + .iter() + .map(|(key, value)| Some((key.clone(), value.to_json(form)?))) + .collect::<Option<_>>()?, + form, + ), + Val::List(items) => JsonValue::Array( + items + .iter() + .map(|item| item.to_json(form)) + .collect::<Option<_>>()?, + ), + Val::Text(text) => text_to_json(text.clone(), form), + Val::Scalar(scalar) => scalar_to_json(scalar, form)?, + }) + } +} |
