diff options
Diffstat (limited to 'makima/src/server/handlers/chains.rs')
| -rw-r--r-- | makima/src/server/handlers/chains.rs | 609 |
1 files changed, 609 insertions, 0 deletions
diff --git a/makima/src/server/handlers/chains.rs b/makima/src/server/handlers/chains.rs new file mode 100644 index 0000000..136a868 --- /dev/null +++ b/makima/src/server/handlers/chains.rs @@ -0,0 +1,609 @@ +//! 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<String>, + /// 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<ChainSummary>, + 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<SharedState>, + Authenticated(auth): Authenticated, + Query(query): Query<ListChainsQuery>, +) -> 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<SharedState>, + Authenticated(auth): Authenticated, + Json(req): Json<CreateChainRequest>, +) -> 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<SharedState>, + Authenticated(auth): Authenticated, + Path(chain_id): Path<Uuid>, +) -> 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<SharedState>, + Authenticated(auth): Authenticated, + Path(chain_id): Path<Uuid>, + Json(req): Json<UpdateChainRequest>, +) -> 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<SharedState>, + Authenticated(auth): Authenticated, + Path(chain_id): Path<Uuid>, +) -> 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<ChainContractDetail>), + (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<SharedState>, + Authenticated(auth): Authenticated, + Path(chain_id): Path<Uuid>, +) -> 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<SharedState>, + Authenticated(auth): Authenticated, + Path(chain_id): Path<Uuid>, +) -> 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<ChainEvent>), + (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<SharedState>, + Authenticated(auth): Authenticated, + Path(chain_id): Path<Uuid>, +) -> 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<SharedState>, + Authenticated(auth): Authenticated, + Path(chain_id): Path<Uuid>, +) -> 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() + } + } +} |
