aboutsummaryrefslogtreecommitdiffstats
path: root/src/document/typed.rs
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-02 10:25:16 +0100
committertv <tv@krebsco.de>2026-11-02 10:25:16 +0100
commit74ced20c6013b7ced9ae14a41033f60239790f6a (patch)
tree4998e857559e74ddde74974d431079f33cd0f77b /src/document/typed.rs
parent279701059c958555ab3096adb733c865ffdb91ff (diff)
api: add application/vnd.continuum.* scalar types
Diffstat (limited to 'src/document/typed.rs')
-rw-r--r--src/document/typed.rs318
1 files changed, 318 insertions, 0 deletions
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<Self> {
+ 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<i64> {
+ 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<JsonValue> {
+ 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<u8>)> {
+ 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<Result<ScalarValue, String>> {
+ 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::<f64>()
+ .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::<i64>()
+ .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<String> {
+ 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())
+ })
+}