diff options
Diffstat (limited to 'src/api')
| -rw-r--r-- | src/api/mod.rs | 3 | ||||
| -rw-r--r-- | src/api/store.rs | 123 |
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 { |
