use crate::browse::{ProjectionInfo, SchemaInfo};
use crate::control::{Projection, ProjectionBinding, SchemaSource, SourceSelector};
use crate::fork::ForkInfo;
use crate::graph::GraphQuery;
use crate::http::{
self, Capabilities, CasCommittedView, DecodeRecordBody, DeletedManyView, ErrorBody,
ForkCreateBody, ForkPutBody, GraphNeighborsQuery, GraphResultView, KvCasQuery, KvPageView,
KvPutQuery, KvScanQuery, ProjectionListQuery, PromotedView, RemoveBindingBody, SchemaListQuery,
};
use crate::kv::{CasExpect, KvNamespaceInfo};
use crate::query::{Query, QueryResult};
use crate::result::ResultCode;
use serde::Serialize;
use serde::de::DeserializeOwned;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Method {
Get,
Post,
Put,
Delete,
}
impl Method {
pub fn as_str(self) -> &'static str {
match self {
Method::Get => "GET",
Method::Post => "POST",
Method::Put => "PUT",
Method::Delete => "DELETE",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HttpRequest {
pub method: Method,
pub path: String,
pub body: Option<Vec<u8>>,
}
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct HttpResponse {
pub status: u16,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
impl HttpResponse {
pub fn new(status: u16, body: Vec<u8>) -> Self {
Self {
status,
headers: Vec::new(),
body,
}
}
#[must_use]
pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.push((name.into(), value.into()));
self
}
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct KvValue {
pub value: Vec<u8>,
pub expires_at_micros: Option<u64>,
}
#[allow(async_fn_in_trait)]
pub trait Transport {
type Error: core::fmt::Display;
async fn send(&self, request: HttpRequest) -> Result<HttpResponse, Self::Error>;
}
#[derive(Debug)]
pub enum ClientError<E> {
Transport(E),
Decode(String),
Api(ErrorBody),
}
impl<E: core::fmt::Display> core::fmt::Display for ClientError<E> {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
ClientError::Transport(error) => write!(f, "transport error: {error}"),
ClientError::Decode(detail) => write!(f, "decode error: {detail}"),
ClientError::Api(body) => write!(f, "api error ({:?}): {}", body.code, body.message),
}
}
}
impl<E: core::fmt::Display + core::fmt::Debug> std::error::Error for ClientError<E> {}
impl<E> ClientError<E> {
pub fn code(&self) -> Option<ResultCode> {
match self {
ClientError::Api(body) => Some(body.code),
_ => None,
}
}
}
type ClientResult<T, E> = Result<T, ClientError<E>>;
#[derive(Clone, Debug)]
pub struct HttpClient<T> {
transport: T,
}
impl<T: Transport> HttpClient<T> {
pub fn new(transport: T) -> Self {
Self { transport }
}
pub fn transport(&self) -> &T {
&self.transport
}
pub async fn capabilities(&self) -> ClientResult<Capabilities, T::Error> {
self.get(http::CAPABILITIES_PATH.to_owned()).await
}
pub async fn query(&self, query: &Query) -> ClientResult<QueryResult, T::Error> {
self.send_json(Method::Post, http::QUERY_PATH.to_owned(), query)
.await
}
pub async fn list_projections(
&self,
filter: &ProjectionListQuery,
) -> ClientResult<Vec<ProjectionInfo>, T::Error> {
self.get(with_query(http::PROJECTIONS_PATH, filter)?).await
}
pub async fn list_schemas(
&self,
filter: &SchemaListQuery,
) -> ClientResult<Vec<SchemaInfo>, T::Error> {
self.get(with_query(http::SCHEMAS_PATH, filter)?).await
}
pub async fn register_schema(
&self,
source: SchemaSource,
name: Option<String>,
version: Option<u32>,
) -> ClientResult<u32, T::Error> {
let body = http::RegisterSchemaBody {
source,
name,
version,
};
self.send_json(Method::Post, http::SCHEMAS_PATH.to_owned(), &body)
.await
}
pub async fn kv_get(
&self,
namespace: &str,
key: &[u8],
) -> ClientResult<Option<KvValue>, T::Error> {
let path = http::kv_entry_path(namespace, &base64url_encode(key));
let response = self.dispatch(Method::Get, path, None).await?;
if response.status == 404 {
return Ok(None);
}
if !(200..300).contains(&response.status) {
return Err(api_error(&response));
}
let expires_at_micros = response
.header(http::KV_EXPIRES_AT_MICROS_HEADER)
.and_then(|value| value.parse::<u64>().ok());
Ok(Some(KvValue {
value: response.body,
expires_at_micros,
}))
}
pub async fn kv_set(
&self,
namespace: &str,
key: &[u8],
value: &[u8],
expires_at_micros: Option<u64>,
) -> ClientResult<(), T::Error> {
let path = with_query(
&http::kv_entry_path(namespace, &base64url_encode(key)),
&KvPutQuery { expires_at_micros },
)?;
self.expect_ok(Method::Put, path, Some(value.to_vec()))
.await
}
pub async fn kv_cas(
&self,
namespace: &str,
key: &[u8],
value: &[u8],
expect: CasExpect,
expires_at_micros: Option<u64>,
) -> ClientResult<u64, T::Error> {
let (expect_version, expect_absent) = match expect {
CasExpect::Match(version) => (Some(version), None),
CasExpect::Absent => (None, Some(true)),
};
let path = with_query(
&http::kv_cas_path(namespace, &base64url_encode(key)),
&KvCasQuery {
expect_version,
expect_absent,
expires_at_micros,
},
)?;
let response = self
.dispatch(Method::Put, path, Some(value.to_vec()))
.await?;
let view: CasCommittedView = decode_ok(&response)?;
Ok(view.version)
}
pub async fn kv_delete(&self, namespace: &str, key: &[u8]) -> ClientResult<bool, T::Error> {
let path = http::kv_entry_path(namespace, &base64url_encode(key));
self.send_empty(Method::Delete, path).await
}
pub async fn kv_scan(
&self,
namespace: &str,
filter: &KvScanQuery,
) -> ClientResult<KvPageView, T::Error> {
self.get(with_query(&http::kv_namespace_path(namespace), filter)?)
.await
}
pub async fn create_fork(&self, body: &ForkCreateBody) -> ClientResult<ForkInfo, T::Error> {
self.send_json(Method::Post, http::FORKS_PATH.to_owned(), body)
.await
}
pub async fn list_forks(&self) -> ClientResult<Vec<ForkInfo>, T::Error> {
self.get(http::FORKS_PATH.to_owned()).await
}
pub async fn get_projection(&self, id: &str) -> ClientResult<Option<ProjectionInfo>, T::Error> {
self.get_optional(http::projection_path(id)).await
}
pub async fn register_projection(&self, projection: &Projection) -> ClientResult<(), T::Error> {
self.send_json_ok(Method::Post, http::PROJECTIONS_PATH.to_owned(), projection)
.await
}
pub async fn drop_projection(&self, id: &str) -> ClientResult<(), T::Error> {
self.expect_ok(Method::Delete, http::projection_path(id), None)
.await
}
pub async fn apply_binding(&self, binding: &ProjectionBinding) -> ClientResult<(), T::Error> {
self.send_json_ok(Method::Post, http::BINDINGS_PATH.to_owned(), binding)
.await
}
pub async fn remove_binding(
&self,
source: &SourceSelector,
projection_ref: Option<String>,
) -> ClientResult<(), T::Error> {
let body = RemoveBindingBody {
stream: source.stream.clone(),
topic: source.topic.clone(),
projection_ref,
};
self.send_json_ok(Method::Delete, http::BINDINGS_PATH.to_owned(), &body)
.await
}
pub async fn get_schema(&self, id: u32) -> ClientResult<Option<SchemaInfo>, T::Error> {
self.get_optional(http::schema_path(id)).await
}
pub async fn drop_schema(&self, id: u32) -> ClientResult<(), T::Error> {
self.expect_ok(Method::Delete, http::schema_path(id), None)
.await
}
pub async fn decode_record(
&self,
id: u32,
payload: &[u8],
) -> ClientResult<Option<serde_json::Value>, T::Error> {
let body = DecodeRecordBody {
payload: base64url_encode(payload),
};
self.send_json(Method::Post, http::schema_decode_path(id), &body)
.await
}
pub async fn kv_namespaces(&self) -> ClientResult<Vec<KvNamespaceInfo>, T::Error> {
self.get(http::KV_PATH.to_owned()).await
}
pub async fn kv_delete_many(
&self,
namespace: &str,
filter: &KvScanQuery,
) -> ClientResult<usize, T::Error> {
let path = with_query(&http::kv_namespace_path(namespace), filter)?;
let view: DeletedManyView = self.send_empty(Method::Delete, path).await?;
Ok(view.deleted)
}
pub async fn promote_fork(&self, id: &str) -> ClientResult<usize, T::Error> {
let view: PromotedView = self
.send_empty(Method::Post, http::fork_promote_path(id))
.await?;
Ok(view.rows)
}
pub async fn delete_fork(&self, id: &str) -> ClientResult<(), T::Error> {
self.expect_ok(Method::Delete, http::fork_path(id), None)
.await
}
pub async fn put_fork_row(&self, id: &str, body: &ForkPutBody) -> ClientResult<(), T::Error> {
self.send_json_ok(Method::Put, http::fork_rows_path(id), body)
.await
}
pub async fn graph_query(
&self,
name: &str,
query: &GraphQuery,
) -> ClientResult<GraphResultView, T::Error> {
self.send_json(Method::Post, http::graph_query_path(name), query)
.await
}
pub async fn graph_neighbors(
&self,
name: &str,
node: &str,
query: &GraphNeighborsQuery,
) -> ClientResult<GraphResultView, T::Error> {
self.get(with_query(&http::graph_neighbors_path(name, node), query)?)
.await
}
pub async fn list_graphs(
&self,
filter: &ProjectionListQuery,
) -> ClientResult<Vec<ProjectionInfo>, T::Error> {
self.get(with_query(http::GRAPHS_PATH, filter)?).await
}
pub async fn register_graph(&self, projection: &Projection) -> ClientResult<(), T::Error> {
self.send_json_ok(Method::Post, http::GRAPHS_PATH.to_owned(), projection)
.await
}
pub async fn get_graph(&self, id: &str) -> ClientResult<Option<ProjectionInfo>, T::Error> {
self.get_optional(http::graph_path(id)).await
}
pub async fn drop_graph(&self, id: &str) -> ClientResult<(), T::Error> {
self.expect_ok(Method::Delete, http::graph_path(id), None)
.await
}
async fn get<R: DeserializeOwned>(&self, path: String) -> ClientResult<R, T::Error> {
let response = self.dispatch(Method::Get, path, None).await?;
decode_ok(&response)
}
async fn get_optional<R: DeserializeOwned>(
&self,
path: String,
) -> ClientResult<Option<R>, T::Error> {
let response = self.dispatch(Method::Get, path, None).await?;
if response.status == 404 {
return Ok(None);
}
decode_ok(&response).map(Some)
}
async fn send_json<B: Serialize, R: DeserializeOwned>(
&self,
method: Method,
path: String,
body: &B,
) -> ClientResult<R, T::Error> {
let bytes = serde_json::to_vec(body)
.map_err(|error| ClientError::Decode(format!("request body: {error}")))?;
let response = self.dispatch(method, path, Some(bytes)).await?;
decode_ok(&response)
}
async fn send_empty<R: DeserializeOwned>(
&self,
method: Method,
path: String,
) -> ClientResult<R, T::Error> {
let response = self.dispatch(method, path, None).await?;
decode_ok(&response)
}
async fn send_json_ok<B: Serialize>(
&self,
method: Method,
path: String,
body: &B,
) -> ClientResult<(), T::Error> {
let bytes = serde_json::to_vec(body)
.map_err(|error| ClientError::Decode(format!("request body: {error}")))?;
let response = self.dispatch(method, path, Some(bytes)).await?;
check_status(&response)
}
async fn expect_ok(
&self,
method: Method,
path: String,
body: Option<Vec<u8>>,
) -> ClientResult<(), T::Error> {
let response = self.dispatch(method, path, body).await?;
check_status(&response)
}
async fn dispatch(
&self,
method: Method,
path: String,
body: Option<Vec<u8>>,
) -> ClientResult<HttpResponse, T::Error> {
self.transport
.send(HttpRequest { method, path, body })
.await
.map_err(ClientError::Transport)
}
}
fn with_query<E, P: Serialize>(path: &str, params: &P) -> Result<String, ClientError<E>> {
let query = serde_urlencoded::to_string(params)
.map_err(|error| ClientError::Decode(format!("query params: {error}")))?;
if query.is_empty() {
Ok(path.to_owned())
} else {
Ok(format!("{path}?{query}"))
}
}
fn decode_ok<E, R: DeserializeOwned>(response: &HttpResponse) -> Result<R, ClientError<E>> {
if (200..300).contains(&response.status) {
serde_json::from_slice(&response.body)
.map_err(|error| ClientError::Decode(format!("response body: {error}")))
} else {
Err(api_error(response))
}
}
fn check_status<E>(response: &HttpResponse) -> Result<(), ClientError<E>> {
if (200..300).contains(&response.status) {
Ok(())
} else {
Err(api_error(response))
}
}
fn api_error<E>(response: &HttpResponse) -> ClientError<E> {
let body = serde_json::from_slice::<ErrorBody>(&response.body).unwrap_or_else(|_| {
ErrorBody::new(
code_for_status(response.status),
String::from_utf8_lossy(&response.body).into_owned(),
)
});
ClientError::Api(body)
}
fn code_for_status(status: u16) -> ResultCode {
match status {
404 => ResultCode::NotFound,
400 => ResultCode::InvalidArgument,
401 => ResultCode::Unauthorized,
409 => ResultCode::Conflict,
413 => ResultCode::TooLarge,
501 => ResultCode::Unsupported,
503 => ResultCode::Stale,
_ => ResultCode::Backend,
}
}
const B64URL: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
pub fn base64url_encode(input: &[u8]) -> String {
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
for chunk in input.chunks(3) {
let b0 = chunk[0] as usize;
out.push(B64URL[b0 >> 2] as char);
match chunk.len() {
1 => out.push(B64URL[(b0 & 0b11) << 4] as char),
2 => {
let b1 = chunk[1] as usize;
out.push(B64URL[((b0 & 0b11) << 4) | (b1 >> 4)] as char);
out.push(B64URL[(b1 & 0b1111) << 2] as char);
}
_ => {
let b1 = chunk[1] as usize;
let b2 = chunk[2] as usize;
out.push(B64URL[((b0 & 0b11) << 4) | (b1 >> 4)] as char);
out.push(B64URL[((b1 & 0b1111) << 2) | (b2 >> 6)] as char);
out.push(B64URL[b2 & 0b111111] as char);
}
}
}
out
}
pub fn base64url_decode(input: &str) -> Option<Vec<u8>> {
fn val(byte: u8) -> Option<u8> {
match byte {
b'A'..=b'Z' => Some(byte - b'A'),
b'a'..=b'z' => Some(byte - b'a' + 26),
b'0'..=b'9' => Some(byte - b'0' + 52),
b'-' => Some(62),
b'_' => Some(63),
_ => None,
}
}
let bytes = input.as_bytes();
if bytes.len() % 4 == 1 {
return None;
}
let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
for chunk in bytes.chunks(4) {
let mut acc = 0u32;
for &byte in chunk {
acc = (acc << 6) | u32::from(val(byte)?);
}
acc <<= 6 * (4 - chunk.len());
match chunk.len() {
2 => out.push((acc >> 16) as u8),
3 => {
out.push((acc >> 16) as u8);
out.push((acc >> 8) as u8);
}
_ => {
out.push((acc >> 16) as u8);
out.push((acc >> 8) as u8);
out.push(acc as u8);
}
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn given_bytes_when_base64url_round_tripped_then_should_preserve_them() {
for case in [
&b""[..],
&b"f"[..],
&b"fo"[..],
&b"foo"[..],
&b"foob"[..],
&b"fooba"[..],
&b"foobar"[..],
&[0x00, 0xff, 0x10, 0x80][..],
] {
let encoded = base64url_encode(case);
assert!(
!encoded.contains('=') && !encoded.contains('+') && !encoded.contains('/'),
"url-safe unpadded: {encoded}"
);
assert_eq!(base64url_decode(&encoded).as_deref(), Some(case));
}
}
#[test]
fn given_known_vectors_when_encoded_then_should_match_rfc_url_alphabet() {
assert_eq!(base64url_encode(b"foobar"), "Zm9vYmFy");
assert_eq!(base64url_encode(&[0xfb, 0xff]), "-_8");
}
#[test]
fn given_a_bad_base64_string_when_decoded_then_should_reject() {
assert!(
base64url_decode("====").is_none(),
"padding is not alphabet"
);
assert!(
base64url_decode("A").is_none(),
"a lone char carries no byte"
);
assert!(base64url_decode("a b").is_none(), "space is not alphabet");
}
struct CannedTransport {
response: HttpResponse,
}
impl Transport for CannedTransport {
type Error = std::convert::Infallible;
async fn send(&self, _request: HttpRequest) -> Result<HttpResponse, Self::Error> {
Ok(self.response.clone())
}
}
fn block_on<F: core::future::Future>(future: F) -> F::Output {
use core::task::{Context, Poll, Waker};
let mut context = Context::from_waker(Waker::noop());
let mut future = core::pin::pin!(future);
loop {
if let Poll::Ready(output) = future.as_mut().poll(&mut context) {
return output;
}
}
}
#[test]
fn given_an_ok_capabilities_response_when_fetched_then_should_decode() {
let body = serde_json::to_vec(&Capabilities::new(
true,
crate::hello::OpVersions::new(1, 1, 1, 1),
))
.unwrap();
let client = HttpClient::new(CannedTransport {
response: HttpResponse::new(200, body),
});
let caps = block_on(client.capabilities()).expect("decodes");
assert!(caps.managed && !caps.kv.cas);
}
#[test]
fn given_an_error_status_when_called_then_should_surface_the_typed_code() {
let body =
serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "no such fork")).unwrap();
let client = HttpClient::new(CannedTransport {
response: HttpResponse::new(404, body),
});
let error = block_on(client.list_forks()).expect_err("a 404 is an error");
assert_eq!(error.code(), Some(ResultCode::NotFound));
}
#[test]
fn given_a_missing_kv_entry_when_fetched_then_should_be_none() {
let body = serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "absent")).unwrap();
let client = HttpClient::new(CannedTransport {
response: HttpResponse::new(404, body),
});
let entry = block_on(client.kv_get("sessions", b"user:1")).expect("404 maps to None");
assert!(entry.is_none());
}
#[test]
fn given_a_present_kv_entry_when_fetched_then_should_read_raw_body_and_expiry_header() {
let response = HttpResponse::new(200, b"world".to_vec())
.with_header(http::KV_EXPIRES_AT_MICROS_HEADER, "1700000000000000");
let client = HttpClient::new(CannedTransport { response });
let entry = block_on(client.kv_get("sessions", b"user:1"))
.expect("decodes")
.expect("present");
assert_eq!(entry.value, b"world");
assert_eq!(entry.expires_at_micros, Some(1_700_000_000_000_000));
}
#[test]
fn given_a_missing_projection_when_fetched_then_should_be_none() {
let body = serde_json::to_vec(&ErrorBody::new(ResultCode::NotFound, "absent")).unwrap();
let client = HttpClient::new(CannedTransport {
response: HttpResponse::new(404, body),
});
let info = block_on(client.get_projection("order.v1")).expect("404 maps to None");
assert!(info.is_none());
}
#[test]
fn given_a_delete_many_reply_when_received_then_should_return_the_count() {
let body = serde_json::to_vec(&DeletedManyView { deleted: 7 }).unwrap();
let client = HttpClient::new(CannedTransport {
response: HttpResponse::new(200, body),
});
let removed = block_on(client.kv_delete_many("sessions", &KvScanQuery::default()))
.expect("decodes the count");
assert_eq!(removed, 7);
}
#[test]
fn given_an_empty_2xx_when_dropping_a_projection_then_should_succeed() {
let client = HttpClient::new(CannedTransport {
response: HttpResponse::new(204, Vec::new()),
});
block_on(client.drop_projection("order.v1")).expect("a 204 is a success");
}
#[test]
fn given_a_cas_commit_when_received_then_should_return_the_new_version() {
let body = serde_json::to_vec(&CasCommittedView { version: 4 }).unwrap();
let client = HttpClient::new(CannedTransport {
response: HttpResponse::new(200, body),
});
let version = block_on(client.kv_cas("locks", b"job", b"held", CasExpect::Match(3), None))
.expect("a commit returns the new version");
assert_eq!(version, 4);
}
#[test]
fn given_a_cas_conflict_when_received_then_should_surface_a_typed_conflict() {
let body = serde_json::to_vec(
&ErrorBody::new(ResultCode::Conflict, "version conflict")
.with_detail(serde_json::json!({ "current": 3 })),
)
.unwrap();
let client = HttpClient::new(CannedTransport {
response: HttpResponse::new(409, body),
});
let error = block_on(client.kv_cas("locks", b"job", b"steal", CasExpect::Absent, None))
.expect_err("a precondition miss is an error");
assert_eq!(error.code(), Some(ResultCode::Conflict));
}
}