1use std::collections::HashMap;
4use std::fmt;
5use std::sync::Arc;
6
7use tokio::sync::Mutex;
8
9use crate::error::ClientError;
10use crate::start::{CachedStart, StartFingerprint};
11use crate::transport::{GrpcWorkflowTransport, WorkflowTransport};
12
13#[derive(Clone)]
33pub struct Client {
34 pub(crate) transport: Arc<dyn WorkflowTransport>,
35 pub(crate) config: ClientConfig,
36 idempotent_starts: Arc<Mutex<HashMap<String, CachedStart>>>,
37}
38
39impl Client {
40 #[must_use]
42 pub fn builder(endpoint: impl Into<String>) -> ClientBuilder {
43 ClientBuilder::new(endpoint)
44 }
45
46 pub(crate) fn from_transport(
47 config: ClientConfig,
48 transport: Arc<dyn WorkflowTransport>,
49 ) -> Self {
50 Self {
51 transport,
52 config,
53 idempotent_starts: Arc::new(Mutex::new(HashMap::new())),
54 }
55 }
56
57 #[cfg(feature = "embedded")]
58 #[must_use]
60 pub fn embedded(engine: Arc<aion::Engine>) -> Self {
61 let config = ClientConfig {
62 endpoint: String::from("embedded://engine"),
63 stream_endpoint: None,
64 auth: None,
65 tls: None,
66 namespace: String::from("default"),
67 subject: None,
68 authorized_namespaces: Vec::new(),
69 };
70 Self::from_transport(
71 config,
72 Arc::new(crate::transport::EmbeddedWorkflowTransport::new(engine)),
73 )
74 }
75
76 pub(crate) fn namespace(&self) -> &str {
77 &self.config.namespace
78 }
79
80 pub(crate) async fn cached_start(
88 &self,
89 fingerprint: &StartFingerprint,
90 ) -> Result<Option<CachedStart>, ClientError> {
91 let cache = self.idempotent_starts.lock().await;
92 let Some(cached) = cache.get(fingerprint.key()) else {
93 return Ok(None);
94 };
95 if cached.fingerprint() == fingerprint {
96 Ok(Some(cached.clone()))
97 } else {
98 Err(idempotency_conflict())
99 }
100 }
101
102 pub(crate) async fn record_start(&self, cached: CachedStart) -> Result<(), ClientError> {
103 let mut cache = self.idempotent_starts.lock().await;
104 let key = cached.fingerprint().key().to_owned();
105 match cache.get(&key) {
106 Some(existing) if existing.fingerprint() == cached.fingerprint() => Ok(()),
107 Some(_) => Err(idempotency_conflict()),
108 None => {
109 cache.insert(key, cached);
110 Ok(())
111 }
112 }
113 }
114}
115
116fn idempotency_conflict() -> ClientError {
119 ClientError::already_exists(
120 "idempotency key was already used by a different start request \
121 (namespace, workflow type, or input differ)",
122 )
123}
124
125#[derive(Clone, Debug)]
127pub struct ClientBuilder {
128 endpoint: String,
129 stream_endpoint: Option<String>,
130 auth: Option<ClientAuth>,
131 tls: Option<TlsOptions>,
132 namespace: String,
133 subject: Option<String>,
134 authorized_namespaces: Vec<String>,
135}
136
137impl ClientBuilder {
138 #[must_use]
140 pub fn new(endpoint: impl Into<String>) -> Self {
141 Self {
142 endpoint: endpoint.into(),
143 stream_endpoint: None,
144 auth: None,
145 tls: None,
146 namespace: String::from("default"),
147 subject: None,
148 authorized_namespaces: Vec::new(),
149 }
150 }
151
152 #[must_use]
162 pub fn with_stream_endpoint(mut self, stream_endpoint: impl Into<String>) -> Self {
163 self.stream_endpoint = Some(stream_endpoint.into());
164 self
165 }
166
167 #[must_use]
169 pub fn with_auth(mut self, auth: ClientAuth) -> Self {
170 self.auth = Some(auth);
171 self
172 }
173
174 #[must_use]
176 pub fn with_tls(mut self, tls: TlsOptions) -> Self {
177 self.tls = Some(tls);
178 self
179 }
180
181 #[must_use]
183 pub fn with_namespace(mut self, namespace: impl Into<String>) -> Self {
184 self.namespace = namespace.into();
185 self
186 }
187
188 #[must_use]
190 pub fn with_subject(mut self, subject: impl Into<String>) -> Self {
191 self.subject = Some(subject.into());
192 self
193 }
194
195 #[must_use]
197 pub fn with_authorized_namespaces<I, S>(mut self, namespaces: I) -> Self
198 where
199 I: IntoIterator<Item = S>,
200 S: Into<String>,
201 {
202 self.authorized_namespaces = namespaces.into_iter().map(Into::into).collect();
203 self
204 }
205
206 pub async fn build(self) -> Result<Client, ClientError> {
214 let config = ClientConfig::from(self);
215 let transport = GrpcWorkflowTransport::connect(config.clone()).await?;
216 Ok(Client::from_transport(config, Arc::new(transport)))
217 }
218}
219
220#[derive(Clone, PartialEq, Eq)]
222pub struct ClientAuth {
223 token: String,
224}
225
226impl ClientAuth {
227 #[must_use]
229 pub fn bearer(token: impl Into<String>) -> Self {
230 Self {
231 token: token.into(),
232 }
233 }
234
235 pub(crate) fn token(&self) -> &str {
236 &self.token
237 }
238}
239
240impl fmt::Debug for ClientAuth {
241 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
242 formatter
243 .debug_struct("ClientAuth")
244 .field("token", &"<redacted>")
245 .finish()
246 }
247}
248
249#[derive(Clone, Debug, Default, PartialEq, Eq)]
251pub struct TlsOptions {
252 pub(crate) domain_name: Option<String>,
253 pub(crate) ca_certificate_pem: Option<Vec<u8>>,
254}
255
256impl TlsOptions {
257 #[must_use]
259 pub fn new() -> Self {
260 Self::default()
261 }
262
263 #[must_use]
269 pub fn with_domain_name(mut self, domain_name: impl Into<String>) -> Self {
270 self.domain_name = Some(domain_name.into());
271 self
272 }
273
274 #[must_use]
277 pub fn with_ca_certificate_pem(mut self, ca_certificate_pem: impl Into<Vec<u8>>) -> Self {
278 self.ca_certificate_pem = Some(ca_certificate_pem.into());
279 self
280 }
281}
282
283#[derive(Clone, Debug, PartialEq, Eq)]
285pub struct ClientConfig {
286 pub(crate) endpoint: String,
287 pub(crate) stream_endpoint: Option<String>,
288 pub(crate) auth: Option<ClientAuth>,
289 pub(crate) tls: Option<TlsOptions>,
290 pub(crate) namespace: String,
291 pub(crate) subject: Option<String>,
292 pub(crate) authorized_namespaces: Vec<String>,
293}
294
295impl From<ClientBuilder> for ClientConfig {
296 fn from(builder: ClientBuilder) -> Self {
297 Self {
298 endpoint: builder.endpoint,
299 stream_endpoint: builder.stream_endpoint,
300 auth: builder.auth,
301 tls: builder.tls,
302 namespace: builder.namespace,
303 subject: builder.subject,
304 authorized_namespaces: builder.authorized_namespaces,
305 }
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::{Client, ClientAuth, ClientBuilder, ClientConfig, TlsOptions};
312
313 fn assert_send_sync<T: Send + Sync>() {}
314
315 #[test]
316 fn client_is_clone_send_sync() {
317 assert_send_sync::<Client>();
318 }
319
320 #[test]
321 fn auth_debug_redacts_token() {
322 let auth = ClientAuth::bearer("secret-token");
323 assert_eq!(format!("{auth:?}"), "ClientAuth { token: \"<redacted>\" }");
324 }
325
326 #[test]
327 fn builder_captures_connection_options() {
328 let config = ClientConfig::from(
329 ClientBuilder::new("https://aion.example.com")
330 .with_stream_endpoint("wss://aion-http.example.com/events/stream")
331 .with_auth(ClientAuth::bearer("secret-token"))
332 .with_tls(TlsOptions::new().with_domain_name("aion.example.com"))
333 .with_namespace("tenant-a")
334 .with_subject("alice")
335 .with_authorized_namespaces(["tenant-a", "tenant-b"]),
336 );
337
338 assert_eq!(config.endpoint, "https://aion.example.com");
339 assert_eq!(
340 config.stream_endpoint,
341 Some(String::from("wss://aion-http.example.com/events/stream"))
342 );
343 assert!(config.auth.is_some());
344 assert!(config.tls.is_some());
345 assert_eq!(config.namespace, "tenant-a");
346 assert_eq!(config.subject, Some(String::from("alice")));
347 assert_eq!(
348 config.authorized_namespaces,
349 vec![String::from("tenant-a"), String::from("tenant-b")]
350 );
351 }
352
353 #[test]
354 fn stream_endpoint_has_no_default() {
355 let config = ClientConfig::from(ClientBuilder::new("https://aion.example.com"));
356 assert_eq!(config.stream_endpoint, None);
357 }
358}