summaryrefslogtreecommitdiff
path: root/makima/src/server/handlers
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/server/handlers')
-rw-r--r--makima/src/server/handlers/chains.rs609
-rw-r--r--makima/src/server/handlers/mod.rs1
2 files changed, 610 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()
+ }
+ }
+}
diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs
index a14c4f7..3e01a3e 100644
--- a/makima/src/server/handlers/mod.rs
+++ b/makima/src/server/handlers/mod.rs
@@ -1,6 +1,7 @@
//! HTTP and WebSocket request handlers.
pub mod api_keys;
+pub mod chains;
pub mod chat;
pub mod contract_chat;
pub mod contract_daemon;