Skip to main content
Glama
workspace_management.rs14 kB
use axum::{ Router, extract::rejection::JsonRejection, http::StatusCode, middleware, response::IntoResponse, routing::{ delete, get, patch, post, }, }; use chrono::{ DateTime, Utc, }; use create_workspace::CreateWorkspaceRequest; use serde::{ Deserialize, Serialize, }; use si_id::WorkspaceId; use thiserror::Error; use update_workspace::UpdateWorkspaceRequest; use utoipa::{ OpenApi, ToSchema, }; use crate::{ AppState, extract::workspace::{ AuthorizedForWorkspaceManagement, TargetWorkspaceIdFromPath, }, }; mod create_workspace; mod delete_workspace; mod get_workspace; mod invite_member; mod leave_workspace; mod list_members; mod list_workspaces; mod remove_member; mod sync_members; mod update_member_role; mod update_workspace; use sync_members::sync_members; pub type WorkspaceManagementResult<T> = Result<T, WorkspaceManagementError>; #[remain::sorted] #[derive(Debug, Error)] pub enum WorkspaceManagementError { #[error("auth api error: {message}")] AuthApiError { status: StatusCode, message: String }, #[error("dal error: {0}")] Dal(#[from] Box<dal::TransactionsError>), #[error("invalid instance url: {0}")] InvalidInstanceUrl(String), #[error("key pair error: {0}")] KeyPair(#[from] Box<dal::KeyPairError>), #[error("permissions error: {0}")] Permissions(#[from] Box<permissions::Error>), #[error("http error: {0}")] Request(#[from] reqwest::Error), #[error("si-db error: {0}")] SiDb(#[from] Box<si_db::Error>), #[error("user not found: {0}")] UserNotFound(String), #[error("validation error: {0}")] Validation(String), #[error("workspace integration error: {0}")] WorkspaceIntegration(#[from] Box<dal::workspace_integrations::WorkspaceIntegrationsError>), #[error("workspace permission denied: {0}")] WorkspacePermission(String), } impl From<dal::TransactionsError> for WorkspaceManagementError { fn from(value: dal::TransactionsError) -> Self { Box::new(value).into() } } impl From<permissions::Error> for WorkspaceManagementError { fn from(value: permissions::Error) -> Self { Box::new(value).into() } } impl From<si_db::Error> for WorkspaceManagementError { fn from(value: si_db::Error) -> Self { Box::new(value).into() } } impl From<dal::workspace_integrations::WorkspaceIntegrationsError> for WorkspaceManagementError { fn from(value: dal::workspace_integrations::WorkspaceIntegrationsError) -> Self { Box::new(value).into() } } impl From<dal::KeyPairError> for WorkspaceManagementError { fn from(value: dal::KeyPairError) -> Self { Box::new(value).into() } } impl IntoResponse for WorkspaceManagementError { fn into_response(self) -> axum::response::Response { use crate::service::v1::common::ErrorIntoResponse; self.to_api_response() } } impl From<JsonRejection> for WorkspaceManagementError { fn from(rejection: JsonRejection) -> Self { match rejection { JsonRejection::JsonDataError(_) => WorkspaceManagementError::Validation(format!( "Invalid JSON data format: {rejection}" )), JsonRejection::JsonSyntaxError(_) => { WorkspaceManagementError::Validation(format!("Invalid JSON syntax: {rejection}")) } JsonRejection::MissingJsonContentType(_) => WorkspaceManagementError::Validation( "Request must have Content-Type: application/json header".to_string(), ), _ => { WorkspaceManagementError::Validation(format!("JSON validation error: {rejection}")) } } } } impl crate::service::v1::common::ErrorIntoResponse for WorkspaceManagementError { fn status_and_message(&self) -> (StatusCode, String) { match self { WorkspaceManagementError::AuthApiError { status, message } => { (*status, message.clone()) } WorkspaceManagementError::Validation(msg) => (StatusCode::BAD_REQUEST, msg.clone()), WorkspaceManagementError::InvalidInstanceUrl(msg) => { (StatusCode::BAD_REQUEST, msg.clone()) } WorkspaceManagementError::UserNotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), WorkspaceManagementError::WorkspacePermission(msg) => { (StatusCode::FORBIDDEN, msg.clone()) } WorkspaceManagementError::Request(_) | WorkspaceManagementError::Dal(_) | WorkspaceManagementError::KeyPair(_) | WorkspaceManagementError::Permissions(_) | WorkspaceManagementError::SiDb(_) | WorkspaceManagementError::WorkspaceIntegration(_) => { (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()) } } } } #[derive(Debug, Serialize, Deserialize)] pub struct AuthApiErrBody { pub kind: String, pub message: String, } // Helper function to handle auth-api error responses async fn handle_auth_api_error(res: reqwest::Response) -> WorkspaceManagementError { let status = res.status(); let status_code = match status.as_u16() { 400 => StatusCode::BAD_REQUEST, 401 => StatusCode::UNAUTHORIZED, 403 => StatusCode::FORBIDDEN, 404 => StatusCode::NOT_FOUND, 409 => StatusCode::CONFLICT, _ => StatusCode::INTERNAL_SERVER_ERROR, }; let message = match res.json::<AuthApiErrBody>().await { Ok(err_body) => err_body.message, Err(_) => format!("Auth API returned error status: {status}"), }; WorkspaceManagementError::AuthApiError { status: status_code, message, } } pub fn routes(state: AppState) -> Router<AppState> { Router::new() .nest( "/workspaces", Router::new() .route("/", get(list_workspaces::list_workspaces)) .route("/", post(create_workspace::create_workspace)) .nest( "/:workspace_id", Router::new() .route("/", get(get_workspace::get_workspace)) .route("/", delete(delete_workspace::delete_workspace)) .route("/", patch(update_workspace::update_workspace)) .route("/members", get(list_members::list_members)) .route("/members", post(invite_member::invite_member)) .route("/members", delete(remove_member::remove_member)) .route("/leave", delete(leave_workspace::leave_workspace)) .route( "/update_member_access", post(update_member_role::update_member_role), ) .layer(middleware::from_extractor::<TargetWorkspaceIdFromPath>()), ), ) .route_layer(middleware::from_extractor_with_state::< AuthorizedForWorkspaceManagement, AppState, >(state)) } #[derive(OpenApi)] #[openapi( info( title = "System Initiative API - Workspace Management", description = "System Initiative External API server - Workspace Management Routes", version = "1.0.0" ), servers( (url = "/management", description = "Workspace Management API") ), paths( list_workspaces::list_workspaces, get_workspace::get_workspace, delete_workspace::delete_workspace, update_workspace::update_workspace, create_workspace::create_workspace, list_members::list_members, update_member_role::update_member_role, invite_member::invite_member, remove_member::remove_member, leave_workspace::leave_workspace, ), components(schemas( Workspace, WorkspaceManagementRequestPath, CreatorUser, InitialApiToken, CreateWorkspaceRequest, UpdateWorkspaceRequest, Member, UpdateMemberRoleRequest, InviteMemberRequest, RemoveMemberRequest, )), tags() )] pub struct WorkspaceManagementApiDoc; pub fn get_openapi() -> utoipa::openapi::OpenApi { WorkspaceManagementApiDoc::openapi() } #[derive(Deserialize, ToSchema)] pub struct WorkspaceManagementRequestPath { #[schema(value_type = String)] pub workspace_id: WorkspaceId, } // Auth API Response types // These are only used for internal translation to public facing data // For example, we shouldn't return the secret key for a workspace #[allow(dead_code)] #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] struct AuthApiWorkspace { pub id: String, pub instance_env_type: String, pub instance_url: Option<String>, pub display_name: String, pub creator_user_id: String, pub deleted_at: Option<DateTime<Utc>>, pub token: Option<String>, pub is_default: bool, pub quarantined_at: Option<DateTime<Utc>>, pub description: Option<String>, pub is_favourite: bool, pub is_hidden: bool, pub approvals_enabled: bool, #[serde(default)] pub role: Option<String>, pub invited_at: Option<DateTime<Utc>>, #[serde(default)] pub creator_user: Option<CreatorUser>, } #[allow(dead_code)] #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct AuthApiCreateWorkspaceResponse { pub workspaces: Vec<AuthApiWorkspace>, pub new_workspace_id: String, } // Public workspace response object #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct Workspace { #[schema(value_type = String)] pub id: String, #[schema(value_type = String)] pub instance_env_type: String, #[schema(value_type = Option<String>)] pub instance_url: Option<String>, #[schema(value_type = String)] pub display_name: String, #[schema(value_type = String)] pub creator_user_id: String, #[schema(value_type = bool)] pub is_default: bool, #[schema(value_type = Option<String>)] pub quarantined_at: Option<DateTime<Utc>>, #[schema(value_type = Option<String>)] pub description: Option<String>, #[schema(value_type = bool)] pub approvals_enabled: bool, #[schema(value_type = Option<String>)] pub role: Option<String>, #[schema(value_type = Option<CreatorUser>)] pub creator_user: Option<CreatorUser>, #[schema(value_type = Option<String>)] pub external_id: Option<String>, #[schema(value_type = Option<InitialApiToken>)] pub initial_api_token: Option<InitialApiToken>, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct CreatorUser { #[schema(value_type = Option<String>)] pub first_name: Option<String>, #[schema(value_type = Option<String>)] pub last_name: Option<String>, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct InitialApiToken { #[schema(value_type = String, example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")] pub token: String, #[schema(value_type = Option<String>)] pub expires_at: Option<DateTime<Utc>>, } // Member types #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct Member { #[schema(value_type = String, example = "01GW0KXH4YJBWC7BTBAZ6ZR7EA")] pub user_id: String, #[schema(example = "user@example.com")] pub email: String, #[schema(example = "John Doe")] pub nickname: String, #[schema(example = "OWNER")] pub role: String, #[schema(value_type = Option<String>)] pub signup_at: Option<DateTime<Utc>>, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct UpdateMemberRoleRequest { #[schema(example = "01GW0KXH4YJBWC7BTBAZ6ZR7EA")] pub user_id: String, #[schema(example = "EDITOR")] pub role: String, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct InviteMemberRequest { #[schema(example = "newuser@example.com")] pub email: String, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct RemoveMemberRequest { #[schema(example = "user@example.com")] pub email: String, } impl From<AuthApiWorkspace> for Workspace { fn from(auth: AuthApiWorkspace) -> Self { Workspace { id: auth.id, instance_env_type: auth.instance_env_type, instance_url: auth.instance_url, display_name: auth.display_name, creator_user_id: auth.creator_user_id, is_default: auth.is_default, quarantined_at: auth.quarantined_at, description: auth.description, approvals_enabled: auth.approvals_enabled, role: auth.role, creator_user: auth.creator_user, external_id: auth.token, initial_api_token: None, } } } // Map Auth API roles (EDITOR) to Luminork roles (COLLABORATOR) for responses fn map_member_role_from_auth_api(role: String) -> String { if role.eq_ignore_ascii_case("EDITOR") { "COLLABORATOR".to_string() } else { role } } // Map Luminork roles (COLLABORATOR) to Auth API roles (EDITOR) for requests pub(super) fn map_role_to_auth_api(role: &str) -> String { if role.eq_ignore_ascii_case("COLLABORATOR") { "EDITOR".to_string() } else { role.to_string() } } pub(super) fn transform_members(members: Vec<Member>) -> Vec<Member> { members .into_iter() .map(|mut member| { member.role = map_member_role_from_auth_api(member.role); member }) .collect() }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/systeminit/si'

If you have feedback or need assistance with the MCP directory API, please join our Discord server