use serde::{Deserialize, Serialize}; use std::fmt; const DEFAULT_WORKSPACE_LIMIT: usize = 200; #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] pub struct BackendWorkspace { pub workspace_id: String, pub owner_account_id: Option, pub display_name: String, pub state: String, pub created_at: String, pub updated_at: String, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct CreateBackendWorkspaceRequest { pub operation_key: String, pub display_name: String, pub repository: CreateBackendWorkspaceRepository, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct CreateBackendWorkspaceRepository { pub uri: String, pub display_name: Option, pub default_ref: Option, } #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] pub struct CreateBackendWorkspaceResponse { pub workspace: BackendWorkspace, pub repository: CreateBackendWorkspaceRepositoryRecord, pub config_revision: u64, pub request_fingerprint: String, pub replayed: bool, } #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] pub struct CreateBackendWorkspaceRepositoryRecord { pub workspace_id: String, pub repository_id: String, pub name: String, pub kind: String, pub uri: String, pub default_ref: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct BackendWorkspaceCatalogTarget { pub base_url: String, } impl BackendWorkspaceCatalogTarget { pub fn new(base_url: impl Into) -> Self { Self { base_url: base_url.into(), } } } #[derive(Debug)] pub enum BackendWorkspaceClientError { InvalidTarget(String), RequestFailed { status: u16, message: String }, Http(reqwest::Error), } impl fmt::Display for BackendWorkspaceClientError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidTarget(message) => f.write_str(message), Self::RequestFailed { status, message } => { write!(f, "Backend request failed with HTTP {status}: {message}") } Self::Http(error) => write!(f, "{error}"), } } } impl std::error::Error for BackendWorkspaceClientError {} impl From for BackendWorkspaceClientError { fn from(error: reqwest::Error) -> Self { Self::Http(error) } } pub async fn list_backend_workspaces( target: &BackendWorkspaceCatalogTarget, ) -> Result, BackendWorkspaceClientError> { validate_target(target)?; let url = format!( "{}/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}", target.base_url.trim_end_matches('/') ); let response = reqwest::Client::new().get(url).send().await?; let response = require_success(response).await?; Ok(response.json::>().await?) } pub async fn create_backend_workspace( target: &BackendWorkspaceCatalogTarget, request: &CreateBackendWorkspaceRequest, ) -> Result { validate_target(target)?; let url = format!("{}/api/workspaces", target.base_url.trim_end_matches('/')); let response = reqwest::Client::new() .post(url) .json(request) .send() .await?; let response = require_success(response).await?; Ok(response.json::().await?) } async fn require_success( response: reqwest::Response, ) -> Result { if response.status().is_success() { return Ok(response); } let status = response.status().as_u16(); let message = response.text().await.unwrap_or_default(); Err(BackendWorkspaceClientError::RequestFailed { status, message }) } fn validate_target( target: &BackendWorkspaceCatalogTarget, ) -> Result<(), BackendWorkspaceClientError> { if !(target.base_url.starts_with("http://") || target.base_url.starts_with("https://")) { return Err(BackendWorkspaceClientError::InvalidTarget( "Backend API base URL must start with http:// or https://".to_string(), )); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn create_request_keeps_operation_key_for_exact_retry() { let request = CreateBackendWorkspaceRequest { operation_key: "workspace-create-1".to_string(), display_name: "Alpha".to_string(), repository: CreateBackendWorkspaceRepository { uri: "/srv/repos/alpha".to_string(), display_name: Some("Main".to_string()), default_ref: Some("develop".to_string()), }, }; let retry = request.clone(); assert_eq!(retry.operation_key, "workspace-create-1"); assert_eq!(retry, request); } }