aboutsummaryrefslogtreecommitdiffstats
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
commit9a73a89e5888df59bbdaff1279bd65e51264cd61 (patch)
tree45195b508cf89f01b04ec78e7354704189b00744
parent21f11eba10d34fb9ab1cbeabb70cc86328185163 (diff)
api: add application/vnd.continuum.* scalar types
-rw-r--r--Cargo.toml2
-rw-r--r--docs/openapi.yaml47
-rw-r--r--src/api/mod.rs7
-rw-r--r--src/api/store.rs364
-rw-r--r--src/document/mod.rs1
-rw-r--r--src/document/store.rs200
-rw-r--r--src/document/typed.rs318
7 files changed, 809 insertions, 130 deletions
diff --git a/Cargo.toml b/Cargo.toml
index 070e1f5..888c4b9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -51,6 +51,8 @@ libp2p = { version = "0.57", optional = true, features = ["tokio", "tcp", "quic"
libp2p-stream = { version = "0.5.0-alpha", optional = true }
ed25519-dalek = "3"
getrandom = "0.4"
+# Timestamps written and read as text (RFC 3339, RFC 9110 dates): no time zones needed.
+jiff = { version = "0.2", default-features = false, features = ["std"] }
libc = { version = "0.2", optional = true }
password-hash = "0.6"
# Its TLS (only for a Mycelium daemon elsewhere, over https) is rustls with ring, which
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index 0aea4a6..5402389 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -77,9 +77,14 @@ paths:
tags: [Store]
summary: Read a value
description: |
- The value at the path, as JSON, or with `Accept: text/plain` as text (strings
- unquoted, anything else as JSON). Concurrent writes resolve as follows:
- concurrent maps merge, anything else keeps the newest write.
+ 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
+ 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
+ keeps the newest write.
parameters:
- $ref: '#/components/parameters/conflicts'
- $ref: '#/components/parameters/heads'
@@ -94,6 +99,8 @@ paths:
$ref: '#/components/responses/Unauthorized'
'404':
description: No value at this path (with `at`, then), or this node lacks the heads `at` names.
+ '406':
+ description: '`Accept` names nothing the value can be shown as; the message says what it can.'
'500':
description: Reading or serializing failed.
'503':
@@ -105,11 +112,16 @@ paths:
Writes the value, creating intermediate maps (not lists): the path then reads as
the value, what was there changed only where it differs (`api.md`, Concurrent
writes). In a list, an index overwrites the item there, the list's length
- appends, and past it is `404`. One request is one Automerge change. Answers with the value written (JSON, or text with
- `Accept: text/plain`).
+ appends, and past it is `404`. One request is one Automerge change. Answers
+ with the value written, shown as `Accept` asks (as a read).
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):
+ `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`;
+ a body that isn't one is `400`, saying why.
requestBody:
required: true
content:
@@ -120,6 +132,14 @@ paths:
schema:
type: string
example: dark
+ application/octet-stream:
+ schema:
+ type: string
+ format: binary
+ application/vnd.continuum.timestamp:
+ schema:
+ type: string
+ example: '2026-11-01T21:50:20.345Z'
responses:
'200':
$ref: '#/components/responses/Value'
@@ -132,7 +152,7 @@ paths:
'409':
description: The path crosses an existing non-map value.
'415':
- description: Content-Type is neither `application/json` nor `text/plain` (absent means JSON).
+ description: Content-Type is none the API takes (`application/json`, the default; `text/plain`; a type's own, `api.md`, Types).
'500':
description: Writing failed.
'503':
@@ -202,7 +222,7 @@ paths:
'409':
description: Not a list there, or the index is past its end (the message says which, and the length); with `increment`, not a counter there.
'415':
- description: Content-Type is neither `application/json` nor `text/plain` (absent means JSON).
+ description: Content-Type is none the API takes (`application/json`, the default; `text/plain`; a type's own, `api.md`, Types).
'503':
description: 'Node is not ready (and isn''t rejoining: a rejoining node takes writes into its document before the rejoin, carried over when it ends).'
delete:
@@ -1263,7 +1283,7 @@ components:
description: |
Instead of the value, what is there one level deep, as JSON, to browse a large
store a level at a time: `type` (`map`, `list`, `text`, `string`, `bytes`,
- `number`, `boolean`, `null`, `unknown`), `size` (a map's keys, a list's items,
+ `int`, `uint`, `float`, `boolean`, `null`, `counter`, `timestamp`, `unknown`), `size` (a map's keys, a list's items,
a text's characters, a string's or bytes' bytes), `value` for a scalar up to
256 bytes, and for a map `entries`: each key's summary (without entries of its
own). Concurrent values resolved as a read resolves them. Not with `conflicts`
@@ -1344,6 +1364,10 @@ components:
description: SHA-256 of the body.
schema:
type: string
+ x-continuum-type:
+ description: What the value is stored as (`map`, `list`, `text`, `string`, `bytes`, `int`, `uint`, `float`, `boolean`, `null`, `counter`, `timestamp`).
+ schema:
+ type: string
content:
application/json:
schema:
@@ -1353,6 +1377,13 @@ components:
text/plain:
schema:
type: string
+ application/octet-stream:
+ schema:
+ type: string
+ format: binary
+ application/vnd.continuum.timestamp:
+ schema:
+ type: string
Error:
description: Error.
content:
diff --git a/src/api/mod.rs b/src/api/mod.rs
index dfefcc8..713f248 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -199,6 +199,13 @@ impl IntoResponse for ApiError {
/// A store error as an answer, saying why (`StoreError`'s text) where it is the
/// client's; others are logged with `context`, and are a bare `500`.
+/// A read's (`store_status`).
+impl From<StoreError> for ApiError {
+ fn from(error: StoreError) -> Self {
+ store_status(error, "Failed to read store path")
+ }
+}
+
pub(crate) fn store_status(error: StoreError, context: &str) -> ApiError {
let status = match error {
StoreError::Conflict
diff --git a/src/api/store.rs b/src/api/store.rs
index 1478d14..13f5012 100644
--- a/src/api/store.rs
+++ b/src/api/store.rs
@@ -8,7 +8,7 @@ use axum::{
extract::{Path, Query, RawQuery, State},
http::{
HeaderMap, HeaderValue, StatusCode,
- header::{ACCEPT, CONTENT_TYPE, ETAG, LOCATION},
+ header::{ACCEPT, CONTENT_TYPE, ETAG, LOCATION, VARY},
},
response::{IntoResponse, Response},
};
@@ -19,10 +19,11 @@ use tokio::sync::broadcast;
use tracing::{debug, error, instrument, warn};
use super::auth::AuthenticatedUser;
-use super::{ApiError, media_type, store_status};
+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::state::{AppState, ReadSource};
use crate::transport::Hex;
@@ -71,7 +72,7 @@ macro_rules! read_at {
let $d = &at;
$read
}
- None => Err(StoreError::UnknownHeads),
+ None => Err(From::from(StoreError::UnknownHeads)),
},
}
};
@@ -90,14 +91,6 @@ pub(crate) struct DeleteQuery {
prune: bool,
}
-fn conflicts_response(
- value: JsonValue,
- conflicts: Vec<store::ConflictReport>,
-) -> Result<Response, ApiError> {
- let body = serde_json::json!({ "value": value, "conflicts": conflicts });
- response_for_value(&body, None) // always JSON
-}
-
/// `GET /store`: the whole store (`docs/openapi.yaml`).
pub(crate) async fn get_store_root(
State(state): State<AppState>,
@@ -147,30 +140,92 @@ async fn read(
root,
query.heads,
move |doc| {
- read_at!(doc, at.as_deref(), |doc| if want_conflicts {
- store::read_path_with_conflicts(doc, &path)
- .map(|found| found.map(|(value, conflicts)| (value, Some(conflicts))))
- } else {
- store::read_path(doc, &path).map(|found| found.map(|value| (value, None)))
- })
+ read_at!(doc, at.as_deref(), |doc| read_shown(
+ doc,
+ &path,
+ accept.as_ref(),
+ want_conflicts
+ ))
},
- move |result| {
- let found = result.map_err(|e| store_status(e, "Failed to read store path"))?;
- let (value, conflicts) = match found {
- Some(found) => found,
- None if root => (JsonValue::Object(Default::default()), Some(Vec::new())),
- None => return Err(StatusCode::NOT_FOUND.into()),
- };
- match conflicts {
- Some(conflicts) => conflicts_response(value, conflicts),
- None => response_for_value(&value, accept.as_ref()),
+ move |result| match result {
+ Ok(Some(read)) => read.respond(),
+ Ok(None) if root => Read {
+ held: store::Held {
+ kind: Kind::Map,
+ value: None,
+ },
+ shown: Shown::Json(JsonForm::default()),
+ json: Some(JsonValue::Object(Default::default())),
+ conflicts: want_conflicts.then(Vec::new),
}
+ .respond(),
+ Ok(None) => Err(StatusCode::NOT_FOUND.into()),
+ Err(error) => Err(error),
},
)
.await?;
mark_source(response, source, heads)
}
+/// A value read, to show as asked (`Shown`).
+struct Read {
+ held: store::Held,
+ shown: Shown,
+ /// Its JSON, of the form asked, unless shown natively.
+ json: Option<JsonValue>,
+ /// With `?conflicts=true`: those on the way (then always JSON).
+ conflicts: Option<Vec<store::ConflictReport>>,
+}
+
+impl Read {
+ fn respond(self) -> Result<Response, ApiError> {
+ if let Some(conflicts) = self.conflicts {
+ let value = self.json.unwrap_or(JsonValue::Null);
+ let body = serde_json::json!({ "value": value, "conflicts": conflicts });
+ return show(&self.held, Shown::Json(JsonForm::default()), Some(&body));
+ }
+ show(&self.held, self.shown, self.json.as_ref())
+ }
+}
+
+/// What `path` holds, read to show as `accept` asks; `406` if it can't be.
+fn read_shown(
+ doc: &impl crate::document::ReadDoc,
+ path: &[String],
+ accept: Option<&HeaderValue>,
+ conflicts: bool,
+) -> Result<Option<Read>, ApiError> {
+ let failed = |e| store_status(e, "Failed to read store path");
+ let Some(held) = store::read_held(doc, path).map_err(failed)? else {
+ return Ok(None);
+ };
+ let shown = if conflicts {
+ Shown::Json(json_form(accept)?)
+ } else {
+ shown(accept, held.kind)?
+ };
+ let (json, conflicts) = match (shown, conflicts) {
+ (Shown::Native(_), _) => (None, None),
+ (Shown::Json(form), true) => {
+ match store::read_path_with_conflicts_as(doc, path, form).map_err(failed)? {
+ Some((json, conflicts)) => (Some(json), Some(conflicts)),
+ None => return Ok(None),
+ }
+ }
+ (Shown::Json(form), false) => (store::read_path_as(doc, path, form).map_err(failed)?, None),
+ (Shown::Text, _) => (
+ store::read_path_as(doc, path, JsonForm::default()).map_err(failed)?,
+ None,
+ ),
+ };
+ Ok(Some(Read {
+ held,
+ shown,
+ json,
+ conflicts,
+ }))
+}
+
/// `?shallow=true`: what's at `path`, one level deep, as JSON (`store::read_shallow`);
/// with conflicts asked for too, `400`. `unready_live` as `render_read` has it.
async fn shallow(
@@ -192,7 +247,7 @@ async fn shallow(
let value = result
.map_err(|e| store_status(e, "Failed to read store path"))?
.ok_or(StatusCode::NOT_FOUND)?;
- response_for_value(&value, None)
+ respond(None, "application/json", json_bytes(&value)?)
},
)
.await?;
@@ -254,19 +309,19 @@ pub(crate) async fn put_store_entry(
return Ok(redirect);
}
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 written = value.clone();
let ((), heads) = state
- .mutate_doc_for(Some(user.0.clone()), move |doc| {
- store::write_path(doc, &path, &written)
+ .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),
})
.await
.map_err(|e| store_status(e, "Failed to write store value"))?;
- Ok(with_heads(
- response_for_value(&value, headers.get(ACCEPT))?,
- &heads,
- ))
+ Ok(with_heads(show(&held, shown, json.as_ref())?, &heads))
}
fn etag_for(bytes: &[u8]) -> String {
@@ -309,62 +364,205 @@ fn parse_write_path(
Ok(path)
}
-fn response_for_value(
- value: &JsonValue,
- accept: Option<&HeaderValue>,
-) -> Result<Response, ApiError> {
- let wants_text = accept
- .and_then(|value| value.to_str().ok())
- .is_some_and(|value| {
- value
- .split(',')
- .any(|item| media_type(item) == "text/plain")
- });
+/// How a value is shown, as the request's `Accept` asks.
+#[derive(Clone, Copy, Debug)]
+enum Shown {
+ Json(JsonForm),
+ /// `text/plain`: a string as it is, anything else as JSON.
+ Text,
+ /// Natively, as its type's own media type (`typed::native`).
+ Native(TimestampFormat),
+}
- let (content_type, body) = if wants_text {
- let body = match value {
- JsonValue::String(value) => value.clone(),
- other => serde_json::to_string(other).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
- };
+/// The JSON form `accept` asks for (`application/json; timestamp-format=…`): `400` for
+/// 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);
+ }
+ }
+ Ok(JsonForm::default())
+}
- ("text/plain; charset=utf-8", body)
- } else {
- (
- "application/json",
- serde_json::to_string(value).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
- )
+fn json_form_of(params: &str) -> Result<JsonForm, ApiError> {
+ let timestamps = match typed::param(params, "timestamp-format") {
+ None => TimestampFormat::default(),
+ Some(name) => TimestampFormat::named(&name).ok_or_else(|| {
+ ApiError(
+ StatusCode::BAD_REQUEST,
+ Some(format!("no timestamp format {name:?}")),
+ )
+ })?,
};
+ Ok(JsonForm { timestamps })
+}
- let etag = etag_for(body.as_bytes());
+/// The media types of `accept`, in its order, each with its parameters (q-values are not
+/// weighed). Without an `Accept`, none.
+fn accepted(accept: Option<&HeaderValue>) -> Vec<(String, &str)> {
+ let Some(accept) = accept.and_then(|value| value.to_str().ok()) else {
+ return Vec::new();
+ };
+ accept
+ .split(',')
+ .map(|item| {
+ let (media, params) = item.split_once(';').unwrap_or((item, ""));
+ (media.trim().to_ascii_lowercase(), params)
+ })
+ .collect()
+}
- let mut response = body.into_response();
+/// How a value of `kind` is shown, as `accept` asks: the first it names that the value
+/// can be shown as. JSON without an `Accept`, or for `*/*`; `406` naming what it can be
+/// shown as if `accept` names none of them.
+fn shown(accept: Option<&HeaderValue>, kind: Kind) -> Result<Shown, ApiError> {
+ let items = accepted(accept);
+ if items.is_empty() {
+ return Ok(Shown::Json(JsonForm::default()));
+ }
+ let native = kind.media_type();
+ for (media, params) in &items {
+ match media.as_str() {
+ "application/json" => return Ok(Shown::Json(json_form_of(params)?)),
+ typed::TEXT => return Ok(Shown::Text),
+ "*/*" | "application/*" => return Ok(Shown::Json(JsonForm::default())),
+ media if Some(media) == native => {
+ let format = match typed::param(params, "format") {
+ None => TimestampFormat::default(),
+ Some(name) => TimestampFormat::named(&name).ok_or_else(|| {
+ ApiError(
+ StatusCode::BAD_REQUEST,
+ Some(format!("no timestamp format {name:?}")),
+ )
+ })?,
+ };
+ return Ok(Shown::Native(format));
+ }
+ _ => {}
+ }
+ }
+ let mut offered = vec!["application/json", typed::TEXT];
+ if let Some(native) = native.filter(|native| *native != typed::TEXT) {
+ offered.push(native);
+ }
+ Err(ApiError(
+ StatusCode::NOT_ACCEPTABLE,
+ Some(format!(
+ "a {} is shown as {}",
+ kind.name(),
+ offered.join(", ")
+ )),
+ ))
+}
- response
- .headers_mut()
- .insert(CONTENT_TYPE, HeaderValue::from_static(content_type));
+/// The response showing `held` as `shown`: `json` is its JSON, of the form `shown` asks,
+/// where it isn't shown natively.
+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::Text => {
+ let body = match json()? {
+ JsonValue::String(value) => value.clone().into_bytes(),
+ other => json_bytes(other)?,
+ };
+ respond(Some(held.kind), "text/plain; charset=utf-8", body)
+ }
+ Shown::Native(format) => {
+ let (content_type, body) = held
+ .value
+ .as_ref()
+ .and_then(|value| typed::native(value, format))
+ .ok_or(ApiError(StatusCode::INTERNAL_SERVER_ERROR, None))?;
+ respond(Some(held.kind), &content_type, body)
+ }
+ }
+}
- response.headers_mut().insert(
+fn json_bytes(value: &JsonValue) -> Result<Vec<u8>, ApiError> {
+ serde_json::to_vec(value).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR.into())
+}
+
+/// A response of `body` as `content_type`, with its `ETag`, and what the value is
+/// stored as (`x-continuum-type`), if it is one.
+fn respond(kind: Option<Kind>, content_type: &str, body: Vec<u8>) -> Result<Response, ApiError> {
+ let etag = etag_for(&body);
+ let mut response = body.into_response();
+ let headers = response.headers_mut();
+ headers.insert(
+ CONTENT_TYPE,
+ HeaderValue::from_str(content_type).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
+ );
+ headers.insert(
ETAG,
HeaderValue::from_str(&etag).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
);
-
+ if let Some(kind) = kind {
+ headers.insert(TYPE_HEADER, HeaderValue::from_static(kind.name()));
+ headers.insert(VARY, HeaderValue::from_static("Accept"));
+ }
Ok(response)
}
-/// A write's body as a value: JSON (the default), or text as a string.
-fn body_value(headers: &HeaderMap, body: &Bytes) -> Result<JsonValue, StatusCode> {
+/// 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> {
let content_type = headers
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("application/json");
- match media_type(content_type) {
- "application/json" => {
- serde_json::from_slice::<JsonValue>(body).map_err(|_| StatusCode::BAD_REQUEST)
- }
- "text/plain" => String::from_utf8(body.to_vec())
- .map(JsonValue::String)
- .map_err(|_| StatusCode::BAD_REQUEST),
- _ => Err(StatusCode::UNSUPPORTED_MEDIA_TYPE),
+ let (media, params) = content_type.split_once(';').unwrap_or((content_type, ""));
+ let media = media.trim().to_ascii_lowercase();
+ match media.as_str() {
+ "application/json" => serde_json::from_slice::<JsonValue>(body)
+ .map(Written::Json)
+ .map_err(|_| StatusCode::BAD_REQUEST.into()),
+ typed::TEXT => String::from_utf8(body.to_vec())
+ .map(|text| Written::Json(JsonValue::String(text)))
+ .map_err(|_| StatusCode::BAD_REQUEST.into()),
+ 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))),
+ None => Err(StatusCode::UNSUPPORTED_MEDIA_TYPE.into()),
+ },
}
}
@@ -402,15 +600,27 @@ pub(crate) async fn post_store_entry(
.await
.map_err(|e| store_status(e, "Failed to increment a counter"))?;
return Ok(with_heads(
- response_for_value(&JsonValue::from(value), headers.get(ACCEPT))?,
+ {
+ let counter = automerge::ScalarValue::counter(value);
+ let held = store::Held {
+ kind: Kind::Counter,
+ value: Some(counter),
+ };
+ show(
+ &held,
+ shown(headers.get(ACCEPT), Kind::Counter)?,
+ Some(&JsonValue::from(value)),
+ )?
+ },
&heads,
));
}
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| {
- store::insert_path(doc, &path, &value)
+ .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),
})
.await;
match inserted {
diff --git a/src/document/mod.rs b/src/document/mod.rs
index d7c2fcb..4d7fbfe 100644
--- a/src/document/mod.rs
+++ b/src/document/mod.rs
@@ -46,6 +46,7 @@ pub(crate) mod store;
pub(crate) mod sync;
#[cfg(test)]
pub(crate) mod testing;
+pub(crate) mod typed;
#[cfg(test)]
mod tests {
diff --git a/src/document/store.rs b/src/document/store.rs
index cc67f77..0f1fd58 100644
--- a/src/document/store.rs
+++ b/src/document/store.rs
@@ -5,11 +5,12 @@ use automerge::{
ActorId, Automerge, ObjId, ObjType, Prop, ReadDoc, ScalarValue, Value,
transaction::Transactable,
};
-use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use serde::Serialize;
use serde_json::Value as JsonValue;
use std::collections::BTreeSet;
+use super::typed::{self, JsonForm, Kind};
+
/// Store paths touched by one batch of document changes (`AppState::change_events`).
#[derive(Debug)]
pub(crate) struct ChangeSet {
@@ -283,14 +284,15 @@ fn merged_keys(doc: &impl ReadDoc, maps: &[ObjId]) -> BTreeSet<String> {
fn resolved_to_json(
doc: &impl ReadDoc,
resolved: &Resolved,
+ form: JsonForm,
mut log: Option<&mut ConflictLog>,
) -> Result<JsonValue, StoreError> {
let maps = match resolved {
Resolved::Maps(maps) => maps,
Resolved::Other(Value::Scalar(scalar), _) => {
- return scalar_to_json(scalar.clone().into_owned());
+ return scalar_to_json(scalar.clone().into_owned(), form);
}
- Resolved::Other(Value::Object(_), id) => return object_to_json(doc, id, log),
+ Resolved::Other(Value::Object(_), id) => return object_to_json(doc, id, form, log),
};
let mut out = serde_json::Map::new();
@@ -313,7 +315,9 @@ fn resolved_to_json(
}
};
- let json = nested(&mut log, &key, |log| resolved_to_json(doc, &child, log))?;
+ let json = nested(&mut log, &key, |log| {
+ resolved_to_json(doc, &child, form, log)
+ })?;
out.insert(key, json);
}
} else {
@@ -322,7 +326,9 @@ fn resolved_to_json(
let Some(child) = resolve(doc, maps, &key, log.as_deref_mut())? else {
continue;
};
- let json = nested(&mut log, &key, |log| resolved_to_json(doc, &child, log))?;
+ let json = nested(&mut log, &key, |log| {
+ resolved_to_json(doc, &child, form, log)
+ })?;
out.insert(key, json);
}
}
@@ -333,19 +339,20 @@ fn resolved_to_json(
fn object_to_json(
doc: &impl ReadDoc,
id: &ObjId,
+ form: JsonForm,
mut log: Option<&mut ConflictLog>,
) -> Result<JsonValue, StoreError> {
match doc.object_type(id)? {
- ObjType::Map => resolved_to_json(doc, &Resolved::Maps(vec![id.clone()]), log),
+ ObjType::Map => resolved_to_json(doc, &Resolved::Maps(vec![id.clone()]), form, log),
ObjType::List => {
let mut out = Vec::new();
for (index, item) in doc.list_range(id, ..).enumerate() {
let child_id = item.id();
out.push(match Value::from(item.value) {
Value::Object(_) => nested(&mut log, &index.to_string(), |log| {
- object_to_json(doc, &child_id, log)
+ object_to_json(doc, &child_id, form, log)
})?,
- Value::Scalar(scalar) => scalar_to_json(scalar.into_owned())?,
+ Value::Scalar(scalar) => scalar_to_json(scalar.into_owned(), form)?,
});
}
Ok(JsonValue::Array(out))
@@ -367,12 +374,13 @@ fn delete_in_all(doc: &mut impl Transactable, maps: &[ObjId], key: &str) -> Resu
fn read_path_inner(
doc: &impl ReadDoc,
path: &[String],
+ form: JsonForm,
mut log: Option<&mut ConflictLog>,
) -> Result<Option<JsonValue>, StoreError> {
let Some(current) = resolve_logged(doc, path, log.as_deref_mut())? else {
return Ok(None);
};
- Ok(Some(resolved_to_json(doc, &current, log)?))
+ Ok(Some(resolved_to_json(doc, &current, form, log)?))
}
/// Reads a path; the empty path is the whole store.
@@ -380,21 +388,82 @@ pub(crate) fn read_path(
doc: &impl ReadDoc,
path: &[String],
) -> Result<Option<JsonValue>, StoreError> {
- read_path_inner(doc, path, None)
+ read_path_as(doc, path, JsonForm::default())
+}
+
+/// [`read_path`], in JSON of `form`.
+pub(crate) fn read_path_as(
+ doc: &impl ReadDoc,
+ path: &[String],
+ form: JsonForm,
+) -> Result<Option<JsonValue>, StoreError> {
+ read_path_inner(doc, path, form, None)
}
+#[cfg(test)]
pub(crate) fn read_path_with_conflicts(
doc: &impl ReadDoc,
path: &[String],
) -> Result<Option<(JsonValue, Vec<ConflictReport>)>, StoreError> {
+ read_path_with_conflicts_as(doc, path, JsonForm::default())
+}
+
+/// [`read_path_with_conflicts`], in JSON of `form`.
+pub(crate) fn read_path_with_conflicts_as(
+ doc: &impl ReadDoc,
+ path: &[String],
+ form: JsonForm,
+) -> Result<Option<(JsonValue, Vec<ConflictReport>)>, StoreError> {
let mut log = ConflictLog {
want_values: true,
+ form,
..Default::default()
};
- let value = read_path_inner(doc, path, Some(&mut log))?;
+ let value = read_path_inner(doc, path, form, Some(&mut log))?;
Ok(value.map(|value| (value, log.reports)))
}
+/// What is stored at `path` (the empty path: the root map), and, where it is a scalar
+/// or a text, the value itself, to show natively (`typed::native`); None if nothing is
+/// there.
+pub(crate) fn read_held(doc: &impl ReadDoc, path: &[String]) -> Result<Option<Held>, StoreError> {
+ let Some(resolved) = resolve_through(doc, path)? else {
+ return Ok(None);
+ };
+ Ok(Some(match resolved {
+ Resolved::Maps(_) | Resolved::Other(Value::Object(ObjType::Map), _) => Held {
+ kind: Kind::Map,
+ value: None,
+ },
+ Resolved::Other(Value::Object(ObjType::List), _) => Held {
+ kind: Kind::List,
+ value: None,
+ },
+ Resolved::Other(Value::Object(ObjType::Text), id) => Held {
+ kind: Kind::Text,
+ value: Some(ScalarValue::Str(doc.text(&id)?.into())),
+ },
+ Resolved::Other(Value::Object(ObjType::Table), _) => {
+ return Err(anyhow!("Automerge tables are not supported").into());
+ }
+ Resolved::Other(Value::Scalar(scalar), _) => {
+ let scalar = scalar.into_owned();
+ Held {
+ kind: Kind::of(&scalar),
+ value: Some(scalar),
+ }
+ }
+ }))
+}
+
+/// What a path holds (`read_held`).
+#[derive(Debug, Clone)]
+pub(crate) struct Held {
+ pub(crate) kind: Kind,
+ /// A scalar, or a text's characters as a string.
+ pub(crate) value: Option<ScalarValue>,
+}
+
/// For streams: the value, plus whether the path or subtree involves any conflict.
/// Reports are collected without values, so this costs little more than read_path.
pub(crate) fn read_path_traced(
@@ -402,7 +471,7 @@ pub(crate) fn read_path_traced(
path: &[String],
) -> Result<(Option<JsonValue>, bool), StoreError> {
let mut log = ConflictLog::default();
- let value = read_path_inner(doc, path, Some(&mut log))?;
+ let value = read_path_inner(doc, path, JsonForm::default(), Some(&mut log))?;
Ok((value, !log.reports.is_empty()))
}
@@ -506,20 +575,21 @@ fn summary(
return Err(anyhow!("Automerge tables are not supported").into());
}
Resolved::Other(Value::Scalar(scalar), _) => {
- let (kind, size) = match &**scalar {
- ScalarValue::Str(text) => ("string", Some(text.len())),
- ScalarValue::Bytes(bytes) => ("bytes", Some(bytes.len())),
- ScalarValue::Null => ("null", None),
- ScalarValue::Boolean(_) => ("boolean", None),
- ScalarValue::Unknown { .. } => ("unknown", None),
- _ => ("number", None),
+ let kind = Kind::of(scalar);
+ let size = match &**scalar {
+ ScalarValue::Str(text) => Some(text.len()),
+ ScalarValue::Bytes(bytes) => Some(bytes.len()),
+ _ => None,
};
- out.insert("type".into(), kind.into());
+ out.insert("type".into(), kind.name().into());
if let Some(size) = size {
out.insert("size".into(), size.into());
}
- if kind != "unknown" && size.is_none_or(|size| size <= SHALLOW_INLINE) {
- out.insert("value".into(), scalar_to_json(scalar.clone().into_owned())?);
+ if kind != Kind::Unknown && size.is_none_or(|size| size <= SHALLOW_INLINE) {
+ out.insert(
+ "value".into(),
+ scalar_to_json(scalar.clone().into_owned(), JsonForm::default())?,
+ );
}
}
}
@@ -547,8 +617,8 @@ pub(crate) fn read_candidates(
candidates(doc, &maps, leaf.as_str())?
.into_iter()
.map(|(value, id)| match value {
- Value::Scalar(scalar) => scalar_to_json(scalar.into_owned()),
- Value::Object(_) => object_to_json(doc, &id, None),
+ Value::Scalar(scalar) => scalar_to_json(scalar.into_owned(), JsonForm::default()),
+ Value::Object(_) => object_to_json(doc, &id, JsonForm::default(), None),
})
.collect()
}
@@ -581,6 +651,38 @@ 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(())
+}
+
// 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
@@ -591,7 +693,7 @@ pub(crate) fn write_path(
// conflict.
/// What `value` would be stored as, if it is a scalar.
-fn json_scalar(value: &JsonValue) -> Result<Option<ScalarValue>, StoreError> {
+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),
@@ -815,6 +917,22 @@ pub(crate) fn insert_path(
path: &[String],
value: &JsonValue,
) -> Result<(), StoreError> {
+ let (list, index) = insertion(doc, path)?;
+ write_value(doc, &list, Slot::Index(index), value)
+}
+
+/// [`insert_path`] for a scalar (`typed::from_native`).
+pub(crate) fn insert_scalar_path(
+ doc: &mut impl Transactable,
+ path: &[String],
+ scalar: &ScalarValue,
+) -> Result<(), StoreError> {
+ let (list, index) = insertion(doc, path)?;
+ put_scalar(doc, &list, Slot::Index(index), scalar.clone())
+}
+
+/// The list an insert at `path` goes into, and where (`insert_path`).
+fn insertion(doc: &impl ReadDoc, path: &[String]) -> Result<(ObjId, usize), StoreError> {
let (leaf, parents) = path.split_last().ok_or(StoreError::NotFound)?;
let list = match resolve_through(doc, parents)? {
None => return Err(StoreError::NotFound),
@@ -826,8 +944,7 @@ pub(crate) fn insert_path(
if index > length {
return Err(StoreError::PastTheEnd { index, length });
}
- write_value(doc, &list, Slot::Index(index), value)?;
- Ok(())
+ Ok((list, index))
}
/// Makes `dst` hold at `path` what `src`'s merged view holds there: a type-preserving
@@ -1150,21 +1267,11 @@ fn json_to_automerge(
write_value(doc, parent, Slot::Key(key), value)
}
-fn scalar_to_json(scalar: ScalarValue) -> Result<JsonValue, StoreError> {
- match scalar {
- ScalarValue::Null => Ok(JsonValue::Null),
- ScalarValue::Boolean(b) => Ok(JsonValue::Bool(b)),
- ScalarValue::Int(i) => Ok(JsonValue::Number(i.into())),
- ScalarValue::Uint(u) => Ok(JsonValue::Number(u.into())),
- ScalarValue::F64(f) => serde_json::Number::from_f64(f)
- .map(JsonValue::Number)
- .ok_or(anyhow!("Non-finite float cannot be represented as JSON").into()),
- ScalarValue::Str(s) => Ok(JsonValue::String(s.to_string())),
- ScalarValue::Bytes(b) => Ok(JsonValue::String(BASE64.encode(b))),
- ScalarValue::Counter(c) => Ok(JsonValue::Number(i64::from(c).into())),
- ScalarValue::Timestamp(t) => Ok(JsonValue::Number(t.into())),
- ScalarValue::Unknown { .. } => Err(anyhow!("Unsupported Automerge scalar type").into()),
- }
+fn scalar_to_json(scalar: ScalarValue, form: JsonForm) -> Result<JsonValue, StoreError> {
+ typed::scalar_to_json(&scalar, form).ok_or_else(|| match scalar {
+ ScalarValue::F64(_) => anyhow!("Non-finite float cannot be represented as JSON").into(),
+ _ => anyhow!("Unsupported Automerge scalar type").into(),
+ })
}
// --- conflict log ------------------------------------------------------------
@@ -1204,6 +1311,8 @@ struct ConflictLog {
want_values: bool,
/// Currently walking the ancestors of the requested path.
in_ancestors: bool,
+ /// How candidate values are shown.
+ form: JsonForm,
}
fn op_id_string(id: &ObjId) -> String {
@@ -1217,10 +1326,11 @@ fn value_to_json(
doc: &impl ReadDoc,
value: &Value<'_>,
id: &ObjId,
+ form: JsonForm,
) -> Result<JsonValue, StoreError> {
match value {
- Value::Scalar(scalar) => Ok(scalar_to_json(scalar.clone().into_owned())?),
- Value::Object(_) => object_to_json(doc, id, None),
+ Value::Scalar(scalar) => Ok(scalar_to_json(scalar.clone().into_owned(), form)?),
+ Value::Object(_) => object_to_json(doc, id, form, None),
}
}
@@ -1247,7 +1357,7 @@ impl ConflictLog {
op_id: op_id_string(id),
selected,
value: if with_values {
- Some(value_to_json(doc, value, id)?)
+ Some(value_to_json(doc, value, id, self.form)?)
} else {
None
},
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())
+ })
+}