mockforge-registry-server 0.3.131

Plugin registry server for MockForge
Documentation
//! Service CRUD handlers for cloud mode

use axum::{
    extract::{Path, Query, State},
    http::HeaderMap,
    Json,
};
use serde::{de::Deserializer, Deserialize};
use uuid::Uuid;

/// Custom deserializer that distinguishes a missing field from an explicit
/// `null`. Used so PATCH bodies can express "unassign this relation" with
/// `{"workspace_id": null}` while an absent key still means "leave unchanged".
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>,
    /// Tri-state: `None` = field absent (leave unchanged);
    /// `Some(None)` = explicit `null` (unassign from workspace);
    /// `Some(Some(id))` = assign to workspace `id`.
    #[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(),
        ));
    }

    // Only validate ownership when the caller is reassigning to a specific
    // workspace — explicit `null` (unassign) needs no validation.
    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 })))
}