1use axum::{
25 extract::{Path, State},
26 http::HeaderMap,
27 Json,
28};
29use mockforge_registry_core::models::contract_verification::CreateMonitoredService;
30use mockforge_registry_core::models::test_run::EnqueueTestRun;
31use serde::Deserialize;
32use uuid::Uuid;
33
34use crate::{
35 error::{ApiError, ApiResult},
36 middleware::{resolve_org_context, AuthUser},
37 models::{
38 CloudWorkspace, ContractDiffFinding, ContractDiffRun, FitnessFunction, MonitoredService,
39 TestRun, VerificationSuite,
40 },
41 AppState,
42};
43
44const DEFAULT_RUN_LIMIT: i64 = 50;
45const MAX_RUN_LIMIT: i64 = 500;
46
47pub async fn list_monitored_services(
51 State(state): State<AppState>,
52 AuthUser(user_id): AuthUser,
53 Path(workspace_id): Path<Uuid>,
54 headers: HeaderMap,
55) -> ApiResult<Json<Vec<MonitoredService>>> {
56 authorize_workspace(&state, user_id, &headers, workspace_id).await?;
57 let rows = MonitoredService::list_by_workspace(state.db.pool(), workspace_id)
58 .await
59 .map_err(ApiError::Database)?;
60 Ok(Json(rows))
61}
62
63#[derive(Debug, Deserialize)]
64pub struct CreateMonitoredServiceRequest {
65 pub name: String,
66 pub base_url: String,
67 #[serde(default)]
68 pub openapi_spec_url: Option<String>,
69 #[serde(default)]
70 pub openapi_spec_inline: Option<serde_json::Value>,
71 #[serde(default)]
72 pub auth_config: Option<serde_json::Value>,
73 pub traffic_source: String,
74 #[serde(default)]
75 pub traffic_source_ref: Option<String>,
76}
77
78pub async fn create_monitored_service(
80 State(state): State<AppState>,
81 AuthUser(user_id): AuthUser,
82 Path(workspace_id): Path<Uuid>,
83 headers: HeaderMap,
84 Json(request): Json<CreateMonitoredServiceRequest>,
85) -> ApiResult<Json<MonitoredService>> {
86 authorize_workspace(&state, user_id, &headers, workspace_id).await?;
87
88 if request.name.trim().is_empty() {
89 return Err(ApiError::InvalidRequest("name must not be empty".into()));
90 }
91 if request.base_url.trim().is_empty() {
92 return Err(ApiError::InvalidRequest("base_url must not be empty".into()));
93 }
94 if !MonitoredService::is_valid_traffic_source(&request.traffic_source) {
95 return Err(ApiError::InvalidRequest(format!(
96 "traffic_source must be one of: {}",
97 MonitoredService::VALID_TRAFFIC_SOURCES.join(", ")
98 )));
99 }
100
101 let row = MonitoredService::create(
102 state.db.pool(),
103 CreateMonitoredService {
104 workspace_id,
105 name: &request.name,
106 base_url: &request.base_url,
107 openapi_spec_url: request.openapi_spec_url.as_deref(),
108 openapi_spec_inline: request.openapi_spec_inline.as_ref(),
109 auth_config: request.auth_config.as_ref(),
110 traffic_source: &request.traffic_source,
111 traffic_source_ref: request.traffic_source_ref.as_deref(),
112 },
113 )
114 .await
115 .map_err(ApiError::Database)?;
116 Ok(Json(row))
117}
118
119pub async fn delete_monitored_service(
121 State(state): State<AppState>,
122 AuthUser(user_id): AuthUser,
123 Path(id): Path<Uuid>,
124 headers: HeaderMap,
125) -> ApiResult<Json<serde_json::Value>> {
126 let svc = MonitoredService::find_by_id(state.db.pool(), id)
127 .await
128 .map_err(ApiError::Database)?
129 .ok_or_else(|| ApiError::InvalidRequest("Monitored service not found".into()))?;
130 authorize_workspace(&state, user_id, &headers, svc.workspace_id).await?;
131
132 let deleted = MonitoredService::delete(state.db.pool(), id)
133 .await
134 .map_err(ApiError::Database)?;
135 if !deleted {
136 return Err(ApiError::InvalidRequest("Monitored service not found".into()));
137 }
138 Ok(Json(serde_json::json!({ "deleted": true })))
139}
140
141pub async fn list_service_diff_runs(
143 State(state): State<AppState>,
144 AuthUser(user_id): AuthUser,
145 Path(id): Path<Uuid>,
146 headers: HeaderMap,
147) -> ApiResult<Json<Vec<ContractDiffRun>>> {
148 let svc = MonitoredService::find_by_id(state.db.pool(), id)
149 .await
150 .map_err(ApiError::Database)?
151 .ok_or_else(|| ApiError::InvalidRequest("Monitored service not found".into()))?;
152 authorize_workspace(&state, user_id, &headers, svc.workspace_id).await?;
153
154 let runs = ContractDiffRun::list_by_service(state.db.pool(), id, MAX_RUN_LIMIT)
155 .await
156 .map_err(ApiError::Database)?;
157 let _ = DEFAULT_RUN_LIMIT; Ok(Json(runs))
159}
160
161pub async fn get_diff_run(
163 State(state): State<AppState>,
164 AuthUser(user_id): AuthUser,
165 Path(id): Path<Uuid>,
166 headers: HeaderMap,
167) -> ApiResult<Json<ContractDiffRun>> {
168 let run = ContractDiffRun::find_by_id(state.db.pool(), id)
169 .await
170 .map_err(ApiError::Database)?
171 .ok_or_else(|| ApiError::InvalidRequest("Diff run not found".into()))?;
172 let svc = MonitoredService::find_by_id(state.db.pool(), run.monitored_service_id)
173 .await
174 .map_err(ApiError::Database)?
175 .ok_or_else(|| ApiError::InvalidRequest("Diff run not found".into()))?;
176 authorize_workspace(&state, user_id, &headers, svc.workspace_id).await?;
177 Ok(Json(run))
178}
179
180pub async fn list_diff_findings(
182 State(state): State<AppState>,
183 AuthUser(user_id): AuthUser,
184 Path(id): Path<Uuid>,
185 headers: HeaderMap,
186) -> ApiResult<Json<Vec<ContractDiffFinding>>> {
187 let run = ContractDiffRun::find_by_id(state.db.pool(), id)
188 .await
189 .map_err(ApiError::Database)?
190 .ok_or_else(|| ApiError::InvalidRequest("Diff run not found".into()))?;
191 let svc = MonitoredService::find_by_id(state.db.pool(), run.monitored_service_id)
192 .await
193 .map_err(ApiError::Database)?
194 .ok_or_else(|| ApiError::InvalidRequest("Diff run not found".into()))?;
195 authorize_workspace(&state, user_id, &headers, svc.workspace_id).await?;
196
197 let findings = ContractDiffFinding::list_by_run(state.db.pool(), id)
198 .await
199 .map_err(ApiError::Database)?;
200 Ok(Json(findings))
201}
202
203pub async fn trigger_diff_run(
210 State(state): State<AppState>,
211 AuthUser(user_id): AuthUser,
212 Path(id): Path<Uuid>,
213 headers: HeaderMap,
214) -> ApiResult<Json<TestRun>> {
215 let svc = MonitoredService::find_by_id(state.db.pool(), id)
216 .await
217 .map_err(ApiError::Database)?
218 .ok_or_else(|| ApiError::InvalidRequest("Monitored service not found".into()))?;
219 authorize_workspace(&state, user_id, &headers, svc.workspace_id).await?;
220
221 let workspace = CloudWorkspace::find_by_id(state.db.pool(), svc.workspace_id)
222 .await?
223 .ok_or_else(|| ApiError::InvalidRequest("Workspace not found".into()))?;
224
225 let run = TestRun::enqueue(
226 state.db.pool(),
227 EnqueueTestRun {
228 suite_id: svc.id,
229 org_id: workspace.org_id,
230 kind: "contract_diff",
231 triggered_by: "manual",
232 triggered_by_user: Some(user_id),
233 git_ref: None,
234 git_sha: None,
235 },
236 )
237 .await
238 .map_err(ApiError::Database)?;
239
240 if let Err(e) = crate::run_queue::enqueue(
241 state.redis.as_ref(),
242 crate::run_queue::EnqueuedJob {
243 run_id: run.id,
244 org_id: run.org_id,
245 source_id: svc.id,
246 kind: "contract_diff",
247 payload: serde_json::json!({
248 "service_name": svc.name,
249 "base_url": svc.base_url,
250 "openapi_spec_url": svc.openapi_spec_url,
251 "traffic_source": svc.traffic_source,
252 "workspace_id": svc.workspace_id,
253 }),
254 },
255 )
256 .await
257 {
258 tracing::error!(run_id = %run.id, error = %e, "failed to enqueue contract_diff run");
259 }
260
261 Ok(Json(run))
262}
263
264pub async fn list_fitness_functions(
268 State(state): State<AppState>,
269 AuthUser(user_id): AuthUser,
270 Path(workspace_id): Path<Uuid>,
271 headers: HeaderMap,
272) -> ApiResult<Json<Vec<FitnessFunction>>> {
273 authorize_workspace(&state, user_id, &headers, workspace_id).await?;
274 let rows = FitnessFunction::list_by_workspace(state.db.pool(), workspace_id)
275 .await
276 .map_err(ApiError::Database)?;
277 Ok(Json(rows))
278}
279
280#[derive(Debug, Deserialize)]
281pub struct CreateFitnessFunctionRequest {
282 pub name: String,
283 pub kind: String,
284 pub config: serde_json::Value,
285}
286
287pub async fn create_fitness_function(
289 State(state): State<AppState>,
290 AuthUser(user_id): AuthUser,
291 Path(workspace_id): Path<Uuid>,
292 headers: HeaderMap,
293 Json(request): Json<CreateFitnessFunctionRequest>,
294) -> ApiResult<Json<FitnessFunction>> {
295 authorize_workspace(&state, user_id, &headers, workspace_id).await?;
296
297 if request.name.trim().is_empty() {
298 return Err(ApiError::InvalidRequest("name must not be empty".into()));
299 }
300 if !FitnessFunction::is_valid_kind(&request.kind) {
301 return Err(ApiError::InvalidRequest(format!(
302 "kind must be one of: {}",
303 FitnessFunction::VALID_KINDS.join(", ")
304 )));
305 }
306 if request.kind == "custom_query" {
314 return Err(ApiError::InvalidRequest(
315 "kind 'custom_query' is self-hosted only — \
316 arbitrary evaluator code isn't supported on cloud workers. \
317 Use latency_threshold, error_rate, or contract_stability instead, \
318 or run a self-hosted MockForge instance."
319 .into(),
320 ));
321 }
322
323 let row = FitnessFunction::create(
324 state.db.pool(),
325 workspace_id,
326 &request.name,
327 &request.kind,
328 &request.config,
329 )
330 .await
331 .map_err(ApiError::Database)?;
332 Ok(Json(row))
333}
334
335pub async fn update_fitness_function(
345 State(state): State<AppState>,
346 AuthUser(user_id): AuthUser,
347 Path(id): Path<Uuid>,
348 headers: HeaderMap,
349 Json(request): Json<CreateFitnessFunctionRequest>,
350) -> ApiResult<Json<FitnessFunction>> {
351 let existing = FitnessFunction::find_by_id(state.db.pool(), id)
355 .await
356 .map_err(ApiError::Database)?
357 .ok_or_else(|| ApiError::InvalidRequest("Fitness function not found".into()))?;
358 authorize_workspace(&state, user_id, &headers, existing.workspace_id).await?;
359
360 if request.name.trim().is_empty() {
361 return Err(ApiError::InvalidRequest("name must not be empty".into()));
362 }
363 if !FitnessFunction::is_valid_kind(&request.kind) {
364 return Err(ApiError::InvalidRequest(format!(
365 "kind must be one of: {}",
366 FitnessFunction::VALID_KINDS.join(", ")
367 )));
368 }
369
370 let row = FitnessFunction::update(
371 state.db.pool(),
372 id,
373 request.name.trim(),
374 &request.kind,
375 &request.config,
376 )
377 .await
378 .map_err(ApiError::Database)?
379 .ok_or_else(|| ApiError::InvalidRequest("Fitness function not found".into()))?;
382 Ok(Json(row))
383}
384
385pub async fn delete_fitness_function(
387 State(state): State<AppState>,
388 AuthUser(user_id): AuthUser,
389 Path(id): Path<Uuid>,
390 headers: HeaderMap,
391) -> ApiResult<Json<serde_json::Value>> {
392 let fn_row = FitnessFunction::find_by_id(state.db.pool(), id)
393 .await
394 .map_err(ApiError::Database)?
395 .ok_or_else(|| ApiError::InvalidRequest("Fitness function not found".into()))?;
396 authorize_workspace(&state, user_id, &headers, fn_row.workspace_id).await?;
397
398 let deleted = FitnessFunction::delete(state.db.pool(), id).await.map_err(ApiError::Database)?;
399 if !deleted {
400 return Err(ApiError::InvalidRequest("Fitness function not found".into()));
401 }
402 Ok(Json(serde_json::json!({ "deleted": true })))
403}
404
405pub async fn list_verification_suites(
409 State(state): State<AppState>,
410 AuthUser(user_id): AuthUser,
411 Path(workspace_id): Path<Uuid>,
412 headers: HeaderMap,
413) -> ApiResult<Json<Vec<VerificationSuite>>> {
414 authorize_workspace(&state, user_id, &headers, workspace_id).await?;
415 let rows = VerificationSuite::list_by_workspace(state.db.pool(), workspace_id)
416 .await
417 .map_err(ApiError::Database)?;
418 Ok(Json(rows))
419}
420
421#[derive(Debug, Deserialize)]
422pub struct CreateVerificationSuiteRequest {
423 pub name: String,
424 #[serde(default)]
425 pub contract_check_ids: Vec<Uuid>,
426 #[serde(default)]
427 pub fitness_function_ids: Vec<Uuid>,
428}
429
430pub async fn create_verification_suite(
432 State(state): State<AppState>,
433 AuthUser(user_id): AuthUser,
434 Path(workspace_id): Path<Uuid>,
435 headers: HeaderMap,
436 Json(request): Json<CreateVerificationSuiteRequest>,
437) -> ApiResult<Json<VerificationSuite>> {
438 authorize_workspace(&state, user_id, &headers, workspace_id).await?;
439
440 if request.name.trim().is_empty() {
441 return Err(ApiError::InvalidRequest("name must not be empty".into()));
442 }
443 if request.contract_check_ids.is_empty() && request.fitness_function_ids.is_empty() {
444 return Err(ApiError::InvalidRequest(
445 "Suite must reference at least one contract check or fitness function".into(),
446 ));
447 }
448
449 let row = VerificationSuite::create(
450 state.db.pool(),
451 workspace_id,
452 &request.name,
453 &request.contract_check_ids,
454 &request.fitness_function_ids,
455 )
456 .await
457 .map_err(ApiError::Database)?;
458 Ok(Json(row))
459}
460
461pub async fn delete_verification_suite(
463 State(state): State<AppState>,
464 AuthUser(user_id): AuthUser,
465 Path(id): Path<Uuid>,
466 headers: HeaderMap,
467) -> ApiResult<Json<serde_json::Value>> {
468 let suite = VerificationSuite::find_by_id(state.db.pool(), id)
469 .await
470 .map_err(ApiError::Database)?
471 .ok_or_else(|| ApiError::InvalidRequest("Verification suite not found".into()))?;
472 authorize_workspace(&state, user_id, &headers, suite.workspace_id).await?;
473
474 let deleted = VerificationSuite::delete(state.db.pool(), id)
475 .await
476 .map_err(ApiError::Database)?;
477 if !deleted {
478 return Err(ApiError::InvalidRequest("Verification suite not found".into()));
479 }
480 Ok(Json(serde_json::json!({ "deleted": true })))
481}
482
483async fn authorize_workspace(
484 state: &AppState,
485 user_id: Uuid,
486 headers: &HeaderMap,
487 workspace_id: Uuid,
488) -> ApiResult<()> {
489 let workspace = CloudWorkspace::find_by_id(state.db.pool(), workspace_id)
490 .await?
491 .ok_or_else(|| ApiError::InvalidRequest("Workspace not found".into()))?;
492 let ctx = resolve_org_context(state, user_id, headers, None)
493 .await
494 .map_err(|_| ApiError::InvalidRequest("Organization not found".into()))?;
495 if ctx.org_id != workspace.org_id {
496 return Err(ApiError::InvalidRequest("Workspace not found".into()));
497 }
498 Ok(())
499}