use super::{StorageBackend, StorageError};
use async_trait::async_trait;
use serde_json;
const MAX_DOMAIN_LENGTH: usize = 100;
fn validate_domain_slug(domain: &str) -> Result<(), StorageError> {
if domain.is_empty() {
return Err(StorageError::BackendError(
"Domain name cannot be empty".to_string(),
));
}
if domain.len() > MAX_DOMAIN_LENGTH {
return Err(StorageError::BackendError(format!(
"Domain name too long (max {} characters)",
MAX_DOMAIN_LENGTH
)));
}
if !domain
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Err(StorageError::BackendError(
"Domain contains invalid characters. Only alphanumeric, hyphens, and underscores are allowed.".to_string()
));
}
if domain == "." || domain == ".." || domain.starts_with('.') {
return Err(StorageError::BackendError(
"Domain name cannot start with a period".to_string(),
));
}
Ok(())
}
pub struct ApiStorageBackend {
base_url: String,
auth_token: Option<String>,
client: reqwest::Client,
}
impl ApiStorageBackend {
pub fn new(base_url: impl Into<String>, auth_token: Option<String>) -> Self {
Self {
base_url: base_url.into(),
auth_token,
client: reqwest::Client::new(),
}
}
fn build_request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
let url = format!("{}{}", self.base_url, path);
let mut request = self.client.request(method, &url);
if let Some(ref token) = self.auth_token {
request = request.header("Authorization", format!("Bearer {}", token));
}
request
}
pub async fn get_workspace_info(&self) -> Result<WorkspaceInfo, StorageError> {
let response = self
.build_request(reqwest::Method::GET, "/workspace/info")
.send()
.await
.map_err(|e| {
StorageError::NetworkError(format!("Failed to get workspace info: {}", e))
})?;
if !response.status().is_success() {
return Err(StorageError::BackendError(format!(
"Workspace info request failed: {}",
response.status()
)));
}
let info: WorkspaceInfo = response.json().await.map_err(|e| {
StorageError::SerializationError(format!("Failed to parse workspace info: {}", e))
})?;
Ok(info)
}
pub async fn load_tables(&self, domain: &str) -> Result<Vec<serde_json::Value>, StorageError> {
validate_domain_slug(domain)?;
let encoded_domain = urlencoding::encode(domain);
let response = self
.build_request(
reqwest::Method::GET,
&format!("/workspace/domains/{}/tables", encoded_domain),
)
.send()
.await
.map_err(|e| StorageError::NetworkError(format!("Failed to load tables: {}", e)))?;
if !response.status().is_success() {
return Err(StorageError::BackendError(format!(
"Load tables request failed: {}",
response.status()
)));
}
let tables: Vec<serde_json::Value> = response.json().await.map_err(|e| {
StorageError::SerializationError(format!("Failed to parse tables: {}", e))
})?;
Ok(tables)
}
pub async fn load_relationships(
&self,
domain: &str,
) -> Result<Vec<serde_json::Value>, StorageError> {
validate_domain_slug(domain)?;
let encoded_domain = urlencoding::encode(domain);
let response = self
.build_request(
reqwest::Method::GET,
&format!("/workspace/domains/{}/relationships", encoded_domain),
)
.send()
.await
.map_err(|e| {
StorageError::NetworkError(format!("Failed to load relationships: {}", e))
})?;
if !response.status().is_success() {
return Err(StorageError::BackendError(format!(
"Load relationships request failed: {}",
response.status()
)));
}
let relationships: Vec<serde_json::Value> = response.json().await.map_err(|e| {
StorageError::SerializationError(format!("Failed to parse relationships: {}", e))
})?;
Ok(relationships)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_domain_slug_valid() {
assert!(validate_domain_slug("my-domain").is_ok());
assert!(validate_domain_slug("my_domain").is_ok());
assert!(validate_domain_slug("domain123").is_ok());
assert!(validate_domain_slug("MyDomain").is_ok());
}
#[test]
fn test_validate_domain_slug_empty() {
let result = validate_domain_slug("");
assert!(matches!(result, Err(StorageError::BackendError(_))));
}
#[test]
fn test_validate_domain_slug_too_long() {
let long_domain = "a".repeat(101);
let result = validate_domain_slug(&long_domain);
assert!(matches!(result, Err(StorageError::BackendError(_))));
}
#[test]
fn test_validate_domain_slug_invalid_chars() {
assert!(validate_domain_slug("../etc").is_err());
assert!(validate_domain_slug("domain/path").is_err());
assert!(validate_domain_slug("domain?query").is_err());
assert!(validate_domain_slug("domain#hash").is_err());
assert!(validate_domain_slug("domain with spaces").is_err());
}
#[test]
fn test_validate_domain_slug_dot_patterns() {
assert!(validate_domain_slug(".").is_err());
assert!(validate_domain_slug("..").is_err());
assert!(validate_domain_slug(".hidden").is_err());
}
}
#[derive(Debug, serde::Deserialize)]
pub struct WorkspaceInfo {
pub workspace_path: String,
pub email: String,
}
#[async_trait(?Send)]
impl StorageBackend for ApiStorageBackend {
async fn read_file(&self, _path: &str) -> Result<Vec<u8>, StorageError> {
Err(StorageError::BackendError(
"Direct file reading not supported in API backend. Use load_model() instead."
.to_string(),
))
}
async fn write_file(&self, _path: &str, _content: &[u8]) -> Result<(), StorageError> {
Err(StorageError::BackendError(
"Direct file writing not supported in API backend. Use save_table() or save_relationships() instead.".to_string(),
))
}
async fn list_files(&self, _dir: &str) -> Result<Vec<String>, StorageError> {
Err(StorageError::BackendError(
"File listing not supported in API backend. Use load_tables() or load_relationships() instead.".to_string(),
))
}
async fn file_exists(&self, _path: &str) -> Result<bool, StorageError> {
Ok(false)
}
async fn delete_file(&self, _path: &str) -> Result<(), StorageError> {
Err(StorageError::BackendError(
"File deletion not supported in API backend. Use dedicated table/relationship DELETE endpoints.".to_string(),
))
}
async fn create_dir(&self, _path: &str) -> Result<(), StorageError> {
Err(StorageError::BackendError(
"Directory creation not supported in API backend. Use workspace/domain creation endpoints.".to_string(),
))
}
async fn dir_exists(&self, _path: &str) -> Result<bool, StorageError> {
#[cfg(not(target_arch = "wasm32"))]
{
let response = self
.build_request(reqwest::Method::HEAD, "/workspace/info")
.send()
.await
.map_err(|e| {
StorageError::NetworkError(format!("Failed to check directory: {}", e))
})?;
Ok(response.status().is_success())
}
#[cfg(target_arch = "wasm32")]
{
Err(StorageError::BackendError(
"API backend not supported in WASM. Use browser storage backend instead."
.to_string(),
))
}
}
}