Skip to main content

mockforge_registry_server/handlers/
contract_verification.rs

1//! Contract Diff / Verification / Fitness Functions handlers
2//! (cloud-enablement task #8 / Phase 1).
3//!
4//! Phase 1 surface: monitored-service CRUD, fitness-function CRUD,
5//! verification-suite CRUD, diff-run + finding read paths. Probe worker
6//! / scheduler / IncidentBus wiring land in follow-up slices.
7//!
8//! Routes:
9//!   GET    /api/v1/workspaces/{workspace_id}/monitored-services
10//!   POST   /api/v1/workspaces/{workspace_id}/monitored-services
11//!   DELETE /api/v1/monitored-services/{id}
12//!   GET    /api/v1/monitored-services/{id}/diffs
13//!   GET    /api/v1/contract-diff-runs/{id}
14//!   GET    /api/v1/contract-diff-runs/{id}/findings
15//!
16//!   GET    /api/v1/workspaces/{workspace_id}/fitness-functions
17//!   POST   /api/v1/workspaces/{workspace_id}/fitness-functions
18//!   DELETE /api/v1/fitness-functions/{id}
19//!
20//!   GET    /api/v1/workspaces/{workspace_id}/verification-suites
21//!   POST   /api/v1/workspaces/{workspace_id}/verification-suites
22//!   DELETE /api/v1/verification-suites/{id}
23
24use 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
47// --- monitored services ----------------------------------------------------
48
49/// `GET /api/v1/workspaces/{workspace_id}/monitored-services`
50pub 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
78/// `POST /api/v1/workspaces/{workspace_id}/monitored-services`
79pub 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
119/// `DELETE /api/v1/monitored-services/{id}`
120pub 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
141/// `GET /api/v1/monitored-services/{id}/diffs`
142pub 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; // reserved for future ?limit= query
158    Ok(Json(runs))
159}
160
161/// `GET /api/v1/contract-diff-runs/{id}`
162pub 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
180/// `GET /api/v1/contract-diff-runs/{id}/findings`
181pub 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
203/// `POST /api/v1/monitored-services/{id}/diff`
204///
205/// Triggers a contract diff run. Same lifecycle pattern as #4 test runs:
206/// pushes a test_runs row + Redis job with kind='contract_diff'. The
207/// runner-side ContractExecutor synthesizes findings until real impl
208/// (ai_contract_diff pipeline) lands.
209pub 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
264// --- fitness functions -----------------------------------------------------
265
266/// `GET /api/v1/workspaces/{workspace_id}/fitness-functions`
267pub 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
287/// `POST /api/v1/workspaces/{workspace_id}/fitness-functions`
288pub 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    // `custom_query` runs arbitrary user-supplied evaluator code on
307    // self-hosted MockForge — fine for trusted single-tenant
308    // deployments, not safe to honour on shared cloud workers
309    // (would let any workspace owner execute code on the runner
310    // pool). Cloud rejects the kind at this boundary so a row in
311    // the state matching `kind='custom_query'` never exists in
312    // cloud, even if the user tried to bypass the UI gate.
313    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
335/// `PATCH /api/v1/fitness-functions/{id}`
336///
337/// Replace name + kind + config on an existing fitness function. The
338/// surface mirrors `create_fitness_function` so the cloud UI can reuse
339/// the same form payload for both create and edit.
340///
341/// 404 InvalidRequest when the row doesn't exist or the caller's org
342/// doesn't own its workspace (cross-org access surfaces as not-found
343/// to avoid leaking existence — matches `delete_fitness_function`).
344pub 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    // Existence + auth check via the existing row's workspace_id, mirroring
352    // delete_fitness_function. The body's workspace_id is implicit (we don't
353    // allow re-homing fitness functions across workspaces in the same call).
354    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    // Lost a race with a concurrent delete — surface as not-found, same
380    // as the cross-org check above.
381    .ok_or_else(|| ApiError::InvalidRequest("Fitness function not found".into()))?;
382    Ok(Json(row))
383}
384
385/// `DELETE /api/v1/fitness-functions/{id}`
386pub 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
405// --- verification suites ---------------------------------------------------
406
407/// `GET /api/v1/workspaces/{workspace_id}/verification-suites`
408pub 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
430/// `POST /api/v1/workspaces/{workspace_id}/verification-suites`
431pub 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
461/// `DELETE /api/v1/verification-suites/{id}`
462pub 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}