aboutsummaryrefslogtreecommitdiffstats
path: root/src/api
diff options
context:
space:
mode:
Diffstat (limited to 'src/api')
-rw-r--r--src/api/mod.rs3
-rw-r--r--src/api/store.rs123
2 files changed, 82 insertions, 44 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs
index 713f248..ede1cfa 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -211,7 +211,8 @@ pub(crate) fn store_status(error: StoreError, context: &str) -> ApiError {
StoreError::Conflict
| StoreError::NotAList
| StoreError::PastTheEnd { .. }
- | StoreError::NotACounter => StatusCode::CONFLICT,
+ | StoreError::NotACounter
+ | StoreError::Retyping(_) => StatusCode::CONFLICT,
StoreError::NotFound | StoreError::UnknownHeads => StatusCode::NOT_FOUND,
StoreError::Rejoining => StatusCode::SERVICE_UNAVAILABLE,
other => {
diff --git a/src/api/store.rs b/src/api/store.rs
index 47aade1..e6921cd 100644
--- a/src/api/store.rs
+++ b/src/api/store.rs
@@ -291,11 +291,30 @@ 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,
@@ -308,19 +327,36 @@ pub(crate) async fn put_store_entry(
if let Some(redirect) = to_positive(&state, &path, false, None, raw_query.as_deref()).await? {
return Ok(redirect);
}
- let value = body_value(&headers, &body)?;
- // Answered with the value written, shown as asked.
- let shown = shown(headers.get(ACCEPT), value.kind())?;
- let (held, json) = written(&value, shown);
+ let body = body_value(&headers, &body)?;
+ let accept = headers.get(ACCEPT).cloned();
+ let writing = store::Writing::new(body.sticky, body.timestamps, query.retype);
- let ((), heads) = state
+ // 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| {
- store::write_val_path(doc, &path, &value)
+ 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
- .map_err(|e| store_status(e, "Failed to write store value"))?;
+ .await?;
+ let read = read.ok_or(ApiError(StatusCode::INTERNAL_SERVER_ERROR, None))?;
- Ok(with_heads(show(&held, shown, json.as_ref())?, &heads))
+ let mut response = with_heads(read.respond()?, &heads);
+ 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);
+ }
+ }
+ Ok(response)
}
fn etag_for(bytes: &[u8]) -> String {
@@ -530,9 +566,18 @@ fn respond(kind: Option<Kind>, content_type: &str, body: Vec<u8>) -> Result<Resp
/// 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 as a value to write (`typed::Val`): JSON (the default), typed JSON,
-/// text as a string, or a scalar of a type of its own (`typed::from_native`).
-fn body_value(headers: &HeaderMap, body: &Bytes) -> Result<Val, ApiError> {
+/// 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,
+ /// Plain JSON or text, whose values keep the types of what they are written over
+ /// where they can (`store::Writing::sticky`).
+ sticky: bool,
+ /// 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())
@@ -543,38 +588,30 @@ fn body_value(headers: &HeaderMap, body: &Bytes) -> Result<Val, ApiError> {
let json = || {
serde_json::from_slice::<JsonValue>(body).map_err(|error| bad(format!("not JSON: {error}")))
};
- match media.as_str() {
- "application/json" => Val::from_json(&json()?).map_err(bad),
- typed::TYPED_JSON => {
- let form = json_form_of(params)?;
- Val::from_typed_json(&json()?, form.timestamps).map_err(bad)
- }
- 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())),
+ let timestamps = json_form_of(params)?.timestamps;
+ let (value, sticky) = match media.as_str() {
+ "application/json" => (Val::from_json(&json()?).map_err(bad)?, true),
+ typed::TYPED_JSON => (
+ Val::from_typed_json(&json()?, timestamps).map_err(bad)?,
+ false,
+ ),
+ 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()))?,
+ true,
+ ),
media => match typed::from_native(media, params, body) {
- Some(Ok(scalar)) => Ok(Val::Scalar(scalar)),
- Some(Err(error)) => Err(bad(error)),
- None => Err(StatusCode::UNSUPPORTED_MEDIA_TYPE.into()),
- },
- }
-}
-
-/// What `value` is stored as, and its JSON of `shown`'s form, to answer its write with.
-fn written(value: &Val, shown: Shown) -> (store::Held, Option<JsonValue>) {
- let held = store::Held {
- kind: value.kind(),
- value: match value {
- Val::Scalar(scalar) => Some(scalar.clone()),
- Val::Text(text) => Some(automerge::ScalarValue::Str(text.as_str().into())),
- Val::Map(_) | Val::List(_) => None,
+ Some(Ok(scalar)) => (Val::Scalar(scalar), false),
+ Some(Err(error)) => return Err(bad(error)),
+ None => return Err(StatusCode::UNSUPPORTED_MEDIA_TYPE.into()),
},
};
- let form = match shown {
- Shown::Json(form) => form,
- Shown::Text | Shown::Native(_) => JsonForm::default(),
- };
- (held, value.to_json(form))
+ Ok(WriteBody {
+ value,
+ sticky,
+ timestamps,
+ })
}
/// `POST /store/{key}`: inserts the body into a list, `key` ending in the index it
@@ -630,7 +667,7 @@ pub(crate) async fn post_store_entry(
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)
+ store::insert_val_path(doc, &path, &value.value)
})
.await;
match inserted {