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 the given user data
66    ///
67    /// This uses the same algorithm as the Node.js SDK to ensure
68    /// complete compatibility with React providers.
69    ///
70    /// # Arguments
71    ///
72    /// * `user_id` - Unique identifier for the user
73    /// * `identifiers` - List of identifiers (email, sms)
74    /// * `groups` - List of groups the user belongs to
75    /// * `role` - Optional user role
76    ///
77    /// # Example
78    ///
79    /// ```
80    /// use vortex_sdk::{VortexClient, Identifier, Group};
81    ///
82    /// // API key format: VRTX.base64_encoded_uuid.secret_key
83    /// let client = VortexClient::new("VRTX.AAAAAAAAAAAAAAAAAAAAAA.test_secret_key".to_string());
84    /// let jwt = client.generate_jwt(
85    ///     "user-123",
86    ///     vec![Identifier::new("email", "user@example.com")],
87    ///     vec![Group::new("team", "team-1", "Engineering")],
88    ///     Some("admin")
89    /// ).unwrap();
90    /// ```
91    pub fn generate_jwt(
92        &self,
93        user_id: &str,
94        identifiers: Vec<Identifier>,
95        groups: Vec<Group>,
96        role: Option<&str>,
97    ) -> Result<String, VortexError> {
98        // Parse API key: format is VRTX.base64encodedId.key
99        let parts: Vec<&str> = self.api_key.split('.').collect();
100        if parts.len() != 3 {
101            return Err(VortexError::InvalidApiKey(
102                "Invalid API key format".to_string(),
103            ));
104        }
105
106        let prefix = parts[0];
107        let encoded_id = parts[1];
108        let key = parts[2];
109
110        if prefix != "VRTX" {
111            return Err(VortexError::InvalidApiKey(
112                "Invalid API key prefix".to_string(),
113            ));
114        }
115
116        // Decode the UUID from base64url
117        let id_bytes = URL_SAFE_NO_PAD
118            .decode(encoded_id)
119            .map_err(|e| VortexError::InvalidApiKey(format!("Failed to decode ID: {}", e)))?;
120
121        if id_bytes.len() != 16 {
122            return Err(VortexError::InvalidApiKey("ID must be 16 bytes".to_string()));
123        }
124
125        let uuid = Uuid::from_slice(&id_bytes)
126            .map_err(|e| VortexError::InvalidApiKey(format!("Invalid UUID: {}", e)))?;
127        let uuid_str = uuid.to_string();
128
129        let now = SystemTime::now()
130            .duration_since(UNIX_EPOCH)
131            .unwrap()
132            .as_secs();
133        let expires = now + 3600; // 1 hour from now
134
135        // Step 1: Derive signing key from API key + ID
136        let mut hmac = HmacSha256::new_from_slice(key.as_bytes())
137            .map_err(|e| VortexError::CryptoError(format!("HMAC error: {}", e)))?;
138        hmac.update(uuid_str.as_bytes());
139        let signing_key = hmac.finalize().into_bytes();
140
141        // Step 2: Build header + payload (same structure as Node.js)
142        let header = json!({
143            "iat": now,
144            "alg": "HS256",
145            "typ": "JWT",
146            "kid": uuid_str,
147        });
148
149        let payload = json!({
150            "userId": user_id,
151            "identifiers": identifiers,
152            "groups": groups,
153            "role": role,
154            "expires": expires,
155        });
156
157        // Step 3: Base64URL encode header and payload
158        let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
159        let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap());
160
161        // Step 4: Sign with HMAC-SHA256
162        let to_sign = format!("{}.{}", header_b64, payload_b64);
163        let mut sig_hmac = HmacSha256::new_from_slice(&signing_key)
164            .map_err(|e| VortexError::CryptoError(format!("HMAC error: {}", e)))?;
165        sig_hmac.update(to_sign.as_bytes());
166        let signature = sig_hmac.finalize().into_bytes();
167        let sig_b64 = URL_SAFE_NO_PAD.encode(&signature);
168
169        Ok(format!("{}.{}.{}", header_b64, payload_b64, sig_b64))
170    }
171
172    /// Get invitations by target (email or sms)
173    pub async fn get_invitations_by_target(
174        &self,
175        target_type: &str,
176        target_value: &str,
177    ) -> Result<Vec<Invitation>, VortexError> {
178        let mut params = HashMap::new();
179        params.insert("targetType", target_type);
180        params.insert("targetValue", target_value);
181
182        let response: InvitationsResponse = self
183            .api_request("GET", "/api/v1/invitations", None::<&()>, Some(params))
184            .await?;
185
186        Ok(response.invitations.unwrap_or_default())
187    }
188
189    /// Get a specific invitation by ID
190    pub async fn get_invitation(&self, invitation_id: &str) -> Result<Invitation, VortexError> {
191        self.api_request(
192            "GET",
193            &format!("/api/v1/invitations/{}", invitation_id),
194            None::<&()>,
195            None,
196        )
197        .await
198    }
199
200    /// Revoke (delete) an invitation
201    pub async fn revoke_invitation(&self, invitation_id: &str) -> Result<(), VortexError> {
202        self.api_request::<(), ()>(
203            "DELETE",
204            &format!("/api/v1/invitations/{}", invitation_id),
205            None,
206            None,
207        )
208        .await?;
209        Ok(())
210    }
211
212    /// Accept multiple invitations
213    pub async fn accept_invitations(
214        &self,
215        invitation_ids: Vec<String>,
216        target: InvitationTarget,
217    ) -> Result<Invitation, VortexError> {
218        let body = json!({
219            "invitationIds": invitation_ids,
220            "target": target,
221        });
222
223        self.api_request("POST", "/api/v1/invitations/accept", Some(&body), None)
224            .await
225    }
226
227    /// Delete all invitations for a specific group
228    pub async fn delete_invitations_by_group(
229        &self,
230        group_type: &str,
231        group_id: &str,
232    ) -> Result<(), VortexError> {
233        self.api_request::<(), ()>(
234            "DELETE",
235            &format!("/api/v1/invitations/by-group/{}/{}", group_type, group_id),
236            None,
237            None,
238        )
239        .await?;
240        Ok(())
241    }
242
243    /// Get all invitations for a specific group
244    pub async fn get_invitations_by_group(
245        &self,
246        group_type: &str,
247        group_id: &str,
248    ) -> Result<Vec<Invitation>, VortexError> {
249        let response: InvitationsResponse = self
250            .api_request(
251                "GET",
252                &format!("/api/v1/invitations/by-group/{}/{}", group_type, group_id),
253                None::<&()>,
254                None,
255            )
256            .await?;
257
258        Ok(response.invitations.unwrap_or_default())
259    }
260
261    /// Reinvite a user (send invitation again)
262    pub async fn reinvite(&self, invitation_id: &str) -> Result<Invitation, VortexError> {
263        self.api_request(
264            "POST",
265            &format!("/api/v1/invitations/{}/reinvite", invitation_id),
266            None::<&()>,
267            None,
268        )
269        .await
270    }
271
272    async fn api_request<T, B>(
273        &self,
274        method: &str,
275        path: &str,
276        body: Option<&B>,
277        query_params: Option<HashMap<&str, &str>>,
278    ) -> Result<T, VortexError>
279    where
280        T: serde::de::DeserializeOwned,
281        B: serde::Serialize,
282    {
283        let url = format!("{}{}", self.base_url, path);
284
285        let mut request = match method {
286            "GET" => self.http_client.get(&url),
287            "POST" => self.http_client.post(&url),
288            "PUT" => self.http_client.put(&url),
289            "DELETE" => self.http_client.delete(&url),
290            _ => return Err(VortexError::InvalidRequest("Invalid HTTP method".to_string())),
291        };
292
293        // Add headers
294        request = request
295            .header("Content-Type", "application/json")
296            .header("x-api-key", &self.api_key)
297            .header("User-Agent", "vortex-rust-sdk/1.0.0");
298
299        // Add query parameters
300        if let Some(params) = query_params {
301            request = request.query(&params);
302        }
303
304        // Add body
305        if let Some(b) = body {
306            request = request.json(b);
307        }
308
309        let response = request
310            .send()
311            .await
312            .map_err(|e| VortexError::HttpError(e.to_string()))?;
313
314        if !response.status().is_success() {
315            let status = response.status();
316            let error_text = response
317                .text()
318                .await
319                .unwrap_or_else(|_| "Unknown error".to_string());
320            return Err(VortexError::ApiError(format!(
321                "API request failed: {} - {}",
322                status, error_text
323            )));
324        }
325
326        let text = response
327            .text()
328            .await
329            .map_err(|e| VortexError::HttpError(e.to_string()))?;
330
331        // Handle empty responses
332        if text.is_empty() {
333            return serde_json::from_str("{}")
334                .map_err(|e| VortexError::SerializationError(e.to_string()));
335        }
336
337        serde_json::from_str(&text)
338            .map_err(|e| VortexError::SerializationError(e.to_string()))
339    }
340}