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    pub async fn accept_invitations(
222        &self,
223        invitation_ids: Vec<String>,
224        target: InvitationTarget,
225    ) -> Result<Invitation, VortexError> {
226        let body = json!({
227            "invitationIds": invitation_ids,
228            "target": target,
229        });
230
231        self.api_request("POST", "/api/v1/invitations/accept", Some(&body), None)
232            .await
233    }
234
235    /// Delete all invitations for a specific group
236    pub async fn delete_invitations_by_group(
237        &self,
238        group_type: &str,
239        group_id: &str,
240    ) -> Result<(), VortexError> {
241        self.api_request::<(), ()>(
242            "DELETE",
243            &format!("/api/v1/invitations/by-group/{}/{}", group_type, group_id),
244            None,
245            None,
246        )
247        .await?;
248        Ok(())
249    }
250
251    /// Get all invitations for a specific group
252    pub async fn get_invitations_by_group(
253        &self,
254        group_type: &str,
255        group_id: &str,
256    ) -> Result<Vec<Invitation>, VortexError> {
257        let response: InvitationsResponse = self
258            .api_request(
259                "GET",
260                &format!("/api/v1/invitations/by-group/{}/{}", group_type, group_id),
261                None::<&()>,
262                None,
263            )
264            .await?;
265
266        Ok(response.invitations.unwrap_or_default())
267    }
268
269    /// Reinvite a user (send invitation again)
270    pub async fn reinvite(&self, invitation_id: &str) -> Result<Invitation, VortexError> {
271        self.api_request(
272            "POST",
273            &format!("/api/v1/invitations/{}/reinvite", invitation_id),
274            None::<&()>,
275            None,
276        )
277        .await
278    }
279
280    async fn api_request<T, B>(
281        &self,
282        method: &str,
283        path: &str,
284        body: Option<&B>,
285        query_params: Option<HashMap<&str, &str>>,
286    ) -> Result<T, VortexError>
287    where
288        T: serde::de::DeserializeOwned,
289        B: serde::Serialize,
290    {
291        let url = format!("{}{}", self.base_url, path);
292
293        let mut request = match method {
294            "GET" => self.http_client.get(&url),
295            "POST" => self.http_client.post(&url),
296            "PUT" => self.http_client.put(&url),
297            "DELETE" => self.http_client.delete(&url),
298            _ => return Err(VortexError::InvalidRequest("Invalid HTTP method".to_string())),
299        };
300
301        // Add headers
302        request = request
303            .header("Content-Type", "application/json")
304            .header("x-api-key", &self.api_key)
305            .header("User-Agent", "vortex-rust-sdk/1.0.0");
306
307        // Add query parameters
308        if let Some(params) = query_params {
309            request = request.query(&params);
310        }
311
312        // Add body
313        if let Some(b) = body {
314            request = request.json(b);
315        }
316
317        let response = request
318            .send()
319            .await
320            .map_err(|e| VortexError::HttpError(e.to_string()))?;
321
322        if !response.status().is_success() {
323            let status = response.status();
324            let error_text = response
325                .text()
326                .await
327                .unwrap_or_else(|_| "Unknown error".to_string());
328            return Err(VortexError::ApiError(format!(
329                "API request failed: {} - {}",
330                status, error_text
331            )));
332        }
333
334        let text = response
335            .text()
336            .await
337            .map_err(|e| VortexError::HttpError(e.to_string()))?;
338
339        // Handle empty responses
340        if text.is_empty() {
341            return serde_json::from_str("{}")
342                .map_err(|e| VortexError::SerializationError(e.to_string()));
343        }
344
345        serde_json::from_str(&text)
346            .map_err(|e| VortexError::SerializationError(e.to_string()))
347    }
348}