//! HTTP handlers for chain CRUD operations. //! //! Chains are DAGs (directed acyclic graphs) of contracts for multi-contract orchestration. use axum::{ extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, Json, }; use serde::Deserialize; use utoipa::ToSchema; use uuid::Uuid; use crate::db::models::{ ChainContractDetail, ChainEditorData, ChainEvent, ChainGraphResponse, ChainSummary, ChainWithContracts, CreateChainRequest, UpdateChainRequest, }; use crate::db::repository::{self, RepositoryError}; use crate::server::auth::Authenticated; use crate::server::messages::ApiError; use crate::server::state::SharedState; // ============================================================================= // Query Parameters // ============================================================================= /// Query parameters for listing chains. #[derive(Debug, Deserialize, ToSchema)] pub struct ListChainsQuery { /// Filter by status (active, completed, archived) pub status: Option, /// Maximum number of results #[serde(default = "default_limit")] pub limit: i32, /// Offset for pagination #[serde(default)] pub offset: i32, } fn default_limit() -> i32 { 50 } // ============================================================================= // Response Types // ============================================================================= /// Response for listing chains. #[derive(Debug, serde::Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ChainListResponse { pub chains: Vec, pub total: i64, } // ============================================================================= // Handlers // ============================================================================= /// List chains for the authenticated user. /// /// GET /api/v1/chains #[utoipa::path( get, path = "/api/v1/chains", responses( (status = 200, description = "List of chains", body = ChainListResponse), (status = 401, description = "Unauthorized", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError) ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Chains" )] pub async fn list_chains( State(state): State, Authenticated(auth): Authenticated, Query(query): Query, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; match repository::list_chains_for_owner(pool, auth.owner_id).await { Ok(mut chains) => { // Apply filters if let Some(status) = &query.status { chains.retain(|c| c.status == *status); } // Apply pagination let total = chains.len() as i64; let chains: Vec<_> = chains .into_iter() .skip(query.offset as usize) .take(query.limit as usize) .collect(); Json(ChainListResponse { chains, total }).into_response() } Err(e) => { tracing::error!("Failed to list chains: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } /// Create a new chain with contracts. /// /// POST /api/v1/chains #[utoipa::path( post, path = "/api/v1/chains", request_body = CreateChainRequest, responses( (status = 201, description = "Chain created"), (status = 400, description = "Invalid request", body = ApiError), (status = 401, description = "Unauthorized", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError) ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Chains" )] pub async fn create_chain( State(state): State, Authenticated(auth): Authenticated, Json(req): Json, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Validate the request if req.name.trim().is_empty() { return ( StatusCode::BAD_REQUEST, Json(ApiError::new("VALIDATION_ERROR", "Chain name cannot be empty")), ) .into_response(); } match repository::create_chain_for_owner(pool, auth.owner_id, req).await { Ok(chain) => (StatusCode::CREATED, Json(chain)).into_response(), Err(e) => { tracing::error!("Failed to create chain: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } /// Get a chain by ID. /// /// GET /api/v1/chains/{id} #[utoipa::path( get, path = "/api/v1/chains/{id}", params( ("id" = Uuid, Path, description = "Chain ID") ), responses( (status = 200, description = "Chain with contracts", body = ChainWithContracts), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Chain not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError) ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Chains" )] pub async fn get_chain( State(state): State, Authenticated(auth): Authenticated, Path(chain_id): Path, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; match repository::get_chain_with_contracts(pool, chain_id, auth.owner_id).await { Ok(Some(chain)) => Json(chain).into_response(), Ok(None) => ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Chain not found")), ) .into_response(), Err(e) => { tracing::error!("Failed to get chain: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } /// Update a chain. /// /// PUT /api/v1/chains/{id} #[utoipa::path( put, path = "/api/v1/chains/{id}", params( ("id" = Uuid, Path, description = "Chain ID") ), request_body = UpdateChainRequest, responses( (status = 200, description = "Chain updated"), (status = 400, description = "Invalid request", body = ApiError), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Chain not found", body = ApiError), (status = 409, description = "Version conflict", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError) ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Chains" )] pub async fn update_chain( State(state): State, Authenticated(auth): Authenticated, Path(chain_id): Path, Json(req): Json, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; match repository::update_chain_for_owner(pool, chain_id, auth.owner_id, req).await { Ok(chain) => Json(chain).into_response(), Err(RepositoryError::VersionConflict { expected, actual }) => ( StatusCode::CONFLICT, Json(ApiError::new( "VERSION_CONFLICT", format!("Version conflict: expected {}, found {}", expected, actual), )), ) .into_response(), Err(RepositoryError::Database(e)) => { // Check if it's a "row not found" error let error_str = e.to_string(); if error_str.contains("no rows") || error_str.contains("RowNotFound") { ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Chain not found")), ) .into_response() } else { tracing::error!("Failed to update chain: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } } /// Delete (archive) a chain. /// /// DELETE /api/v1/chains/{id} #[utoipa::path( delete, path = "/api/v1/chains/{id}", params( ("id" = Uuid, Path, description = "Chain ID") ), responses( (status = 200, description = "Chain archived"), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Chain not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError) ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Chains" )] pub async fn delete_chain( State(state): State, Authenticated(auth): Authenticated, Path(chain_id): Path, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; match repository::delete_chain_for_owner(pool, chain_id, auth.owner_id).await { Ok(true) => Json(serde_json::json!({"archived": true})).into_response(), Ok(false) => ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Chain not found")), ) .into_response(), Err(e) => { tracing::error!("Failed to delete chain: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } /// Get contracts in a chain. /// /// GET /api/v1/chains/{id}/contracts #[utoipa::path( get, path = "/api/v1/chains/{id}/contracts", params( ("id" = Uuid, Path, description = "Chain ID") ), responses( (status = 200, description = "List of contracts in chain", body = Vec), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Chain not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError) ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Chains" )] pub async fn get_chain_contracts( State(state): State, Authenticated(auth): Authenticated, Path(chain_id): Path, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Verify ownership match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { Ok(Some(_)) => {} Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Chain not found")), ) .into_response(); } Err(e) => { tracing::error!("Failed to verify chain ownership: {}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } } match repository::list_chain_contracts(pool, chain_id).await { Ok(contracts) => Json(contracts).into_response(), Err(e) => { tracing::error!("Failed to list chain contracts: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } /// Get chain DAG structure for visualization. /// /// GET /api/v1/chains/{id}/graph #[utoipa::path( get, path = "/api/v1/chains/{id}/graph", params( ("id" = Uuid, Path, description = "Chain ID") ), responses( (status = 200, description = "Chain graph structure", body = ChainGraphResponse), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Chain not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError) ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Chains" )] pub async fn get_chain_graph( State(state): State, Authenticated(auth): Authenticated, Path(chain_id): Path, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Verify ownership first match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { Ok(Some(_)) => {} Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Chain not found")), ) .into_response(); } Err(e) => { tracing::error!("Failed to verify chain ownership: {}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } } match repository::get_chain_graph(pool, chain_id).await { Ok(Some(graph)) => Json(graph).into_response(), Ok(None) => ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Chain not found")), ) .into_response(), Err(e) => { tracing::error!("Failed to get chain graph: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } /// Get chain events. /// /// GET /api/v1/chains/{id}/events #[utoipa::path( get, path = "/api/v1/chains/{id}/events", params( ("id" = Uuid, Path, description = "Chain ID") ), responses( (status = 200, description = "Chain events", body = Vec), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Chain not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError) ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Chains" )] pub async fn get_chain_events( State(state): State, Authenticated(auth): Authenticated, Path(chain_id): Path, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Verify ownership match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await { Ok(Some(_)) => {} Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Chain not found")), ) .into_response(); } Err(e) => { tracing::error!("Failed to verify chain ownership: {}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } } match repository::list_chain_events(pool, chain_id).await { Ok(events) => Json(events).into_response(), Err(e) => { tracing::error!("Failed to list chain events: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } /// Get chain editor data. /// /// GET /api/v1/chains/{id}/editor #[utoipa::path( get, path = "/api/v1/chains/{id}/editor", params( ("id" = Uuid, Path, description = "Chain ID") ), responses( (status = 200, description = "Chain editor data", body = ChainEditorData), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Chain not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError) ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Chains" )] pub async fn get_chain_editor( State(state): State, Authenticated(auth): Authenticated, Path(chain_id): Path, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; match repository::get_chain_editor_data(pool, chain_id, auth.owner_id).await { Ok(Some(editor_data)) => Json(editor_data).into_response(), Ok(None) => ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Chain not found")), ) .into_response(), Err(e) => { tracing::error!("Failed to get chain editor data: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } }