data_modelling_sdk/storage/
api.rs1use super::{StorageBackend, StorageError};
12use async_trait::async_trait;
13use serde_json;
14
15const MAX_DOMAIN_LENGTH: usize = 100;
17
18fn 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 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 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
62pub struct ApiStorageBackend {
64 base_url: String,
65 auth_token: Option<String>,
66 client: reqwest::Client,
67}
68
69impl ApiStorageBackend {
70 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 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 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 pub async fn load_tables(&self, domain: &str) -> Result<Vec<serde_json::Value>, StorageError> {
153 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 pub async fn load_relationships(
186 &self,
187 domain: &str,
188 ) -> Result<Vec<serde_json::Value>, StorageError> {
189 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 assert!(validate_domain_slug("../etc").is_err());
248 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 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 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 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 async fn file_exists(&self, _path: &str) -> Result<bool, StorageError> {
310 Ok(false)
313 }
314
315 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 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 #[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 Err(StorageError::BackendError(
360 "API backend not supported in WASM. Use browser storage backend instead."
361 .to_string(),
362 ))
363 }
364 }
365}