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 admin_scopes
70    /// * `extra` - Optional additional properties to include in the JWT payload
71    ///
72    /// # Example
73    ///
74    /// ```
75    /// use vortex_sdk::{VortexClient, User};
76    /// use std::collections::HashMap;
77    ///
78    /// let client = VortexClient::new("VRTX.AAAAAAAAAAAAAAAAAAAAAA.test_secret_key".to_string());
79    ///
80    /// // Simple usage
81    /// let user = User::new("user-123", "user@example.com")
82    ///     .with_admin_scopes(vec!["autojoin".to_string()]);
83    /// let jwt = client.generate_jwt(&user, None).unwrap();
84    ///
85    /// // With additional properties
86    /// let mut extra = HashMap::new();
87    /// extra.insert("role".to_string(), serde_json::json!("admin"));
88    /// let jwt = client.generate_jwt(&user, Some(extra)).unwrap();
89    /// ```
90    pub fn generate_jwt(
91        &self,
92        user: &User,
93        extra: Option<HashMap<String, serde_json::Value>>,
94    ) -> Result<String, VortexError> {
95        // Parse API key: format is VRTX.base64encodedId.key
96        let parts: Vec<&str> = self.api_key.split('.').collect();
97        if parts.len() != 3 {
98            return Err(VortexError::InvalidApiKey(
99                "Invalid API key format".to_string(),
100            ));
101        }
102
103        let prefix = parts[0];
104        let encoded_id = parts[1];
105        let key = parts[2];
106
107        if prefix != "VRTX" {
108            return Err(VortexError::InvalidApiKey(
109                "Invalid API key prefix".to_string(),
110            ));
111        }
112
113        // Decode the UUID from base64url
114        let id_bytes = URL_SAFE_NO_PAD
115            .decode(encoded_id)
116            .map_err(|e| VortexError::InvalidApiKey(format!("Failed to decode ID: {}", e)))?;
117
118        if id_bytes.len() != 16 {
119            return Err(VortexError::InvalidApiKey("ID must be 16 bytes".to_string()));
120        }
121
122        let uuid = Uuid::from_slice(&id_bytes)
123            .map_err(|e| VortexError::InvalidApiKey(format!("Invalid UUID: {}", e)))?;
124        let uuid_str = uuid.to_string();
125
126        let now = SystemTime::now()
127            .duration_since(UNIX_EPOCH)
128            .unwrap()
129            .as_secs();
130        let expires = now + 3600; // 1 hour from now
131
132        // Step 1: Derive signing key from API key + ID
133        let mut hmac = HmacSha256::new_from_slice(key.as_bytes())
134            .map_err(|e| VortexError::CryptoError(format!("HMAC error: {}", e)))?;
135        hmac.update(uuid_str.as_bytes());
136        let signing_key = hmac.finalize().into_bytes();
137
138        // Step 2: Build header + payload
139        let header = json!({
140            "iat": now,
141            "alg": "HS256",
142            "typ": "JWT",
143            "kid": uuid_str,
144        });
145
146        // Build payload with user data
147        let mut payload_json = json!({
148            "userId": user.id,
149            "userEmail": user.email,
150            "expires": expires,
151        });
152
153        // Add adminScopes if present
154        if let Some(ref scopes) = user.admin_scopes {
155            payload_json["adminScopes"] = json!(scopes);
156        }
157
158        // Add any additional properties from extra parameter
159        if let Some(extra_props) = extra {
160            for (key, value) in extra_props {
161                payload_json[key] = value;
162            }
163        }
164
165        // Step 3: Base64URL encode header and payload
166        let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
167        let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload_json).unwrap());
168
169        // Step 4: Sign with HMAC-SHA256
170        let to_sign = format!("{}.{}", header_b64, payload_b64);
171        let mut sig_hmac = HmacSha256::new_from_slice(&signing_key)
172            .map_err(|e| VortexError::CryptoError(format!("HMAC error: {}", e)))?;
173        sig_hmac.update(to_sign.as_bytes());
174        let signature = sig_hmac.finalize().into_bytes();
175        let sig_b64 = URL_SAFE_NO_PAD.encode(&signature);
176
177        Ok(format!("{}.{}.{}", header_b64, payload_b64, sig_b64))
178    }
179
180    /// Get invitations by target (email or sms)
181    pub async fn get_invitations_by_target(
182        &self,
183        target_type: &str,
184        target_value: &str,
185    ) -> Result<Vec<Invitation>, VortexError> {
186        let mut params = HashMap::new();
187        params.insert("targetType", target_type);
188        params.insert("targetValue", target_value);
189
190        let response: InvitationsResponse = self
191            .api_request("GET", "/api/v1/invitations", None::<&()>, Some(params))
192            .await?;
193
194        Ok(response.invitations.unwrap_or_default())
195    }
196
197    /// Get a specific invitation by ID
198    pub async fn get_invitation(&self, invitation_id: &str) -> Result<Invitation, VortexError> {
199        self.api_request(
200            "GET",
201            &format!("/api/v1/invitations/{}", invitation_id),
202            None::<&()>,
203            None,
204        )
205        .await
206    }
207
208    /// Revoke (delete) an invitation
209    pub async fn revoke_invitation(&self, invitation_id: &str) -> Result<(), VortexError> {
210        self.api_request::<(), ()>(
211            "DELETE",
212            &format!("/api/v1/invitations/{}", invitation_id),
213            None,
214            None,
215        )
216        .await?;
217        Ok(())
218    }
219
220    /// Accept multiple invitations
221    ///
222    /// # Arguments
223    ///
224    /// * `invitation_ids` - Vector of invitation IDs to accept
225    /// * `param` - User data (preferred) or legacy target format
226    ///
227    /// # New User Format (Preferred)
228    ///
229    /// ```
230    /// use vortex_sdk::{VortexClient, AcceptUser};
231    ///
232    /// # async fn example() {
233    /// let client = VortexClient::new("VRTX.key.secret".to_string());
234    /// let user = AcceptUser::new().with_email("user@example.com");
235    /// let result = client.accept_invitations(vec!["inv-123".to_string()], user).await;
236    /// # }
237    /// ```
238    ///
239    /// # Legacy Target Format (Deprecated)
240    ///
241    /// ```
242    /// use vortex_sdk::{VortexClient, InvitationTarget};
243    ///
244    /// # async fn example() {
245    /// let client = VortexClient::new("VRTX.key.secret".to_string());
246    /// let target = InvitationTarget::new("email", "user@example.com");
247    /// let result = client.accept_invitations(vec!["inv-123".to_string()], target).await;
248    /// # }
249    /// ```
250    pub async fn accept_invitations(
251        &self,
252        invitation_ids: Vec<String>,
253        param: impl Into<crate::types::AcceptInvitationParam>,
254    ) -> Result<Invitation, VortexError> {
255        use crate::types::{AcceptInvitationParam, AcceptUser};
256
257        let param = param.into();
258
259        // Convert all parameter types to User format to avoid async recursion
260        let user = match param {
261            AcceptInvitationParam::Targets(targets) => {
262                eprintln!("[Vortex SDK] DEPRECATED: Passing a vector of targets is deprecated. Use the AcceptUser format and call once per user instead.");
263
264                if targets.is_empty() {
265                    return Err(VortexError::InvalidRequest("No targets provided".to_string()));
266                }
267
268                let mut last_result = None;
269                let mut last_error = None;
270
271                for target in targets {
272                    // Convert target to user
273                    let user = match target.target_type.as_str() {
274                        "email" => AcceptUser::new().with_email(&target.value),
275                        "sms" | "phoneNumber" => AcceptUser::new().with_phone(&target.value),
276                        _ => AcceptUser::new().with_email(&target.value),
277                    };
278
279                    match Box::pin(self.accept_invitations(invitation_ids.clone(), user)).await {
280                        Ok(result) => last_result = Some(result),
281                        Err(e) => last_error = Some(e),
282                    }
283                }
284
285                if let Some(err) = last_error {
286                    return Err(err);
287                }
288
289                return last_result.ok_or_else(|| VortexError::InvalidRequest("No results".to_string()));
290            }
291            AcceptInvitationParam::Target(target) => {
292                eprintln!("[Vortex SDK] DEPRECATED: Passing an InvitationTarget is deprecated. Use the AcceptUser format instead: AcceptUser::new().with_email(\"user@example.com\")");
293
294                // Convert target to User format
295                match target.target_type.as_str() {
296                    "email" => AcceptUser::new().with_email(&target.value),
297                    "sms" | "phoneNumber" => AcceptUser::new().with_phone(&target.value),
298                    _ => AcceptUser::new().with_email(&target.value), // Default to email
299                }
300            }
301            AcceptInvitationParam::User(user) => user,
302        };
303
304        // Validate that either email or phone is provided
305        if user.email.is_none() && user.phone.is_none() {
306            return Err(VortexError::InvalidRequest(
307                "User must have either email or phone".to_string(),
308            ));
309        }
310
311        let body = json!({
312            "invitationIds": invitation_ids,
313            "user": user,
314        });
315
316        self.api_request("POST", "/api/v1/invitations/accept", Some(&body), None)
317            .await
318    }
319
320    /// Delete all invitations for a specific group
321    pub async fn delete_invitations_by_group(
322        &self,
323        group_type: &str,
324        group_id: &str,
325    ) -> Result<(), VortexError> {
326        self.api_request::<(), ()>(
327            "DELETE",
328            &format!("/api/v1/invitations/by-group/{}/{}", group_type, group_id),
329            None,
330            None,
331        )
332        .await?;
333        Ok(())
334    }
335
336    /// Get all invitations for a specific group
337    pub async fn get_invitations_by_group(
338        &self,
339        group_type: &str,
340        group_id: &str,
341    ) -> Result<Vec<Invitation>, VortexError> {
342        let response: InvitationsResponse = self
343            .api_request(
344                "GET",
345                &format!("/api/v1/invitations/by-group/{}/{}", group_type, group_id),
346                None::<&()>,
347                None,
348            )
349            .await?;
350
351        Ok(response.invitations.unwrap_or_default())
352    }
353
354    /// Reinvite a user (send invitation again)
355    pub async fn reinvite(&self, invitation_id: &str) -> Result<Invitation, VortexError> {
356        self.api_request(
357            "POST",
358            &format!("/api/v1/invitations/{}/reinvite", invitation_id),
359            None::<&()>,
360            None,
361        )
362        .await
363    }
364
365    async fn api_request<T, B>(
366        &self,
367        method: &str,
368        path: &str,
369        body: Option<&B>,
370        query_params: Option<HashMap<&str, &str>>,
371    ) -> Result<T, VortexError>
372    where
373        T: serde::de::DeserializeOwned,
374        B: serde::Serialize,
375    {
376        let url = format!("{}{}", self.base_url, path);
377
378        let mut request = match method {
379            "GET" => self.http_client.get(&url),
380            "POST" => self.http_client.post(&url),
381            "PUT" => self.http_client.put(&url),
382            "DELETE" => self.http_client.delete(&url),
383            _ => return Err(VortexError::InvalidRequest("Invalid HTTP method".to_string())),
384        };
385
386        // Add headers
387        request = request
388            .header("Content-Type", "application/json")
389            .header("x-api-key", &self.api_key)
390            .header("User-Agent", "vortex-rust-sdk/1.0.0");
391
392        // Add query parameters
393        if let Some(params) = query_params {
394            request = request.query(&params);
395        }
396
397        // Add body
398        if let Some(b) = body {
399            request = request.json(b);
400        }
401
402        let response = request
403            .send()
404            .await
405            .map_err(|e| VortexError::HttpError(e.to_string()))?;
406
407        if !response.status().is_success() {
408            let status = response.status();
409            let error_text = response
410                .text()
411                .await
412                .unwrap_or_else(|_| "Unknown error".to_string());
413            return Err(VortexError::ApiError(format!(
414                "API request failed: {} - {}",
415                status, error_text
416            )));
417        }
418
419        let text = response
420            .text()
421            .await
422            .map_err(|e| VortexError::HttpError(e.to_string()))?;
423
424        // Handle empty responses
425        if text.is_empty() {
426            return serde_json::from_str("{}")
427                .map_err(|e| VortexError::SerializationError(e.to_string()));
428        }
429
430        serde_json::from_str(&text)
431            .map_err(|e| VortexError::SerializationError(e.to_string()))
432    }
433}