From 9a73a89e5888df59bbdaff1279bd65e51264cd61 Mon Sep 17 00:00:00 2001 From: tv Date: Mon, 2 Nov 2026 10:25:16 +0100 Subject: api: add application/vnd.continuum.* scalar types --- src/document/typed.rs | 318 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 src/document/typed.rs (limited to 'src/document/typed.rs') diff --git a/src/document/typed.rs b/src/document/typed.rs new file mode 100644 index 0000000..bca79b3 --- /dev/null +++ b/src/document/typed.rs @@ -0,0 +1,318 @@ +//! The types a value is stored as, and how each is shown: as JSON (every type has a +//! JSON form), and natively, as a media type of its own, where it is a scalar or a text +//! (`docs/api.md`, Types). + +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; +use serde_json::Value as JsonValue; + +use automerge::ScalarValue; + +/// What a value is stored as. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Kind { + Map, + List, + /// An Automerge text (characters that merge one by one), shown as a string. + Text, + String, + Bytes, + Int, + Uint, + Float, + Boolean, + Null, + Counter, + Timestamp, + /// A scalar of a later Automerge version. + Unknown, +} + +pub(crate) const OCTET_STREAM: &str = "application/octet-stream"; +pub(crate) const TEXT: &str = "text/plain"; +pub(crate) const INT: &str = "application/vnd.continuum.int"; +pub(crate) const UINT: &str = "application/vnd.continuum.uint"; +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"; + +impl Kind { + pub(crate) fn of(scalar: &ScalarValue) -> Self { + match scalar { + ScalarValue::Str(_) => Kind::String, + ScalarValue::Bytes(_) => Kind::Bytes, + ScalarValue::Int(_) => Kind::Int, + ScalarValue::Uint(_) => Kind::Uint, + ScalarValue::F64(_) => Kind::Float, + ScalarValue::Boolean(_) => Kind::Boolean, + ScalarValue::Null => Kind::Null, + ScalarValue::Counter(_) => Kind::Counter, + ScalarValue::Timestamp(_) => Kind::Timestamp, + ScalarValue::Unknown { .. } => Kind::Unknown, + } + } + + /// As `x-continuum-type` and shallow reads name it. + pub(crate) fn name(self) -> &'static str { + match self { + Kind::Map => "map", + Kind::List => "list", + Kind::Text => "text", + Kind::String => "string", + Kind::Bytes => "bytes", + Kind::Int => "int", + Kind::Uint => "uint", + Kind::Float => "float", + Kind::Boolean => "boolean", + Kind::Null => "null", + Kind::Counter => "counter", + Kind::Timestamp => "timestamp", + Kind::Unknown => "unknown", + } + } + + /// The media type a value of this kind is shown as natively, if any. + pub(crate) fn media_type(self) -> Option<&'static str> { + Some(match self { + Kind::Text | Kind::String => TEXT, + Kind::Bytes => OCTET_STREAM, + Kind::Int => INT, + Kind::Uint => UINT, + Kind::Float => FLOAT, + Kind::Boolean => BOOLEAN, + Kind::Counter => COUNTER, + Kind::Timestamp => TIMESTAMP, + Kind::Map | Kind::List | Kind::Null | Kind::Unknown => return None, + }) + } +} + +/// How a timestamp is written out: as text natively (`format=`), and in JSON +/// (`timestamp-format=`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum TimestampFormat { + /// `2026-11-01T21:50:20.345Z` (a string in JSON). + #[default] + Rfc3339, + /// Seconds since 1970, with milliseconds as a fraction (a number in JSON). + Unix, + /// Milliseconds since 1970 (an integer in JSON). + UnixMs, + /// `Sun, 01 Nov 2026 21:50:20 GMT` (RFC 9110; whole seconds; a string in JSON). + HttpDate, +} + +impl TimestampFormat { + pub(crate) fn named(name: &str) -> Option { + Some(match name { + "rfc3339" => Self::Rfc3339, + "unix" => Self::Unix, + "unix-ms" => Self::UnixMs, + "http-date" => Self::HttpDate, + _ => return None, + }) + } + + pub(crate) fn name(self) -> &'static str { + match self { + Self::Rfc3339 => "rfc3339", + Self::Unix => "unix", + Self::UnixMs => "unix-ms", + Self::HttpDate => "http-date", + } + } + + /// `ms` (milliseconds since 1970) as text. + pub(crate) fn text(self, ms: i64) -> String { + match self { + Self::UnixMs => ms.to_string(), + Self::Unix => { + let sign = if ms < 0 { "-" } else { "" }; + let (seconds, millis) = (ms.unsigned_abs() / 1000, ms.unsigned_abs() % 1000); + if millis == 0 { + format!("{sign}{seconds}") + } else { + format!("{sign}{seconds}.{millis:03}") + .trim_end_matches('0') + .to_owned() + } + } + Self::Rfc3339 | Self::HttpDate => match jiff::Timestamp::from_millisecond(ms) { + Ok(at) if self == Self::Rfc3339 => at.to_string(), + Ok(at) => jiff::fmt::rfc2822::DateTimePrinter::new() + .timestamp_to_rfc9110_string(&at) + .unwrap_or_else(|_| ms.to_string()), + // Beyond what a date shows: the number itself. + Err(_) => ms.to_string(), + }, + } + } + + /// `ms` in JSON: a string, or for the Unix formats a number. + pub(crate) fn json(self, ms: i64) -> JsonValue { + match self { + Self::UnixMs => JsonValue::from(ms), + Self::Unix => serde_json::Number::from_f64(ms as f64 / 1000.0) + .map_or(JsonValue::from(ms / 1000), JsonValue::Number), + Self::Rfc3339 | Self::HttpDate => JsonValue::String(self.text(ms)), + } + } + + /// Milliseconds since 1970 from `text` in this format; None if it isn't one, or is + /// finer than a millisecond. + pub(crate) fn read(self, text: &str) -> Option { + let text = text.trim(); + match self { + Self::UnixMs => text.parse().ok(), + Self::Unix => { + let (seconds, fraction) = text.split_once('.').unwrap_or((text, "")); + if fraction.len() > 3 || !fraction.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + let seconds: i64 = seconds.parse().ok()?; + let millis: i64 = format!("{fraction:0<3}").parse().ok()?; + let millis = if text.starts_with('-') { + -millis + } else { + millis + }; + seconds.checked_mul(1000)?.checked_add(millis) + } + Self::Rfc3339 => { + let at: jiff::Timestamp = text.parse().ok()?; + (at.subsec_nanosecond() % 1_000_000 == 0).then(|| at.as_millisecond()) + } + Self::HttpDate => jiff::fmt::rfc2822::DateTimeParser::new() + .parse_timestamp(text) + .ok() + .map(|at| at.as_millisecond()), + } + } +} + +/// How values are shown in JSON. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct JsonForm { + pub(crate) timestamps: TimestampFormat, +} + +/// `scalar` in JSON: integers, floats and counters as numbers, bytes as base64, +/// timestamps as `form` says. +pub(crate) fn scalar_to_json(scalar: &ScalarValue, form: JsonForm) -> Option { + Some(match scalar { + ScalarValue::Null => JsonValue::Null, + ScalarValue::Boolean(b) => JsonValue::Bool(*b), + ScalarValue::Int(i) => JsonValue::Number((*i).into()), + ScalarValue::Uint(u) => JsonValue::Number((*u).into()), + ScalarValue::F64(f) => JsonValue::Number(serde_json::Number::from_f64(*f)?), + ScalarValue::Str(s) => JsonValue::String(s.to_string()), + ScalarValue::Bytes(b) => JsonValue::String(BASE64.encode(b)), + ScalarValue::Counter(c) => JsonValue::Number(i64::from(c).into()), + ScalarValue::Timestamp(t) => form.timestamps.json(*t), + ScalarValue::Unknown { .. } => return None, + }) +} + +/// `scalar` shown natively: its media type (with the timestamp format) and body; None +/// for a null or an unknown scalar, which have none. +pub(crate) fn native( + scalar: &ScalarValue, + timestamps: TimestampFormat, +) -> Option<(String, Vec)> { + let media = Kind::of(scalar).media_type()?; + let body = match scalar { + ScalarValue::Str(text) => { + return Some((format!("{TEXT}; charset=utf-8"), text.as_bytes().to_vec())); + } + ScalarValue::Bytes(bytes) => bytes.clone(), + ScalarValue::Int(i) => i.to_string().into_bytes(), + ScalarValue::Uint(u) => u.to_string().into_bytes(), + ScalarValue::F64(f) => f.to_string().into_bytes(), + ScalarValue::Boolean(b) => b.to_string().into_bytes(), + ScalarValue::Counter(c) => i64::from(c).to_string().into_bytes(), + ScalarValue::Timestamp(t) => { + return Some(( + format!("{TIMESTAMP}; format={}", timestamps.name()), + timestamps.text(*t).into_bytes(), + )); + } + ScalarValue::Null | ScalarValue::Unknown { .. } => return None, + }; + Some((media.to_owned(), body)) +} + +/// The value of media type `media` (with its `params`) that `body` holds, for a write; +/// None if `media` isn't one of the native types (`text/plain` is a JSON string's, and +/// read as one by the caller). An error says what's wrong with the body. +pub(crate) fn from_native( + media: &str, + params: &str, + body: &[u8], +) -> Option> { + let text = || { + std::str::from_utf8(body) + .map(str::trim) + .map_err(|_| "not UTF-8".to_owned()) + }; + Some(match media { + OCTET_STREAM => Ok(ScalarValue::Bytes(body.to_vec())), + INT => text().and_then(|text| { + text.parse() + .map(ScalarValue::Int) + .map_err(|_| format!("not an integer: {text}")) + }), + UINT => text().and_then(|text| { + text.parse() + .map(ScalarValue::Uint) + .map_err(|_| format!("not an unsigned integer: {text}")) + }), + FLOAT => text().and_then(|text| { + text.parse::() + .ok() + .filter(|f| f.is_finite()) + .map(ScalarValue::F64) + .ok_or_else(|| format!("not a finite number: {text}")) + }), + BOOLEAN => text().and_then(|text| match text { + "true" => Ok(ScalarValue::Boolean(true)), + "false" => Ok(ScalarValue::Boolean(false)), + other => Err(format!("not true or false: {other}")), + }), + COUNTER => text().and_then(|text| { + text.parse::() + .map(ScalarValue::counter) + .map_err(|_| format!("not an integer: {text}")) + }), + TIMESTAMP => { + let format = match param(params, "format") { + None => TimestampFormat::default(), + Some(name) => match TimestampFormat::named(&name) { + Some(format) => format, + None => return Some(Err(format!("unknown timestamp format: {name}"))), + }, + }; + text().and_then(|text| { + format + .read(text) + .map(ScalarValue::Timestamp) + .ok_or_else(|| { + format!( + "not a {} timestamp (to the millisecond): {text}", + format.name() + ) + }) + }) + } + _ => return None, + }) +} + +/// The value of parameter `name` among a media type's `params` (`; a=b; c="d"`). +pub(crate) fn param(params: &str, name: &str) -> Option { + params.split(';').find_map(|param| { + let (key, value) = param.split_once('=')?; + key.trim() + .eq_ignore_ascii_case(name) + .then(|| value.trim().trim_matches('"').to_owned()) + }) +} -- cgit v1.3.1