use std::convert::Infallible;
use std::sync::Arc;
use bytes::Bytes;
use futures_core::Stream;
use futures_util::StreamExt;
use http_body_util::{combinators::UnsyncBoxBody, BodyExt, Full, StreamBody};
use hyper::body::{Frame as HttpFrame, Incoming};
use hyper::header::{ACCEPT, CONTENT_TYPE, TRANSFER_ENCODING};
use hyper::{HeaderMap, Method, Request, Response, StatusCode};
use dynomite::embed::hooks::DatastoreByteStream;
use dynomite::embed::Datastore;
use dynomite::msg::{Msg, MsgType};
#[cfg(feature = "noxu")]
use dyn_encoding::WireValue;
use crate::proto::http::content_type::{select_codec, SUPPORTED_CONTENT_TYPES};
#[cfg(feature = "noxu")]
use crate::proto::http::object::{object_codecs, HttpIndex, HttpLink, HttpObject};
use crate::txn::{HttpTxnRequest, HttpTxnResponse, TransactionalStore, TxnOutcome, TxnStoreError};
pub(crate) type ResponseBody = UnsyncBoxBody<Bytes, Infallible>;
pub(crate) const HTTP_LIST_CHUNK_SIZE: usize = 256;
fn buffered_body(bytes: Bytes) -> ResponseBody {
BodyExt::boxed_unsync(Full::new(bytes))
}
const MAX_BODY_LEN: usize = 16 * 1024 * 1024;
const SERVER_NAME: &str = "dyniak";
const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Clone)]
pub(crate) struct RouteCtx {
pub(crate) datastore: Arc<dyn Datastore>,
#[cfg(feature = "search")]
pub(crate) search: Option<Arc<crate::proto::http::search::SearchState>>,
#[cfg(feature = "wasm")]
pub(crate) wasm: Option<Arc<crate::mapreduce::wasm::WasmModuleStore>>,
}
impl RouteCtx {
pub(crate) fn new(datastore: Arc<dyn Datastore>) -> Self {
Self {
datastore,
#[cfg(feature = "search")]
search: None,
#[cfg(feature = "wasm")]
wasm: None,
}
}
#[cfg(feature = "search")]
pub(crate) fn with_search(
datastore: Arc<dyn Datastore>,
search: Arc<crate::proto::http::search::SearchState>,
) -> Self {
Self {
datastore,
search: Some(search),
#[cfg(feature = "wasm")]
wasm: None,
}
}
#[cfg(feature = "wasm")]
pub(crate) fn with_wasm(
datastore: Arc<dyn Datastore>,
wasm: Arc<crate::mapreduce::wasm::WasmModuleStore>,
) -> Self {
Self {
datastore,
#[cfg(feature = "search")]
search: None,
wasm: Some(wasm),
}
}
#[cfg(all(feature = "wasm", feature = "search"))]
pub(crate) fn set_wasm(mut self, wasm: Arc<crate::mapreduce::wasm::WasmModuleStore>) -> Self {
self.wasm = Some(wasm);
self
}
}
impl From<Arc<dyn Datastore>> for RouteCtx {
fn from(datastore: Arc<dyn Datastore>) -> Self {
Self::new(datastore)
}
}
pub(crate) async fn dispatch(req: Request<Incoming>, ctx: RouteCtx) -> Response<ResponseBody> {
let (parts, body) = req.into_parts();
let Some(route) = Route::parse(&parts.method, parts.uri.path(), parts.uri.query()) else {
return text_response(StatusCode::NOT_FOUND, "not found");
};
let body_bytes = match collect_body(body).await {
Ok(b) => b,
Err(resp) => return resp,
};
handle_route(route, &parts.method, &parts.headers, body_bytes, ctx).await
}
#[derive(Debug, Eq, PartialEq)]
enum Route<'a> {
Ping,
Stats,
GetObject { bucket: &'a str, key: &'a str },
PutObject { bucket: &'a str, key: &'a str },
PostObject { bucket: &'a str, key: &'a str },
DeleteObject { bucket: &'a str, key: &'a str },
ListBuckets,
ListKeys { bucket: &'a str },
GetProps { bucket: &'a str },
SetProps { bucket: &'a str },
MapRed,
Transaction { bucket: Option<&'a str> },
#[cfg(feature = "search")]
DeclareTextIndex { bucket: &'a str, field: &'a str },
#[cfg(feature = "search")]
CreateVectorIndex { bucket: &'a str },
#[cfg(feature = "search")]
ListIndexes { bucket: &'a str },
#[cfg(feature = "search")]
SearchText {
bucket: &'a str,
field: &'a str,
query: Option<&'a str>,
},
#[cfg(feature = "search")]
SearchRegex {
bucket: &'a str,
field: &'a str,
query: Option<&'a str>,
},
#[cfg(feature = "search")]
SearchVector { bucket: &'a str },
}
impl<'a> Route<'a> {
fn parse(method: &Method, path: &'a str, query: Option<&'a str>) -> Option<Self> {
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
let m = method.as_str();
match (m, parts.as_slice()) {
("GET" | "HEAD", ["ping"]) => Some(Self::Ping),
("GET", ["stats"]) => Some(Self::Stats),
("GET", ["buckets"]) if has_flag(query, "buckets", "true") => Some(Self::ListBuckets),
("GET" | "HEAD", ["buckets", b, "keys", k]) => {
Some(Self::GetObject { bucket: b, key: k })
}
("PUT", ["buckets", b, "keys", k]) => Some(Self::PutObject { bucket: b, key: k }),
("POST", ["buckets", b, "keys", k]) => Some(Self::PostObject { bucket: b, key: k }),
("DELETE", ["buckets", b, "keys", k]) => Some(Self::DeleteObject { bucket: b, key: k }),
("GET", ["buckets", b, "keys"]) if has_flag(query, "keys", "true") => {
Some(Self::ListKeys { bucket: b })
}
("GET", ["buckets", b, "props"]) => Some(Self::GetProps { bucket: b }),
("PUT", ["buckets", b, "props"]) => Some(Self::SetProps { bucket: b }),
("POST", ["mapred"]) => Some(Self::MapRed),
("POST", ["transactions"]) => Some(Self::Transaction { bucket: None }),
("POST", ["buckets", b, "transactions"]) => Some(Self::Transaction { bucket: Some(b) }),
#[cfg(feature = "search")]
("PUT", ["buckets", b, "index", "text", f]) => Some(Self::DeclareTextIndex {
bucket: b,
field: f,
}),
#[cfg(feature = "search")]
("POST", ["buckets", b, "index", "vector"]) => {
Some(Self::CreateVectorIndex { bucket: b })
}
#[cfg(feature = "search")]
("GET", ["buckets", b, "index"]) => Some(Self::ListIndexes { bucket: b }),
#[cfg(feature = "search")]
("GET", ["buckets", b, "search", "text", f]) => Some(Self::SearchText {
bucket: b,
field: f,
query,
}),
#[cfg(feature = "search")]
("GET", ["buckets", b, "search", "regex", f]) => Some(Self::SearchRegex {
bucket: b,
field: f,
query,
}),
#[cfg(feature = "search")]
("POST", ["buckets", b, "search", "vector"]) => Some(Self::SearchVector { bucket: b }),
_ => None,
}
}
}
fn has_flag(query: Option<&str>, key: &str, expected: &str) -> bool {
let Some(q) = query else { return false };
for pair in q.split('&') {
let mut it = pair.splitn(2, '=');
let k = it.next().unwrap_or("");
let v = it.next().unwrap_or("");
if k == key && v == expected {
return true;
}
}
false
}
async fn collect_body(body: Incoming) -> Result<Bytes, Response<ResponseBody>> {
let collected = body
.collect()
.await
.map_err(|e| text_response(StatusCode::BAD_REQUEST, &format!("body read error: {e}")))?
.to_bytes();
if collected.len() > MAX_BODY_LEN {
return Err(text_response(
StatusCode::PAYLOAD_TOO_LARGE,
"request body exceeds 16 MiB",
));
}
Ok(collected)
}
async fn handle_route(
route: Route<'_>,
method: &Method,
headers: &HeaderMap,
body: Bytes,
ctx: impl Into<RouteCtx>,
) -> Response<ResponseBody> {
let ctx = ctx.into();
let head_only = method == Method::HEAD;
match route {
Route::Ping => ping_response(head_only),
Route::Stats => stats_response(headers),
Route::GetObject { bucket, key } => {
handle_get(bucket, key, headers, head_only, ctx.datastore.as_ref()).await
}
Route::PutObject { bucket, key } | Route::PostObject { bucket, key } => {
handle_put(bucket, key, headers, body, &ctx).await
}
Route::DeleteObject { bucket, key } => {
handle_delete(bucket, key, ctx.datastore.as_ref()).await
}
Route::ListBuckets => list_buckets_response(headers, &ctx.datastore),
Route::ListKeys { bucket } => list_keys_response(bucket, headers, &ctx.datastore),
Route::GetProps { bucket } => get_props_response(bucket, headers),
Route::SetProps { bucket } => set_props_response(bucket, headers, &body),
Route::MapRed => mapred_response(headers, &body, &ctx),
Route::Transaction { bucket } => {
transaction_response(bucket, headers, &body, ctx.datastore.as_ref())
}
#[cfg(feature = "search")]
Route::DeclareTextIndex { bucket, field } => {
super::search::declare_text_index(ctx.search.as_deref(), bucket, field, headers)
}
#[cfg(feature = "search")]
Route::CreateVectorIndex { bucket } => {
super::search::create_vector_index(ctx.search.as_deref(), bucket, headers, &body)
}
#[cfg(feature = "search")]
Route::ListIndexes { bucket } => {
super::search::list_indexes(ctx.search.as_deref(), bucket, headers)
}
#[cfg(feature = "search")]
Route::SearchText {
bucket,
field,
query,
} => super::search::search_text(ctx.search.as_deref(), bucket, field, query, headers),
#[cfg(feature = "search")]
Route::SearchRegex {
bucket,
field,
query,
} => super::search::search_regex(ctx.search.as_deref(), bucket, field, query, headers),
#[cfg(feature = "search")]
Route::SearchVector { bucket } => {
super::search::search_vector(ctx.search.as_deref(), bucket, headers, &body)
}
}
}
fn ping_response(head_only: bool) -> Response<ResponseBody> {
let body = if head_only {
Bytes::new()
} else {
Bytes::from_static(b"OK")
};
Response::builder()
.status(StatusCode::OK)
.header(CONTENT_TYPE, "text/plain; charset=utf-8")
.header("Server", SERVER_NAME)
.body(buffered_body(body))
.expect("invariant: ping response builder is well-formed")
}
fn stats_response(headers: &HeaderMap) -> Response<ResponseBody> {
let accept = header_str(headers, ACCEPT);
let Some(ct) = select_codec(accept, Some("application/json")) else {
return not_acceptable_response();
};
let payload = serde_json::json!({
"name": SERVER_NAME,
"version": SERVER_VERSION,
"supported_content_types": SUPPORTED_CONTENT_TYPES,
});
let body_bytes = match ct {
"application/json" => serde_json::to_vec(&payload).unwrap_or_else(|_| b"{}".to_vec()),
_ => serde_json::to_vec(&payload).unwrap_or_else(|_| b"{}".to_vec()),
};
Response::builder()
.status(StatusCode::OK)
.header(CONTENT_TYPE, ct)
.header("Server", SERVER_NAME)
.body(buffered_body(Bytes::from(body_bytes)))
.expect("invariant: stats response builder is well-formed")
}
async fn handle_get(
bucket: &str,
key: &str,
headers: &HeaderMap,
head_only: bool,
datastore: &dyn Datastore,
) -> Response<ResponseBody> {
let accept = header_str(headers, ACCEPT);
let req_ct = header_str_opt(headers, CONTENT_TYPE);
let Some(ct) = select_codec(accept, req_ct) else {
return not_acceptable_response();
};
let routing = Msg::new(0, MsgType::Unknown, true);
if let Err(e) = datastore.dispatch(routing).await {
return text_response(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("datastore error: {e}"),
);
}
#[cfg(feature = "noxu")]
{
if let Some(store) = object_store(datastore) {
return get_object_from_store(store, bucket, key, ct, head_only);
}
}
#[cfg(not(feature = "noxu"))]
{
let _ = (bucket, key, ct, head_only);
}
text_response(StatusCode::NOT_FOUND, "not found")
}
async fn handle_put(
bucket: &str,
key: &str,
headers: &HeaderMap,
body: Bytes,
ctx: &RouteCtx,
) -> Response<ResponseBody> {
let datastore = ctx.datastore.as_ref();
let accept = header_str(headers, ACCEPT);
let req_ct = header_str_opt(headers, CONTENT_TYPE);
if select_codec(accept, req_ct).is_none() {
return not_acceptable_response();
}
if body.is_empty() {
return text_response(StatusCode::BAD_REQUEST, "PUT body must not be empty");
}
if let Some(ct) = req_ct {
if super::content_type::canonicalize(ct).is_none() {
return text_response(
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"request Content-Type is not supported",
);
}
}
let routing = Msg::new(0, MsgType::Unknown, true);
if let Err(e) = datastore.dispatch(routing).await {
return text_response(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("datastore error: {e}"),
);
}
#[cfg(feature = "noxu")]
{
if let Some(store) = object_store(datastore) {
return put_object_into_store(store, bucket, key, headers, &body, req_ct, ctx);
}
}
#[cfg(not(feature = "noxu"))]
{
let _ = (bucket, key, &body, ctx);
}
Response::builder()
.status(StatusCode::NO_CONTENT)
.header("Server", SERVER_NAME)
.body(buffered_body(Bytes::new()))
.expect("invariant: put response builder is well-formed")
}
async fn handle_delete(
bucket: &str,
key: &str,
datastore: &dyn Datastore,
) -> Response<ResponseBody> {
let routing = Msg::new(0, MsgType::Unknown, true);
if let Err(e) = datastore.dispatch(routing).await {
return text_response(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("datastore error: {e}"),
);
}
#[cfg(feature = "noxu")]
{
if let Some(store) = object_store(datastore) {
return match store.delete_object(bucket.as_bytes(), key.as_bytes()) {
Ok(_) => no_content_response(),
Err(e) => storage_error_response(&e),
};
}
}
#[cfg(not(feature = "noxu"))]
{
let _ = (bucket, key);
}
no_content_response()
}
fn no_content_response() -> Response<ResponseBody> {
Response::builder()
.status(StatusCode::NO_CONTENT)
.header("Server", SERVER_NAME)
.body(buffered_body(Bytes::new()))
.expect("invariant: no-content response builder is well-formed")
}
fn get_props_response(bucket: &str, headers: &HeaderMap) -> Response<ResponseBody> {
let accept = header_str(headers, ACCEPT);
let Some(ct) = select_codec(accept, Some("application/json")) else {
return not_acceptable_response();
};
let props = serde_json::json!({
"props": {
"name": bucket,
"n_val": 3,
"allow_mult": false,
"last_write_wins": false,
"r": "quorum",
"w": "quorum",
"pr": 0,
"pw": 0,
"dw": "quorum",
"rw": "quorum",
"basic_quorum": false,
"notfound_ok": true,
}
});
let body = serde_json::to_vec(&props).unwrap_or_else(|_| b"{}".to_vec());
Response::builder()
.status(StatusCode::OK)
.header(CONTENT_TYPE, ct)
.header("Server", SERVER_NAME)
.body(buffered_body(Bytes::from(body)))
.expect("invariant: get-props response builder is well-formed")
}
fn set_props_response(_bucket: &str, headers: &HeaderMap, body: &Bytes) -> Response<ResponseBody> {
let req_ct = header_str_opt(headers, CONTENT_TYPE);
if let Some(ct) = req_ct {
if super::content_type::canonicalize(ct).is_none() {
return text_response(
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"request Content-Type is not supported",
);
}
}
if body.is_empty() {
return text_response(StatusCode::BAD_REQUEST, "set-props body must not be empty");
}
Response::builder()
.status(StatusCode::NO_CONTENT)
.header("Server", SERVER_NAME)
.body(buffered_body(Bytes::new()))
.expect("invariant: set-props response builder is well-formed")
}
fn transaction_response(
bucket: Option<&str>,
headers: &HeaderMap,
body: &Bytes,
datastore: &dyn Datastore,
) -> Response<ResponseBody> {
let req_ct = header_str_opt(headers, CONTENT_TYPE);
let ct = req_ct.unwrap_or("application/json");
if super::content_type::canonicalize(ct) != Some("application/json") {
return text_response(
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"transactions require Content-Type: application/json",
);
}
if body.is_empty() {
return text_response(
StatusCode::BAD_REQUEST,
"transaction body must not be empty",
);
}
let request: HttpTxnRequest = match serde_json::from_slice(body) {
Ok(r) => r,
Err(e) => {
return text_response(StatusCode::BAD_REQUEST, &format!("transaction decode: {e}"));
}
};
let batch = request.into_batch();
if let Some(b) = bucket {
if batch.ops.iter().any(|op| op.bucket() != b.as_bytes()) {
return text_response(
StatusCode::BAD_REQUEST,
"every operation must target the bucket named in the URL",
);
}
}
let Some(store) = txn_store(datastore) else {
return text_response(
StatusCode::NOT_IMPLEMENTED,
"the configured datastore does not support transactions",
);
};
match store.execute_batch(&batch) {
Ok(outcome) => txn_outcome_response(&outcome),
Err(TxnStoreError::EmptyBatch) => {
text_response(StatusCode::BAD_REQUEST, "empty transaction batch")
}
Err(e @ TxnStoreError::Conflict(_)) => txn_error_response(StatusCode::CONFLICT, &e),
Err(e @ TxnStoreError::Backend(_)) => {
txn_error_response(StatusCode::INTERNAL_SERVER_ERROR, &e)
}
}
}
fn txn_store(datastore: &dyn Datastore) -> Option<&dyn TransactionalStore> {
#[cfg(feature = "noxu")]
{
if let Some(any) = datastore.as_any() {
if let Some(noxu) = any.downcast_ref::<crate::datastore::NoxuDatastore>() {
return Some(noxu as &dyn TransactionalStore);
}
}
None
}
#[cfg(not(feature = "noxu"))]
{
let _ = datastore;
None
}
}
#[cfg(feature = "noxu")]
fn object_store(datastore: &dyn Datastore) -> Option<&crate::datastore::NoxuDatastore> {
datastore
.as_any()
.and_then(|any| any.downcast_ref::<crate::datastore::NoxuDatastore>())
}
#[cfg(feature = "noxu")]
fn get_object_from_store(
store: &crate::datastore::NoxuDatastore,
bucket: &str,
key: &str,
ct: &'static str,
head_only: bool,
) -> Response<ResponseBody> {
let stored = match store.get_object(bucket.as_bytes(), key.as_bytes()) {
Ok(Some(v)) => v,
Ok(None) => return text_response(StatusCode::NOT_FOUND, "not found"),
Err(e) => return storage_error_response(&e),
};
let obj = match HttpObject::from_storage_bytes(&stored) {
Ok(o) => o,
Err(e) => {
return text_response(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("stored object is corrupt: {e}"),
);
}
};
let Some(codec) = object_codecs().for_content_type(ct) else {
return not_acceptable_response();
};
let encoded = match codec.encode(&obj) {
Ok(b) => b,
Err(e) => {
return text_response(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("object encode: {e}"),
);
}
};
let body = if head_only {
Bytes::new()
} else {
Bytes::from(encoded)
};
let mut builder = Response::builder()
.status(StatusCode::OK)
.header(CONTENT_TYPE, ct)
.header("Server", SERVER_NAME)
.header("Link", format!("</buckets/{bucket}>; rel=\"up\""));
for link in &obj.links {
builder = builder.header(
"Link",
format!(
"</buckets/{}/keys/{}>; riaktag=\"{}\"",
link.bucket, link.key, link.tag
),
);
}
builder
.body(buffered_body(body))
.expect("invariant: object response builder is well-formed")
}
#[cfg(feature = "noxu")]
fn put_object_into_store(
store: &crate::datastore::NoxuDatastore,
bucket: &str,
key: &str,
headers: &HeaderMap,
body: &Bytes,
req_ct: Option<&str>,
ctx: &RouteCtx,
) -> Response<ResponseBody> {
let req_ct = req_ct.unwrap_or("application/json");
let canonical = super::content_type::canonicalize(req_ct).unwrap_or("application/json");
let Some(codec) = object_codecs().for_content_type(canonical) else {
return text_response(
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"request Content-Type is not supported",
);
};
let decoded = match codec.decode(HttpObject::wire_type_id(), body) {
Ok(v) => v,
Err(e) => {
return text_response(StatusCode::BAD_REQUEST, &format!("object decode: {e}"));
}
};
let Some(obj) = decoded.as_any().downcast_ref::<HttpObject>() else {
return text_response(
StatusCode::INTERNAL_SERVER_ERROR,
"decoded value was not an object",
);
};
let mut obj = obj.clone();
obj.indexes.extend(collect_index_headers(headers));
obj.links.extend(collect_link_headers(headers));
let indexes = obj.index_pairs();
let storage = obj.to_storage_bytes();
match store.put_object(bucket.as_bytes(), key.as_bytes(), &storage, &indexes) {
Ok(()) => {
#[cfg(feature = "search")]
if let Some(state) = ctx.search.as_deref() {
state.index_object(bucket, key.as_bytes(), &obj.value);
}
#[cfg(not(feature = "search"))]
let _ = ctx;
no_content_response()
}
Err(e) => storage_error_response(&e),
}
}
#[cfg(feature = "noxu")]
fn collect_index_headers(headers: &HeaderMap) -> Vec<HttpIndex> {
const PREFIX: &str = "x-riak-index-";
let mut out = Vec::new();
for (name, value) in headers {
let name = name.as_str();
let Some(index_name) = name.strip_prefix(PREFIX) else {
continue;
};
if index_name.is_empty() {
continue;
}
let Ok(value) = value.to_str() else {
continue;
};
for part in value.split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}
out.push(HttpIndex {
name: index_name.to_string(),
value: part.to_string(),
});
}
}
out
}
#[cfg(feature = "noxu")]
fn collect_link_headers(headers: &HeaderMap) -> Vec<HttpLink> {
let mut out = Vec::new();
for value in headers.get_all("link") {
let Ok(value) = value.to_str() else {
continue;
};
for part in split_link_values(value) {
if let Some(link) = parse_link_value(&part) {
out.push(link);
}
}
}
out
}
#[cfg(feature = "noxu")]
fn split_link_values(header: &str) -> Vec<String> {
let mut parts = Vec::new();
let mut depth: usize = 0;
let mut start = 0;
let bytes = header.as_bytes();
for (i, &b) in bytes.iter().enumerate() {
match b {
b'<' => depth += 1,
b'>' => depth = depth.saturating_sub(1),
b',' if depth == 0 => {
parts.push(header[start..i].to_string());
start = i + 1;
}
_ => {}
}
}
parts.push(header[start..].to_string());
parts
}
#[cfg(feature = "noxu")]
fn parse_link_value(value: &str) -> Option<HttpLink> {
let value = value.trim();
let open = value.find('<')?;
let close = value[open + 1..].find('>')? + open + 1;
let resource = value[open + 1..close].trim();
let (target_bucket, target_key) = parse_link_resource(resource)?;
let mut tag = None;
for param in value[close + 1..].split(';') {
let param = param.trim();
let Some((name, raw)) = param.split_once('=') else {
continue;
};
let name = name.trim();
if name.eq_ignore_ascii_case("riaktag") || name.eq_ignore_ascii_case("tag") {
tag = Some(unquote(raw.trim()).to_string());
}
}
let tag = tag?;
Some(HttpLink {
bucket: target_bucket,
key: target_key,
tag,
})
}
#[cfg(feature = "noxu")]
fn parse_link_resource(resource: &str) -> Option<(String, String)> {
if let Some(rest) = resource.strip_prefix("/buckets/") {
let (bucket, rest) = rest.split_once('/')?;
let key = rest.strip_prefix("keys/")?;
if bucket.is_empty() || key.is_empty() {
return None;
}
return Some((decode_path_segment(bucket), decode_path_segment(key)));
}
if let Some(rest) = resource.strip_prefix("/riak/") {
let (bucket, key) = rest.split_once('/')?;
if bucket.is_empty() || key.is_empty() {
return None;
}
return Some((decode_path_segment(bucket), decode_path_segment(key)));
}
None
}
#[cfg(feature = "noxu")]
fn unquote(s: &str) -> &str {
s.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or(s)
}
#[cfg(feature = "noxu")]
fn decode_path_segment(seg: &str) -> String {
if !seg.contains('%') {
return seg.to_string();
}
let bytes = seg.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hi = hex_digit(bytes[i + 1]);
let lo = hex_digit(bytes[i + 2]);
if let (Some(hi), Some(lo)) = (hi, lo) {
out.push(hi * 16 + lo);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(feature = "noxu")]
fn hex_digit(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
#[cfg(feature = "noxu")]
fn storage_error_response(err: &crate::datastore::NoxuDatastoreError) -> Response<ResponseBody> {
use crate::datastore::NoxuDatastoreError;
let status = match err {
NoxuDatastoreError::InvalidName { .. } | NoxuDatastoreError::BadIntValue { .. } => {
StatusCode::BAD_REQUEST
}
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
text_response(status, &format!("storage error: {err}"))
}
fn txn_outcome_response(outcome: &TxnOutcome) -> Response<ResponseBody> {
let status = match outcome {
TxnOutcome::Committed { .. } => StatusCode::OK,
TxnOutcome::Aborted { .. } => StatusCode::CONFLICT,
};
let payload = HttpTxnResponse::from_outcome(outcome);
let body = serde_json::to_vec(&payload).unwrap_or_else(|_| b"{}".to_vec());
json_response(status, body)
}
fn txn_error_response(status: StatusCode, err: &TxnStoreError) -> Response<ResponseBody> {
let payload = HttpTxnResponse::from_outcome(&TxnOutcome::Aborted {
reason: err.to_string(),
});
let body = serde_json::to_vec(&payload).unwrap_or_else(|_| b"{}".to_vec());
json_response(status, body)
}
fn json_response(status: StatusCode, body: Vec<u8>) -> Response<ResponseBody> {
Response::builder()
.status(status)
.header(CONTENT_TYPE, "application/json")
.header("Server", SERVER_NAME)
.body(buffered_body(Bytes::from(body)))
.expect("invariant: json response builder is well-formed")
}
pub(crate) fn text_response(status: StatusCode, msg: &str) -> Response<ResponseBody> {
Response::builder()
.status(status)
.header(CONTENT_TYPE, "text/plain; charset=utf-8")
.header("Server", SERVER_NAME)
.body(buffered_body(Bytes::copy_from_slice(msg.as_bytes())))
.expect("invariant: text response builder is well-formed")
}
#[cfg(feature = "search")]
pub(crate) fn encoded_response(ct: &'static str, body: Vec<u8>) -> Response<ResponseBody> {
Response::builder()
.status(StatusCode::OK)
.header(CONTENT_TYPE, ct)
.header("Server", SERVER_NAME)
.body(buffered_body(Bytes::from(body)))
.expect("invariant: encoded response builder is well-formed")
}
pub(crate) fn not_acceptable_response() -> Response<ResponseBody> {
text_response(
StatusCode::NOT_ACCEPTABLE,
"no supported codec in Accept header",
)
}
pub(crate) fn header_str(headers: &HeaderMap, name: hyper::header::HeaderName) -> &str {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
}
pub(crate) fn header_str_opt(headers: &HeaderMap, name: hyper::header::HeaderName) -> Option<&str> {
headers.get(name).and_then(|v| v.to_str().ok())
}
use crate::mapreduce::{
builtins::default_registry, run_job_streaming_full, MapReduceJob, MrError, PhaseBatch,
};
use tokio::sync::mpsc;
fn mapred_response(headers: &HeaderMap, body: &Bytes, ctx: &RouteCtx) -> Response<ResponseBody> {
let req_ct = header_str_opt(headers, CONTENT_TYPE);
let ct = req_ct.unwrap_or("application/json");
if super::content_type::canonicalize(ct) != Some("application/json") {
return text_response(
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"MapReduce requires Content-Type: application/json",
);
}
let job: MapReduceJob = match serde_json::from_slice(body) {
Ok(j) => j,
Err(e) => {
return text_response(
StatusCode::BAD_REQUEST,
&format!("MapReduce job decode: {e}"),
);
}
};
let registry = std::sync::Arc::new(default_registry());
let datastore = ctx.datastore.clone();
#[cfg(feature = "wasm")]
let rx = match ctx.wasm.clone() {
Some(store) => {
let hook: std::sync::Arc<dyn crate::mapreduce::WasmHook> = store;
run_job_streaming_full(job, registry, Some(hook), Some(datastore))
}
None => run_job_streaming_full(job, registry, None, Some(datastore)),
};
#[cfg(not(feature = "wasm"))]
let rx = run_job_streaming_full(job, registry, None, Some(datastore));
let boundary = mapred_boundary();
let body_stream = mapred_multipart_body(rx, boundary.clone());
let body_stream: Pin<Box<dyn Stream<Item = Result<HttpFrame<Bytes>, Infallible>> + Send>> =
Box::pin(body_stream);
let body = BodyExt::boxed_unsync(StreamBody::new(body_stream));
let ct_value = format!("multipart/mixed; boundary={boundary}");
Response::builder()
.status(StatusCode::OK)
.header(CONTENT_TYPE, ct_value)
.header(TRANSFER_ENCODING, "chunked")
.header("Server", SERVER_NAME)
.body(body)
.expect("invariant: mapred response builder is well-formed")
}
fn mapred_boundary() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| {
u64::try_from(d.as_nanos() & u128::from(u64::MAX)).unwrap_or(0)
});
format!("dyniak-mr-{nanos:016x}-{n:016x}")
}
enum MapRedMultipartState {
Streaming {
rx: mpsc::Receiver<Result<PhaseBatch, MrError>>,
boundary: String,
},
Close { boundary: String },
Done,
}
fn mapred_multipart_body(
rx: mpsc::Receiver<Result<PhaseBatch, MrError>>,
boundary: String,
) -> impl Stream<Item = Result<HttpFrame<Bytes>, Infallible>> + Send {
futures_util::stream::unfold(
MapRedMultipartState::Streaming { rx, boundary },
|state| async move {
match state {
MapRedMultipartState::Done => None,
MapRedMultipartState::Close { boundary } => {
let chunk = format!("--{boundary}--\r\n");
Some((
Ok(HttpFrame::data(Bytes::from(chunk))),
MapRedMultipartState::Done,
))
}
MapRedMultipartState::Streaming { mut rx, boundary } => match rx.recv().await {
None => {
let chunk = format!("--{boundary}--\r\n");
Some((
Ok(HttpFrame::data(Bytes::from(chunk))),
MapRedMultipartState::Done,
))
}
Some(Ok(batch)) => {
let body = mapred_phase_part_body(&batch);
let chunk = format!(
"--{boundary}\r\nContent-Type: application/json\r\n\r\n{body}\r\n"
);
Some((
Ok(HttpFrame::data(Bytes::from(chunk))),
MapRedMultipartState::Streaming { rx, boundary },
))
}
Some(Err(e)) => {
let msg = format!("MapReduce execution: {e}");
let chunk =
format!("--{boundary}\r\nContent-Type: text/plain\r\n\r\n{msg}\r\n");
Some((
Ok(HttpFrame::data(Bytes::from(chunk))),
MapRedMultipartState::Close { boundary },
))
}
},
}
},
)
}
fn mapred_phase_part_body(batch: &PhaseBatch) -> String {
let payload = serde_json::json!([{
"phase": batch.phase,
"data": batch.data,
}]);
serde_json::to_string(&payload).unwrap_or_else(|_| String::from("[]"))
}
fn list_buckets_response(
headers: &HeaderMap,
datastore: &Arc<dyn Datastore>,
) -> Response<ResponseBody> {
let accept = header_str(headers, ACCEPT);
let Some(ct) = select_codec(accept, Some("application/json")) else {
return not_acceptable_response();
};
let stream = datastore.list_buckets_stream();
streaming_list_response(ct, stream)
}
fn list_keys_response(
bucket: &str,
headers: &HeaderMap,
datastore: &Arc<dyn Datastore>,
) -> Response<ResponseBody> {
let accept = header_str(headers, ACCEPT);
let Some(ct) = select_codec(accept, Some("application/json")) else {
return not_acceptable_response();
};
let stream = datastore.list_keys_stream(bucket.as_bytes());
streaming_list_response(ct, stream)
}
fn streaming_list_response(ct: &str, stream: DatastoreByteStream) -> Response<ResponseBody> {
let body_stream: Pin<Box<dyn Stream<Item = Result<HttpFrame<Bytes>, Infallible>> + Send>> =
if ct == "application/json" {
Box::pin(json_array_chunks(stream))
} else {
Box::pin(length_prefixed_chunks(stream))
};
let body = BodyExt::boxed_unsync(StreamBody::new(body_stream));
Response::builder()
.status(StatusCode::OK)
.header(CONTENT_TYPE, ct)
.header(TRANSFER_ENCODING, "chunked")
.header("Server", SERVER_NAME)
.body(body)
.expect("invariant: streaming response builder is well-formed")
}
use std::pin::Pin;
enum JsonChunkState {
Open(DatastoreByteStream),
Streaming {
stream: DatastoreByteStream,
first_emitted: bool,
},
Close,
Done,
}
fn json_array_chunks(
stream: DatastoreByteStream,
) -> impl Stream<Item = Result<HttpFrame<Bytes>, Infallible>> + Send {
futures_util::stream::unfold(JsonChunkState::Open(stream), |state| async move {
match state {
JsonChunkState::Done => None,
JsonChunkState::Open(stream) => Some((
Ok(HttpFrame::data(Bytes::from_static(b"["))),
JsonChunkState::Streaming {
stream,
first_emitted: false,
},
)),
JsonChunkState::Close => Some((
Ok(HttpFrame::data(Bytes::from_static(b"]"))),
JsonChunkState::Done,
)),
JsonChunkState::Streaming {
mut stream,
mut first_emitted,
} => {
let mut buf: Vec<u8> = Vec::new();
let mut packed = 0usize;
while packed < HTTP_LIST_CHUNK_SIZE {
match stream.next().await {
None => {
if buf.is_empty() {
return Some((
Ok(HttpFrame::data(Bytes::from_static(b"]"))),
JsonChunkState::Done,
));
}
return Some((
Ok(HttpFrame::data(Bytes::from(buf))),
JsonChunkState::Close,
));
}
Some(Err(_e)) => {
if !buf.is_empty() {
return Some((
Ok(HttpFrame::data(Bytes::from(buf))),
JsonChunkState::Close,
));
}
return Some((
Ok(HttpFrame::data(Bytes::from_static(b"]"))),
JsonChunkState::Done,
));
}
Some(Ok(entry)) => {
if first_emitted {
buf.push(b',');
} else {
first_emitted = true;
}
let s = String::from_utf8_lossy(&entry).into_owned();
let encoded =
serde_json::to_vec(&s).unwrap_or_else(|_| b"\"\"".to_vec());
buf.extend_from_slice(&encoded);
packed += 1;
}
}
}
Some((
Ok(HttpFrame::data(Bytes::from(buf))),
JsonChunkState::Streaming {
stream,
first_emitted,
},
))
}
}
})
}
fn length_prefixed_chunks(
stream: DatastoreByteStream,
) -> impl Stream<Item = Result<HttpFrame<Bytes>, Infallible>> + Send {
enum LpState {
Streaming(DatastoreByteStream),
Done,
}
futures_util::stream::unfold(LpState::Streaming(stream), |state| async move {
match state {
LpState::Done => None,
LpState::Streaming(mut stream) => {
let mut buf: Vec<u8> = Vec::new();
let mut packed = 0usize;
while packed < HTTP_LIST_CHUNK_SIZE {
match stream.next().await {
None => {
buf.extend_from_slice(&0u32.to_be_bytes());
return Some((Ok(HttpFrame::data(Bytes::from(buf))), LpState::Done));
}
Some(Err(_e)) => {
buf.extend_from_slice(&0u32.to_be_bytes());
return Some((Ok(HttpFrame::data(Bytes::from(buf))), LpState::Done));
}
Some(Ok(entry)) => {
let len = u32::try_from(entry.len()).unwrap_or(u32::MAX);
buf.extend_from_slice(&len.to_be_bytes());
buf.extend_from_slice(&entry);
packed += 1;
}
}
}
Some((
Ok(HttpFrame::data(Bytes::from(buf))),
LpState::Streaming(stream),
))
}
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use dynomite::embed::MemoryDatastore;
fn dummy_headers() -> HeaderMap {
HeaderMap::new()
}
struct DispatchFailsStore;
impl Datastore for DispatchFailsStore {
fn protocol(&self) -> dynomite::embed::Protocol {
dynomite::embed::Protocol::Custom
}
fn dispatch(
&self,
_req: Msg,
) -> dynomite::embed::BoxFuture<'_, Result<Msg, dynomite::embed::DatastoreError>> {
Box::pin(async move {
Err(dynomite::embed::DatastoreError::Backend(
"dispatch boom".into(),
))
})
}
}
fn fail_store() -> Arc<dyn Datastore> {
Arc::new(DispatchFailsStore)
}
fn unacceptable_headers() -> HeaderMap {
let mut h = HeaderMap::new();
h.insert(ACCEPT, "application/x-nonsense".parse().unwrap());
h
}
#[tokio::test]
async fn get_dispatch_error_is_500() {
let resp = handle_route(
Route::GetObject {
bucket: "u",
key: "k",
},
&Method::GET,
&dummy_headers(),
Bytes::new(),
fail_store(),
)
.await;
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn put_dispatch_error_is_500() {
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, "application/json".parse().unwrap());
let resp = handle_route(
Route::PutObject {
bucket: "u",
key: "k",
},
&Method::PUT,
&headers,
Bytes::from_static(b"{\"value\":\"v\"}"),
fail_store(),
)
.await;
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn delete_dispatch_error_is_500() {
let resp = handle_route(
Route::DeleteObject {
bucket: "u",
key: "k",
},
&Method::DELETE,
&dummy_headers(),
Bytes::new(),
fail_store(),
)
.await;
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn get_with_unsupported_accept_is_406() {
let ds: Arc<dyn Datastore> = Arc::new(MemoryDatastore::new());
let resp = handle_route(
Route::GetObject {
bucket: "u",
key: "k",
},
&Method::GET,
&unacceptable_headers(),
Bytes::new(),
ds,
)
.await;
assert_eq!(resp.status(), StatusCode::NOT_ACCEPTABLE);
}
#[tokio::test]
async fn get_props_with_unsupported_accept_is_406() {
let ds: Arc<dyn Datastore> = Arc::new(MemoryDatastore::new());
let resp = handle_route(
Route::GetProps { bucket: "u" },
&Method::GET,
&unacceptable_headers(),
Bytes::new(),
ds,
)
.await;
assert_eq!(resp.status(), StatusCode::NOT_ACCEPTABLE);
}
#[tokio::test]
async fn list_buckets_with_unsupported_accept_is_406() {
let ds: Arc<dyn Datastore> = Arc::new(MemoryDatastore::new());
let resp = handle_route(
Route::ListBuckets,
&Method::GET,
&unacceptable_headers(),
Bytes::new(),
ds,
)
.await;
assert_eq!(resp.status(), StatusCode::NOT_ACCEPTABLE);
}
#[tokio::test]
async fn list_keys_with_unsupported_accept_is_406() {
let ds: Arc<dyn Datastore> = Arc::new(MemoryDatastore::new());
let resp = handle_route(
Route::ListKeys { bucket: "u" },
&Method::GET,
&unacceptable_headers(),
Bytes::new(),
ds,
)
.await;
assert_eq!(resp.status(), StatusCode::NOT_ACCEPTABLE);
}
#[cfg(feature = "noxu")]
#[test]
fn parse_link_resource_modern_legacy_and_rejections() {
assert_eq!(
parse_link_resource("/buckets/people/keys/bob"),
Some(("people".to_string(), "bob".to_string()))
);
assert_eq!(
parse_link_resource("/riak/people/bob"),
Some(("people".to_string(), "bob".to_string()))
);
assert!(parse_link_resource("/buckets//keys/bob").is_none());
assert!(parse_link_resource("/buckets/people/keys/").is_none());
assert!(parse_link_resource("/buckets/people").is_none());
assert!(parse_link_resource("/riak//bob").is_none());
assert!(parse_link_resource("/riak/people/").is_none());
assert!(parse_link_resource("/elsewhere/x").is_none());
}
#[cfg(feature = "noxu")]
#[test]
fn unquote_strips_one_layer_only() {
assert_eq!(unquote("\"x\""), "x");
assert_eq!(unquote("x"), "x");
assert_eq!(unquote("\"x"), "\"x"); }
#[cfg(feature = "noxu")]
#[test]
fn decode_path_segment_handles_escapes_and_passthrough() {
assert_eq!(decode_path_segment("plain"), "plain");
assert_eq!(decode_path_segment("a%20b"), "a b");
assert_eq!(decode_path_segment("a%zzb"), "a%zzb");
assert_eq!(decode_path_segment("trailing%"), "trailing%");
}
#[cfg(feature = "noxu")]
#[test]
fn hex_digit_decodes_all_cases() {
assert_eq!(hex_digit(b'0'), Some(0));
assert_eq!(hex_digit(b'9'), Some(9));
assert_eq!(hex_digit(b'a'), Some(10));
assert_eq!(hex_digit(b'f'), Some(15));
assert_eq!(hex_digit(b'A'), Some(10));
assert_eq!(hex_digit(b'F'), Some(15));
assert_eq!(hex_digit(b'g'), None);
}
#[cfg(feature = "noxu")]
#[test]
fn storage_error_response_maps_status() {
use crate::datastore::NoxuDatastoreError;
let bad_name = NoxuDatastoreError::InvalidName { what: "bucket" };
assert_eq!(
storage_error_response(&bad_name).status(),
StatusCode::BAD_REQUEST
);
let bad_int = NoxuDatastoreError::BadIntValue { got: 4 };
assert_eq!(
storage_error_response(&bad_int).status(),
StatusCode::BAD_REQUEST
);
}
#[test]
fn txn_outcome_and_error_responses_carry_expected_status() {
let committed = txn_outcome_response(&TxnOutcome::Committed { operations: 2 });
assert_eq!(committed.status(), StatusCode::OK);
let aborted = txn_outcome_response(&TxnOutcome::Aborted {
reason: "client".into(),
});
assert_eq!(aborted.status(), StatusCode::CONFLICT);
let err = txn_error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&TxnStoreError::Backend("boom".into()),
);
assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn set_props_bad_content_type_and_empty_body() {
let ds: Arc<dyn Datastore> = Arc::new(MemoryDatastore::new());
let mut bad_ct = HeaderMap::new();
bad_ct.insert(CONTENT_TYPE, "application/x-nonsense".parse().unwrap());
let resp = handle_route(
Route::SetProps { bucket: "u" },
&Method::PUT,
&bad_ct,
Bytes::from_static(b"{}"),
ds.clone(),
)
.await;
assert_eq!(resp.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
let mut json = HeaderMap::new();
json.insert(CONTENT_TYPE, "application/json".parse().unwrap());
let empty = handle_route(
Route::SetProps { bucket: "u" },
&Method::PUT,
&json,
Bytes::new(),
ds.clone(),
)
.await;
assert_eq!(empty.status(), StatusCode::BAD_REQUEST);
let ok = handle_route(
Route::SetProps { bucket: "u" },
&Method::PUT,
&json,
Bytes::from_static(b"{\"props\":{}}"),
ds,
)
.await;
assert_eq!(ok.status(), StatusCode::NO_CONTENT);
}
#[cfg(feature = "noxu")]
mod noxu_backed {
use super::*;
use crate::datastore::NoxuDatastore;
fn noxu_ctx() -> (Arc<dyn Datastore>, tempfile::TempDir) {
let dir = tempfile::TempDir::new().expect("tempdir");
let ds: Arc<dyn Datastore> =
Arc::new(NoxuDatastore::open_transactional(dir.path()).expect("open"));
(ds, dir)
}
fn ct_headers(accept: &str, content_type: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert(ACCEPT, accept.parse().unwrap());
h.insert(CONTENT_TYPE, content_type.parse().unwrap());
h
}
#[tokio::test]
async fn put_json_then_get_transcodes_across_codecs() {
use crate::proto::http::object::HttpObject;
let (ds, _dir) = noxu_ctx();
let obj = HttpObject {
value: b"hello".to_vec(),
content_type: Some("text/plain".to_string()),
indexes: Vec::new(),
links: Vec::new(),
};
let body = Bytes::from(serde_json::to_vec(&obj).expect("json body"));
let put = handle_route(
Route::PutObject {
bucket: "u",
key: "k",
},
&Method::PUT,
&ct_headers("application/json", "application/json"),
body,
ds.clone(),
)
.await;
assert_eq!(put.status(), StatusCode::NO_CONTENT);
for accept in [
"application/json",
"application/cbor",
"application/x-protobuf",
] {
let mut h = HeaderMap::new();
h.insert(ACCEPT, accept.parse().unwrap());
let get = handle_route(
Route::GetObject {
bucket: "u",
key: "k",
},
&Method::GET,
&h,
Bytes::new(),
ds.clone(),
)
.await;
assert_eq!(get.status(), StatusCode::OK, "accept {accept}");
}
}
#[tokio::test]
async fn get_missing_object_is_404() {
let (ds, _dir) = noxu_ctx();
let mut h = HeaderMap::new();
h.insert(ACCEPT, "application/json".parse().unwrap());
let get = handle_route(
Route::GetObject {
bucket: "u",
key: "ghost",
},
&Method::GET,
&h,
Bytes::new(),
ds,
)
.await;
assert_eq!(get.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn head_object_returns_ok_without_body() {
use crate::proto::http::object::HttpObject;
let (ds, _dir) = noxu_ctx();
let obj = HttpObject {
value: b"hi".to_vec(),
content_type: None,
indexes: Vec::new(),
links: Vec::new(),
};
let body = Bytes::from(serde_json::to_vec(&obj).expect("json body"));
handle_route(
Route::PutObject {
bucket: "u",
key: "k",
},
&Method::PUT,
&ct_headers("application/json", "application/json"),
body,
ds.clone(),
)
.await;
let mut h = HeaderMap::new();
h.insert(ACCEPT, "application/json".parse().unwrap());
let head = handle_route(
Route::GetObject {
bucket: "u",
key: "k",
},
&Method::HEAD,
&h,
Bytes::new(),
ds,
)
.await;
assert_eq!(head.status(), StatusCode::OK);
}
#[tokio::test]
async fn put_with_invalid_bucket_is_storage_error() {
use crate::proto::http::object::HttpObject;
let (ds, _dir) = noxu_ctx();
let obj = HttpObject {
value: b"x".to_vec(),
content_type: None,
indexes: Vec::new(),
links: Vec::new(),
};
let body = Bytes::from(serde_json::to_vec(&obj).expect("json body"));
let put = handle_route(
Route::PutObject {
bucket: "u\u{0}bad",
key: "k",
},
&Method::PUT,
&ct_headers("application/json", "application/json"),
body,
ds,
)
.await;
assert_eq!(put.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn delete_object_against_store_is_204() {
let (ds, _dir) = noxu_ctx();
let del = handle_route(
Route::DeleteObject {
bucket: "u",
key: "k",
},
&Method::DELETE,
&dummy_headers(),
Bytes::new(),
ds,
)
.await;
assert_eq!(del.status(), StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn transaction_bucket_mismatch_is_400() {
let (ds, _dir) = noxu_ctx();
let mut h = HeaderMap::new();
h.insert(CONTENT_TYPE, "application/json".parse().unwrap());
let body = br#"{"operations":[{"op":"put","bucket":"other","key":"k","value":"v"}]}"#;
let resp = handle_route(
Route::Transaction { bucket: Some("u") },
&Method::POST,
&h,
Bytes::from_static(body),
ds,
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn transaction_decode_error_is_400() {
let (ds, _dir) = noxu_ctx();
let mut h = HeaderMap::new();
h.insert(CONTENT_TYPE, "application/json".parse().unwrap());
let resp = handle_route(
Route::Transaction { bucket: None },
&Method::POST,
&h,
Bytes::from_static(b"not json"),
ds,
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
}
#[cfg(feature = "noxu")]
#[test]
fn parse_link_value_modern_form() {
let link =
parse_link_value("</buckets/people/keys/bob>; riaktag=\"friend\"").expect("link");
assert_eq!(link.bucket, "people");
assert_eq!(link.key, "bob");
assert_eq!(link.tag, "friend");
}
#[cfg(feature = "noxu")]
#[test]
fn parse_link_value_legacy_riak_form() {
let link = parse_link_value("</riak/people/bob>; riaktag=\"friend\"").expect("link");
assert_eq!(link.bucket, "people");
assert_eq!(link.key, "bob");
assert_eq!(link.tag, "friend");
}
#[cfg(feature = "noxu")]
#[test]
fn parse_link_value_rejects_rel_up_bucket_link() {
assert!(parse_link_value("</buckets/people>; rel=\"up\"").is_none());
}
#[cfg(feature = "noxu")]
#[test]
fn collect_link_headers_handles_multiple_headers_and_values() {
let mut headers = HeaderMap::new();
headers.append(
"link",
"</buckets/people/keys/bob>; riaktag=\"friend\", \
</buckets/work/keys/acme>; riaktag=\"employer\""
.parse()
.unwrap(),
);
headers.append(
"link",
"</buckets/people/keys/carol>; tag=\"friend\""
.parse()
.unwrap(),
);
let links = collect_link_headers(&headers);
assert_eq!(links.len(), 3);
assert_eq!(links[0].key, "bob");
assert_eq!(links[1].key, "acme");
assert_eq!(links[1].tag, "employer");
assert_eq!(links[2].key, "carol");
assert_eq!(links[2].tag, "friend");
}
#[cfg(feature = "noxu")]
#[test]
fn collect_link_headers_skips_bucket_up_links() {
let mut headers = HeaderMap::new();
headers.append(
"link",
"</buckets/people>; rel=\"up\", </buckets/people/keys/bob>; riaktag=\"friend\""
.parse()
.unwrap(),
);
let links = collect_link_headers(&headers);
assert_eq!(links.len(), 1);
assert_eq!(links[0].key, "bob");
}
#[test]
fn route_parses_ping() {
let r = Route::parse(&Method::GET, "/ping", None).expect("ping");
assert_eq!(r, Route::Ping);
let r = Route::parse(&Method::HEAD, "/ping", None).expect("ping head");
assert_eq!(r, Route::Ping);
}
#[test]
fn route_parses_object_paths() {
let r = Route::parse(&Method::GET, "/buckets/u/keys/k", None).expect("get");
assert_eq!(
r,
Route::GetObject {
bucket: "u",
key: "k",
}
);
let r = Route::parse(&Method::PUT, "/buckets/u/keys/k", None).expect("put");
assert_eq!(
r,
Route::PutObject {
bucket: "u",
key: "k",
}
);
let r = Route::parse(&Method::POST, "/buckets/u/keys/k", None).expect("post");
assert_eq!(
r,
Route::PostObject {
bucket: "u",
key: "k",
}
);
let r = Route::parse(&Method::DELETE, "/buckets/u/keys/k", None).expect("del");
assert_eq!(
r,
Route::DeleteObject {
bucket: "u",
key: "k",
}
);
}
#[test]
fn route_parses_listing_with_query_flag() {
let r = Route::parse(&Method::GET, "/buckets", Some("buckets=true")).expect("buckets");
assert_eq!(r, Route::ListBuckets);
let r = Route::parse(&Method::GET, "/buckets/u/keys", Some("keys=true")).expect("keys");
assert_eq!(r, Route::ListKeys { bucket: "u" });
}
#[test]
fn route_listing_without_flag_misses() {
assert!(Route::parse(&Method::GET, "/buckets", None).is_none());
assert!(Route::parse(&Method::GET, "/buckets/u/keys", None).is_none());
}
#[test]
fn route_parses_props() {
let r = Route::parse(&Method::GET, "/buckets/u/props", None).expect("get props");
assert_eq!(r, Route::GetProps { bucket: "u" });
let r = Route::parse(&Method::PUT, "/buckets/u/props", None).expect("set props");
assert_eq!(r, Route::SetProps { bucket: "u" });
}
#[test]
fn route_unknown_path_misses() {
assert!(Route::parse(&Method::GET, "/", None).is_none());
assert!(Route::parse(&Method::GET, "/foo", None).is_none());
assert!(Route::parse(&Method::GET, "/buckets/u/foo/bar", None).is_none());
}
#[test]
fn has_flag_handles_multi_pair_query() {
assert!(has_flag(Some("a=1&buckets=true"), "buckets", "true"));
assert!(has_flag(Some("buckets=true&extra=x"), "buckets", "true"));
assert!(!has_flag(Some("buckets=stream"), "buckets", "true"));
assert!(!has_flag(Some(""), "buckets", "true"));
assert!(!has_flag(None, "buckets", "true"));
}
#[tokio::test]
async fn list_keys_streams_chunked_json_array() {
let ds = Arc::new(MemoryDatastore::new());
for i in 0..600u16 {
ds.insert(b"u", format!("k{i:04}").as_bytes());
}
let ds_dyn: Arc<dyn Datastore> = ds.clone();
let mut headers = HeaderMap::new();
headers.insert(ACCEPT, "application/json".parse().unwrap());
let resp = handle_route(
Route::ListKeys { bucket: "u" },
&Method::GET,
&headers,
Bytes::new(),
ds_dyn,
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get(TRANSFER_ENCODING)
.map(|v| v.to_str().ok()),
Some(Some("chunked"))
);
assert_eq!(
resp.headers().get(CONTENT_TYPE).map(|v| v.to_str().ok()),
Some(Some("application/json"))
);
let body = resp
.into_body()
.collect()
.await
.expect("collect")
.to_bytes();
let parsed: serde_json::Value = serde_json::from_slice(&body).expect("json");
let arr = parsed.as_array().expect("array");
assert_eq!(arr.len(), 600);
assert_eq!(arr[0], serde_json::Value::String("k0000".to_string()));
assert_eq!(arr[599], serde_json::Value::String("k0599".to_string()));
}
#[tokio::test]
async fn list_buckets_streams_chunked_json_array() {
let ds = Arc::new(MemoryDatastore::new());
for i in 0..3u16 {
ds.insert(format!("b{i}").as_bytes(), b"k");
}
let ds_dyn: Arc<dyn Datastore> = ds.clone();
let mut headers = HeaderMap::new();
headers.insert(ACCEPT, "application/json".parse().unwrap());
let resp = handle_route(
Route::ListBuckets,
&Method::GET,
&headers,
Bytes::new(),
ds_dyn,
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let body = resp
.into_body()
.collect()
.await
.expect("collect")
.to_bytes();
let parsed: serde_json::Value = serde_json::from_slice(&body).expect("json");
let arr = parsed.as_array().expect("array");
assert_eq!(arr.len(), 3);
}
#[tokio::test]
async fn list_buckets_empty_streams_empty_json_array() {
let ds: Arc<dyn Datastore> = Arc::new(MemoryDatastore::new());
let resp = handle_route(
Route::ListBuckets,
&Method::GET,
&dummy_headers(),
Bytes::new(),
ds,
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let body = resp
.into_body()
.collect()
.await
.expect("collect")
.to_bytes();
assert_eq!(body.as_ref(), b"[]");
}
#[tokio::test]
async fn put_with_unsupported_content_type_returns_415() {
let ds: Arc<dyn Datastore> = Arc::new(MemoryDatastore::new());
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, "application/xml".parse().unwrap());
let resp = handle_route(
Route::PutObject {
bucket: "u",
key: "k",
},
&Method::PUT,
&headers,
Bytes::from_static(b"<doc/>"),
ds,
)
.await;
assert_eq!(resp.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
}
#[tokio::test]
async fn put_with_empty_body_returns_400() {
let ds: Arc<dyn Datastore> = Arc::new(MemoryDatastore::new());
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, "application/json".parse().unwrap());
let resp = handle_route(
Route::PutObject {
bucket: "u",
key: "k",
},
&Method::PUT,
&headers,
Bytes::new(),
ds,
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn put_with_unsupported_accept_returns_406() {
let ds: Arc<dyn Datastore> = Arc::new(MemoryDatastore::new());
let mut headers = HeaderMap::new();
headers.insert(ACCEPT, "application/yaml".parse().unwrap());
headers.insert(CONTENT_TYPE, "application/json".parse().unwrap());
let resp = handle_route(
Route::PutObject {
bucket: "u",
key: "k",
},
&Method::PUT,
&headers,
Bytes::from_static(b"{}"),
ds,
)
.await;
assert_eq!(resp.status(), StatusCode::NOT_ACCEPTABLE);
}
#[tokio::test]
async fn put_then_get_drives_dispatch_count() {
let ds = Arc::new(MemoryDatastore::new());
let ds_dyn: Arc<dyn Datastore> = ds.clone();
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, "application/json".parse().unwrap());
let put = handle_route(
Route::PutObject {
bucket: "u",
key: "k",
},
&Method::PUT,
&headers,
Bytes::from_static(br#"{"hello":"world"}"#),
ds_dyn.clone(),
)
.await;
assert_eq!(put.status(), StatusCode::NO_CONTENT);
let get = handle_route(
Route::GetObject {
bucket: "u",
key: "k",
},
&Method::GET,
&dummy_headers(),
Bytes::new(),
ds_dyn.clone(),
)
.await;
assert_eq!(get.status(), StatusCode::NOT_FOUND);
let del = handle_route(
Route::DeleteObject {
bucket: "u",
key: "k",
},
&Method::DELETE,
&dummy_headers(),
Bytes::new(),
ds_dyn,
)
.await;
assert_eq!(del.status(), StatusCode::NO_CONTENT);
assert_eq!(ds.dispatch_count(), 3);
}
#[tokio::test]
async fn ping_returns_200() {
let ds: Arc<dyn Datastore> = Arc::new(MemoryDatastore::new());
let resp = handle_route(
Route::Ping,
&Method::GET,
&dummy_headers(),
Bytes::new(),
ds,
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn head_ping_omits_body() {
let ds: Arc<dyn Datastore> = Arc::new(MemoryDatastore::new());
let resp = handle_route(
Route::Ping,
&Method::HEAD,
&dummy_headers(),
Bytes::new(),
ds,
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let body = resp
.into_body()
.collect()
.await
.expect("collect")
.to_bytes();
assert!(body.is_empty());
}
#[tokio::test]
async fn stats_returns_json_body() {
let ds: Arc<dyn Datastore> = Arc::new(MemoryDatastore::new());
let resp = handle_route(
Route::Stats,
&Method::GET,
&dummy_headers(),
Bytes::new(),
ds,
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let body = resp
.into_body()
.collect()
.await
.expect("collect")
.to_bytes();
let parsed: serde_json::Value = serde_json::from_slice(&body).expect("json");
assert_eq!(parsed["name"], SERVER_NAME);
assert_eq!(parsed["version"], SERVER_VERSION);
}
#[tokio::test]
async fn get_props_returns_defaults() {
let ds: Arc<dyn Datastore> = Arc::new(MemoryDatastore::new());
let resp = handle_route(
Route::GetProps { bucket: "u" },
&Method::GET,
&dummy_headers(),
Bytes::new(),
ds,
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let body = resp
.into_body()
.collect()
.await
.expect("collect")
.to_bytes();
let parsed: serde_json::Value = serde_json::from_slice(&body).expect("json");
assert_eq!(parsed["props"]["n_val"], 3);
assert_eq!(parsed["props"]["name"], "u");
}
#[test]
fn route_parses_mapred() {
let r = Route::parse(&Method::POST, "/mapred", None).expect("mapred");
assert_eq!(r, Route::MapRed);
}
#[test]
fn route_get_mapred_misses() {
assert!(Route::parse(&Method::GET, "/mapred", None).is_none());
}
#[tokio::test]
async fn mapred_runs_simple_job() {
let ds: Arc<dyn Datastore> = Arc::new(MemoryDatastore::new());
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, "application/json".parse().unwrap());
let body = br#"{
"inputs": [
{"bucket":"b","key":"k1","value":1},
{"bucket":"b","key":"k2","value":2},
{"bucket":"b","key":"k3","value":3}
],
"query": [
{"map": {"name":"map_object_value"}},
{"reduce": {"name":"reduce_sum", "keep": true}}
]
}"#;
let resp = handle_route(
Route::MapRed,
&Method::POST,
&headers,
Bytes::copy_from_slice(body),
ds,
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get(CONTENT_TYPE)
.expect("content-type")
.to_str()
.expect("ascii")
.to_string();
assert!(
ct.starts_with("multipart/mixed; boundary="),
"content-type was: {ct}"
);
let boundary = ct
.strip_prefix("multipart/mixed; boundary=")
.expect("boundary")
.to_string();
let body = resp
.into_body()
.collect()
.await
.expect("collect")
.to_bytes();
let parts = parse_multipart_parts(&body, &boundary);
assert_eq!(parts.len(), 1, "one kept (reduce) phase produces one part");
assert_eq!(parts[0].content_type.as_deref(), Some("application/json"));
let parsed: serde_json::Value = serde_json::from_slice(&parts[0].body).expect("json");
let arr = parsed.as_array().expect("array");
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["phase"], 1);
assert_eq!(arr[0]["data"], serde_json::json!([6]));
}
struct MultipartPart {
content_type: Option<String>,
body: Vec<u8>,
}
fn parse_multipart_parts(body: &[u8], boundary: &str) -> Vec<MultipartPart> {
let dash_boundary = format!("--{boundary}");
let close_delim = format!("--{boundary}--");
let text = std::str::from_utf8(body).expect("ascii body");
let mut parts = Vec::new();
let mut cursor = text;
if let Some(idx) = cursor.find(&dash_boundary) {
cursor = &cursor[idx + dash_boundary.len()..];
} else {
return parts;
}
loop {
if cursor.starts_with("--") {
break;
}
cursor = cursor.trim_start_matches("\r\n");
let Some(sep_idx) = cursor.find("\r\n\r\n") else {
break;
};
let head_str = &cursor[..sep_idx];
cursor = &cursor[sep_idx + 4..];
let Some(next_idx) = cursor.find(&dash_boundary) else {
break;
};
let body_str = &cursor[..next_idx];
let body_str = body_str.strip_suffix("\r\n").unwrap_or(body_str);
let mut content_type = None;
for line in head_str.split("\r\n") {
if let Some(v) = line.strip_prefix("Content-Type:") {
content_type = Some(v.trim().to_string());
}
}
parts.push(MultipartPart {
content_type,
body: body_str.as_bytes().to_vec(),
});
cursor = &cursor[next_idx + dash_boundary.len()..];
if cursor.starts_with("--") {
break;
}
}
let _ = close_delim;
parts
}
#[tokio::test]
async fn mapred_streams_multiple_kept_phases() {
let ds: Arc<dyn Datastore> = Arc::new(MemoryDatastore::new());
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, "application/json".parse().unwrap());
let body = br#"{
"inputs": [
{"bucket":"b","key":"k1","value":1},
{"bucket":"b","key":"k2","value":2}
],
"query": [
{"map": {"name":"map_object_value", "keep": true}},
{"reduce": {"name":"reduce_sum", "keep": true}}
]
}"#;
let resp = handle_route(
Route::MapRed,
&Method::POST,
&headers,
Bytes::copy_from_slice(body),
ds,
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get(CONTENT_TYPE)
.expect("ct")
.to_str()
.unwrap()
.to_string();
let boundary = ct
.strip_prefix("multipart/mixed; boundary=")
.expect("boundary")
.to_string();
let bytes = resp
.into_body()
.collect()
.await
.expect("collect")
.to_bytes();
let parts = parse_multipart_parts(&bytes, &boundary);
assert_eq!(parts.len(), 2);
let p0: serde_json::Value = serde_json::from_slice(&parts[0].body).expect("json0");
let p1: serde_json::Value = serde_json::from_slice(&parts[1].body).expect("json1");
assert_eq!(p0[0]["phase"], 0);
assert_eq!(p0[0]["data"].as_array().expect("arr").len(), 2);
assert_eq!(p1[0]["phase"], 1);
assert_eq!(p1[0]["data"], serde_json::json!([3]));
let tail = &bytes[bytes.len().saturating_sub(boundary.len() + 6)..];
let tail_str = std::str::from_utf8(tail).expect("ascii tail");
assert!(
tail_str.contains(&format!("--{boundary}--\r\n")),
"tail was: {tail_str:?}"
);
}
#[tokio::test]
async fn mapred_phase_failure_emits_text_part_and_closing_delimiter() {
let ds: Arc<dyn Datastore> = Arc::new(MemoryDatastore::new());
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, "application/json".parse().unwrap());
let body = br#"{
"inputs": [{"bucket":"b","key":"k","value":1}],
"query": [{"map": {"name": "no_such_function", "keep": true}}]
}"#;
let resp = handle_route(
Route::MapRed,
&Method::POST,
&headers,
Bytes::copy_from_slice(body),
ds,
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get(CONTENT_TYPE)
.expect("ct")
.to_str()
.unwrap()
.to_string();
let boundary = ct
.strip_prefix("multipart/mixed; boundary=")
.expect("boundary")
.to_string();
let bytes = resp
.into_body()
.collect()
.await
.expect("collect")
.to_bytes();
let parts = parse_multipart_parts(&bytes, &boundary);
assert_eq!(parts.len(), 1);
assert_eq!(parts[0].content_type.as_deref(), Some("text/plain"));
let msg = std::str::from_utf8(&parts[0].body).expect("ascii");
assert!(
msg.contains("MapReduce execution") && msg.contains("no_such_function"),
"error msg was: {msg:?}"
);
let tail = std::str::from_utf8(&bytes).expect("ascii");
assert!(tail.contains(&format!("--{boundary}--\r\n")));
}
#[tokio::test]
async fn mapred_unsupported_content_type_returns_415() {
let ds: Arc<dyn Datastore> = Arc::new(MemoryDatastore::new());
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, "application/xml".parse().unwrap());
let resp = handle_route(
Route::MapRed,
&Method::POST,
&headers,
Bytes::from_static(b"<doc/>"),
ds,
)
.await;
assert_eq!(resp.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
}
#[tokio::test]
async fn mapred_malformed_job_returns_400() {
let ds: Arc<dyn Datastore> = Arc::new(MemoryDatastore::new());
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, "application/json".parse().unwrap());
let resp = handle_route(
Route::MapRed,
&Method::POST,
&headers,
Bytes::from_static(b"not json"),
ds,
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[test]
fn route_parses_transactions() {
let r = Route::parse(&Method::POST, "/transactions", None).expect("txn");
assert_eq!(r, Route::Transaction { bucket: None });
let r = Route::parse(&Method::POST, "/buckets/u/transactions", None).expect("bucket txn");
assert_eq!(r, Route::Transaction { bucket: Some("u") });
}
#[test]
fn route_get_transactions_misses() {
assert!(Route::parse(&Method::GET, "/transactions", None).is_none());
}
#[tokio::test]
async fn transaction_on_non_transactional_backend_returns_501() {
let ds: Arc<dyn Datastore> = Arc::new(MemoryDatastore::new());
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, "application/json".parse().unwrap());
let body = br#"{"operations":[{"op":"put","bucket":"b","key":"k","value":"v"}]}"#;
let resp = handle_route(
Route::Transaction { bucket: None },
&Method::POST,
&headers,
Bytes::copy_from_slice(body),
ds,
)
.await;
assert_eq!(resp.status(), StatusCode::NOT_IMPLEMENTED);
}
#[tokio::test]
async fn transaction_empty_body_returns_400() {
let ds: Arc<dyn Datastore> = Arc::new(MemoryDatastore::new());
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, "application/json".parse().unwrap());
let resp = handle_route(
Route::Transaction { bucket: None },
&Method::POST,
&headers,
Bytes::new(),
ds,
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn transaction_unsupported_content_type_returns_415() {
let ds: Arc<dyn Datastore> = Arc::new(MemoryDatastore::new());
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, "application/xml".parse().unwrap());
let resp = handle_route(
Route::Transaction { bucket: None },
&Method::POST,
&headers,
Bytes::from_static(b"<doc/>"),
ds,
)
.await;
assert_eq!(resp.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
}
#[cfg(feature = "noxu")]
#[tokio::test]
async fn transaction_commits_and_aborts_against_noxu() {
use crate::datastore::NoxuDatastore;
use tempfile::TempDir;
let dir = TempDir::new().expect("tempdir");
let ds: Arc<dyn Datastore> =
Arc::new(NoxuDatastore::open_transactional(dir.path()).expect("open"));
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, "application/json".parse().unwrap());
let body = br#"{"operations":[
{"op":"put","bucket":"users","key":"alice","value":"a"},
{"op":"put","bucket":"users","key":"bob","value":"b"},
{"op":"put","bucket":"users","key":"carol","value":"c"}
]}"#;
let resp = handle_route(
Route::Transaction { bucket: None },
&Method::POST,
&headers,
Bytes::copy_from_slice(body),
Arc::clone(&ds),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp
.into_body()
.collect()
.await
.expect("collect")
.to_bytes();
let parsed: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(parsed["result"], "committed");
assert_eq!(parsed["operations"], 3);
let body = br#"{"abort":true,"operations":[
{"op":"put","bucket":"users","key":"dave","value":"d"}
]}"#;
let resp = handle_route(
Route::Transaction { bucket: None },
&Method::POST,
&headers,
Bytes::copy_from_slice(body),
Arc::clone(&ds),
)
.await;
assert_eq!(resp.status(), StatusCode::CONFLICT);
let bytes = resp
.into_body()
.collect()
.await
.expect("collect")
.to_bytes();
let parsed: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
assert_eq!(parsed["result"], "aborted");
let body = br#"{"operations":[
{"op":"put","bucket":"other","key":"k","value":"v"}
]}"#;
let resp = handle_route(
Route::Transaction {
bucket: Some("users"),
},
&Method::POST,
&headers,
Bytes::copy_from_slice(body),
ds,
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
}