data_modelling_sdk/storage/
api.rs

1//! API storage backend
2//!
3//! Implements StorageBackend for HTTP API operations.
4//! Used for online mode (default).
5//!
6//! ## Security
7//!
8//! All domain parameters are validated to prevent injection attacks.
9//! Only alphanumeric characters, hyphens, and underscores are allowed.
10
11use super::{StorageBackend, StorageError};
12use async_trait::async_trait;
13use serde_json;
14
15/// Maximum allowed length for domain slugs
16const MAX_DOMAIN_LENGTH: usize = 100;
17
18/// Validate a domain slug for safe use in API paths.
19///
20/// # Security
21///
22/// This function ensures domain names cannot contain:
23/// - Path traversal sequences
24/// - URL injection characters
25/// - Excessively long values
26///
27/// Only alphanumeric characters, hyphens, and underscores are allowed.
28fn validate_domain_slug(domain: &str) -> Result<(), StorageError> {
29    if domain.is_empty() {
30        return Err(StorageError::BackendError(
31            "Domain name cannot be empty".to_string(),
32        ));
33    }
34
35    if domain.len() > MAX_DOMAIN_LENGTH {
36        return Err(StorageError::BackendError(format!(
37            "Domain name too long (max {} characters)",
38            MAX_DOMAIN_LENGTH
39        )));
40    }
41
42    // Only allow safe characters: alphanumeric, hyphens, underscores
43    if !domain
44        .chars()
45        .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
46    {
47        return Err(StorageError::BackendError(
48            "Domain contains invalid characters. Only alphanumeric, hyphens, and underscores are allowed.".to_string()
49        ));
50    }
51
52    // Prevent reserved patterns
53    if domain == "." || domain == ".." || domain.starts_with('.') {
54        return Err(StorageError::BackendError(
55            "Domain name cannot start with a period".to_string(),
56        ));
57    }
58
59    Ok(())
60}
61
62/// API storage backend that communicates with HTTP API
63pub struct ApiStorageBackend {
64    base_url: String,
65    auth_token: Option<String>,
66    client: reqwest::Client,
67}
68
69impl ApiStorageBackend {
70    /// Create a new API storage backend
71    ///
72    /// # Arguments
73    ///
74    /// * `base_url` - Base URL of the API server (e.g., "https://api.example.com/api/v1")
75    /// * `auth_token` - Optional bearer token for authentication
76    ///
77    /// # Example
78    ///
79    /// ```rust
80    /// use data_modelling_sdk::storage::api::ApiStorageBackend;
81    ///
82    /// let backend = ApiStorageBackend::new(
83    ///     "https://api.example.com/api/v1",
84    ///     Some("bearer_token_here".to_string()),
85    /// );
86    /// ```
87    pub fn new(base_url: impl Into<String>, auth_token: Option<String>) -> Self {
88        Self {
89            base_url: base_url.into(),
90            auth_token,
91            client: reqwest::Client::new(),
92        }
93    }
94
95    /// Build a request with authentication headers
96    fn build_request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
97        let url = format!("{}{}", self.base_url, path);
98        let mut request = self.client.request(method, &url);
99
100        if let Some(ref token) = self.auth_token {
101            request = request.header("Authorization", format!("Bearer {}", token));
102        }
103
104        request
105    }
106
107    /// Get workspace info to check if workspace exists
108    ///
109    /// # Returns
110    ///
111    /// `WorkspaceInfo` if the workspace exists, or an error if not found or network error occurs.
112    ///
113    /// # Example
114    ///
115    /// ```rust,no_run
116    /// # use data_modelling_sdk::storage::api::ApiStorageBackend;
117    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
118    /// # let backend = ApiStorageBackend::new("http://localhost:8080/api/v1", None);
119    /// # let info = backend.get_workspace_info().await?;
120    /// # println!("Workspace: {}", info.workspace_path);
121    /// # Ok(())
122    /// # }
123    /// ```
124    pub async fn get_workspace_info(&self) -> Result<WorkspaceInfo, StorageError> {
125        let response = self
126            .build_request(reqwest::Method::GET, "/workspace/info")
127            .send()
128            .await
129            .map_err(|e| {
130                StorageError::NetworkError(format!("Failed to get workspace info: {}", e))
131            })?;
132
133        if !response.status().is_success() {
134            return Err(StorageError::BackendError(format!(
135                "Workspace info request failed: {}",
136                response.status()
137            )));
138        }
139
140        let info: WorkspaceInfo = response.json().await.map_err(|e| {
141            StorageError::SerializationError(format!("Failed to parse workspace info: {}", e))
142        })?;
143
144        Ok(info)
145    }
146
147    /// Load tables from API
148    ///
149    /// # Security
150    ///
151    /// The domain parameter is validated to prevent injection attacks.
152    pub async fn load_tables(&self, domain: &str) -> Result<Vec<serde_json::Value>, StorageError> {
153        // Validate domain slug for security
154        validate_domain_slug(domain)?;
155
156        let encoded_domain = urlencoding::encode(domain);
157        let response = self
158            .build_request(
159                reqwest::Method::GET,
160                &format!("/workspace/domains/{}/tables", encoded_domain),
161            )
162            .send()
163            .await
164            .map_err(|e| StorageError::NetworkError(format!("Failed to load tables: {}", e)))?;
165
166        if !response.status().is_success() {
167            return Err(StorageError::BackendError(format!(
168                "Load tables request failed: {}",
169                response.status()
170            )));
171        }
172
173        let tables: Vec<serde_json::Value> = response.json().await.map_err(|e| {
174            StorageError::SerializationError(format!("Failed to parse tables: {}", e))
175        })?;
176
177        Ok(tables)
178    }
179
180    /// Load relationships from API
181    ///
182    /// # Security
183    ///
184    /// The domain parameter is validated to prevent injection attacks.
185    pub async fn load_relationships(
186        &self,
187        domain: &str,
188    ) -> Result<Vec<serde_json::Value>, StorageError> {
189        // Validate domain slug for security
190        validate_domain_slug(domain)?;
191
192        let encoded_domain = urlencoding::encode(domain);
193        let response = self
194            .build_request(
195                reqwest::Method::GET,
196                &format!("/workspace/domains/{}/relationships", encoded_domain),
197            )
198            .send()
199            .await
200            .map_err(|e| {
201                StorageError::NetworkError(format!("Failed to load relationships: {}", e))
202            })?;
203
204        if !response.status().is_success() {
205            return Err(StorageError::BackendError(format!(
206                "Load relationships request failed: {}",
207                response.status()
208            )));
209        }
210
211        let relationships: Vec<serde_json::Value> = response.json().await.map_err(|e| {
212            StorageError::SerializationError(format!("Failed to parse relationships: {}", e))
213        })?;
214
215        Ok(relationships)
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn test_validate_domain_slug_valid() {
225        assert!(validate_domain_slug("my-domain").is_ok());
226        assert!(validate_domain_slug("my_domain").is_ok());
227        assert!(validate_domain_slug("domain123").is_ok());
228        assert!(validate_domain_slug("MyDomain").is_ok());
229    }
230
231    #[test]
232    fn test_validate_domain_slug_empty() {
233        let result = validate_domain_slug("");
234        assert!(matches!(result, Err(StorageError::BackendError(_))));
235    }
236
237    #[test]
238    fn test_validate_domain_slug_too_long() {
239        let long_domain = "a".repeat(101);
240        let result = validate_domain_slug(&long_domain);
241        assert!(matches!(result, Err(StorageError::BackendError(_))));
242    }
243
244    #[test]
245    fn test_validate_domain_slug_invalid_chars() {
246        // Path traversal
247        assert!(validate_domain_slug("../etc").is_err());
248        // Special characters
249        assert!(validate_domain_slug("domain/path").is_err());
250        assert!(validate_domain_slug("domain?query").is_err());
251        assert!(validate_domain_slug("domain#hash").is_err());
252        assert!(validate_domain_slug("domain with spaces").is_err());
253    }
254
255    #[test]
256    fn test_validate_domain_slug_dot_patterns() {
257        assert!(validate_domain_slug(".").is_err());
258        assert!(validate_domain_slug("..").is_err());
259        assert!(validate_domain_slug(".hidden").is_err());
260    }
261}
262
263#[derive(Debug, serde::Deserialize)]
264pub struct WorkspaceInfo {
265    pub workspace_path: String,
266    pub email: String,
267}
268
269#[async_trait(?Send)]
270impl StorageBackend for ApiStorageBackend {
271    async fn read_file(&self, _path: &str) -> Result<Vec<u8>, StorageError> {
272        // For API backend, file reading is done through model endpoints
273        // Direct file reading not supported - use load_model() instead
274        Err(StorageError::BackendError(
275            "Direct file reading not supported in API backend. Use load_model() instead."
276                .to_string(),
277        ))
278    }
279
280    async fn write_file(&self, _path: &str, _content: &[u8]) -> Result<(), StorageError> {
281        // For API backend, file writing is done through model endpoints
282        // Direct file writing not supported - use save_table() or save_relationships() instead
283        Err(StorageError::BackendError(
284            "Direct file writing not supported in API backend. Use save_table() or save_relationships() instead.".to_string(),
285        ))
286    }
287
288    /// List files in a directory.
289    ///
290    /// # Note
291    ///
292    /// This method is intentionally not supported in the API backend.
293    /// The API uses a model-based approach where tables and relationships
294    /// are accessed via dedicated endpoints rather than as files.
295    ///
296    /// Use `load_tables()` and `load_relationships()` instead.
297    async fn list_files(&self, _dir: &str) -> Result<Vec<String>, StorageError> {
298        Err(StorageError::BackendError(
299            "File listing not supported in API backend. Use load_tables() or load_relationships() instead.".to_string(),
300        ))
301    }
302
303    /// Check if a file exists.
304    ///
305    /// # Note
306    ///
307    /// File existence checks are not meaningful in the API backend.
308    /// The API uses model endpoints - use `load_tables()` to check for tables.
309    async fn file_exists(&self, _path: &str) -> Result<bool, StorageError> {
310        // For API backend, we cannot check individual file existence
311        // Return false to indicate the concept doesn't apply
312        Ok(false)
313    }
314
315    /// Delete a file.
316    ///
317    /// # Note
318    ///
319    /// This method is intentionally not supported in the API backend.
320    /// Use the API's table/relationship DELETE endpoints directly.
321    async fn delete_file(&self, _path: &str) -> Result<(), StorageError> {
322        Err(StorageError::BackendError(
323            "File deletion not supported in API backend. Use dedicated table/relationship DELETE endpoints.".to_string(),
324        ))
325    }
326
327    /// Create a directory.
328    ///
329    /// # Note
330    ///
331    /// Directory creation is not supported in the API backend.
332    /// Workspaces and domains are created via dedicated API endpoints.
333    async fn create_dir(&self, _path: &str) -> Result<(), StorageError> {
334        Err(StorageError::BackendError(
335            "Directory creation not supported in API backend. Use workspace/domain creation endpoints.".to_string(),
336        ))
337    }
338
339    async fn dir_exists(&self, _path: &str) -> Result<bool, StorageError> {
340        // Check directory existence via API
341        // For API backend, directories are virtual - assume they exist if workspace is accessible
342        // Use a simple HEAD request to check instead of get_workspace_info to avoid Send issues
343        // Note: reqwest in WASM is not Send, but this is only used in non-WASM builds
344        #[cfg(not(target_arch = "wasm32"))]
345        {
346            let response = self
347                .build_request(reqwest::Method::HEAD, "/workspace/info")
348                .send()
349                .await
350                .map_err(|e| {
351                    StorageError::NetworkError(format!("Failed to check directory: {}", e))
352                })?;
353
354            Ok(response.status().is_success())
355        }
356        #[cfg(target_arch = "wasm32")]
357        {
358            // For WASM, API backend shouldn't be used - return error
359            Err(StorageError::BackendError(
360                "API backend not supported in WASM. Use browser storage backend instead."
361                    .to_string(),
362            ))
363        }
364    }
365}