use axum::extract::{Path as AxumPath, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use mockforge_core::ai_contract_diff::{ContractDiffAnalyzer, ContractDiffConfig};
use mockforge_core::openapi::OpenApiSpec;
use mockforge_core::request_capture::get_global_capture_manager;
use serde::Deserialize;
use std::sync::Arc;
#[derive(Clone)]
pub struct ContractDiffApiState {
pub spec_path: Option<String>,
}
impl ContractDiffApiState {
pub fn new(spec_path: Option<String>) -> Self {
Self { spec_path }
}
}
#[derive(Debug, Deserialize)]
struct CapturesQuery {
#[serde(default)]
limit: Option<usize>,
}
async fn list_captures_handler(Query(q): Query<CapturesQuery>) -> Response {
let Some(manager) = get_global_capture_manager() else {
return capture_manager_unavailable();
};
let limit = q.limit.unwrap_or(100).min(1000);
let captures = manager.get_recent_captures(Some(limit)).await;
let payload: Vec<serde_json::Value> = captures
.into_iter()
.map(|(request, metadata)| {
serde_json::json!({
"id": metadata.id,
"captured_at": metadata.captured_at,
"source": metadata.source,
"analyzed": metadata.analyzed,
"request": request,
})
})
.collect();
Json(serde_json::json!({
"count": payload.len(),
"captures": payload,
}))
.into_response()
}
async fn get_capture_handler(AxumPath(id): AxumPath<String>) -> Response {
let Some(manager) = get_global_capture_manager() else {
return capture_manager_unavailable();
};
match manager.get_capture(&id).await {
Some((request, metadata)) => Json(serde_json::json!({
"id": metadata.id,
"captured_at": metadata.captured_at,
"source": metadata.source,
"analyzed": metadata.analyzed,
"request": request,
}))
.into_response(),
None => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "capture_not_found",
"message": format!("No capture with id '{}'", id),
})),
)
.into_response(),
}
}
async fn delete_captures_handler() -> Response {
let Some(manager) = get_global_capture_manager() else {
return capture_manager_unavailable();
};
manager.clear_captures().await;
StatusCode::NO_CONTENT.into_response()
}
async fn statistics_handler() -> Response {
let Some(manager) = get_global_capture_manager() else {
return capture_manager_unavailable();
};
let stats = manager.get_statistics().await;
Json(stats).into_response()
}
#[derive(Debug, Deserialize)]
struct AnalyzeAllQuery {
#[serde(default)]
limit: Option<usize>,
}
async fn analyze_one_handler(
State(state): State<Arc<ContractDiffApiState>>,
AxumPath(id): AxumPath<String>,
) -> Response {
let Some(manager) = get_global_capture_manager() else {
return capture_manager_unavailable();
};
let Some(spec_path) = state.spec_path.as_ref() else {
return spec_unavailable();
};
let (request, _metadata) = match manager.get_capture(&id).await {
Some(c) => c,
None => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "capture_not_found",
"message": format!("No capture with id '{}'", id),
})),
)
.into_response();
}
};
let spec = match OpenApiSpec::from_file(spec_path).await {
Ok(s) => s,
Err(e) => return spec_load_failed(&e.to_string()),
};
let analyzer = match ContractDiffAnalyzer::new(ContractDiffConfig::default()) {
Ok(a) => a,
Err(e) => return analyzer_init_failed(&e.to_string()),
};
match analyzer.analyze(&request, &spec).await {
Ok(result) => Json(result).into_response(),
Err(e) => analyzer_failed(&e.to_string()),
}
}
async fn analyze_all_handler(
State(state): State<Arc<ContractDiffApiState>>,
Query(q): Query<AnalyzeAllQuery>,
) -> Response {
let Some(manager) = get_global_capture_manager() else {
return capture_manager_unavailable();
};
let Some(spec_path) = state.spec_path.as_ref() else {
return spec_unavailable();
};
let limit = q.limit.unwrap_or(50).min(500);
let captures = manager.get_recent_captures(Some(limit)).await;
if captures.is_empty() {
return Json(serde_json::json!({ "results": [], "analyzed": 0 })).into_response();
}
let spec = match OpenApiSpec::from_file(spec_path).await {
Ok(s) => s,
Err(e) => return spec_load_failed(&e.to_string()),
};
let analyzer = match ContractDiffAnalyzer::new(ContractDiffConfig::default()) {
Ok(a) => a,
Err(e) => return analyzer_init_failed(&e.to_string()),
};
let mut results = Vec::with_capacity(captures.len());
for (request, metadata) in &captures {
match analyzer.analyze(request, &spec).await {
Ok(result) => {
results.push(serde_json::json!({
"capture_id": metadata.id,
"ok": true,
"result": result,
}));
}
Err(e) => {
results.push(serde_json::json!({
"capture_id": metadata.id,
"ok": false,
"error": e.to_string(),
}));
}
}
}
Json(serde_json::json!({
"analyzed": results.len(),
"results": results,
}))
.into_response()
}
fn capture_manager_unavailable() -> Response {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({
"error": "capture_manager_not_initialised",
"message": "Request capture is not enabled on this deployment",
})),
)
.into_response()
}
fn spec_unavailable() -> Response {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({
"error": "no_openapi_spec",
"message": "Analysis requires the deployment to be running with an OpenAPI spec",
})),
)
.into_response()
}
fn spec_load_failed(err: &str) -> Response {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "spec_load_failed",
"message": err,
})),
)
.into_response()
}
fn analyzer_init_failed(err: &str) -> Response {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "analyzer_init_failed",
"message": err,
})),
)
.into_response()
}
fn analyzer_failed(err: &str) -> Response {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "analyze_failed",
"message": err,
})),
)
.into_response()
}
pub fn contract_diff_api_router(state: Arc<ContractDiffApiState>) -> Router {
Router::new()
.route("/captures", get(list_captures_handler).delete(delete_captures_handler))
.route("/captures/{id}", get(get_capture_handler))
.route("/statistics", get(statistics_handler))
.route("/analyze", post(analyze_all_handler))
.route("/analyze/{id}", post(analyze_one_handler))
.with_state(state)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn state_holds_optional_spec_path() {
let s = ContractDiffApiState::new(None);
assert!(s.spec_path.is_none());
let s = ContractDiffApiState::new(Some("/tmp/spec.yaml".to_string()));
assert_eq!(s.spec_path.as_deref(), Some("/tmp/spec.yaml"));
}
}