1use std::{
4 collections::BTreeSet,
5 sync::{Arc, Mutex},
6};
7
8use feltdb::{
9 state_contract::{
10 AuthorizationContext, CanonicalQuery, QueryResult, TransactionOperation,
11 TransactionOperationKind, TransactionPrecondition, TransactionRequest, TransactionResult,
12 },
13 ReadBasis, ReadSet,
14};
15use serde::{Deserialize, Serialize};
16use serde_json::{json, Value};
17
18use crate::{encode, Client, Error, EventStream, Method, Result};
19
20#[derive(Debug, Clone, PartialEq, Deserialize)]
22pub struct ApplicationInfo {
23 pub application_id: String,
24 #[serde(default)]
25 pub application_name: Option<String>,
26 pub environment: String,
27 pub revision_id: String,
29 #[serde(default)]
30 pub version: Option<u64>,
31 pub contract_hash: String,
32 #[serde(default)]
33 pub flow_hash: Option<String>,
34 #[serde(default)]
35 pub dsl_version: Option<u32>,
36 #[serde(default)]
38 pub snapshot: Value,
39}
40
41#[derive(Debug, Clone)]
44pub struct AppTransaction {
45 transaction_id: String,
46 operations: Vec<TransactionOperation>,
47 preconditions: Vec<TransactionPrecondition>,
48 fences: Vec<ReadBasis>,
49}
50
51impl AppTransaction {
52 pub fn new(transaction_id: impl Into<String>) -> Self {
54 Self {
55 transaction_id: transaction_id.into(),
56 operations: Vec::new(),
57 preconditions: Vec::new(),
58 fences: Vec::new(),
59 }
60 }
61
62 pub fn generated() -> Self {
64 Self::new(format!("tx-{}", uuid::Uuid::new_v4()))
65 }
66
67 pub fn id(&self) -> &str {
68 &self.transaction_id
69 }
70
71 fn push(
72 mut self,
73 kind: TransactionOperationKind,
74 collection: String,
75 id: String,
76 value: Value,
77 ) -> Self {
78 self.operations.push(TransactionOperation {
79 kind,
80 collection,
81 id,
82 value,
83 if_version: None,
84 });
85 self
86 }
87
88 pub fn insert(
90 self,
91 collection: impl Into<String>,
92 id: impl Into<String>,
93 value: Value,
94 ) -> Self {
95 self.push(
96 TransactionOperationKind::Insert,
97 collection.into(),
98 id.into(),
99 value,
100 )
101 }
102
103 pub fn update(
105 self,
106 collection: impl Into<String>,
107 id: impl Into<String>,
108 value: Value,
109 ) -> Self {
110 self.push(
111 TransactionOperationKind::Update,
112 collection.into(),
113 id.into(),
114 value,
115 )
116 }
117
118 pub fn delete(self, collection: impl Into<String>, id: impl Into<String>) -> Self {
119 self.push(
120 TransactionOperationKind::Delete,
121 collection.into(),
122 id.into(),
123 Value::Null,
124 )
125 }
126
127 pub fn precondition(mut self, precondition: TransactionPrecondition) -> Self {
142 self.preconditions.push(precondition);
143 self
144 }
145
146 pub fn expect_version(
148 self,
149 collection: impl Into<String>,
150 id: impl Into<String>,
151 version: u64,
152 ) -> Self {
153 self.precondition(TransactionPrecondition::Record {
154 collection: collection.into(),
155 id: id.into(),
156 require_absent: false,
157 expected_version: Some(version),
158 expected_epoch: None,
159 expected_lease_id: None,
160 })
161 }
162
163 pub fn expect_absent(self, collection: impl Into<String>, id: impl Into<String>) -> Self {
165 self.precondition(TransactionPrecondition::Record {
166 collection: collection.into(),
167 id: id.into(),
168 require_absent: true,
169 expected_version: None,
170 expected_epoch: None,
171 expected_lease_id: None,
172 })
173 }
174
175 pub fn fence(mut self, basis: ReadBasis) -> Self {
177 self.fences.push(basis);
178 self
179 }
180}
181
182#[derive(Debug, Clone, Deserialize)]
184pub struct AppCommit {
185 #[serde(flatten)]
186 pub result: TransactionResult,
187 pub revision_id: String,
188 #[serde(default)]
189 pub operation_ids: Vec<String>,
190}
191
192#[derive(Debug, Clone, PartialEq, Deserialize)]
194pub struct ReplayPage {
195 pub contract_version: String,
196 pub cursor_format: String,
197 pub application_id: String,
198 pub environment: String,
199 pub events: Vec<Value>,
201 pub next_cursor: String,
203}
204
205#[derive(Debug, Clone)]
207pub struct Application {
208 client: Client,
209 application_id: String,
210 environment: String,
211 revision: Arc<Mutex<Option<String>>>,
213}
214
215#[derive(Serialize)]
216struct Envelope<'a, T: Serialize> {
217 application_id: &'a str,
218 revision_id: &'a str,
219 environment: &'a str,
220 #[serde(flatten)]
221 payload: T,
222}
223
224impl Application {
225 pub(crate) fn new(client: Client, application_id: String) -> Self {
226 Self {
227 client,
228 application_id,
229 environment: "production".into(),
230 revision: Arc::default(),
231 }
232 }
233
234 pub fn environment(mut self, environment: impl Into<String>) -> Self {
236 self.environment = environment.into();
237 self.revision = Arc::default();
238 self
239 }
240
241 pub fn id(&self) -> &str {
242 &self.application_id
243 }
244
245 pub fn environment_name(&self) -> &str {
246 &self.environment
247 }
248
249 fn scoped(&self, path: &str) -> String {
250 let separator = if path.contains('?') { '&' } else { '?' };
251 format!(
252 "/v1{path}{separator}application_id={}&environment={}",
253 encode(&self.application_id),
254 encode(&self.environment)
255 )
256 }
257
258 pub async fn discover(&self) -> Result<ApplicationInfo> {
260 let info: ApplicationInfo = self
261 .client
262 .send::<_, ()>(Method::GET, &self.scoped("/application"), None)
263 .await?;
264 *self
265 .revision
266 .lock()
267 .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(info.revision_id.clone());
268 Ok(info)
269 }
270
271 pub async fn revision_id(&self) -> Result<String> {
274 let cached = self
275 .revision
276 .lock()
277 .unwrap_or_else(|poisoned| poisoned.into_inner())
278 .clone();
279 match cached {
280 Some(revision) => Ok(revision),
281 None => Ok(self.discover().await?.revision_id),
282 }
283 }
284
285 async fn canonical<T: serde::de::DeserializeOwned>(
286 &self,
287 path: &str,
288 payload: impl Serialize,
289 ) -> Result<T> {
290 let revision_id = self.revision_id().await?;
291 let body = Envelope {
292 application_id: &self.application_id,
293 revision_id: &revision_id,
294 environment: &self.environment,
295 payload,
296 };
297 self.client
298 .send(Method::POST, &self.scoped(path), Some(&body))
299 .await
300 }
301
302 pub async fn query(&self, query: &CanonicalQuery) -> Result<QueryResult> {
304 self.canonical("/query", json!({ "query": query })).await
305 }
306
307 pub async fn commit(&self, transaction: &AppTransaction) -> Result<AppCommit> {
309 if transaction.operations.is_empty() {
310 return Err(Error::InvalidRequest(
311 "a transaction requires at least one operation".into(),
312 ));
313 }
314 let revision_id = self.revision_id().await?;
315 let tenant_id = match &self.client.inner.scope {
319 Some(crate::AuthorityScope::Tenant { tenant_id, .. }) => tenant_id.clone(),
320 _ => String::new(),
321 };
322 let request = TransactionRequest {
323 transaction_id: Some(transaction.transaction_id.clone()),
324 tenant_id: tenant_id.clone(),
325 application_id: self.application_id.clone(),
326 revision_id: revision_id.clone(),
327 schema_version: 0,
328 state_namespace: None,
329 causal_parent: None,
330 authorization: AuthorizationContext {
331 subject: String::new(),
332 tenant_id,
333 application_id: self.application_id.clone(),
334 revision_id,
335 capabilities: BTreeSet::new(),
336 readable_collections: BTreeSet::new(),
337 writable_collections: BTreeSet::new(),
338 field_projections: Default::default(),
339 },
340 operations: transaction.operations.clone(),
341 preconditions: transaction.preconditions.clone(),
342 fences: transaction.fences.clone(),
343 };
344 self.canonical("/transactions", json!({ "transaction": request }))
345 .await
346 }
347
348 pub async fn read_basis(&self, reads: &ReadSet) -> Result<ReadBasis> {
350 self.canonical("/transactions/read-basis", json!({ "reads": reads }))
351 .await
352 }
353
354 pub async fn events(&self, cursor: Option<&str>) -> Result<EventStream> {
356 self.client
357 .open_events(&self.scoped("/events"), cursor)
358 .await
359 }
360
361 pub async fn replay(&self, cursor: Option<&str>, limit: Option<usize>) -> Result<ReplayPage> {
364 let mut path = self.scoped("/public/events/replay");
365 if let Some(cursor) = cursor {
366 path.push_str(&format!("&cursor={}", encode(cursor)));
367 }
368 if let Some(limit) = limit {
369 path.push_str(&format!("&limit={limit}"));
370 }
371 self.client.send::<_, ()>(Method::GET, &path, None).await
372 }
373
374 pub async fn provenance(&self, collection: &str, id: &str) -> Result<Value> {
376 let path = self.scoped(&format!(
377 "/control-plane/provenance/{}/{}",
378 encode(collection),
379 encode(id)
380 ));
381 self.client.send::<_, ()>(Method::GET, &path, None).await
382 }
383
384 pub async fn sign_in(&self, email: &str, password: &str) -> Result<Value> {
387 let body =
388 json!({ "application_id": self.application_id, "email": email, "password": password });
389 self.authenticate("/v1/auth/signin", &body).await
390 }
391
392 pub async fn sign_up(
394 &self,
395 email: &str,
396 password: &str,
397 display_name: Option<&str>,
398 ) -> Result<Value> {
399 let body = json!({
400 "application_id": self.application_id,
401 "email": email,
402 "password": password,
403 "display_name": display_name,
404 });
405 self.authenticate("/v1/auth/signup", &body).await
406 }
407
408 async fn authenticate(&self, path: &str, body: &Value) -> Result<Value> {
409 let session: Value = self.client.send(Method::POST, path, Some(body)).await?;
410 if let Some(token) = session.get("access_token").and_then(Value::as_str) {
411 self.client.set_token(token);
412 }
413 Ok(session)
414 }
415
416 pub async fn session(&self) -> Result<Value> {
418 self.client
419 .send::<_, ()>(Method::GET, "/v1/auth/session", None)
420 .await
421 }
422
423 pub async fn sign_out(&self) -> Result<()> {
425 self.client
426 .exchange::<()>(Method::POST, "/v1/auth/signout", None)
427 .await?;
428 self.client.set_token("");
429 Ok(())
430 }
431}
432
433impl Client {
434 pub async fn capabilities(&self) -> Result<Value> {
436 self.send::<_, ()>(Method::GET, "/v1/control-plane/capabilities", None)
437 .await
438 }
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444
445 #[test]
446 fn scoped_paths_carry_the_application_and_environment() {
447 let app = Client::builder("http://localhost:1")
448 .build()
449 .unwrap()
450 .application("app 1")
451 .environment("staging");
452 assert_eq!(
453 app.scoped("/application"),
454 "/v1/application?application_id=app%201&environment=staging"
455 );
456 assert_eq!(
457 app.scoped("/x?y=1"),
458 "/v1/x?y=1&application_id=app%201&environment=staging"
459 );
460 }
461}