From 0302b4596e14210884df5d645df9a179d8f0c1c6 Mon Sep 17 00:00:00 2001 From: soryu Date: Thu, 5 Feb 2026 00:48:38 +0000 Subject: Add multi-repository support for chains Chains can now have multiple repositories attached, with one marked as primary. Repositories are used by contracts created from chain definitions. Backend changes: - Add chain_repositories table migration - Add ChainRepository model with CRUD operations - Add API endpoints for listing, adding, deleting repositories - Add endpoint to set a repository as primary - Update Chain and ChainEditorData models to use repositories - Update chain parser to support repositories in YAML format - Remove deprecated repository_url/local_path from Chain Frontend changes: - Add ChainRepository interface and API functions - Add repository section to ChainEditor showing attached repos - Add modal for adding new repositories (remote or local) - Support setting primary repository and removing repositories Co-Authored-By: Claude Opus 4.5 --- makima/src/server/handlers/chains.rs | 335 ++++++++++++++++++++++++++++++++++- makima/src/server/mod.rs | 13 ++ 2 files changed, 344 insertions(+), 4 deletions(-) (limited to 'makima/src/server') diff --git a/makima/src/server/handlers/chains.rs b/makima/src/server/handlers/chains.rs index 6cef72c..9b32495 100644 --- a/makima/src/server/handlers/chains.rs +++ b/makima/src/server/handlers/chains.rs @@ -13,10 +13,10 @@ use utoipa::ToSchema; use uuid::Uuid; use crate::db::models::{ - AddContractDefinitionRequest, ChainContractDefinition, ChainContractDetail, - ChainDefinitionGraphResponse, ChainEditorData, ChainEvent, ChainGraphResponse, ChainSummary, - ChainWithContracts, CreateChainRequest, StartChainRequest, StartChainResponse, - UpdateChainRequest, UpdateContractDefinitionRequest, + AddChainRepositoryRequest, AddContractDefinitionRequest, ChainContractDefinition, + ChainContractDetail, ChainDefinitionGraphResponse, ChainEditorData, ChainEvent, + ChainGraphResponse, ChainRepository, ChainSummary, ChainWithContracts, CreateChainRequest, + StartChainRequest, StartChainResponse, UpdateChainRequest, UpdateContractDefinitionRequest, }; use crate::db::repository::{self, RepositoryError}; use crate::server::auth::Authenticated; @@ -1255,3 +1255,330 @@ pub async fn stop_chain( } } } + +// ============================================================================= +// 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), + (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, + 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_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, + 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(); + }; + + // 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, + 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, + 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() + } + } +} diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index 5dde099..f6d2eda 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -245,6 +245,19 @@ pub fn make_router(state: SharedState) -> Router { // Chain control .route("/chains/{id}/start", post(chains::start_chain)) .route("/chains/{id}/stop", post(chains::stop_chain)) + // Chain repositories + .route( + "/chains/{id}/repositories", + get(chains::list_chain_repositories).post(chains::add_chain_repository), + ) + .route( + "/chains/{chain_id}/repositories/{repository_id}", + axum::routing::delete(chains::delete_chain_repository), + ) + .route( + "/chains/{chain_id}/repositories/{repository_id}/primary", + put(chains::set_chain_repository_primary), + ) // Contract type templates (built-in only) .route("/contract-types", get(templates::list_contract_types)) // Settings endpoints -- cgit v1.2.3