vortex_sdk/client.rs
1use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
2use hmac::{Hmac, Mac};
3use reqwest::Client as HttpClient;
4use serde_json::json;
5use sha2::Sha256;
6use std::collections::HashMap;
7use std::time::{SystemTime, UNIX_EPOCH};
8use uuid::Uuid;
9
10use crate::error::VortexError;
11use crate::types::*;
12
13type HmacSha256 = Hmac<Sha256>;
14
15/// Vortex Rust SDK Client
16///
17/// Provides JWT generation and Vortex API integration for Rust applications.
18/// Compatible with React providers and follows the same paradigms as other Vortex SDKs.
19pub struct VortexClient {
20 api_key: String,
21 base_url: String,
22 http_client: HttpClient,
23}
24
25impl VortexClient {
26 /// Create a new Vortex client
27 ///
28 /// # Arguments
29 ///
30 /// * `api_key` - Your Vortex API key
31 ///
32 /// # Example
33 ///
34 /// ```
35 /// use vortex_sdk::VortexClient;
36 ///
37 /// let api_key = "VRTX.your_encoded_id.your_key".to_string();
38 /// let client = VortexClient::new(api_key);
39 /// ```
40 pub fn new(api_key: String) -> Self {
41 let base_url = std::env::var("VORTEX_API_BASE_URL")
42 .unwrap_or_else(|_| "https://api.vortexsoftware.com".to_string());
43
44 Self {
45 api_key,
46 base_url,
47 http_client: HttpClient::new(),
48 }
49 }
50
51 /// Create a new Vortex client with a custom base URL
52 ///
53 /// # Arguments
54 ///
55 /// * `api_key` - Your Vortex API key
56 /// * `base_url` - Custom base URL for the Vortex API
57 pub fn with_base_url(api_key: String, base_url: String) -> Self {
58 Self {
59 api_key,
60 base_url,
61 http_client: HttpClient::new(),
62 }
63 }
64
65 /// Generate a JWT token for a user
66 ///
67 /// # Arguments
68 ///
69 /// * `user` - User object with id, email, and optional fields:
70 /// - name: user's display name (max 200 characters)
71 /// - avatar_url: user's avatar URL (must be HTTPS, max 2000 characters)
72 /// - admin_scopes: list of admin scopes (e.g., vec!["autojoin"])
73 /// * `extra` - Optional additional properties to include in the JWT payload
74 ///
75 /// # Example
76 ///
77 /// ```
78 /// use vortex_sdk::{VortexClient, User};
79 /// use std::collections::HashMap;
80 ///
81 /// let client = VortexClient::new("VRTX.AAAAAAAAAAAAAAAAAAAAAA.test_secret_key".to_string());
82 ///
83 /// // Simple usage
84 /// let user = User::new("user-123", "user@example.com")
85 /// .with_user_name("Jane Doe") // Optional: user's display name
86 /// .with_user_avatar_url("https://example.com/avatars/jane.jpg") // Optional: user's avatar URL
87 /// .with_admin_scopes(vec!["autojoin".to_string()]); // Optional: grants admin privileges
88 /// let jwt = client.generate_jwt(&user, None).unwrap();
89 ///
90 /// // With additional properties
91 /// let mut extra = HashMap::new();
92 /// extra.insert("role".to_string(), serde_json::json!("admin"));
93 /// let jwt = client.generate_jwt(&user, Some(extra)).unwrap();
94 /// ```
95 pub fn generate_jwt(
96 &self,
97 user: &User,
98 extra: Option<HashMap<String, serde_json::Value>>,
99 ) -> Result<String, VortexError> {
100 // Parse API key: format is VRTX.base64encodedId.key
101 let parts: Vec<&str> = self.api_key.split('.').collect();
102 if parts.len() != 3 {
103 return Err(VortexError::InvalidApiKey(
104 "Invalid API key format".to_string(),
105 ));
106 }
107
108 let prefix = parts[0];
109 let encoded_id = parts[1];
110 let key = parts[2];
111
112 if prefix != "VRTX" {
113 return Err(VortexError::InvalidApiKey(
114 "Invalid API key prefix".to_string(),
115 ));
116 }
117
118 // Decode the UUID from base64url
119 let id_bytes = URL_SAFE_NO_PAD
120 .decode(encoded_id)
121 .map_err(|e| VortexError::InvalidApiKey(format!("Failed to decode ID: {}", e)))?;
122
123 if id_bytes.len() != 16 {
124 return Err(VortexError::InvalidApiKey("ID must be 16 bytes".to_string()));
125 }
126
127 let uuid = Uuid::from_slice(&id_bytes)
128 .map_err(|e| VortexError::InvalidApiKey(format!("Invalid UUID: {}", e)))?;
129 let uuid_str = uuid.to_string();
130
131 let now = SystemTime::now()
132 .duration_since(UNIX_EPOCH)
133 .unwrap()
134 .as_secs();
135 let expires = now + 3600; // 1 hour from now
136
137 // Step 1: Derive signing key from API key + ID
138 let mut hmac = HmacSha256::new_from_slice(key.as_bytes())
139 .map_err(|e| VortexError::CryptoError(format!("HMAC error: {}", e)))?;
140 hmac.update(uuid_str.as_bytes());
141 let signing_key = hmac.finalize().into_bytes();
142
143 // Step 2: Build header + payload
144 let header = json!({
145 "iat": now,
146 "alg": "HS256",
147 "typ": "JWT",
148 "kid": uuid_str,
149 });
150
151 // Build payload with user data
152 let mut payload_json = json!({
153 "userId": user.id,
154 "userEmail": user.email,
155 "expires": expires,
156 });
157
158 // Add name if present
159 if let Some(ref user_name) = user.user_name {
160 payload_json["userName"] = json!(user_name);
161 }
162
163 // Add userAvatarUrl if present
164 if let Some(ref user_avatar_url) = user.user_avatar_url {
165 payload_json["userAvatarUrl"] = json!(user_avatar_url);
166 }
167
168 // Add adminScopes if present
169 if let Some(ref scopes) = user.admin_scopes {
170 payload_json["adminScopes"] = json!(scopes);
171 }
172
173 // Add allowedEmailDomains if present (for domain-restricted invitations)
174 if let Some(ref domains) = user.allowed_email_domains {
175 if !domains.is_empty() {
176 payload_json["allowedEmailDomains"] = json!(domains);
177 }
178 }
179
180 // Add any additional properties from extra parameter
181 if let Some(extra_props) = extra {
182 for (key, value) in extra_props {
183 payload_json[key] = value;
184 }
185 }
186
187 // Step 3: Base64URL encode header and payload
188 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
189 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload_json).unwrap());
190
191 // Step 4: Sign with HMAC-SHA256
192 let to_sign = format!("{}.{}", header_b64, payload_b64);
193 let mut sig_hmac = HmacSha256::new_from_slice(&signing_key)
194 .map_err(|e| VortexError::CryptoError(format!("HMAC error: {}", e)))?;
195 sig_hmac.update(to_sign.as_bytes());
196 let signature = sig_hmac.finalize().into_bytes();
197 let sig_b64 = URL_SAFE_NO_PAD.encode(&signature);
198
199 Ok(format!("{}.{}.{}", header_b64, payload_b64, sig_b64))
200 }
201
202 /// Get invitations by target (email or sms)
203 pub async fn get_invitations_by_target(
204 &self,
205 target_type: &str,
206 target_value: &str,
207 ) -> Result<Vec<Invitation>, VortexError> {
208 let mut params = HashMap::new();
209 params.insert("targetType", target_type);
210 params.insert("targetValue", target_value);
211
212 let response: InvitationsResponse = self
213 .api_request("GET", "/api/v1/invitations", None::<&()>, Some(params))
214 .await?;
215
216 Ok(response.invitations.unwrap_or_default())
217 }
218
219 /// Get a specific invitation by ID
220 pub async fn get_invitation(&self, invitation_id: &str) -> Result<Invitation, VortexError> {
221 self.api_request(
222 "GET",
223 &format!("/api/v1/invitations/{}", invitation_id),
224 None::<&()>,
225 None,
226 )
227 .await
228 }
229
230 /// Revoke (delete) an invitation
231 pub async fn revoke_invitation(&self, invitation_id: &str) -> Result<(), VortexError> {
232 self.api_request::<(), ()>(
233 "DELETE",
234 &format!("/api/v1/invitations/{}", invitation_id),
235 None,
236 None,
237 )
238 .await?;
239 Ok(())
240 }
241
242 /// Accept multiple invitations
243 ///
244 /// # Arguments
245 ///
246 /// * `invitation_ids` - Vector of invitation IDs to accept
247 /// * `param` - User data (preferred) or legacy target format
248 ///
249 /// # New User Format (Preferred)
250 ///
251 /// ```
252 /// use vortex_sdk::{VortexClient, AcceptUser};
253 ///
254 /// # async fn example() {
255 /// let client = VortexClient::new("VRTX.key.secret".to_string());
256 /// let user = AcceptUser::new().with_email("user@example.com");
257 /// let result = client.accept_invitations(vec!["inv-123".to_string()], user).await;
258 /// # }
259 /// ```
260 ///
261 /// # Legacy Target Format (Deprecated)
262 ///
263 /// ```
264 /// use vortex_sdk::{VortexClient, InvitationTarget};
265 ///
266 /// # async fn example() {
267 /// let client = VortexClient::new("VRTX.key.secret".to_string());
268 /// let target = InvitationTarget::email("user@example.com");
269 /// let result = client.accept_invitations(vec!["inv-123".to_string()], target).await;
270 /// # }
271 /// ```
272 pub async fn accept_invitations(
273 &self,
274 invitation_ids: Vec<String>,
275 param: impl Into<crate::types::AcceptInvitationParam>,
276 ) -> Result<Invitation, VortexError> {
277 use crate::types::{AcceptInvitationParam, AcceptUser};
278
279 let param = param.into();
280
281 // Convert all parameter types to User format to avoid async recursion
282 let user = match param {
283 AcceptInvitationParam::Targets(targets) => {
284 eprintln!("[Vortex SDK] DEPRECATED: Passing a vector of targets is deprecated. Use the AcceptUser format and call once per user instead.");
285
286 if targets.is_empty() {
287 return Err(VortexError::InvalidRequest("No targets provided".to_string()));
288 }
289
290 let mut last_result = None;
291 let mut last_error = None;
292
293 for target in targets {
294 // Convert target to user
295 let user = match target.target_type {
296 InvitationTargetType::Email => AcceptUser::new().with_email(&target.value),
297 InvitationTargetType::Phone => AcceptUser::new().with_phone(&target.value),
298 _ => AcceptUser::new().with_email(&target.value),
299 };
300
301 match Box::pin(self.accept_invitations(invitation_ids.clone(), user)).await {
302 Ok(result) => last_result = Some(result),
303 Err(e) => last_error = Some(e),
304 }
305 }
306
307 if let Some(err) = last_error {
308 return Err(err);
309 }
310
311 return last_result.ok_or_else(|| VortexError::InvalidRequest("No results".to_string()));
312 }
313 AcceptInvitationParam::Target(target) => {
314 eprintln!("[Vortex SDK] DEPRECATED: Passing an InvitationTarget is deprecated. Use the AcceptUser format instead: AcceptUser::new().with_email(\"user@example.com\")");
315
316 // Convert target to User format
317 match target.target_type {
318 InvitationTargetType::Email => AcceptUser::new().with_email(&target.value),
319 InvitationTargetType::Phone => AcceptUser::new().with_phone(&target.value),
320 _ => AcceptUser::new().with_email(&target.value), // Default to email
321 }
322 }
323 AcceptInvitationParam::User(user) => user,
324 };
325
326 // Validate that either email or phone is provided
327 if user.email.is_none() && user.phone.is_none() {
328 return Err(VortexError::InvalidRequest(
329 "User must have either email or phone".to_string(),
330 ));
331 }
332
333 let body = json!({
334 "invitationIds": invitation_ids,
335 "user": user,
336 });
337
338 self.api_request("POST", "/api/v1/invitations/accept", Some(&body), None)
339 .await
340 }
341
342 /// Accept a single invitation (recommended method)
343 ///
344 /// This is the recommended method for accepting invitations.
345 ///
346 /// # Arguments
347 ///
348 /// * `invitation_id` - Single invitation ID to accept
349 /// * `user` - User object with email and/or phone
350 ///
351 /// # Returns
352 ///
353 /// * `Result<Invitation, VortexError>` - The accepted invitation result
354 ///
355 /// # Example
356 ///
357 /// ```
358 /// use vortex_sdk::{VortexClient, AcceptUser};
359 ///
360 /// # async fn example() {
361 /// let client = VortexClient::new("VRTX.key.secret".to_string());
362 /// let user = AcceptUser::new().with_email("user@example.com");
363 /// let result = client.accept_invitation("inv-123", user).await;
364 /// # }
365 /// ```
366 pub async fn accept_invitation(
367 &self,
368 invitation_id: &str,
369 user: crate::types::AcceptUser,
370 ) -> Result<Invitation, VortexError> {
371 self.accept_invitations(vec![invitation_id.to_string()], user).await
372 }
373
374 /// Delete all invitations for a specific group
375 pub async fn delete_invitations_by_group(
376 &self,
377 group_type: &str,
378 group_id: &str,
379 ) -> Result<(), VortexError> {
380 self.api_request::<(), ()>(
381 "DELETE",
382 &format!("/api/v1/invitations/by-group/{}/{}", group_type, group_id),
383 None,
384 None,
385 )
386 .await?;
387 Ok(())
388 }
389
390 /// Get all invitations for a specific group
391 pub async fn get_invitations_by_group(
392 &self,
393 group_type: &str,
394 group_id: &str,
395 ) -> Result<Vec<Invitation>, VortexError> {
396 let response: InvitationsResponse = self
397 .api_request(
398 "GET",
399 &format!("/api/v1/invitations/by-group/{}/{}", group_type, group_id),
400 None::<&()>,
401 None,
402 )
403 .await?;
404
405 Ok(response.invitations.unwrap_or_default())
406 }
407
408 /// Reinvite a user (send invitation again)
409 pub async fn reinvite(&self, invitation_id: &str) -> Result<Invitation, VortexError> {
410 self.api_request(
411 "POST",
412 &format!("/api/v1/invitations/{}/reinvite", invitation_id),
413 None::<&()>,
414 None,
415 )
416 .await
417 }
418
419 /// Create an invitation from your backend
420 ///
421 /// This method allows you to create invitations programmatically using your API key,
422 /// without requiring a user JWT token. Useful for server-side invitation creation,
423 /// such as "People You May Know" flows or admin-initiated invitations.
424 ///
425 /// # Target types
426 ///
427 /// - `email`: Send an email invitation
428 /// - `sms`: Create an SMS invitation (short link returned for you to send)
429 /// - `internal`: Create an internal invitation for PYMK flows (no email sent)
430 ///
431 /// # Example
432 ///
433 /// ```no_run
434 /// use vortex_sdk::{VortexClient, CreateInvitationRequest, CreateInvitationTarget, Inviter, CreateInvitationGroup};
435 ///
436 /// #[tokio::main]
437 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
438 /// let client = VortexClient::new("VRTX.xxx.yyy".to_string());
439 ///
440 /// // Create an email invitation
441 /// let request = CreateInvitationRequest::new(
442 /// "widget-config-123",
443 /// CreateInvitationTarget::email("invitee@example.com"),
444 /// Inviter::new("user-456")
445 /// .with_email("inviter@example.com")
446 /// .with_user_name("John Doe"),
447 /// )
448 /// .with_groups(vec![
449 /// CreateInvitationGroup::new("team", "team-789", "Engineering"),
450 /// ]);
451 ///
452 /// let result = client.create_invitation(&request).await?;
453 ///
454 /// // Create an internal invitation (PYMK flow - no email sent)
455 /// let request = CreateInvitationRequest::new(
456 /// "widget-config-123",
457 /// CreateInvitationTarget::internal("internal-user-abc"),
458 /// Inviter::new("user-456"),
459 /// )
460 /// .with_source("pymk");
461 ///
462 /// let result = client.create_invitation(&request).await?;
463 /// Ok(())
464 /// }
465 /// ```
466 pub async fn create_invitation(
467 &self,
468 request: &CreateInvitationRequest,
469 ) -> Result<CreateInvitationResponse, VortexError> {
470 self.api_request("POST", "/api/v1/invitations", Some(request), None)
471 .await
472 }
473
474 /// Get autojoin domains configured for a specific scope
475 ///
476 /// # Arguments
477 ///
478 /// * `scope_type` - The type of scope (e.g., "organization", "team", "project")
479 /// * `scope` - The scope identifier (customer's group ID)
480 ///
481 /// # Returns
482 ///
483 /// AutojoinDomainsResponse with autojoin domains and associated invitation
484 ///
485 /// # Example
486 ///
487 /// ```no_run
488 /// use vortex_sdk::VortexClient;
489 ///
490 /// #[tokio::main]
491 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
492 /// let client = VortexClient::new("VRTX.your_key_here".to_string());
493 ///
494 /// let result = client.get_autojoin_domains("organization", "acme-org").await?;
495 /// for domain in &result.autojoin_domains {
496 /// println!("Domain: {}", domain.domain);
497 /// }
498 /// Ok(())
499 /// }
500 /// ```
501 pub async fn get_autojoin_domains(
502 &self,
503 scope_type: &str,
504 scope: &str,
505 ) -> Result<AutojoinDomainsResponse, VortexError> {
506 let encoded_scope_type = urlencoding::encode(scope_type);
507 let encoded_scope = urlencoding::encode(scope);
508 let path = format!(
509 "/api/v1/invitations/by-scope/{}/{}/autojoin",
510 encoded_scope_type, encoded_scope
511 );
512 self.api_request::<AutojoinDomainsResponse, ()>("GET", &path, None, None)
513 .await
514 }
515
516 /// Configure autojoin domains for a specific scope
517 ///
518 /// This endpoint syncs autojoin domains - it will add new domains, remove domains
519 /// not in the provided list, and deactivate the autojoin invitation if all domains
520 /// are removed (empty array).
521 ///
522 /// # Arguments
523 ///
524 /// * `request` - The configure autojoin request
525 ///
526 /// # Returns
527 ///
528 /// AutojoinDomainsResponse with updated autojoin domains and associated invitation
529 ///
530 /// # Example
531 ///
532 /// ```no_run
533 /// use vortex_sdk::{VortexClient, ConfigureAutojoinRequest};
534 ///
535 /// #[tokio::main]
536 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
537 /// let client = VortexClient::new("VRTX.your_key_here".to_string());
538 ///
539 /// let request = ConfigureAutojoinRequest::new(
540 /// "acme-org",
541 /// "organization",
542 /// vec!["acme.com".to_string(), "acme.org".to_string()],
543 /// "widget-123",
544 /// )
545 /// .with_scope_name("Acme Corporation");
546 ///
547 /// let result = client.configure_autojoin(&request).await?;
548 /// Ok(())
549 /// }
550 /// ```
551 ///
552 /// Sync an internal invitation action (accept or decline)
553 ///
554 /// This method notifies Vortex that an internal invitation was accepted or declined
555 /// within your application, so Vortex can update the invitation status accordingly.
556 ///
557 /// # Example
558 ///
559 /// ```no_run
560 /// use vortex_sdk::{VortexClient, SyncInternalInvitationRequest};
561 ///
562 /// #[tokio::main]
563 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
564 /// let client = VortexClient::new("VRTX.xxx.yyy".to_string());
565 /// let request = SyncInternalInvitationRequest::new(
566 /// "user-123", "user-456", "accepted", "component-uuid",
567 /// );
568 /// let result = client.sync_internal_invitation(&request).await?;
569 /// println!("Processed {} invitations", result.processed);
570 /// Ok(())
571 /// }
572 /// ```
573 pub async fn sync_internal_invitation(
574 &self,
575 request: &SyncInternalInvitationRequest,
576 ) -> Result<SyncInternalInvitationResponse, VortexError> {
577 self.api_request(
578 "POST",
579 "/api/v1/invitations/sync-internal-invitation",
580 Some(request),
581 None,
582 )
583 .await
584 }
585
586 pub async fn configure_autojoin(
587 &self,
588 request: &ConfigureAutojoinRequest,
589 ) -> Result<AutojoinDomainsResponse, VortexError> {
590 self.api_request("POST", "/api/v1/invitations/autojoin", Some(request), None)
591 .await
592 }
593
594 async fn api_request<T, B>(
595 &self,
596 method: &str,
597 path: &str,
598 body: Option<&B>,
599 query_params: Option<HashMap<&str, &str>>,
600 ) -> Result<T, VortexError>
601 where
602 T: serde::de::DeserializeOwned,
603 B: serde::Serialize,
604 {
605 let url = format!("{}{}", self.base_url, path);
606
607 let mut request = match method {
608 "GET" => self.http_client.get(&url),
609 "POST" => self.http_client.post(&url),
610 "PUT" => self.http_client.put(&url),
611 "DELETE" => self.http_client.delete(&url),
612 _ => return Err(VortexError::InvalidRequest("Invalid HTTP method".to_string())),
613 };
614
615 // Add headers
616 request = request
617 .header("Content-Type", "application/json")
618 .header("x-api-key", &self.api_key)
619 .header("User-Agent", format!("vortex-rust-sdk/{}", env!("CARGO_PKG_VERSION")))
620 .header("x-vortex-sdk-name", "vortex-rust-sdk")
621 .header("x-vortex-sdk-version", env!("CARGO_PKG_VERSION"));
622
623 // Add query parameters
624 if let Some(params) = query_params {
625 request = request.query(¶ms);
626 }
627
628 // Add body
629 if let Some(b) = body {
630 request = request.json(b);
631 }
632
633 let response = request
634 .send()
635 .await
636 .map_err(|e| VortexError::HttpError(e.to_string()))?;
637
638 if !response.status().is_success() {
639 let status = response.status();
640 let error_text = response
641 .text()
642 .await
643 .unwrap_or_else(|_| "Unknown error".to_string());
644 return Err(VortexError::ApiError(format!(
645 "API request failed: {} - {}",
646 status, error_text
647 )));
648 }
649
650 let text = response
651 .text()
652 .await
653 .map_err(|e| VortexError::HttpError(e.to_string()))?;
654
655 // Handle empty responses
656 if text.is_empty() {
657 return serde_json::from_str("{}")
658 .map_err(|e| VortexError::SerializationError(e.to_string()));
659 }
660
661 serde_json::from_str(&text)
662 .map_err(|e| VortexError::SerializationError(e.to_string()))
663 }
664}