mockforge_registry_server/handlers/
cloud_services.rs1use axum::{
4 extract::{Path, Query, State},
5 http::HeaderMap,
6 Json,
7};
8use serde::{de::Deserializer, Deserialize};
9use uuid::Uuid;
10
11fn 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 #[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 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}