mod application;
mod authority;
mod control;
mod error;
mod events;
use std::{
sync::{Arc, RwLock},
time::Duration,
};
use serde::{de::DeserializeOwned, Serialize};
use serde_json::Value;
pub use application::{
AppCommit, AppTransaction, Application, ApplicationInfo, ReplayPage, StateVersion,
};
pub use authority::{
BoundedQuery, CasConflict, CasOutcome, CasRequest, CommitReceipt, Condition, Direction,
Operation, Precondition, QueryPage, Record, Revision, Transaction,
};
pub use control::{ApplicationRecord, Control, Health, Promotion, Tenant};
pub use error::{ApiError, Error, Result};
pub use events::{EventStream, ServerEvent};
pub use feltdb;
pub use feltdb::{parse_version_token, HistoryOrigin, VersionHistory};
pub use feltdb::{
PreconditionFailure, ReadBasis, ReadCondition, ReadPredicate, ReadRecord, ReadSet,
TransactionStatus,
};
pub use reqwest::Method;
pub fn version_token(history: &str, version: u64) -> String {
format!("{}:{history}:{version}", feltdb::VERSION_TOKEN_PREFIX)
}
pub mod wire {
pub use feltdb::state_contract::{
AggregateDefinition, AggregateFunction, CanonicalQuery, QueryFilter, QueryOrder,
QueryReference, QueryResult, SortDirection, TransactionOperation, TransactionOperationKind,
TransactionPrecondition, TransactionResult,
};
}
const PROTOCOL_VERSION: &str = "1";
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, PartialEq, Eq)]
enum AuthorityScope {
Tenant {
tenant_id: String,
environment_id: Option<String>,
},
Platform,
}
#[derive(Debug, Clone)]
pub struct ClientBuilder {
url: String,
token: Option<String>,
scope: Option<AuthorityScope>,
timeout: Duration,
root_certificates: Vec<Vec<u8>>,
}
impl ClientBuilder {
pub fn token(mut self, token: impl Into<String>) -> Self {
let token = token.into();
self.token = (!token.is_empty()).then_some(token);
self
}
pub fn tenant(mut self, tenant_id: impl Into<String>) -> Self {
self.scope = Some(AuthorityScope::Tenant {
tenant_id: tenant_id.into(),
environment_id: None,
});
self
}
pub fn tenant_environment(
mut self,
tenant_id: impl Into<String>,
environment_id: impl Into<String>,
) -> Self {
self.scope = Some(AuthorityScope::Tenant {
tenant_id: tenant_id.into(),
environment_id: Some(environment_id.into()),
});
self
}
pub fn platform(mut self) -> Self {
self.scope = Some(AuthorityScope::Platform);
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn add_root_certificate_pem(mut self, pem: impl Into<Vec<u8>>) -> Self {
self.root_certificates.push(pem.into());
self
}
pub fn build(self) -> Result<Client> {
let url = self.url.trim_end_matches('/').to_owned();
let parsed = reqwest::Url::parse(&url)
.map_err(|error| Error::InvalidRequest(format!("server URL {url:?}: {error}")))?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(Error::InvalidRequest(format!(
"server URL {url:?} must be http or https"
)));
}
let mut http = reqwest::Client::builder();
for pem in &self.root_certificates {
let certificates = reqwest::Certificate::from_pem_bundle(pem)
.ok()
.filter(|certificates| !certificates.is_empty())
.ok_or_else(|| {
Error::InvalidRequest("root certificate is not a PEM certificate".into())
})?;
for certificate in certificates {
http = http.add_root_certificate(certificate);
}
}
let http = http
.build()
.map_err(|source| Error::transport(source, false))?;
Ok(Client {
inner: Arc::new(Inner {
http,
url,
token: RwLock::new(self.token),
scope: self.scope,
timeout: self.timeout,
}),
})
}
}
struct Inner {
http: reqwest::Client,
url: String,
token: RwLock<Option<String>>,
scope: Option<AuthorityScope>,
timeout: Duration,
}
#[derive(Clone)]
pub struct Client {
inner: Arc<Inner>,
}
impl std::fmt::Debug for Client {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Client")
.field("url", &self.inner.url)
.field("scope", &self.inner.scope)
.finish_non_exhaustive()
}
}
impl Client {
pub fn builder(url: impl Into<String>) -> ClientBuilder {
ClientBuilder {
url: url.into(),
token: None,
scope: None,
timeout: DEFAULT_TIMEOUT,
root_certificates: Vec::new(),
}
}
pub fn url(&self) -> &str {
&self.inner.url
}
pub fn set_token(&self, token: impl Into<String>) {
let token = token.into();
*self
.inner
.token
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner()) =
(!token.is_empty()).then_some(token);
}
pub fn application(&self, application_id: impl Into<String>) -> Application {
Application::new(self.clone(), application_id.into())
}
pub async fn request<B: Serialize + ?Sized>(
&self,
method: Method,
path: &str,
body: Option<&B>,
) -> Result<Value> {
self.send(method, path, body).await
}
pub async fn request_with_headers<B: Serialize + ?Sized>(
&self,
method: Method,
path: &str,
headers: &[(&str, &str)],
body: Option<&B>,
) -> Result<Value> {
self.send_with_headers(method, path, headers, body).await
}
async fn send_with_headers<T: DeserializeOwned, B: Serialize + ?Sized>(
&self,
method: Method,
path: &str,
headers: &[(&str, &str)],
body: Option<&B>,
) -> Result<T> {
let mutating = !matches!(method, Method::GET | Method::HEAD | Method::OPTIONS);
let mut builder = self.builder_for(method, path).timeout(self.inner.timeout);
for (name, value) in headers {
builder = builder.header(*name, *value);
}
if let Some(body) = body {
builder = builder.json(body);
}
let response = self.respond(builder, mutating).await?;
decode(response, mutating).await
}
fn builder_for(&self, method: Method, path: &str) -> reqwest::RequestBuilder {
let mut builder = self
.inner
.http
.request(method, format!("{}{path}", self.inner.url))
.header("FeltDB-Protocol", PROTOCOL_VERSION);
if let Some(token) = self
.inner
.token
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.as_deref()
{
builder = builder.bearer_auth(token);
}
match &self.inner.scope {
Some(AuthorityScope::Tenant {
tenant_id,
environment_id,
}) => {
builder = builder.header("FeltDB-Tenant-Id", tenant_id);
if let Some(environment_id) = environment_id {
builder = builder.header("FeltDB-Environment-Id", environment_id);
}
}
Some(AuthorityScope::Platform) => {
builder = builder.header("FeltDB-Authority-Mode", "platform");
}
None => {}
}
builder
}
async fn exchange<B: Serialize + ?Sized>(
&self,
method: Method,
path: &str,
body: Option<&B>,
) -> Result<reqwest::Response> {
let mutating = !matches!(method, Method::GET | Method::HEAD | Method::OPTIONS);
let mut builder = self.builder_for(method, path).timeout(self.inner.timeout);
if let Some(body) = body {
builder = builder.json(body);
}
self.respond(builder, mutating).await
}
async fn respond(
&self,
builder: reqwest::RequestBuilder,
mutating: bool,
) -> Result<reqwest::Response> {
let response = builder
.send()
.await
.map_err(|source| Error::transport(source, mutating))?;
if response.status().is_success() {
return Ok(response);
}
let status = response.status().as_u16();
let text = response
.text()
.await
.map_err(|source| Error::transport(source, mutating))?;
Err(Error::Api(Box::new(ApiError::from_body(status, &text))))
}
async fn send<T: DeserializeOwned, B: Serialize + ?Sized>(
&self,
method: Method,
path: &str,
body: Option<&B>,
) -> Result<T> {
let mutating = !matches!(method, Method::GET | Method::HEAD | Method::OPTIONS);
let response = self.exchange(method, path, body).await?;
decode(response, mutating).await
}
async fn open_events(&self, path: &str, cursor: Option<&str>) -> Result<EventStream> {
let mut builder = self.builder_for(Method::GET, path);
if let Some(cursor) = cursor {
builder = builder.header("Last-Event-ID", cursor);
}
let response = builder
.send()
.await
.map_err(|source| Error::transport(source, false))?;
if !response.status().is_success() {
let status = response.status().as_u16();
let text = response
.text()
.await
.map_err(|source| Error::transport(source, false))?;
return Err(Error::Api(Box::new(ApiError::from_body(status, &text))));
}
Ok(EventStream::new(response, cursor.map(str::to_owned)))
}
}
async fn decode<T: DeserializeOwned>(response: reqwest::Response, mutating: bool) -> Result<T> {
let status = response.status().as_u16();
let bytes = response
.bytes()
.await
.map_err(|source| Error::transport(source, mutating))?;
let bytes: &[u8] = if bytes.is_empty() { b"null" } else { &bytes };
serde_json::from_slice(bytes).map_err(|source| Error::Decode { status, source })
}
pub fn path_segment(value: &str) -> String {
encode(value)
}
fn encode(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
out.push(byte as char);
} else {
out.push_str(&format!("%{byte:02X}"));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn path_segments_are_encoded() {
assert_eq!(encode("o-1"), "o-1");
assert_eq!(encode("a/b c:é"), "a%2Fb%20c%3A%C3%A9");
}
#[test]
fn a_url_that_is_not_http_is_refused() {
assert!(matches!(
Client::builder("ftp://example.com").build(),
Err(Error::InvalidRequest(_))
));
assert_eq!(
Client::builder("http://127.0.0.1:7700/")
.build()
.unwrap()
.url(),
"http://127.0.0.1:7700"
);
}
}