Skip to main content

mockforge_registry_server/handlers/
cloud_services.rs

1//! Service CRUD handlers for cloud mode
2
3use axum::{
4    extract::{Path, Query, State},
5    http::HeaderMap,
6    Json,
7};
8use serde::{de::Deserializer, Deserialize};
9use uuid::Uuid;
10
11/// Custom deserializer that distinguishes a missing field from an explicit
12/// `null`. Used so PATCH bodies can express "unassign this relation" with
13/// `{"workspace_id": null}` while an absent key still means "leave unchanged".
14fn deserialize_optional_nullable_uuid<'de, D>(
15    deserializer: D,
16) -> Result<Option<Option<Uuid>>, D::Error>
17where
18    D: Deserializer<'de>,
19{
20    Option::<Uuid>::deserialize(deserializer).map(Some)
21}
22
23use crate::{
24    error::{ApiError, ApiResult},
25    middleware::{resolve_org_context, AuthUser},
26    models::{cloud_service::CloudService, AuditEventType, FeatureType},
27    AppState,
28};
29
30async fn ensure_workspace_in_org(
31    state: &AppState,
32    org_id: Uuid,
33    workspace_id: Uuid,
34) -> ApiResult<()> {
35    let workspace = state
36        .store
37        .find_cloud_workspace_by_id(workspace_id)
38        .await?
39        .ok_or_else(|| ApiError::InvalidRequest("Workspace not found".to_string()))?;
40    if workspace.org_id != org_id {
41        return Err(ApiError::InvalidRequest(
42            "Workspace does not belong to this organization".to_string(),
43        ));
44    }
45    Ok(())
46}
47
48#[derive(Debug, Deserialize, Default)]
49pub struct ListServicesQuery {
50    #[serde(default)]
51    pub workspace_id: Option<Uuid>,
52}
53
54pub async fn list_services(
55    State(state): State<AppState>,
56    AuthUser(user_id): AuthUser,
57    headers: HeaderMap,
58    Query(params): Query<ListServicesQuery>,
59) -> ApiResult<Json<Vec<CloudService>>> {
60    let org_ctx = resolve_org_context(&state, user_id, &headers, None)
61        .await
62        .map_err(|_| ApiError::InvalidRequest("Organization not found".to_string()))?;
63
64    let services = if let Some(workspace_id) = params.workspace_id {
65        ensure_workspace_in_org(&state, org_ctx.org_id, workspace_id).await?;
66        state
67            .store
68            .list_cloud_services_by_workspace(org_ctx.org_id, workspace_id)
69            .await?
70    } else {
71        state.store.list_cloud_services_by_org(org_ctx.org_id).await?
72    };
73
74    Ok(Json(services))
75}
76
77pub async fn get_service(
78    State(state): State<AppState>,
79    AuthUser(user_id): AuthUser,
80    headers: HeaderMap,
81    Path(id): Path<Uuid>,
82) -> ApiResult<Json<CloudService>> {
83    let org_ctx = resolve_org_context(&state, user_id, &headers, None)
84        .await
85        .map_err(|_| ApiError::InvalidRequest("Organization not found".to_string()))?;
86
87    let service = state
88        .store
89        .find_cloud_service_by_id(id)
90        .await?
91        .ok_or_else(|| ApiError::InvalidRequest("Service not found".to_string()))?;
92
93    if service.org_id != org_ctx.org_id {
94        return Err(ApiError::InvalidRequest(
95            "Service does not belong to this organization".to_string(),
96        ));
97    }
98
99    Ok(Json(service))
100}
101
102#[derive(Debug, Deserialize)]
103pub struct CreateServiceRequest {
104    pub name: String,
105    #[serde(default)]
106    pub description: String,
107    #[serde(default)]
108    pub base_url: String,
109    #[serde(default)]
110    pub workspace_id: Option<Uuid>,
111}
112
113pub async fn create_service(
114    State(state): State<AppState>,
115    AuthUser(user_id): AuthUser,
116    headers: HeaderMap,
117    Json(request): Json<CreateServiceRequest>,
118) -> ApiResult<Json<CloudService>> {
119    let org_ctx = resolve_org_context(&state, user_id, &headers, None)
120        .await
121        .map_err(|_| ApiError::InvalidRequest("Organization not found".to_string()))?;
122
123    if request.name.trim().is_empty() {
124        return Err(ApiError::InvalidRequest("Service name is required".to_string()));
125    }
126
127    if let Some(workspace_id) = request.workspace_id {
128        ensure_workspace_in_org(&state, org_ctx.org_id, workspace_id).await?;
129    }
130
131    let service = state
132        .store
133        .create_cloud_service(
134            org_ctx.org_id,
135            request.workspace_id,
136            user_id,
137            request.name.trim(),
138            &request.description,
139            &request.base_url,
140        )
141        .await?;
142
143    state
144        .store
145        .record_feature_usage(
146            org_ctx.org_id,
147            Some(user_id),
148            FeatureType::ServiceCreate,
149            Some(serde_json::json!({ "service_id": service.id, "name": service.name })),
150        )
151        .await;
152
153    let ip = headers
154        .get("X-Forwarded-For")
155        .or_else(|| headers.get("X-Real-IP"))
156        .and_then(|h| h.to_str().ok())
157        .map(|s| s.split(',').next().unwrap_or(s).trim());
158    let ua = headers.get("User-Agent").and_then(|h| h.to_str().ok());
159
160    state
161        .store
162        .record_audit_event(
163            org_ctx.org_id,
164            Some(user_id),
165            AuditEventType::ServiceCreated,
166            format!("Service '{}' created", service.name),
167            Some(serde_json::json!({ "service_id": service.id })),
168            ip,
169            ua,
170        )
171        .await;
172
173    Ok(Json(service))
174}
175
176#[derive(Debug, Deserialize)]
177pub struct UpdateServiceRequest {
178    pub name: Option<String>,
179    pub description: Option<String>,
180    pub base_url: Option<String>,
181    pub enabled: Option<bool>,
182    pub tags: Option<serde_json::Value>,
183    pub routes: Option<serde_json::Value>,
184    /// Tri-state: `None` = field absent (leave unchanged);
185    /// `Some(None)` = explicit `null` (unassign from workspace);
186    /// `Some(Some(id))` = assign to workspace `id`.
187    #[serde(default, deserialize_with = "deserialize_optional_nullable_uuid")]
188    pub workspace_id: Option<Option<Uuid>>,
189}
190
191pub async fn update_service(
192    State(state): State<AppState>,
193    AuthUser(user_id): AuthUser,
194    headers: HeaderMap,
195    Path(id): Path<Uuid>,
196    Json(request): Json<UpdateServiceRequest>,
197) -> ApiResult<Json<CloudService>> {
198    let org_ctx = resolve_org_context(&state, user_id, &headers, None)
199        .await
200        .map_err(|_| ApiError::InvalidRequest("Organization not found".to_string()))?;
201
202    let existing = state
203        .store
204        .find_cloud_service_by_id(id)
205        .await?
206        .ok_or_else(|| ApiError::InvalidRequest("Service not found".to_string()))?;
207
208    if existing.org_id != org_ctx.org_id {
209        return Err(ApiError::InvalidRequest(
210            "Service does not belong to this organization".to_string(),
211        ));
212    }
213
214    // Only validate ownership when the caller is reassigning to a specific
215    // workspace — explicit `null` (unassign) needs no validation.
216    if let Some(Some(workspace_id)) = request.workspace_id {
217        ensure_workspace_in_org(&state, org_ctx.org_id, workspace_id).await?;
218    }
219
220    let service = state
221        .store
222        .update_cloud_service(
223            id,
224            request.name.as_deref(),
225            request.description.as_deref(),
226            request.base_url.as_deref(),
227            request.enabled,
228            request.tags.as_ref(),
229            request.routes.as_ref(),
230            request.workspace_id,
231        )
232        .await?
233        .ok_or_else(|| ApiError::InvalidRequest("Service not found".to_string()))?;
234
235    let ip = headers
236        .get("X-Forwarded-For")
237        .or_else(|| headers.get("X-Real-IP"))
238        .and_then(|h| h.to_str().ok())
239        .map(|s| s.split(',').next().unwrap_or(s).trim());
240    let ua = headers.get("User-Agent").and_then(|h| h.to_str().ok());
241
242    state
243        .store
244        .record_audit_event(
245            org_ctx.org_id,
246            Some(user_id),
247            AuditEventType::ServiceUpdated,
248            format!("Service '{}' updated", service.name),
249            Some(serde_json::json!({ "service_id": service.id })),
250            ip,
251            ua,
252        )
253        .await;
254
255    Ok(Json(service))
256}
257
258pub async fn delete_service(
259    State(state): State<AppState>,
260    AuthUser(user_id): AuthUser,
261    headers: HeaderMap,
262    Path(id): Path<Uuid>,
263) -> ApiResult<Json<serde_json::Value>> {
264    let org_ctx = resolve_org_context(&state, user_id, &headers, None)
265        .await
266        .map_err(|_| ApiError::InvalidRequest("Organization not found".to_string()))?;
267
268    let service = state
269        .store
270        .find_cloud_service_by_id(id)
271        .await?
272        .ok_or_else(|| ApiError::InvalidRequest("Service not found".to_string()))?;
273
274    if service.org_id != org_ctx.org_id {
275        return Err(ApiError::InvalidRequest(
276            "Service does not belong to this organization".to_string(),
277        ));
278    }
279
280    let ip = headers
281        .get("X-Forwarded-For")
282        .or_else(|| headers.get("X-Real-IP"))
283        .and_then(|h| h.to_str().ok())
284        .map(|s| s.split(',').next().unwrap_or(s).trim());
285    let ua = headers.get("User-Agent").and_then(|h| h.to_str().ok());
286
287    state
288        .store
289        .record_audit_event(
290            org_ctx.org_id,
291            Some(user_id),
292            AuditEventType::ServiceDeleted,
293            format!("Service '{}' deleted", service.name),
294            Some(serde_json::json!({ "service_id": service.id, "name": service.name })),
295            ip,
296            ua,
297        )
298        .await;
299
300    state.store.delete_cloud_service(id).await?;
301
302    Ok(Json(serde_json::json!({ "success": true })))
303}