//! Action management API routes use axum::{ extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, routing::get, Json, Router, }; use std::sync::Arc; use validator::Validate; use attune_common::rbac::{Action, AuthorizationContext, Resource}; use attune_common::repositories::{ action::{ActionRepository, ActionSearchFilters, CreateActionInput, UpdateActionInput}, pack::PackRepository, queue_stats::QueueStatsRepository, Create, Delete, FindByRef, Patch, Update, }; use crate::{ auth::middleware::RequireAuth, authz::{AuthorizationCheck, AuthorizationService}, dto::{ action::{ ActionResponse, ActionSummary, CreateActionRequest, QueueStatsResponse, RuntimeVersionConstraintPatch, UpdateActionRequest, }, common::{PaginatedResponse, PaginationParams}, ApiResponse, SuccessResponse, }, middleware::{ApiError, ApiResult}, state::AppState, }; /// List all actions with pagination #[utoipa::path( get, path = "/api/v1/actions", tag = "actions", params(PaginationParams), responses( (status = 200, description = "List of actions", body = PaginatedResponse), ), security(("bearer_auth" = [])) )] pub async fn list_actions( State(state): State>, RequireAuth(_user): RequireAuth, Query(pagination): Query, ) -> ApiResult { // All filtering and pagination happen in a single SQL query. let filters = ActionSearchFilters { pack: None, query: None, limit: pagination.limit(), offset: pagination.offset(), }; let result = ActionRepository::list_search(&state.db, &filters).await?; let paginated_actions: Vec = result.rows.into_iter().map(ActionSummary::from).collect(); let response = PaginatedResponse::new(paginated_actions, &pagination, result.total); Ok((StatusCode::OK, Json(response))) } /// List actions by pack reference #[utoipa::path( get, path = "/api/v1/packs/{pack_ref}/actions", tag = "actions", params( ("pack_ref" = String, Path, description = "Pack reference identifier"), PaginationParams ), responses( (status = 200, description = "List of actions for pack", body = PaginatedResponse), (status = 404, description = "Pack not found") ), security(("bearer_auth" = [])) )] pub async fn list_actions_by_pack( State(state): State>, RequireAuth(_user): RequireAuth, Path(pack_ref): Path, Query(pagination): Query, ) -> ApiResult { // Verify pack exists let pack = PackRepository::find_by_ref(&state.db, &pack_ref) .await? .ok_or_else(|| ApiError::NotFound(format!("Pack '{}' not found", pack_ref)))?; // All filtering and pagination happen in a single SQL query. let filters = ActionSearchFilters { pack: Some(pack.id), query: None, limit: pagination.limit(), offset: pagination.offset(), }; let result = ActionRepository::list_search(&state.db, &filters).await?; let paginated_actions: Vec = result.rows.into_iter().map(ActionSummary::from).collect(); let response = PaginatedResponse::new(paginated_actions, &pagination, result.total); Ok((StatusCode::OK, Json(response))) } /// Get a single action by reference #[utoipa::path( get, path = "/api/v1/actions/{ref}", tag = "actions", params( ("ref" = String, Path, description = "Action reference identifier") ), responses( (status = 200, description = "Action details", body = inline(ApiResponse)), (status = 404, description = "Action not found") ), security(("bearer_auth" = [])) )] pub async fn get_action( State(state): State>, RequireAuth(_user): RequireAuth, Path(action_ref): Path, ) -> ApiResult { let action = ActionRepository::find_by_ref(&state.db, &action_ref) .await? .ok_or_else(|| ApiError::NotFound(format!("Action '{}' not found", action_ref)))?; let response = ApiResponse::new(ActionResponse::from(action)); Ok((StatusCode::OK, Json(response))) } /// Create a new action #[utoipa::path( post, path = "/api/v1/actions", tag = "actions", request_body = CreateActionRequest, responses( (status = 201, description = "Action created successfully", body = inline(ApiResponse)), (status = 400, description = "Validation error"), (status = 404, description = "Pack not found"), (status = 409, description = "Action with same ref already exists") ), security(("bearer_auth" = [])) )] pub async fn create_action( State(state): State>, RequireAuth(user): RequireAuth, Json(request): Json, ) -> ApiResult { // Validate request request.validate()?; // Check if action with same ref already exists if ActionRepository::find_by_ref(&state.db, &request.r#ref) .await? .is_some() { return Err(ApiError::Conflict(format!( "Action with ref '{}' already exists", request.r#ref ))); } // Verify pack exists and get its ID let pack = PackRepository::find_by_ref(&state.db, &request.pack_ref) .await? .ok_or_else(|| ApiError::NotFound(format!("Pack '{}' not found", request.pack_ref)))?; if user.claims.token_type == crate::auth::jwt::TokenType::Access { let identity_id = user .identity_id() .map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?; let authz = AuthorizationService::new(state.db.clone()); let mut ctx = AuthorizationContext::new(identity_id); ctx.pack_ref = Some(pack.r#ref.clone()); ctx.target_ref = Some(request.r#ref.clone()); authz .authorize( &user, AuthorizationCheck { resource: Resource::Actions, action: Action::Create, context: ctx, }, ) .await?; } // If runtime is specified, we could verify it exists (future enhancement) // For now, the database foreign key constraint will handle invalid runtime IDs // Create action input let action_input = CreateActionInput { r#ref: request.r#ref, pack: pack.id, pack_ref: pack.r#ref.clone(), label: request.label, description: request.description, entrypoint: request.entrypoint, runtime: request.runtime, runtime_version_constraint: request.runtime_version_constraint, param_schema: request.param_schema, out_schema: request.out_schema, is_adhoc: true, // Actions created via API are ad-hoc (not from pack installation) }; let action = ActionRepository::create(&state.db, action_input).await?; let response = ApiResponse::with_message(ActionResponse::from(action), "Action created successfully"); Ok((StatusCode::CREATED, Json(response))) } /// Update an existing action #[utoipa::path( put, path = "/api/v1/actions/{ref}", tag = "actions", params( ("ref" = String, Path, description = "Action reference identifier") ), request_body = UpdateActionRequest, responses( (status = 200, description = "Action updated successfully", body = inline(ApiResponse)), (status = 400, description = "Validation error"), (status = 404, description = "Action not found") ), security(("bearer_auth" = [])) )] pub async fn update_action( State(state): State>, RequireAuth(user): RequireAuth, Path(action_ref): Path, Json(request): Json, ) -> ApiResult { // Validate request request.validate()?; // Check if action exists let existing_action = ActionRepository::find_by_ref(&state.db, &action_ref) .await? .ok_or_else(|| ApiError::NotFound(format!("Action '{}' not found", action_ref)))?; if user.claims.token_type == crate::auth::jwt::TokenType::Access { let identity_id = user .identity_id() .map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?; let authz = AuthorizationService::new(state.db.clone()); let mut ctx = AuthorizationContext::new(identity_id); ctx.target_id = Some(existing_action.id); ctx.target_ref = Some(existing_action.r#ref.clone()); ctx.pack_ref = Some(existing_action.pack_ref.clone()); authz .authorize( &user, AuthorizationCheck { resource: Resource::Actions, action: Action::Update, context: ctx, }, ) .await?; } // Create update input let update_input = UpdateActionInput { label: request.label, description: request.description.map(Patch::Set), entrypoint: request.entrypoint, runtime: request.runtime, runtime_version_constraint: request.runtime_version_constraint.map(|patch| match patch { RuntimeVersionConstraintPatch::Set(value) => Patch::Set(value), RuntimeVersionConstraintPatch::Clear => Patch::Clear, }), param_schema: request.param_schema, out_schema: request.out_schema, parameter_delivery: None, parameter_format: None, output_format: None, }; let action = ActionRepository::update(&state.db, existing_action.id, update_input).await?; let response = ApiResponse::with_message(ActionResponse::from(action), "Action updated successfully"); Ok((StatusCode::OK, Json(response))) } /// Delete an action #[utoipa::path( delete, path = "/api/v1/actions/{ref}", tag = "actions", params( ("ref" = String, Path, description = "Action reference identifier") ), responses( (status = 200, description = "Action deleted successfully", body = SuccessResponse), (status = 404, description = "Action not found") ), security(("bearer_auth" = [])) )] pub async fn delete_action( State(state): State>, RequireAuth(user): RequireAuth, Path(action_ref): Path, ) -> ApiResult { // Check if action exists let action = ActionRepository::find_by_ref(&state.db, &action_ref) .await? .ok_or_else(|| ApiError::NotFound(format!("Action '{}' not found", action_ref)))?; if user.claims.token_type == crate::auth::jwt::TokenType::Access { let identity_id = user .identity_id() .map_err(|_| ApiError::Unauthorized("Invalid user identity".to_string()))?; let authz = AuthorizationService::new(state.db.clone()); let mut ctx = AuthorizationContext::new(identity_id); ctx.target_id = Some(action.id); ctx.target_ref = Some(action.r#ref.clone()); ctx.pack_ref = Some(action.pack_ref.clone()); authz .authorize( &user, AuthorizationCheck { resource: Resource::Actions, action: Action::Delete, context: ctx, }, ) .await?; } // Delete the action let deleted = ActionRepository::delete(&state.db, action.id).await?; if !deleted { return Err(ApiError::NotFound(format!( "Action '{}' not found", action_ref ))); } let response = SuccessResponse::new(format!("Action '{}' deleted successfully", action_ref)); Ok((StatusCode::OK, Json(response))) } /// Get queue statistics for an action #[utoipa::path( get, path = "/api/v1/actions/{ref}/queue-stats", tag = "actions", params( ("ref" = String, Path, description = "Action reference identifier") ), responses( (status = 200, description = "Queue statistics", body = inline(ApiResponse)), (status = 404, description = "Action not found or no queue statistics available") ), security(("bearer_auth" = [])) )] pub async fn get_queue_stats( State(state): State>, RequireAuth(_user): RequireAuth, Path(action_ref): Path, ) -> ApiResult { // Find the action by reference let action = ActionRepository::find_by_ref(&state.db, &action_ref) .await? .ok_or_else(|| ApiError::NotFound(format!("Action '{}' not found", action_ref)))?; // Get queue statistics from database let queue_stats = QueueStatsRepository::find_by_action(&state.db, action.id) .await? .ok_or_else(|| { ApiError::NotFound(format!( "No queue statistics available for action '{}'", action_ref )) })?; // Convert to response DTO and populate action_ref let mut response_stats = QueueStatsResponse::from(queue_stats); response_stats.action_ref = action.r#ref.clone(); let response = ApiResponse::new(response_stats); Ok((StatusCode::OK, Json(response))) } /// Create action routes pub fn routes() -> Router> { Router::new() .route("/actions", get(list_actions).post(create_action)) .route( "/actions/{ref}", get(get_action).put(update_action).delete(delete_action), ) .route("/actions/{ref}/queue-stats", get(get_queue_stats)) .route("/packs/{pack_ref}/actions", get(list_actions_by_pack)) } #[cfg(test)] mod tests { use super::*; #[test] fn test_action_routes_structure() { // Just verify the router can be constructed let _router = routes(); } }