//! 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::{
AddChainRepositoryRequest, AddContractDefinitionRequest, ChainContractDefinition,
ChainContractDetail, ChainDefinitionGraphResponse, ChainEditorData, ChainEvent,
ChainGraphResponse, ChainRepository, ChainSummary, ChainWithContracts, CreateChainRequest,
InitChainRequest, InitChainResponse, StartChainRequest, StartChainResponse, UpdateChainRequest,
UpdateContractDefinitionRequest,
};
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()
}
}
}
/// Initialize a directive-driven chain.
///
/// Creates a directive contract that will research, plan, create, and orchestrate
/// a chain of contracts to accomplish the given goal. The directive contract goes
/// through Research -> Specify -> Plan -> Execute -> Review phases.
///
/// POST /api/v1/chains/init
#[utoipa::path(
post,
path = "/api/v1/chains/init",
request_body = InitChainRequest,
responses(
(status = 201, description = "Directive chain initialized", body = InitChainResponse),
(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 init_chain(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Json(req): Json<InitChainRequest>,
) -> 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.goal.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(ApiError::new("VALIDATION_ERROR", "Goal cannot be empty")),
)
.into_response();
}
match repository::init_chain_for_owner(pool, auth.owner_id, req).await {
Ok(response) => (StatusCode::CREATED, Json(response)).into_response(),
Err(e) => {
tracing::error!("Failed to initialize directive 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()
}
}
}
// =============================================================================
// Contract Definition Handlers
// =============================================================================
/// List contract definitions for a chain.
///
/// GET /api/v1/chains/{id}/definitions
#[utoipa::path(
get,
path = "/api/v1/chains/{id}/definitions",
params(
("id" = Uuid, Path, description = "Chain ID")
),
responses(
(status = 200, description = "List of contract definitions", body = Vec<ChainContractDefinition>),
(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 list_chain_definitions(
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_contract_definitions(pool, chain_id).await {
Ok(definitions) => Json(definitions).into_response(),
Err(e) => {
tracing::error!("Failed to list chain definitions: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response()
}
}
}
/// Create a contract definition for a chain.
///
/// POST /api/v1/chains/{id}/definitions
#[utoipa::path(
post,
path = "/api/v1/chains/{id}/definitions",
params(
("id" = Uuid, Path, description = "Chain ID")
),
request_body = AddContractDefinitionRequest,
responses(
(status = 201, description = "Contract definition created", body = ChainContractDefinition),
(status = 400, description = "Invalid request", body = ApiError),
(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 create_chain_definition(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path(chain_id): Path<Uuid>,
Json(req): Json<AddContractDefinitionRequest>,
) -> 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", "Definition name cannot be empty")),
)
.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::create_chain_contract_definition(pool, chain_id, req).await {
Ok(definition) => (StatusCode::CREATED, Json(definition)).into_response(),
Err(e) => {
tracing::error!("Failed to create chain definition: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response()
}
}
}
/// Update a contract definition.
///
/// PUT /api/v1/chains/{chain_id}/definitions/{definition_id}
#[utoipa::path(
put,
path = "/api/v1/chains/{chain_id}/definitions/{definition_id}",
params(
("chain_id" = Uuid, Path, description = "Chain ID"),
("definition_id" = Uuid, Path, description = "Definition ID")
),
request_body = UpdateContractDefinitionRequest,
responses(
(status = 200, description = "Contract definition updated", body = ChainContractDefinition),
(status = 400, description = "Invalid request", body = ApiError),
(status = 401, description = "Unauthorized", body = ApiError),
(status = 404, description = "Chain or definition 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 update_chain_definition(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path((chain_id, definition_id)): Path<(Uuid, Uuid)>,
Json(req): Json<UpdateContractDefinitionRequest>,
) -> 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();
}
}
// Verify definition belongs to this chain
match repository::get_chain_contract_definition(pool, definition_id).await {
Ok(Some(def)) if def.chain_id == chain_id => {}
Ok(Some(_)) => {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Definition not found in this chain")),
)
.into_response();
}
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Definition not found")),
)
.into_response();
}
Err(e) => {
tracing::error!("Failed to get chain definition: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response();
}
}
match repository::update_chain_contract_definition(pool, definition_id, req).await {
Ok(definition) => Json(definition).into_response(),
Err(e) => {
tracing::error!("Failed to update chain definition: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response()
}
}
}
/// Delete a contract definition.
///
/// DELETE /api/v1/chains/{chain_id}/definitions/{definition_id}
#[utoipa::path(
delete,
path = "/api/v1/chains/{chain_id}/definitions/{definition_id}",
params(
("chain_id" = Uuid, Path, description = "Chain ID"),
("definition_id" = Uuid, Path, description = "Definition ID")
),
responses(
(status = 200, description = "Contract definition deleted"),
(status = 401, description = "Unauthorized", body = ApiError),
(status = 404, description = "Chain or definition 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_definition(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path((chain_id, definition_id)): Path<(Uuid, 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();
}
}
// Verify definition belongs to this chain before deleting
match repository::get_chain_contract_definition(pool, definition_id).await {
Ok(Some(def)) if def.chain_id == chain_id => {}
Ok(Some(_)) => {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Definition not found in this chain")),
)
.into_response();
}
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Definition not found")),
)
.into_response();
}
Err(e) => {
tracing::error!("Failed to get chain definition: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response();
}
}
match repository::delete_chain_contract_definition(pool, definition_id).await {
Ok(true) => Json(serde_json::json!({"deleted": true})).into_response(),
Ok(false) => (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Definition not found")),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to delete chain definition: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response()
}
}
}
/// Get definition graph for a chain (shows definitions + instantiation status).
///
/// GET /api/v1/chains/{id}/definitions/graph
#[utoipa::path(
get,
path = "/api/v1/chains/{id}/definitions/graph",
params(
("id" = Uuid, Path, description = "Chain ID")
),
responses(
(status = 200, description = "Definition graph structure", body = ChainDefinitionGraphResponse),
(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_definition_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_definition_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 definition graph: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response()
}
}
}
// =============================================================================
// Chain Control Handlers
// =============================================================================
/// Start a chain (spawns supervisor and creates root contracts).
///
/// POST /api/v1/chains/{id}/start
#[utoipa::path(
post,
path = "/api/v1/chains/{id}/start",
params(
("id" = Uuid, Path, description = "Chain ID")
),
request_body(content = Option<StartChainRequest>, description = "Optional start options"),
responses(
(status = 200, description = "Chain started", body = StartChainResponse),
(status = 400, description = "Chain cannot be started", body = ApiError),
(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 start_chain(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path(chain_id): Path<Uuid>,
_body: Option<Json<StartChainRequest>>,
) -> 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 and get chain
let chain = match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await {
Ok(Some(c)) => c,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Chain not found")),
)
.into_response();
}
Err(e) => {
tracing::error!("Failed to get chain: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response();
}
};
// Check if chain can be started
if chain.status == "active" {
return (
StatusCode::BAD_REQUEST,
Json(ApiError::new("ALREADY_ACTIVE", "Chain is already active")),
)
.into_response();
}
if chain.status == "completed" {
return (
StatusCode::BAD_REQUEST,
Json(ApiError::new("ALREADY_COMPLETED", "Chain is already completed")),
)
.into_response();
}
// Get definitions to check if there are any
let definitions = match repository::list_chain_contract_definitions(pool, chain_id).await {
Ok(d) => d,
Err(e) => {
tracing::error!("Failed to list chain definitions: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response();
}
};
if definitions.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(ApiError::new("NO_DEFINITIONS", "Chain has no contract definitions")),
)
.into_response();
}
// Update chain status to active
match repository::update_chain_status(pool, chain_id, "active").await {
Ok(_) => {}
Err(e) => {
tracing::error!("Failed to update chain status: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response();
}
}
// Progress the chain - this creates root contracts (definitions with no dependencies)
let progression = match repository::progress_chain(pool, chain_id, auth.owner_id).await {
Ok(p) => p,
Err(e) => {
tracing::error!("Failed to progress chain: {}", e);
// Chain is active but no contracts created - return partial success
return Json(StartChainResponse {
chain_id,
contracts_created: vec![],
status: "active".to_string(),
})
.into_response();
}
};
Json(StartChainResponse {
chain_id,
contracts_created: progression.contracts_created,
status: if progression.chain_completed {
"completed".to_string()
} else {
"active".to_string()
},
})
.into_response()
}
/// Stop a chain (kills supervisor, marks as archived).
///
/// POST /api/v1/chains/{id}/stop
#[utoipa::path(
post,
path = "/api/v1/chains/{id}/stop",
params(
("id" = Uuid, Path, description = "Chain ID")
),
responses(
(status = 200, description = "Chain stopped"),
(status = 400, description = "Chain cannot be stopped", body = ApiError),
(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 stop_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();
};
// Verify ownership and get chain
let chain = match repository::get_chain_for_owner(pool, chain_id, auth.owner_id).await {
Ok(Some(c)) => c,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Chain not found")),
)
.into_response();
}
Err(e) => {
tracing::error!("Failed to get chain: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response();
}
};
// Check if chain can be stopped
if chain.status != "active" {
return (
StatusCode::BAD_REQUEST,
Json(ApiError::new(
"NOT_ACTIVE",
format!("Chain is not active (status: {})", chain.status),
)),
)
.into_response();
}
// Archive the chain
match repository::update_chain_status(pool, chain_id, "archived").await {
Ok(_) => Json(serde_json::json!({"stopped": true, "status": "archived"})).into_response(),
Err(e) => {
tracing::error!("Failed to update chain status: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response()
}
}
}
// =============================================================================
// Chain Repository Handlers
// =============================================================================
/// List repositories for a chain.
///
/// GET /api/v1/chains/{id}/repositories
#[utoipa::path(
get,
path = "/api/v1/chains/{id}/repositories",
params(
("id" = Uuid, Path, description = "Chain ID")
),
responses(
(status = 200, description = "List of repositories", body = Vec<ChainRepository>),
(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 list_chain_repositories(
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_repositories(pool, chain_id).await {
Ok(repos) => Json(repos).into_response(),
Err(e) => {
tracing::error!("Failed to list chain repositories: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response()
}
}
}
/// Add a repository to a chain.
///
/// POST /api/v1/chains/{id}/repositories
#[utoipa::path(
post,
path = "/api/v1/chains/{id}/repositories",
params(
("id" = Uuid, Path, description = "Chain ID")
),
request_body = AddChainRepositoryRequest,
responses(
(status = 201, description = "Repository added", body = ChainRepository),
(status = 400, description = "Invalid request", body = ApiError),
(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 add_chain_repository(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path(chain_id): Path<Uuid>,
Json(req): Json<AddChainRepositoryRequest>,
) -> 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 request
if req.name.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(ApiError::new("VALIDATION_ERROR", "Repository name cannot be empty")),
)
.into_response();
}
// Must have either repository_url or local_path
if req.repository_url.is_none() && req.local_path.is_none() {
return (
StatusCode::BAD_REQUEST,
Json(ApiError::new(
"VALIDATION_ERROR",
"Repository must have either repository_url or local_path",
)),
)
.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::add_chain_repository(pool, chain_id, &req).await {
Ok(repo) => (StatusCode::CREATED, Json(repo)).into_response(),
Err(e) => {
tracing::error!("Failed to add chain repository: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response()
}
}
}
/// Delete a repository from a chain.
///
/// DELETE /api/v1/chains/{chain_id}/repositories/{repository_id}
#[utoipa::path(
delete,
path = "/api/v1/chains/{chain_id}/repositories/{repository_id}",
params(
("chain_id" = Uuid, Path, description = "Chain ID"),
("repository_id" = Uuid, Path, description = "Repository ID")
),
responses(
(status = 200, description = "Repository deleted"),
(status = 401, description = "Unauthorized", body = ApiError),
(status = 404, description = "Chain or repository 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_repository(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path((chain_id, repository_id)): Path<(Uuid, 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::delete_chain_repository(pool, chain_id, repository_id).await {
Ok(true) => Json(serde_json::json!({"deleted": true})).into_response(),
Ok(false) => (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Repository not found")),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to delete chain repository: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response()
}
}
}
/// Set a repository as primary for a chain.
///
/// PUT /api/v1/chains/{chain_id}/repositories/{repository_id}/primary
#[utoipa::path(
put,
path = "/api/v1/chains/{chain_id}/repositories/{repository_id}/primary",
params(
("chain_id" = Uuid, Path, description = "Chain ID"),
("repository_id" = Uuid, Path, description = "Repository ID")
),
responses(
(status = 200, description = "Repository set as primary", body = ChainRepository),
(status = 401, description = "Unauthorized", body = ApiError),
(status = 404, description = "Chain or repository 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 set_chain_repository_primary(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path((chain_id, repository_id)): Path<(Uuid, 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();
}
}
// Verify repository exists for this chain
match repository::get_chain_repository(pool, chain_id, repository_id).await {
Ok(Some(_)) => {}
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Repository not found")),
)
.into_response();
}
Err(e) => {
tracing::error!("Failed to get chain repository: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response();
}
}
match repository::set_chain_repository_primary(pool, chain_id, repository_id).await {
Ok(repo) => Json(repo).into_response(),
Err(e) => {
tracing::error!("Failed to set chain repository as primary: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response()
}
}
}