use axum::{
extract::{Path, Query, State},
http::HeaderMap,
Json,
};
use serde::{de::Deserializer, Deserialize};
use uuid::Uuid;
fn deserialize_optional_nullable_uuid<'de, D>(
deserializer: D,
) -> Result<Option<Option<Uuid>>, D::Error>
where
D: Deserializer<'de>,
{
Option::<Uuid>::deserialize(deserializer).map(Some)
}
use crate::{
error::{ApiError, ApiResult},
middleware::{resolve_org_context, AuthUser},
models::{cloud_service::CloudService, AuditEventType, FeatureType},
AppState,
};
async fn ensure_workspace_in_org(
state: &AppState,
org_id: Uuid,
workspace_id: Uuid,
) -> ApiResult<()> {
let workspace = state
.store
.find_cloud_workspace_by_id(workspace_id)
.await?
.ok_or_else(|| ApiError::InvalidRequest("Workspace not found".to_string()))?;
if workspace.org_id != org_id {
return Err(ApiError::InvalidRequest(
"Workspace does not belong to this organization".to_string(),
));
}
Ok(())
}
#[derive(Debug, Deserialize, Default)]
pub struct ListServicesQuery {
#[serde(default)]
pub workspace_id: Option<Uuid>,
}
pub async fn list_services(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
headers: HeaderMap,
Query(params): Query<ListServicesQuery>,
) -> ApiResult<Json<Vec<CloudService>>> {
let org_ctx = resolve_org_context(&state, user_id, &headers, None)
.await
.map_err(|_| ApiError::InvalidRequest("Organization not found".to_string()))?;
let services = if let Some(workspace_id) = params.workspace_id {
ensure_workspace_in_org(&state, org_ctx.org_id, workspace_id).await?;
state
.store
.list_cloud_services_by_workspace(org_ctx.org_id, workspace_id)
.await?
} else {
state.store.list_cloud_services_by_org(org_ctx.org_id).await?
};
Ok(Json(services))
}
pub async fn get_service(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
headers: HeaderMap,
Path(id): Path<Uuid>,
) -> ApiResult<Json<CloudService>> {
let org_ctx = resolve_org_context(&state, user_id, &headers, None)
.await
.map_err(|_| ApiError::InvalidRequest("Organization not found".to_string()))?;
let service = state
.store
.find_cloud_service_by_id(id)
.await?
.ok_or_else(|| ApiError::InvalidRequest("Service not found".to_string()))?;
if service.org_id != org_ctx.org_id {
return Err(ApiError::InvalidRequest(
"Service does not belong to this organization".to_string(),
));
}
Ok(Json(service))
}
#[derive(Debug, Deserialize)]
pub struct CreateServiceRequest {
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub base_url: String,
#[serde(default)]
pub workspace_id: Option<Uuid>,
}
pub async fn create_service(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
headers: HeaderMap,
Json(request): Json<CreateServiceRequest>,
) -> ApiResult<Json<CloudService>> {
let org_ctx = resolve_org_context(&state, user_id, &headers, None)
.await
.map_err(|_| ApiError::InvalidRequest("Organization not found".to_string()))?;
if request.name.trim().is_empty() {
return Err(ApiError::InvalidRequest("Service name is required".to_string()));
}
if let Some(workspace_id) = request.workspace_id {
ensure_workspace_in_org(&state, org_ctx.org_id, workspace_id).await?;
}
let service = state
.store
.create_cloud_service(
org_ctx.org_id,
request.workspace_id,
user_id,
request.name.trim(),
&request.description,
&request.base_url,
)
.await?;
state
.store
.record_feature_usage(
org_ctx.org_id,
Some(user_id),
FeatureType::ServiceCreate,
Some(serde_json::json!({ "service_id": service.id, "name": service.name })),
)
.await;
let ip = headers
.get("X-Forwarded-For")
.or_else(|| headers.get("X-Real-IP"))
.and_then(|h| h.to_str().ok())
.map(|s| s.split(',').next().unwrap_or(s).trim());
let ua = headers.get("User-Agent").and_then(|h| h.to_str().ok());
state
.store
.record_audit_event(
org_ctx.org_id,
Some(user_id),
AuditEventType::ServiceCreated,
format!("Service '{}' created", service.name),
Some(serde_json::json!({ "service_id": service.id })),
ip,
ua,
)
.await;
Ok(Json(service))
}
#[derive(Debug, Deserialize)]
pub struct UpdateServiceRequest {
pub name: Option<String>,
pub description: Option<String>,
pub base_url: Option<String>,
pub enabled: Option<bool>,
pub tags: Option<serde_json::Value>,
pub routes: Option<serde_json::Value>,
#[serde(default, deserialize_with = "deserialize_optional_nullable_uuid")]
pub workspace_id: Option<Option<Uuid>>,
}
pub async fn update_service(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
headers: HeaderMap,
Path(id): Path<Uuid>,
Json(request): Json<UpdateServiceRequest>,
) -> ApiResult<Json<CloudService>> {
let org_ctx = resolve_org_context(&state, user_id, &headers, None)
.await
.map_err(|_| ApiError::InvalidRequest("Organization not found".to_string()))?;
let existing = state
.store
.find_cloud_service_by_id(id)
.await?
.ok_or_else(|| ApiError::InvalidRequest("Service not found".to_string()))?;
if existing.org_id != org_ctx.org_id {
return Err(ApiError::InvalidRequest(
"Service does not belong to this organization".to_string(),
));
}
if let Some(Some(workspace_id)) = request.workspace_id {
ensure_workspace_in_org(&state, org_ctx.org_id, workspace_id).await?;
}
let service = state
.store
.update_cloud_service(
id,
request.name.as_deref(),
request.description.as_deref(),
request.base_url.as_deref(),
request.enabled,
request.tags.as_ref(),
request.routes.as_ref(),
request.workspace_id,
)
.await?
.ok_or_else(|| ApiError::InvalidRequest("Service not found".to_string()))?;
let ip = headers
.get("X-Forwarded-For")
.or_else(|| headers.get("X-Real-IP"))
.and_then(|h| h.to_str().ok())
.map(|s| s.split(',').next().unwrap_or(s).trim());
let ua = headers.get("User-Agent").and_then(|h| h.to_str().ok());
state
.store
.record_audit_event(
org_ctx.org_id,
Some(user_id),
AuditEventType::ServiceUpdated,
format!("Service '{}' updated", service.name),
Some(serde_json::json!({ "service_id": service.id })),
ip,
ua,
)
.await;
Ok(Json(service))
}
pub async fn delete_service(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
headers: HeaderMap,
Path(id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> {
let org_ctx = resolve_org_context(&state, user_id, &headers, None)
.await
.map_err(|_| ApiError::InvalidRequest("Organization not found".to_string()))?;
let service = state
.store
.find_cloud_service_by_id(id)
.await?
.ok_or_else(|| ApiError::InvalidRequest("Service not found".to_string()))?;
if service.org_id != org_ctx.org_id {
return Err(ApiError::InvalidRequest(
"Service does not belong to this organization".to_string(),
));
}
let ip = headers
.get("X-Forwarded-For")
.or_else(|| headers.get("X-Real-IP"))
.and_then(|h| h.to_str().ok())
.map(|s| s.split(',').next().unwrap_or(s).trim());
let ua = headers.get("User-Agent").and_then(|h| h.to_str().ok());
state
.store
.record_audit_event(
org_ctx.org_id,
Some(user_id),
AuditEventType::ServiceDeleted,
format!("Service '{}' deleted", service.name),
Some(serde_json::json!({ "service_id": service.id, "name": service.name })),
ip,
ua,
)
.await;
state.store.delete_cloud_service(id).await?;
Ok(Json(serde_json::json!({ "success": true })))
}