1mod application;
62mod authority;
63mod control;
64mod error;
65mod events;
66
67use std::{
68 sync::{Arc, RwLock},
69 time::Duration,
70};
71
72use serde::{de::DeserializeOwned, Serialize};
73use serde_json::Value;
74
75pub use application::{
76 AppCommit, AppTransaction, Application, ApplicationInfo, ReplayPage, StateVersion,
77};
78pub use authority::{
79 BoundedQuery, CasConflict, CasOutcome, CasRequest, CommitReceipt, Condition, Direction,
80 Operation, Precondition, QueryPage, Record, Revision, Transaction,
81};
82pub use control::{ApplicationRecord, Control, Health, Promotion, Tenant};
83pub use error::{ApiError, Error, Result};
84pub use events::{EventStream, ServerEvent};
85pub use feltdb;
86pub use feltdb::{parse_version_token, HistoryOrigin, VersionHistory};
87pub use feltdb::{
88 PreconditionFailure, ReadBasis, ReadCondition, ReadPredicate, ReadRecord, ReadSet,
89 TransactionStatus,
90};
91pub use reqwest::Method;
92
93pub fn version_token(history: &str, version: u64) -> String {
99 format!("{}:{history}:{version}", feltdb::VERSION_TOKEN_PREFIX)
100}
101
102pub mod wire {
104 pub use feltdb::state_contract::{
105 AggregateDefinition, AggregateFunction, CanonicalQuery, QueryFilter, QueryOrder,
106 QueryReference, QueryResult, SortDirection, TransactionOperation, TransactionOperationKind,
107 TransactionPrecondition, TransactionResult,
108 };
109}
110
111const PROTOCOL_VERSION: &str = "1";
113const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
114
115#[derive(Debug, Clone, PartialEq, Eq)]
117enum AuthorityScope {
118 Tenant {
119 tenant_id: String,
120 environment_id: Option<String>,
121 },
122 Platform,
123}
124
125#[derive(Debug, Clone)]
127pub struct ClientBuilder {
128 url: String,
129 token: Option<String>,
130 scope: Option<AuthorityScope>,
131 timeout: Duration,
132 root_certificates: Vec<Vec<u8>>,
133}
134
135impl ClientBuilder {
136 pub fn token(mut self, token: impl Into<String>) -> Self {
139 let token = token.into();
140 self.token = (!token.is_empty()).then_some(token);
141 self
142 }
143
144 pub fn tenant(mut self, tenant_id: impl Into<String>) -> Self {
147 self.scope = Some(AuthorityScope::Tenant {
148 tenant_id: tenant_id.into(),
149 environment_id: None,
150 });
151 self
152 }
153
154 pub fn tenant_environment(
156 mut self,
157 tenant_id: impl Into<String>,
158 environment_id: impl Into<String>,
159 ) -> Self {
160 self.scope = Some(AuthorityScope::Tenant {
161 tenant_id: tenant_id.into(),
162 environment_id: Some(environment_id.into()),
163 });
164 self
165 }
166
167 pub fn platform(mut self) -> Self {
169 self.scope = Some(AuthorityScope::Platform);
170 self
171 }
172
173 pub fn timeout(mut self, timeout: Duration) -> Self {
176 self.timeout = timeout;
177 self
178 }
179
180 pub fn add_root_certificate_pem(mut self, pem: impl Into<Vec<u8>>) -> Self {
184 self.root_certificates.push(pem.into());
185 self
186 }
187
188 pub fn build(self) -> Result<Client> {
189 let url = self.url.trim_end_matches('/').to_owned();
190 let parsed = reqwest::Url::parse(&url)
191 .map_err(|error| Error::InvalidRequest(format!("server URL {url:?}: {error}")))?;
192 if !matches!(parsed.scheme(), "http" | "https") {
193 return Err(Error::InvalidRequest(format!(
194 "server URL {url:?} must be http or https"
195 )));
196 }
197 let mut http = reqwest::Client::builder();
198 for pem in &self.root_certificates {
199 let certificates = reqwest::Certificate::from_pem_bundle(pem)
202 .ok()
203 .filter(|certificates| !certificates.is_empty())
204 .ok_or_else(|| {
205 Error::InvalidRequest("root certificate is not a PEM certificate".into())
206 })?;
207 for certificate in certificates {
208 http = http.add_root_certificate(certificate);
209 }
210 }
211 let http = http
212 .build()
213 .map_err(|source| Error::transport(source, false))?;
214 Ok(Client {
215 inner: Arc::new(Inner {
216 http,
217 url,
218 token: RwLock::new(self.token),
219 scope: self.scope,
220 timeout: self.timeout,
221 }),
222 })
223 }
224}
225
226struct Inner {
227 http: reqwest::Client,
228 url: String,
229 token: RwLock<Option<String>>,
230 scope: Option<AuthorityScope>,
231 timeout: Duration,
232}
233
234#[derive(Clone)]
236pub struct Client {
237 inner: Arc<Inner>,
238}
239
240impl std::fmt::Debug for Client {
241 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242 f.debug_struct("Client")
243 .field("url", &self.inner.url)
244 .field("scope", &self.inner.scope)
245 .finish_non_exhaustive()
246 }
247}
248
249impl Client {
250 pub fn builder(url: impl Into<String>) -> ClientBuilder {
252 ClientBuilder {
253 url: url.into(),
254 token: None,
255 scope: None,
256 timeout: DEFAULT_TIMEOUT,
257 root_certificates: Vec::new(),
258 }
259 }
260
261 pub fn url(&self) -> &str {
263 &self.inner.url
264 }
265
266 pub fn set_token(&self, token: impl Into<String>) {
268 let token = token.into();
269 *self
270 .inner
271 .token
272 .write()
273 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
274 (!token.is_empty()).then_some(token);
275 }
276
277 pub fn application(&self, application_id: impl Into<String>) -> Application {
280 Application::new(self.clone(), application_id.into())
281 }
282
283 pub async fn request<B: Serialize + ?Sized>(
286 &self,
287 method: Method,
288 path: &str,
289 body: Option<&B>,
290 ) -> Result<Value> {
291 self.send(method, path, body).await
292 }
293
294 pub async fn request_with_headers<B: Serialize + ?Sized>(
296 &self,
297 method: Method,
298 path: &str,
299 headers: &[(&str, &str)],
300 body: Option<&B>,
301 ) -> Result<Value> {
302 self.send_with_headers(method, path, headers, body).await
303 }
304
305 async fn send_with_headers<T: DeserializeOwned, B: Serialize + ?Sized>(
306 &self,
307 method: Method,
308 path: &str,
309 headers: &[(&str, &str)],
310 body: Option<&B>,
311 ) -> Result<T> {
312 let mutating = !matches!(method, Method::GET | Method::HEAD | Method::OPTIONS);
313 let mut builder = self.builder_for(method, path).timeout(self.inner.timeout);
314 for (name, value) in headers {
315 builder = builder.header(*name, *value);
316 }
317 if let Some(body) = body {
318 builder = builder.json(body);
319 }
320 let response = self.respond(builder, mutating).await?;
321 decode(response, mutating).await
322 }
323
324 fn builder_for(&self, method: Method, path: &str) -> reqwest::RequestBuilder {
325 let mut builder = self
326 .inner
327 .http
328 .request(method, format!("{}{path}", self.inner.url))
329 .header("FeltDB-Protocol", PROTOCOL_VERSION);
330 if let Some(token) = self
331 .inner
332 .token
333 .read()
334 .unwrap_or_else(|poisoned| poisoned.into_inner())
335 .as_deref()
336 {
337 builder = builder.bearer_auth(token);
338 }
339 match &self.inner.scope {
340 Some(AuthorityScope::Tenant {
341 tenant_id,
342 environment_id,
343 }) => {
344 builder = builder.header("FeltDB-Tenant-Id", tenant_id);
345 if let Some(environment_id) = environment_id {
346 builder = builder.header("FeltDB-Environment-Id", environment_id);
347 }
348 }
349 Some(AuthorityScope::Platform) => {
350 builder = builder.header("FeltDB-Authority-Mode", "platform");
351 }
352 None => {}
353 }
354 builder
355 }
356
357 async fn exchange<B: Serialize + ?Sized>(
359 &self,
360 method: Method,
361 path: &str,
362 body: Option<&B>,
363 ) -> Result<reqwest::Response> {
364 let mutating = !matches!(method, Method::GET | Method::HEAD | Method::OPTIONS);
365 let mut builder = self.builder_for(method, path).timeout(self.inner.timeout);
366 if let Some(body) = body {
367 builder = builder.json(body);
368 }
369 self.respond(builder, mutating).await
370 }
371
372 async fn respond(
373 &self,
374 builder: reqwest::RequestBuilder,
375 mutating: bool,
376 ) -> Result<reqwest::Response> {
377 let response = builder
378 .send()
379 .await
380 .map_err(|source| Error::transport(source, mutating))?;
381 if response.status().is_success() {
382 return Ok(response);
383 }
384 let status = response.status().as_u16();
385 let text = response
386 .text()
387 .await
388 .map_err(|source| Error::transport(source, mutating))?;
389 Err(Error::Api(Box::new(ApiError::from_body(status, &text))))
390 }
391
392 async fn send<T: DeserializeOwned, B: Serialize + ?Sized>(
393 &self,
394 method: Method,
395 path: &str,
396 body: Option<&B>,
397 ) -> Result<T> {
398 let mutating = !matches!(method, Method::GET | Method::HEAD | Method::OPTIONS);
399 let response = self.exchange(method, path, body).await?;
400 decode(response, mutating).await
401 }
402
403 async fn open_events(&self, path: &str, cursor: Option<&str>) -> Result<EventStream> {
404 let mut builder = self.builder_for(Method::GET, path);
405 if let Some(cursor) = cursor {
406 builder = builder.header("Last-Event-ID", cursor);
407 }
408 let response = builder
409 .send()
410 .await
411 .map_err(|source| Error::transport(source, false))?;
412 if !response.status().is_success() {
413 let status = response.status().as_u16();
414 let text = response
415 .text()
416 .await
417 .map_err(|source| Error::transport(source, false))?;
418 return Err(Error::Api(Box::new(ApiError::from_body(status, &text))));
419 }
420 Ok(EventStream::new(response, cursor.map(str::to_owned)))
421 }
422}
423
424async fn decode<T: DeserializeOwned>(response: reqwest::Response, mutating: bool) -> Result<T> {
425 let status = response.status().as_u16();
426 let bytes = response
427 .bytes()
428 .await
429 .map_err(|source| Error::transport(source, mutating))?;
430 let bytes: &[u8] = if bytes.is_empty() { b"null" } else { &bytes };
431 serde_json::from_slice(bytes).map_err(|source| Error::Decode { status, source })
432}
433
434pub fn path_segment(value: &str) -> String {
437 encode(value)
438}
439
440fn encode(value: &str) -> String {
441 let mut out = String::with_capacity(value.len());
442 for byte in value.bytes() {
443 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
444 out.push(byte as char);
445 } else {
446 out.push_str(&format!("%{byte:02X}"));
447 }
448 }
449 out
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455
456 #[test]
457 fn path_segments_are_encoded() {
458 assert_eq!(encode("o-1"), "o-1");
459 assert_eq!(encode("a/b c:é"), "a%2Fb%20c%3A%C3%A9");
460 }
461
462 #[test]
463 fn a_url_that_is_not_http_is_refused() {
464 assert!(matches!(
465 Client::builder("ftp://example.com").build(),
466 Err(Error::InvalidRequest(_))
467 ));
468 assert_eq!(
469 Client::builder("http://127.0.0.1:7700/")
470 .build()
471 .unwrap()
472 .url(),
473 "http://127.0.0.1:7700"
474 );
475 }
476}