Skip to main content

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(&params);
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}