aboutsummaryrefslogtreecommitdiffstats
path: root/src/api/store.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/api/store.rs')
-rw-r--r--src/api/store.rs791
1 files changed, 691 insertions, 100 deletions
diff --git a/src/api/store.rs b/src/api/store.rs
index d0fc807..2b21c83 100644
--- a/src/api/store.rs
+++ b/src/api/store.rs
@@ -2,27 +2,30 @@
use std::io::Error as IoError;
-use automerge::ChangeHash;
use axum::{
Extension,
body::{Body, Bytes},
- extract::{Path, Query, State},
+ extract::{Path, Query, RawQuery, State},
http::{
HeaderMap, HeaderValue, StatusCode,
- header::{ACCEPT, CONTENT_TYPE},
+ header::{ACCEPT, CONTENT_TYPE, ETAG, LOCATION, VARY},
},
response::{IntoResponse, Response},
};
use serde::Deserialize;
use serde_json::Value as JsonValue;
+use sha2::{Digest, Sha256};
use tokio::sync::broadcast;
-use tracing::{debug, error, instrument};
+use tracing::{debug, error, instrument, warn};
use super::auth::AuthenticatedUser;
-use super::{parse_key_path, parse_write_path, response_for_value, store_status};
-use crate::document::store::{self, StoreError};
+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, Val};
use crate::state::{AppState, ReadSource};
-use crate::swarm::sync::paths_related;
+use crate::transport::Hex;
#[derive(Debug, Default, Deserialize)]
pub(crate) struct ReadQuery {
@@ -32,6 +35,60 @@ pub(crate) struct ReadQuery {
/// to base a replacing remake on exactly what was read.
#[serde(default)]
heads: bool,
+ /// What's there, one level deep (`store::read_shallow`), to browse a large store a
+ /// level at a time.
+ #[serde(default)]
+ shallow: bool,
+ /// Read as the document was at these heads (comma-separated hex, as
+ /// `x-continuum-heads` gives them), not as it is now (`document::at`).
+ at: Option<String>,
+}
+
+impl ReadQuery {
+ /// The heads `at` names, if any; `400` if it doesn't name them.
+ fn at(&self) -> Result<Option<Vec<ChangeHash>>, ApiError> {
+ let Some(at) = &self.at else {
+ return Ok(None);
+ };
+ at.split(',')
+ .map(|head| head.trim().parse::<ChangeHash>())
+ .collect::<Result<Vec<_>, _>>()
+ .map(Some)
+ .map_err(|_| ApiError(StatusCode::BAD_REQUEST, Some("at: not heads".to_owned())))
+ }
+}
+
+/// Reads `doc` with `read`, as it is or, with `at`, as it was at those heads
+/// (`UnknownHeads` if it lacks one).
+macro_rules! read_at {
+ ($doc:expr, $at:expr, |$d:ident| $read:expr) => {
+ match $at {
+ None => {
+ let $d = $doc;
+ $read
+ }
+ Some(heads) => match crate::document::at::At::new($doc, heads) {
+ Some(at) => {
+ let $d = &at;
+ $read
+ }
+ None => Err(From::from(StoreError::UnknownHeads)),
+ },
+ }
+ };
+}
+
+#[derive(Debug, Deserialize)]
+pub(crate) struct PostQuery {
+ /// Adds this to the counter at the path instead of inserting the body
+ /// (`store::increment_path`).
+ increment: Option<i64>,
+ /// Casts what is at the path to this type instead of inserting the body
+ /// (`store::cast_path`; `typed::Kind::name` names it).
+ cast: Option<String>,
+ /// With `cast`, how timestamps are read from and written as strings (`rfc3339` by
+ /// default; `typed::TimestampFormat`).
+ format: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -40,81 +97,163 @@ pub(crate) struct DeleteQuery {
prune: bool,
}
-fn conflicts_response(
- value: JsonValue,
- conflicts: Vec<store::ConflictReport>,
-) -> Result<Response, StatusCode> {
- 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>,
headers: HeaderMap,
Query(query): Query<ReadQuery>,
-) -> Result<Response, StatusCode> {
+) -> Result<Response, ApiError> {
+ read(&state, Vec::new(), &headers, &query).await
+}
+
+/// `GET /store/{key}`.
+pub(crate) async fn get_store_entry(
+ State(state): State<AppState>,
+ Path(key): Path<String>,
+ headers: HeaderMap,
+ Query(query): Query<ReadQuery>,
+ RawQuery(raw_query): RawQuery,
+) -> Result<Response, ApiError> {
+ let path = parse_key_path(&key)?;
+ let at = query.at()?;
+ if let Some(redirect) =
+ to_positive(&state, &path, false, at.as_deref(), raw_query.as_deref()).await?
+ {
+ return Ok(redirect);
+ }
+ read(&state, path, &headers, &query).await
+}
+
+/// A read of `path` as `query` asks (the empty path: the whole store). The root is read
+/// while not ready too (`render_read`'s `unready_live`), and is never missing: an empty
+/// store is an empty map.
+async fn read(
+ state: &AppState,
+ path: Vec<String>,
+ headers: &HeaderMap,
+ query: &ReadQuery,
+) -> Result<Response, ApiError> {
+ let root = path.is_empty();
+ if query.shallow {
+ return shallow(state, path, root, query).await;
+ }
let accept = headers.get(ACCEPT).cloned();
let want_conflicts = query.conflicts;
+ let at = query.at()?;
let (response, source, heads) = state
.render_read(
- true,
+ root,
query.heads,
move |doc| {
- if want_conflicts {
- store::read_path_with_conflicts(doc, &Vec::new())
- .map(|found| found.map(|(value, conflicts)| (value, Some(conflicts))))
- } else {
- store::read_path(doc, &Vec::new()).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 (value, conflicts) = result
- .map_err(|e| store_status(e, "Failed to read store path"))?
- .unwrap_or_else(|| (JsonValue::Object(Default::default()), Some(Vec::new())));
- 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)
}
-/// `GET /store/{key}`.
-pub(crate) async fn get_store_entry(
- State(state): State<AppState>,
- Path(key): Path<String>,
- headers: HeaderMap,
- Query(query): Query<ReadQuery>,
-) -> Result<Response, StatusCode> {
- let path = parse_key_path(&key)?;
+/// 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>>,
+}
- let accept = headers.get(ACCEPT).cloned();
- let want_conflicts = query.conflicts;
+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(
+ state: &AppState,
+ path: Vec<String>,
+ unready_live: bool,
+ query: &ReadQuery,
+) -> Result<Response, ApiError> {
+ if query.conflicts {
+ return Err(StatusCode::BAD_REQUEST.into());
+ }
+ let at = query.at()?;
let (response, source, heads) = state
.render_read(
- false,
+ unready_live,
query.heads,
- move |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)))
- }
- },
+ move |doc| read_at!(doc, at.as_deref(), |doc| store::read_shallow(doc, &path)),
move |result| {
- let (value, conflicts) = result
+ let value = result
.map_err(|e| store_status(e, "Failed to read store path"))?
.ok_or(StatusCode::NOT_FOUND)?;
- match conflicts {
- Some(conflicts) => conflicts_response(value, conflicts),
- None => response_for_value(&value, accept.as_ref()),
- }
+ respond(None, "application/json", json_bytes(&value)?)
},
)
.await?;
@@ -125,24 +264,30 @@ pub(crate) async fn get_store_entry(
const STALE_HEADER: &str = "x-continuum-stale";
/// Header with the document's heads at the time of the read (sorted, comma-separated
-/// hex), on request (`?heads=true`).
+/// hex), on request (`?heads=true`); and on every write, after it: a read with these
+/// (`?at=`) includes the write.
const HEADS_HEADER: &str = "x-continuum-heads";
+fn with_heads(mut response: Response, heads: &[ChangeHash]) -> Response {
+ let joined = heads
+ .iter()
+ .map(ToString::to_string)
+ .collect::<Vec<_>>()
+ .join(",");
+ if let Ok(value) = HeaderValue::from_str(&joined) {
+ response.headers_mut().insert(HEADS_HEADER, value);
+ }
+ response
+}
+
fn mark_source(
- response: Result<Response, StatusCode>,
+ response: Result<Response, ApiError>,
source: ReadSource,
heads: Option<Vec<ChangeHash>>,
-) -> Result<Response, StatusCode> {
+) -> Result<Response, ApiError> {
let mut response = response?;
if let Some(heads) = heads {
- let joined = heads
- .iter()
- .map(ToString::to_string)
- .collect::<Vec<_>>()
- .join(",");
- if let Ok(value) = HeaderValue::from_str(&joined) {
- response.headers_mut().insert(HEADS_HEADER, value);
- }
+ response = with_heads(response, &heads);
}
if let ReadSource::Stale(epoch) = source
&& let Ok(value) = HeaderValue::from_str(&epoch.to_string())
@@ -152,46 +297,431 @@ fn mark_source(
Ok(response)
}
-/// `PUT /store/{key}`.
+#[derive(Debug, Deserialize)]
+pub(crate) struct PutQuery {
+ /// Whether the write may change the type of what it writes over (`true`, the
+ /// default); if not, one that would is `409` (`store::Writing::retype`).
+ #[serde(default = "yes")]
+ retype: bool,
+}
+
+fn yes() -> bool {
+ true
+}
+
+/// Header listing the places whose type a write changed (`store::Retyped`), as
+/// `<path>: <from> -> <to>`, comma-separated, `MAX_RETYPED_LISTED` at most (then `…`).
+const RETYPED_HEADER: &str = "x-continuum-retyped";
+const MAX_RETYPED_LISTED: usize = 16;
+
+/// `PUT /store/{key}`: answered with what is there after the write, shown as `Accept`
+/// asks (read back: with sticky types, a JSON value may be stored as another type).
pub(crate) async fn put_store_entry(
State(state): State<AppState>,
Extension(user): Extension<AuthenticatedUser>,
Path(key): Path<String>,
+ Query(query): Query<PutQuery>,
+ RawQuery(raw_query): RawQuery,
headers: HeaderMap,
body: Bytes,
-) -> Result<Response, StatusCode> {
+) -> Result<Response, ApiError> {
if !state.is_ready().await {
- return Err(StatusCode::SERVICE_UNAVAILABLE);
+ return Err(StatusCode::SERVICE_UNAVAILABLE.into());
}
let path = parse_write_path(&state, &user, &key, "PUT")?;
+ if let Some(redirect) = to_positive(&state, &path, false, None, raw_query.as_deref()).await? {
+ return Ok(redirect);
+ }
+ let body = body_value(&headers, &body)?;
+ let accept = headers.get(ACCEPT).cloned();
+ let writing = store::Writing::new(body.sticky, body.timestamps, query.retype);
+
+ // A value that can't be shown as asked is refused, and the write with it.
+ let ((retyped, read), heads) = state
+ .mutate_doc_for(Some(user.0.clone()), move |doc| {
+ let retyped = store::write_val_path(doc, &path, &body.value, writing)
+ .map_err(|e| store_status(e, "Failed to write store value"))?;
+ let read = read_shown(&*doc, &path, accept.as_ref(), false)?;
+ Ok::<_, ApiError>((retyped, read))
+ })
+ .await?;
+ let read = read.ok_or(ApiError(StatusCode::INTERNAL_SERVER_ERROR, None))?;
+
+ Ok(with_retyped(with_heads(read.respond()?, &heads), &retyped))
+}
+
+/// `response` saying where a write changed types (`RETYPED_HEADER`), if it did.
+fn with_retyped(mut response: Response, retyped: &[store::Retyped]) -> Response {
+ if !retyped.is_empty() {
+ let mut listed: Vec<String> = retyped
+ .iter()
+ .take(MAX_RETYPED_LISTED)
+ .map(ToString::to_string)
+ .collect();
+ if retyped.len() > MAX_RETYPED_LISTED {
+ listed.push("…".to_owned());
+ }
+ if let Ok(value) = HeaderValue::from_str(&listed.join(", ")) {
+ response.headers_mut().insert(RETYPED_HEADER, value);
+ }
+ }
+ response
+}
+
+fn etag_for(bytes: &[u8]) -> String {
+ format!("\"{}\"", Hex(&Sha256::digest(bytes)))
+}
+
+fn parse_key_path(key: &str) -> Result<Vec<String>, StatusCode> {
+ let segments: Vec<String> = key.split('/').map(str::to_owned).collect();
+ if segments.is_empty() || segments.iter().any(|segment| segment.is_empty()) {
+ return Err(StatusCode::BAD_REQUEST);
+ }
+
+ Ok(segments)
+}
+
+/// A path for a write. Protocol data (`_continuum`: remake records, members, forget
+/// records, epoch metadata) is what every node acts on, so a write there acts on the
+/// whole swarm: only users listed in `--protocol-writers` may, and each such write is
+/// logged. Epoch metadata (and `_continuum` as a whole, which contains it) is never
+/// writable: the protocol reads it at the snapshot change, so an edit only misleads
+/// whoever reads it later. `403` otherwise.
+fn parse_write_path(
+ state: &AppState,
+ user: &AuthenticatedUser,
+ key: &str,
+ method: &str,
+) -> Result<Vec<String>, StatusCode> {
+ let path = parse_key_path(key)?;
+ if !meta::is_protocol(&path) {
+ return Ok(path);
+ }
+ // `_continuum` itself, epoch metadata and the swarm ID: nobody.
+ let fixed = path.len() < 2 || path[1] == meta::EPOCHS || path[1] == meta::SWARM;
+ if fixed || !state.protocol_writers.contains(&user.0) {
+ warn!(user = %user.0, method, path = %key, "Refused a write to protocol data");
+ return Err(StatusCode::FORBIDDEN);
+ }
+ warn!(user = %user.0, method, path = %key, "Protocol data written through the store API; this acts on the whole swarm");
+ Ok(path)
+}
+
+/// 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),
+}
+
+/// 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) {
+ 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())
+}
+
+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,
+ typed: false,
+ })
+}
+
+/// 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()
+}
+
+/// 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::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 => {
+ 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::TYPED_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(", ")
+ )),
+ ))
+}
+
+/// 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(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(),
+ 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)
+ }
+ }
+}
+
+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)
+}
+
+/// 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: the value to write (`typed::Val`), from JSON (the default), typed
+/// JSON, text as a string, or a scalar of a type of its own (`typed::from_native`).
+struct WriteBody {
+ value: Val,
+ /// Which of its values keep the types of what they are written over
+ /// (`store::Sticky`): plain JSON's and text's where they can, typed JSON's numbers
+ /// over floats.
+ sticky: store::Sticky,
+ /// How timestamps are written in it (`timestamp-format`).
+ timestamps: TimestampFormat,
+}
+
+fn body_value(headers: &HeaderMap, body: &Bytes) -> Result<WriteBody, ApiError> {
let content_type = headers
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
- .unwrap_or("application/json")
- .split(';')
- .next()
- .unwrap_or("")
- .trim();
-
- let value = match content_type {
- "application/json" => {
- serde_json::from_slice::<JsonValue>(&body).map_err(|_| StatusCode::BAD_REQUEST)?
- }
- "text/plain" => JsonValue::String(
- String::from_utf8(body.to_vec()).map_err(|_| StatusCode::BAD_REQUEST)?,
+ .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}")))
+ };
+ let timestamps = json_form_of(params)?.timestamps;
+ let (value, sticky) = match media.as_str() {
+ "application/json" => (Val::from_json(&json()?).map_err(bad)?, store::Sticky::All),
+ typed::TYPED_JSON => (
+ Val::from_typed_json(&json()?, timestamps).map_err(bad)?,
+ store::Sticky::Floats,
+ ),
+ typed::TEXT => (
+ String::from_utf8(body.to_vec())
+ .map(|text| Val::Scalar(automerge::ScalarValue::Str(text.into())))
+ .map_err(|_| bad("not UTF-8".to_owned()))?,
+ store::Sticky::All,
),
- _ => return Err(StatusCode::UNSUPPORTED_MEDIA_TYPE),
+ media => match typed::from_native(media, params, body) {
+ Some(Ok(scalar)) => (Val::Scalar(scalar), store::Sticky::No),
+ Some(Err(error)) => return Err(bad(error)),
+ None => return Err(StatusCode::UNSUPPORTED_MEDIA_TYPE.into()),
+ },
};
+ Ok(WriteBody {
+ value,
+ sticky,
+ timestamps,
+ })
+}
- let written = value.clone();
- state
- .mutate_doc(move |doc| store::write_path(doc, &path, &written))
- .await
- .map_err(|e| store_status(e, "Failed to write store value"))?;
-
- response_for_value(&value, headers.get(ACCEPT))
+/// `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
+/// past its end: `409`, saying which. With `?increment=n`, adds `n` to the counter at
+/// `key` instead (`store::increment_path`), answering with its value then; not a
+/// counter there: `409`. With `?cast=<type>`, casts what is at `key` to that type
+/// instead (`store::cast_path`), answering with it then, as `PUT` does; no cast
+/// possible: `409`.
+pub(crate) async fn post_store_entry(
+ State(state): State<AppState>,
+ Extension(user): Extension<AuthenticatedUser>,
+ Path(key): Path<String>,
+ Query(query): Query<PostQuery>,
+ RawQuery(raw_query): RawQuery,
+ headers: HeaderMap,
+ body: Bytes,
+) -> Result<Response, ApiError> {
+ if !state.is_ready().await {
+ return Err(StatusCode::SERVICE_UNAVAILABLE.into());
+ }
+ let path = parse_write_path(&state, &user, &key, "POST")?;
+ if query.increment.is_some() && query.cast.is_some() {
+ return Err(ApiError(
+ StatusCode::BAD_REQUEST,
+ Some("increment or cast, not both".to_owned()),
+ ));
+ }
+ // An increment or a cast names an item (`-1`: the last); an insert where it goes
+ // (`-1`: the end).
+ let insertion = query.increment.is_none() && query.cast.is_none();
+ if let Some(redirect) =
+ to_positive(&state, &path, insertion, None, raw_query.as_deref()).await?
+ {
+ return Ok(redirect);
+ }
+ if let Some(by) = query.increment {
+ let (value, heads) = state
+ .mutate_doc_for(Some(user.0.clone()), move |doc| {
+ store::increment_path(doc, &path, by)
+ })
+ .await
+ .map_err(|e| store_status(e, "Failed to increment a counter"))?;
+ return Ok(with_heads(
+ {
+ 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,
+ ));
+ }
+ if let Some(name) = &query.cast {
+ let bad = |message: String| ApiError(StatusCode::BAD_REQUEST, Some(message));
+ let to = Kind::named(name).ok_or_else(|| bad(format!("no type {name:?}")))?;
+ let timestamps = match &query.format {
+ None => TimestampFormat::default(),
+ Some(format) => TimestampFormat::named(format)
+ .ok_or_else(|| bad(format!("no timestamp format {format:?}")))?,
+ };
+ let accept = headers.get(ACCEPT).cloned();
+ // A value that can't be shown as asked is refused, and the cast with it.
+ let ((retyped, read), heads) = state
+ .mutate_doc_for(Some(user.0.clone()), move |doc| {
+ let retyped = store::cast_path(doc, &path, to, timestamps)
+ .map_err(|e| store_status(e, "Failed to cast a store value"))?;
+ let read = read_shown(&*doc, &path, accept.as_ref(), false)?;
+ Ok::<_, ApiError>((retyped, read))
+ })
+ .await?;
+ let read = read.ok_or(ApiError(StatusCode::INTERNAL_SERVER_ERROR, None))?;
+ return Ok(with_retyped(with_heads(read.respond()?, &heads), &retyped));
+ }
+ 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_val_path(doc, &path, &value.value)
+ })
+ .await;
+ match inserted {
+ Ok(((), heads)) => Ok(with_heads(
+ (
+ StatusCode::CREATED,
+ axum::Json(serde_json::json!({ "index": index })),
+ )
+ .into_response(),
+ &heads,
+ )),
+ Err(error) => Err(store_status(error, "Failed to insert into a list")),
+ }
}
/// `DELETE /store/{key}`.
@@ -201,26 +731,89 @@ pub(crate) async fn delete_store_entry(
Extension(user): Extension<AuthenticatedUser>,
Path(key): Path<String>,
Query(query): Query<DeleteQuery>,
-) -> Result<StatusCode, StatusCode> {
+ RawQuery(raw_query): RawQuery,
+) -> Result<Response, ApiError> {
let path = parse_write_path(&state, &user, &key, "DELETE")?;
tracing::Span::current().record("path", path.join("/").as_str());
if !state.is_ready().await {
- return Err(StatusCode::SERVICE_UNAVAILABLE);
+ return Err(StatusCode::SERVICE_UNAVAILABLE.into());
}
if path.is_empty() {
- return Err(StatusCode::BAD_REQUEST);
+ return Err(StatusCode::BAD_REQUEST.into());
+ }
+
+ if let Some(redirect) = to_positive(&state, &path, false, None, raw_query.as_deref()).await? {
+ return Ok(redirect);
}
let prune = query.prune;
- state
- .mutate_doc(move |doc| store::delete_path(doc, &path, prune))
+ let ((), heads) = state
+ .mutate_doc_for(Some(user.0.clone()), move |doc| {
+ store::delete_path(doc, &path, prune)
+ })
.await
.map_err(|e| store_status(e, "Failed to delete store value"))?;
- Ok(StatusCode::NO_CONTENT)
+ Ok(with_heads(StatusCode::NO_CONTENT.into_response(), &heads))
+}
+
+/// A path with negative list indexes (`-1` the last item; for an insert, the end): a
+/// `307` to it with them made positive (`store::positive_path`), the query kept, so a
+/// client reads, writes or deletes what the index names now. None without any.
+async fn to_positive(
+ state: &AppState,
+ path: &[String],
+ insertion: bool,
+ at: Option<&[ChangeHash]>,
+ query: Option<&str>,
+) -> Result<Option<Response>, ApiError> {
+ if !path.iter().any(|segment| segment.starts_with('-')) {
+ return Ok(None);
+ }
+ let positive = {
+ let replica = state.doc.read().await;
+ // Counted from the end of the list as it was then, for a read at heads.
+ read_at!(&replica.doc, at, |doc| store::positive_path(
+ doc, path, insertion
+ ))
+ }
+ .map_err(|e| store_status(e, "Failed to resolve a negative index"))?;
+ let Some(positive) = positive else {
+ return Ok(None);
+ };
+ let mut location = format!(
+ "/store/{}",
+ positive
+ .iter()
+ .map(|segment| encode_segment(segment))
+ .collect::<Vec<_>>()
+ .join("/")
+ );
+ if let Some(query) = query.filter(|query| !query.is_empty()) {
+ location.push('?');
+ location.push_str(query);
+ }
+ let location = HeaderValue::from_str(&location).map_err(|_| StatusCode::BAD_REQUEST)?;
+ Ok(Some(
+ (StatusCode::TEMPORARY_REDIRECT, [(LOCATION, location)]).into_response(),
+ ))
+}
+
+/// `segment` as a URL path segment: unreserved characters as they are, the rest
+/// percent-encoded (UTF-8).
+fn encode_segment(segment: &str) -> String {
+ let mut encoded = String::with_capacity(segment.len());
+ for byte in segment.bytes() {
+ if byte.is_ascii_alphanumeric() || b"-._~".contains(&byte) {
+ encoded.push(char::from(byte));
+ } else {
+ encoded.push_str(&format!("%{byte:02X}"));
+ }
+ }
+ encoded
}
// --- streams -----------------------------------------------------------------
@@ -239,9 +832,7 @@ async fn render_traced(
/// `GET /stream`: follows the whole store, as `GET /store` reads it (events have an
/// empty `key`).
-pub(crate) async fn stream_store_root(
- State(state): State<AppState>,
-) -> Result<Response, StatusCode> {
+pub(crate) async fn stream_store_root(State(state): State<AppState>) -> Result<Response, ApiError> {
stream(state, String::new(), Vec::new()).await
}
@@ -249,16 +840,16 @@ pub(crate) async fn stream_store_root(
pub(crate) async fn stream_store_entry(
State(state): State<AppState>,
Path(key): Path<String>,
-) -> Result<Response, StatusCode> {
+) -> Result<Response, ApiError> {
let path = parse_key_path(&key)?;
stream(state, key, path).await
}
/// Newline-delimited JSON events for the value at `path` (`key` as the client wrote it):
/// the current value, then one whenever the resolved value changes.
-async fn stream(state: AppState, key: String, path: Vec<String>) -> Result<Response, StatusCode> {
+async fn stream(state: AppState, key: String, path: Vec<String>) -> Result<Response, ApiError> {
if !state.is_ready().await {
- return Err(StatusCode::SERVICE_UNAVAILABLE);
+ return Err(StatusCode::SERVICE_UNAVAILABLE.into());
}
// Subscribe first, so nothing between the initial read and the loop is missed.