summaryrefslogtreecommitdiff
path: root/makima/src/server/handlers
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-02-05 00:48:38 +0000
committersoryu <soryu@soryu.co>2026-02-05 00:48:38 +0000
commit0302b4596e14210884df5d645df9a179d8f0c1c6 (patch)
tree46efe027dffa25a30e4eab87fd62de249c3075ad /makima/src/server/handlers
parente16d49b52a393aa9a762edf57f93434a4bd7844e (diff)
downloadsoryu-0302b4596e14210884df5d645df9a179d8f0c1c6.tar.gz
soryu-0302b4596e14210884df5d645df9a179d8f0c1c6.zip
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 <noreply@anthropic.com>
Diffstat (limited to 'makima/src/server/handlers')
-rw-r--r--makima/src/server/handlers/chains.rs335
1 files changed, 331 insertions, 4 deletions
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<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()
+ }
+ }
+}