use std::{
collections::BTreeSet,
sync::{Arc, Mutex},
};
use feltdb::{
state_contract::{
AuthorizationContext, CanonicalQuery, QueryResult, TransactionOperation,
TransactionOperationKind, TransactionPrecondition, TransactionRequest, TransactionResult,
},
ReadBasis, ReadSet,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::{encode, Client, Error, EventStream, Method, Result};
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct ApplicationInfo {
pub application_id: String,
#[serde(default)]
pub application_name: Option<String>,
pub environment: String,
pub revision_id: String,
#[serde(default)]
pub version: Option<u64>,
pub contract_hash: String,
#[serde(default)]
pub flow_hash: Option<String>,
#[serde(default)]
pub dsl_version: Option<u32>,
#[serde(default)]
pub snapshot: Value,
}
#[derive(Debug, Clone)]
pub struct AppTransaction {
transaction_id: String,
operations: Vec<TransactionOperation>,
preconditions: Vec<TransactionPrecondition>,
fences: Vec<ReadBasis>,
}
impl AppTransaction {
pub fn new(transaction_id: impl Into<String>) -> Self {
Self {
transaction_id: transaction_id.into(),
operations: Vec::new(),
preconditions: Vec::new(),
fences: Vec::new(),
}
}
pub fn generated() -> Self {
Self::new(format!("tx-{}", uuid::Uuid::new_v4()))
}
pub fn id(&self) -> &str {
&self.transaction_id
}
fn push(
self,
kind: TransactionOperationKind,
collection: String,
id: String,
value: Value,
) -> Self {
self.operation(TransactionOperation {
kind,
collection,
id,
value,
if_version: None,
if_version_token: None,
})
}
pub fn insert(
self,
collection: impl Into<String>,
id: impl Into<String>,
value: Value,
) -> Self {
self.push(
TransactionOperationKind::Insert,
collection.into(),
id.into(),
value,
)
}
pub fn update(
self,
collection: impl Into<String>,
id: impl Into<String>,
value: Value,
) -> Self {
self.push(
TransactionOperationKind::Update,
collection.into(),
id.into(),
value,
)
}
pub fn delete(self, collection: impl Into<String>, id: impl Into<String>) -> Self {
self.push(
TransactionOperationKind::Delete,
collection.into(),
id.into(),
Value::Null,
)
}
pub fn update_if_version(
self,
collection: impl Into<String>,
id: impl Into<String>,
value: Value,
version: u64,
) -> Self {
self.operation(TransactionOperation {
kind: TransactionOperationKind::Update,
collection: collection.into(),
id: id.into(),
value,
if_version: Some(version),
if_version_token: None,
})
}
pub fn update_if_token(
self,
collection: impl Into<String>,
id: impl Into<String>,
value: Value,
token: impl Into<String>,
) -> Self {
self.operation(TransactionOperation {
kind: TransactionOperationKind::Update,
collection: collection.into(),
id: id.into(),
value,
if_version: None,
if_version_token: Some(token.into()),
})
}
pub fn delete_if_version(
self,
collection: impl Into<String>,
id: impl Into<String>,
version: u64,
) -> Self {
self.operation(TransactionOperation {
kind: TransactionOperationKind::Delete,
collection: collection.into(),
id: id.into(),
value: Value::Null,
if_version: Some(version),
if_version_token: None,
})
}
pub fn delete_if_token(
self,
collection: impl Into<String>,
id: impl Into<String>,
token: impl Into<String>,
) -> Self {
self.operation(TransactionOperation {
kind: TransactionOperationKind::Delete,
collection: collection.into(),
id: id.into(),
value: Value::Null,
if_version: None,
if_version_token: Some(token.into()),
})
}
pub fn operation(mut self, operation: TransactionOperation) -> Self {
self.operations.push(operation);
self
}
pub fn precondition(mut self, precondition: TransactionPrecondition) -> Self {
self.preconditions.push(precondition);
self
}
pub fn expect_version(
self,
collection: impl Into<String>,
id: impl Into<String>,
version: u64,
) -> Self {
self.precondition(TransactionPrecondition::Record {
collection: collection.into(),
id: id.into(),
require_absent: false,
expected_version: Some(version),
expected_version_token: None,
expected_epoch: None,
expected_lease_id: None,
})
}
pub fn expect_token(
self,
collection: impl Into<String>,
id: impl Into<String>,
token: impl Into<String>,
) -> Self {
self.precondition(TransactionPrecondition::Record {
collection: collection.into(),
id: id.into(),
require_absent: false,
expected_version: None,
expected_version_token: Some(token.into()),
expected_epoch: None,
expected_lease_id: None,
})
}
pub fn expect_absent(self, collection: impl Into<String>, id: impl Into<String>) -> Self {
self.precondition(TransactionPrecondition::Record {
collection: collection.into(),
id: id.into(),
require_absent: true,
expected_version: None,
expected_version_token: None,
expected_epoch: None,
expected_lease_id: None,
})
}
pub fn fence(mut self, basis: ReadBasis) -> Self {
self.fences.push(basis);
self
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct AppCommit {
#[serde(flatten)]
pub result: TransactionResult,
pub revision_id: String,
#[serde(default)]
pub operation_ids: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct StateVersion {
pub application_id: String,
pub revision_id: String,
pub schema_version: u64,
pub state_version: u64,
#[serde(default)]
pub state_namespace: Option<String>,
#[serde(default)]
pub causal_cursor: Value,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct ReplayPage {
pub contract_version: String,
pub cursor_format: String,
pub application_id: String,
pub environment: String,
pub events: Vec<Value>,
pub next_cursor: String,
}
#[derive(Debug, Clone)]
pub struct Application {
client: Client,
application_id: String,
environment: String,
revision: Arc<Mutex<Option<String>>>,
}
#[derive(Serialize)]
struct Envelope<'a, T: Serialize> {
application_id: &'a str,
revision_id: &'a str,
environment: &'a str,
#[serde(flatten)]
payload: T,
}
impl Application {
pub(crate) fn new(client: Client, application_id: String) -> Self {
Self {
client,
application_id,
environment: "production".into(),
revision: Arc::default(),
}
}
pub fn environment(mut self, environment: impl Into<String>) -> Self {
self.environment = environment.into();
self.revision = Arc::default();
self
}
pub fn id(&self) -> &str {
&self.application_id
}
pub fn environment_name(&self) -> &str {
&self.environment
}
fn scoped(&self, path: &str) -> String {
let separator = if path.contains('?') { '&' } else { '?' };
format!(
"/v1{path}{separator}application_id={}&environment={}",
encode(&self.application_id),
encode(&self.environment)
)
}
pub async fn discover(&self) -> Result<ApplicationInfo> {
let info: ApplicationInfo = self
.client
.send::<_, ()>(Method::GET, &self.scoped("/application"), None)
.await?;
*self
.revision
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(info.revision_id.clone());
Ok(info)
}
pub async fn revision_id(&self) -> Result<String> {
let cached = self
.revision
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone();
match cached {
Some(revision) => Ok(revision),
None => Ok(self.discover().await?.revision_id),
}
}
async fn canonical<T: serde::de::DeserializeOwned>(
&self,
path: &str,
payload: impl Serialize,
) -> Result<T> {
let revision_id = self.revision_id().await?;
let body = Envelope {
application_id: &self.application_id,
revision_id: &revision_id,
environment: &self.environment,
payload,
};
self.client
.send(Method::POST, &self.scoped(path), Some(&body))
.await
}
pub async fn state_version(&self) -> Result<StateVersion> {
let revision_id = self.revision_id().await?;
let path = self.scoped(&format!(
"/state/version?revision_id={}",
encode(&revision_id)
));
self.client.send::<_, ()>(Method::GET, &path, None).await
}
pub async fn query(&self, query: &CanonicalQuery) -> Result<QueryResult> {
self.canonical("/query", json!({ "query": query })).await
}
pub async fn commit(&self, transaction: &AppTransaction) -> Result<AppCommit> {
if transaction.operations.is_empty() {
return Err(Error::InvalidRequest(
"a transaction requires at least one operation".into(),
));
}
let revision_id = self.revision_id().await?;
let tenant_id = match &self.client.inner.scope {
Some(crate::AuthorityScope::Tenant { tenant_id, .. }) => tenant_id.clone(),
_ => String::new(),
};
let request = TransactionRequest {
transaction_id: Some(transaction.transaction_id.clone()),
tenant_id: tenant_id.clone(),
application_id: self.application_id.clone(),
revision_id: revision_id.clone(),
schema_version: 0,
state_namespace: None,
causal_parent: None,
authorization: AuthorizationContext {
subject: String::new(),
tenant_id,
application_id: self.application_id.clone(),
revision_id,
capabilities: BTreeSet::new(),
readable_collections: BTreeSet::new(),
writable_collections: BTreeSet::new(),
field_projections: Default::default(),
},
operations: transaction.operations.clone(),
preconditions: transaction.preconditions.clone(),
fences: transaction.fences.clone(),
};
self.canonical("/transactions", json!({ "transaction": request }))
.await
}
pub async fn read_basis(&self, reads: &ReadSet) -> Result<ReadBasis> {
self.canonical("/transactions/read-basis", json!({ "reads": reads }))
.await
}
pub async fn events(&self, cursor: Option<&str>) -> Result<EventStream> {
self.client
.open_events(&self.scoped("/events"), cursor)
.await
}
pub async fn replay(&self, cursor: Option<&str>, limit: Option<usize>) -> Result<ReplayPage> {
let mut path = self.scoped("/public/events/replay");
if let Some(cursor) = cursor {
path.push_str(&format!("&cursor={}", encode(cursor)));
}
if let Some(limit) = limit {
path.push_str(&format!("&limit={limit}"));
}
self.client.send::<_, ()>(Method::GET, &path, None).await
}
pub async fn provenance(&self, collection: &str, id: &str) -> Result<Value> {
let path = self.scoped(&format!(
"/control-plane/provenance/{}/{}",
encode(collection),
encode(id)
));
self.client.send::<_, ()>(Method::GET, &path, None).await
}
pub async fn sign_in(&self, email: &str, password: &str) -> Result<Value> {
let body =
json!({ "application_id": self.application_id, "email": email, "password": password });
self.authenticate("/v1/auth/signin", &body).await
}
pub async fn sign_up(
&self,
email: &str,
password: &str,
display_name: Option<&str>,
) -> Result<Value> {
let body = json!({
"application_id": self.application_id,
"email": email,
"password": password,
"display_name": display_name,
});
self.authenticate("/v1/auth/signup", &body).await
}
async fn authenticate(&self, path: &str, body: &Value) -> Result<Value> {
let session: Value = self.client.send(Method::POST, path, Some(body)).await?;
if let Some(token) = session.get("access_token").and_then(Value::as_str) {
self.client.set_token(token);
}
Ok(session)
}
pub async fn session(&self) -> Result<Value> {
self.client
.send::<_, ()>(Method::GET, "/v1/auth/session", None)
.await
}
pub async fn sign_out(&self) -> Result<()> {
self.client
.exchange::<()>(Method::POST, "/v1/auth/signout", None)
.await?;
self.client.set_token("");
Ok(())
}
}
impl Client {
pub async fn capabilities(&self) -> Result<Value> {
self.send::<_, ()>(Method::GET, "/v1/control-plane/capabilities", None)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scoped_paths_carry_the_application_and_environment() {
let app = Client::builder("http://localhost:1")
.build()
.unwrap()
.application("app 1")
.environment("staging");
assert_eq!(
app.scoped("/application"),
"/v1/application?application_id=app%201&environment=staging"
);
assert_eq!(
app.scoped("/x?y=1"),
"/v1/x?y=1&application_id=app%201&environment=staging"
);
}
}