aboutsummaryrefslogtreecommitdiffstats
path: root/src/document/typed.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/document/typed.rs
parent74ced20c6013b7ced9ae14a41033f60239790f6a (diff)
api: add application/vnd.continuum.typed+json
Diffstat (limited to 'src/document/typed.rs')
-rw-r--r--src/document/typed.rs227
1 files changed, 226 insertions, 1 deletions
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)?,
+ })
+ }
+}