re-uploading work
This commit is contained in:
353
crates/api/src/routes/actions.rs
Normal file
353
crates/api/src/routes/actions.rs
Normal file
@@ -0,0 +1,353 @@
|
||||
//! 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::repositories::{
|
||||
action::{ActionRepository, CreateActionInput, UpdateActionInput},
|
||||
pack::PackRepository,
|
||||
queue_stats::QueueStatsRepository,
|
||||
Create, Delete, FindByRef, List, Update,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::middleware::RequireAuth,
|
||||
dto::{
|
||||
action::{
|
||||
ActionResponse, ActionSummary, CreateActionRequest, QueueStatsResponse,
|
||||
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<ActionSummary>),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn list_actions(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Query(pagination): Query<PaginationParams>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Get all actions (we'll implement pagination in repository later)
|
||||
let actions = ActionRepository::list(&state.db).await?;
|
||||
|
||||
// Calculate pagination
|
||||
let total = actions.len() as u64;
|
||||
let start = ((pagination.page - 1) * pagination.limit()) as usize;
|
||||
let end = (start + pagination.limit() as usize).min(actions.len());
|
||||
|
||||
// Get paginated slice
|
||||
let paginated_actions: Vec<ActionSummary> = actions[start..end]
|
||||
.iter()
|
||||
.map(|a| ActionSummary::from(a.clone()))
|
||||
.collect();
|
||||
|
||||
let response = PaginatedResponse::new(paginated_actions, &pagination, 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<ActionSummary>),
|
||||
(status = 404, description = "Pack not found")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn list_actions_by_pack(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(pack_ref): Path<String>,
|
||||
Query(pagination): Query<PaginationParams>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// 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)))?;
|
||||
|
||||
// Get actions for this pack
|
||||
let actions = ActionRepository::find_by_pack(&state.db, pack.id).await?;
|
||||
|
||||
// Calculate pagination
|
||||
let total = actions.len() as u64;
|
||||
let start = ((pagination.page - 1) * pagination.limit()) as usize;
|
||||
let end = (start + pagination.limit() as usize).min(actions.len());
|
||||
|
||||
// Get paginated slice
|
||||
let paginated_actions: Vec<ActionSummary> = actions[start..end]
|
||||
.iter()
|
||||
.map(|a| ActionSummary::from(a.clone()))
|
||||
.collect();
|
||||
|
||||
let response = PaginatedResponse::new(paginated_actions, &pagination, 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<ActionResponse>)),
|
||||
(status = 404, description = "Action not found")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_action(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(action_ref): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
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<ActionResponse>)),
|
||||
(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<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Json(request): Json<CreateActionRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Validate request
|
||||
request.validate()?;
|
||||
|
||||
// Check if action with same ref already exists
|
||||
if let Some(_) = ActionRepository::find_by_ref(&state.db, &request.r#ref).await? {
|
||||
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 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,
|
||||
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<ActionResponse>)),
|
||||
(status = 400, description = "Validation error"),
|
||||
(status = 404, description = "Action not found")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn update_action(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(action_ref): Path<String>,
|
||||
Json(request): Json<UpdateActionRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// 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)))?;
|
||||
|
||||
// Create update input
|
||||
let update_input = UpdateActionInput {
|
||||
label: request.label,
|
||||
description: request.description,
|
||||
entrypoint: request.entrypoint,
|
||||
runtime: request.runtime,
|
||||
param_schema: request.param_schema,
|
||||
out_schema: request.out_schema,
|
||||
};
|
||||
|
||||
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<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(action_ref): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// 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)))?;
|
||||
|
||||
// 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<QueueStatsResponse>)),
|
||||
(status = 404, description = "Action not found or no queue statistics available")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_queue_stats(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(action_ref): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// 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<Arc<AppState>> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
464
crates/api/src/routes/auth.rs
Normal file
464
crates/api/src/routes/auth.rs
Normal file
@@ -0,0 +1,464 @@
|
||||
//! Authentication routes
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
|
||||
use validator::Validate;
|
||||
|
||||
use attune_common::repositories::{
|
||||
identity::{CreateIdentityInput, IdentityRepository},
|
||||
Create, FindById,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::{
|
||||
hash_password,
|
||||
jwt::{
|
||||
generate_access_token, generate_refresh_token, generate_sensor_token, validate_token,
|
||||
TokenType,
|
||||
},
|
||||
middleware::RequireAuth,
|
||||
verify_password,
|
||||
},
|
||||
dto::{
|
||||
ApiResponse, ChangePasswordRequest, CurrentUserResponse, LoginRequest, RefreshTokenRequest,
|
||||
RegisterRequest, SuccessResponse, TokenResponse,
|
||||
},
|
||||
middleware::error::ApiError,
|
||||
state::SharedState,
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Request body for creating sensor tokens
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Validate, ToSchema)]
|
||||
pub struct CreateSensorTokenRequest {
|
||||
/// Sensor reference (e.g., "core.timer")
|
||||
#[validate(length(min = 1, max = 255))]
|
||||
pub sensor_ref: String,
|
||||
|
||||
/// List of trigger types this sensor can create events for
|
||||
#[validate(length(min = 1))]
|
||||
pub trigger_types: Vec<String>,
|
||||
|
||||
/// Optional TTL in seconds (default: 86400 = 24 hours, max: 259200 = 72 hours)
|
||||
#[validate(range(min = 3600, max = 259200))]
|
||||
pub ttl_seconds: Option<i64>,
|
||||
}
|
||||
|
||||
/// Response for sensor token creation
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SensorTokenResponse {
|
||||
pub identity_id: i64,
|
||||
pub sensor_ref: String,
|
||||
pub token: String,
|
||||
pub expires_at: String,
|
||||
pub trigger_types: Vec<String>,
|
||||
}
|
||||
|
||||
/// Create authentication routes
|
||||
pub fn routes() -> Router<SharedState> {
|
||||
Router::new()
|
||||
.route("/login", post(login))
|
||||
.route("/register", post(register))
|
||||
.route("/refresh", post(refresh_token))
|
||||
.route("/me", get(get_current_user))
|
||||
.route("/change-password", post(change_password))
|
||||
.route("/sensor-token", post(create_sensor_token))
|
||||
.route("/internal/sensor-token", post(create_sensor_token_internal))
|
||||
}
|
||||
|
||||
/// Login endpoint
|
||||
///
|
||||
/// POST /auth/login
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/auth/login",
|
||||
tag = "auth",
|
||||
request_body = LoginRequest,
|
||||
responses(
|
||||
(status = 200, description = "Successfully logged in", body = inline(ApiResponse<TokenResponse>)),
|
||||
(status = 401, description = "Invalid credentials"),
|
||||
(status = 400, description = "Validation error")
|
||||
)
|
||||
)]
|
||||
pub async fn login(
|
||||
State(state): State<SharedState>,
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> Result<Json<ApiResponse<TokenResponse>>, ApiError> {
|
||||
// Validate request
|
||||
payload
|
||||
.validate()
|
||||
.map_err(|e| ApiError::ValidationError(format!("Invalid login request: {}", e)))?;
|
||||
|
||||
// Find identity by login
|
||||
let identity = IdentityRepository::find_by_login(&state.db, &payload.login)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::Unauthorized("Invalid login or password".to_string()))?;
|
||||
|
||||
// Check if identity has a password set
|
||||
let password_hash = identity
|
||||
.password_hash
|
||||
.as_ref()
|
||||
.ok_or_else(|| ApiError::Unauthorized("Invalid login or password".to_string()))?;
|
||||
|
||||
// Verify password
|
||||
let is_valid = verify_password(&payload.password, password_hash)
|
||||
.map_err(|_| ApiError::Unauthorized("Invalid login or password".to_string()))?;
|
||||
|
||||
if !is_valid {
|
||||
return Err(ApiError::Unauthorized(
|
||||
"Invalid login or password".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
let access_token = generate_access_token(identity.id, &identity.login, &state.jwt_config)?;
|
||||
let refresh_token = generate_refresh_token(identity.id, &identity.login, &state.jwt_config)?;
|
||||
|
||||
let response = TokenResponse::new(
|
||||
access_token,
|
||||
refresh_token,
|
||||
state.jwt_config.access_token_expiration,
|
||||
)
|
||||
.with_user(
|
||||
identity.id,
|
||||
identity.login.clone(),
|
||||
identity.display_name.clone(),
|
||||
);
|
||||
|
||||
Ok(Json(ApiResponse::new(response)))
|
||||
}
|
||||
|
||||
/// Register endpoint
|
||||
///
|
||||
/// POST /auth/register
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/auth/register",
|
||||
tag = "auth",
|
||||
request_body = RegisterRequest,
|
||||
responses(
|
||||
(status = 200, description = "Successfully registered", body = inline(ApiResponse<TokenResponse>)),
|
||||
(status = 409, description = "User already exists"),
|
||||
(status = 400, description = "Validation error")
|
||||
)
|
||||
)]
|
||||
pub async fn register(
|
||||
State(state): State<SharedState>,
|
||||
Json(payload): Json<RegisterRequest>,
|
||||
) -> Result<Json<ApiResponse<TokenResponse>>, ApiError> {
|
||||
// Validate request
|
||||
payload
|
||||
.validate()
|
||||
.map_err(|e| ApiError::ValidationError(format!("Invalid registration request: {}", e)))?;
|
||||
|
||||
// Check if login already exists
|
||||
if let Some(_) = IdentityRepository::find_by_login(&state.db, &payload.login).await? {
|
||||
return Err(ApiError::Conflict(format!(
|
||||
"Identity with login '{}' already exists",
|
||||
payload.login
|
||||
)));
|
||||
}
|
||||
|
||||
// Hash password
|
||||
let password_hash = hash_password(&payload.password)?;
|
||||
|
||||
// Create identity with password hash
|
||||
let input = CreateIdentityInput {
|
||||
login: payload.login.clone(),
|
||||
display_name: payload.display_name,
|
||||
password_hash: Some(password_hash),
|
||||
attributes: serde_json::json!({}),
|
||||
};
|
||||
|
||||
let identity = IdentityRepository::create(&state.db, input).await?;
|
||||
|
||||
// Generate tokens
|
||||
let access_token = generate_access_token(identity.id, &identity.login, &state.jwt_config)?;
|
||||
let refresh_token = generate_refresh_token(identity.id, &identity.login, &state.jwt_config)?;
|
||||
|
||||
let response = TokenResponse::new(
|
||||
access_token,
|
||||
refresh_token,
|
||||
state.jwt_config.access_token_expiration,
|
||||
)
|
||||
.with_user(
|
||||
identity.id,
|
||||
identity.login.clone(),
|
||||
identity.display_name.clone(),
|
||||
);
|
||||
|
||||
Ok(Json(ApiResponse::new(response)))
|
||||
}
|
||||
|
||||
/// Refresh token endpoint
|
||||
///
|
||||
/// POST /auth/refresh
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/auth/refresh",
|
||||
tag = "auth",
|
||||
request_body = RefreshTokenRequest,
|
||||
responses(
|
||||
(status = 200, description = "Successfully refreshed token", body = inline(ApiResponse<TokenResponse>)),
|
||||
(status = 401, description = "Invalid or expired refresh token"),
|
||||
(status = 400, description = "Validation error")
|
||||
)
|
||||
)]
|
||||
pub async fn refresh_token(
|
||||
State(state): State<SharedState>,
|
||||
Json(payload): Json<RefreshTokenRequest>,
|
||||
) -> Result<Json<ApiResponse<TokenResponse>>, ApiError> {
|
||||
// Validate request
|
||||
payload
|
||||
.validate()
|
||||
.map_err(|e| ApiError::ValidationError(format!("Invalid refresh token request: {}", e)))?;
|
||||
|
||||
// Validate refresh token
|
||||
let claims = validate_token(&payload.refresh_token, &state.jwt_config)
|
||||
.map_err(|_| ApiError::Unauthorized("Invalid or expired refresh token".to_string()))?;
|
||||
|
||||
// Ensure it's a refresh token
|
||||
if claims.token_type != TokenType::Refresh {
|
||||
return Err(ApiError::Unauthorized("Invalid token type".to_string()));
|
||||
}
|
||||
|
||||
// Parse identity ID
|
||||
let identity_id: i64 = claims
|
||||
.sub
|
||||
.parse()
|
||||
.map_err(|_| ApiError::Unauthorized("Invalid token".to_string()))?;
|
||||
|
||||
// Verify identity still exists
|
||||
let identity = IdentityRepository::find_by_id(&state.db, identity_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::Unauthorized("Identity not found".to_string()))?;
|
||||
|
||||
// Generate new tokens
|
||||
let access_token = generate_access_token(identity.id, &identity.login, &state.jwt_config)?;
|
||||
let refresh_token = generate_refresh_token(identity.id, &identity.login, &state.jwt_config)?;
|
||||
|
||||
let response = TokenResponse::new(
|
||||
access_token,
|
||||
refresh_token,
|
||||
state.jwt_config.access_token_expiration,
|
||||
);
|
||||
|
||||
Ok(Json(ApiResponse::new(response)))
|
||||
}
|
||||
|
||||
/// Get current user endpoint
|
||||
///
|
||||
/// GET /auth/me
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/auth/me",
|
||||
tag = "auth",
|
||||
responses(
|
||||
(status = 200, description = "Current user information", body = inline(ApiResponse<CurrentUserResponse>)),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Identity not found")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn get_current_user(
|
||||
State(state): State<SharedState>,
|
||||
RequireAuth(user): RequireAuth,
|
||||
) -> Result<Json<ApiResponse<CurrentUserResponse>>, ApiError> {
|
||||
let identity_id = user.identity_id()?;
|
||||
|
||||
// Fetch identity from database
|
||||
let identity = IdentityRepository::find_by_id(&state.db, identity_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound("Identity not found".to_string()))?;
|
||||
|
||||
let response = CurrentUserResponse {
|
||||
id: identity.id,
|
||||
login: identity.login,
|
||||
display_name: identity.display_name,
|
||||
};
|
||||
|
||||
Ok(Json(ApiResponse::new(response)))
|
||||
}
|
||||
|
||||
/// Change password endpoint
|
||||
///
|
||||
/// POST /auth/change-password
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/auth/change-password",
|
||||
tag = "auth",
|
||||
request_body = ChangePasswordRequest,
|
||||
responses(
|
||||
(status = 200, description = "Password changed successfully", body = inline(ApiResponse<SuccessResponse>)),
|
||||
(status = 401, description = "Invalid current password or unauthorized"),
|
||||
(status = 400, description = "Validation error"),
|
||||
(status = 404, description = "Identity not found")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn change_password(
|
||||
State(state): State<SharedState>,
|
||||
RequireAuth(user): RequireAuth,
|
||||
Json(payload): Json<ChangePasswordRequest>,
|
||||
) -> Result<Json<ApiResponse<SuccessResponse>>, ApiError> {
|
||||
// Validate request
|
||||
payload.validate().map_err(|e| {
|
||||
ApiError::ValidationError(format!("Invalid change password request: {}", e))
|
||||
})?;
|
||||
|
||||
let identity_id = user.identity_id()?;
|
||||
|
||||
// Fetch identity from database
|
||||
let identity = IdentityRepository::find_by_id(&state.db, identity_id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound("Identity not found".to_string()))?;
|
||||
|
||||
// Get current password hash
|
||||
let current_password_hash = identity
|
||||
.password_hash
|
||||
.as_ref()
|
||||
.ok_or_else(|| ApiError::Unauthorized("No password set".to_string()))?;
|
||||
|
||||
// Verify current password
|
||||
let is_valid = verify_password(&payload.current_password, current_password_hash)
|
||||
.map_err(|_| ApiError::Unauthorized("Invalid current password".to_string()))?;
|
||||
|
||||
if !is_valid {
|
||||
return Err(ApiError::Unauthorized(
|
||||
"Invalid current password".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
let new_password_hash = hash_password(&payload.new_password)?;
|
||||
|
||||
// Update identity in database with new password hash
|
||||
use attune_common::repositories::identity::UpdateIdentityInput;
|
||||
use attune_common::repositories::Update;
|
||||
|
||||
let update_input = UpdateIdentityInput {
|
||||
display_name: None,
|
||||
password_hash: Some(new_password_hash),
|
||||
attributes: None,
|
||||
};
|
||||
|
||||
IdentityRepository::update(&state.db, identity_id, update_input).await?;
|
||||
|
||||
Ok(Json(ApiResponse::new(SuccessResponse::new(
|
||||
"Password changed successfully",
|
||||
))))
|
||||
}
|
||||
|
||||
/// Create sensor token endpoint (internal use by sensor service)
|
||||
///
|
||||
/// POST /auth/sensor-token
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/auth/sensor-token",
|
||||
tag = "auth",
|
||||
request_body = CreateSensorTokenRequest,
|
||||
responses(
|
||||
(status = 200, description = "Sensor token created successfully", body = inline(ApiResponse<SensorTokenResponse>)),
|
||||
(status = 400, description = "Validation error"),
|
||||
(status = 401, description = "Unauthorized")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn create_sensor_token(
|
||||
State(state): State<SharedState>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Json(payload): Json<CreateSensorTokenRequest>,
|
||||
) -> Result<Json<ApiResponse<SensorTokenResponse>>, ApiError> {
|
||||
create_sensor_token_impl(state, payload).await
|
||||
}
|
||||
|
||||
/// Create sensor token endpoint for internal service use (no auth required)
|
||||
///
|
||||
/// POST /auth/internal/sensor-token
|
||||
///
|
||||
/// This endpoint is intended for internal use by the sensor service to provision
|
||||
/// tokens for standalone sensors. In production, this should be restricted by
|
||||
/// network policies or replaced with proper service-to-service authentication.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/auth/internal/sensor-token",
|
||||
tag = "auth",
|
||||
request_body = CreateSensorTokenRequest,
|
||||
responses(
|
||||
(status = 200, description = "Sensor token created successfully", body = inline(ApiResponse<SensorTokenResponse>)),
|
||||
(status = 400, description = "Validation error")
|
||||
)
|
||||
)]
|
||||
pub async fn create_sensor_token_internal(
|
||||
State(state): State<SharedState>,
|
||||
Json(payload): Json<CreateSensorTokenRequest>,
|
||||
) -> Result<Json<ApiResponse<SensorTokenResponse>>, ApiError> {
|
||||
create_sensor_token_impl(state, payload).await
|
||||
}
|
||||
|
||||
/// Shared implementation for sensor token creation
|
||||
async fn create_sensor_token_impl(
|
||||
state: SharedState,
|
||||
payload: CreateSensorTokenRequest,
|
||||
) -> Result<Json<ApiResponse<SensorTokenResponse>>, ApiError> {
|
||||
// Validate request
|
||||
payload
|
||||
.validate()
|
||||
.map_err(|e| ApiError::ValidationError(format!("Invalid sensor token request: {}", e)))?;
|
||||
|
||||
// Create or find sensor identity
|
||||
let sensor_login = format!("sensor:{}", payload.sensor_ref);
|
||||
|
||||
let identity = match IdentityRepository::find_by_login(&state.db, &sensor_login).await? {
|
||||
Some(identity) => identity,
|
||||
None => {
|
||||
// Create new sensor identity
|
||||
let input = CreateIdentityInput {
|
||||
login: sensor_login.clone(),
|
||||
display_name: Some(format!("Sensor: {}", payload.sensor_ref)),
|
||||
password_hash: None, // Sensors don't use passwords
|
||||
attributes: serde_json::json!({
|
||||
"type": "sensor",
|
||||
"sensor_ref": payload.sensor_ref,
|
||||
"trigger_types": payload.trigger_types,
|
||||
}),
|
||||
};
|
||||
IdentityRepository::create(&state.db, input).await?
|
||||
}
|
||||
};
|
||||
|
||||
// Generate sensor token
|
||||
let ttl_seconds = payload.ttl_seconds.unwrap_or(86400); // Default: 24 hours
|
||||
let token = generate_sensor_token(
|
||||
identity.id,
|
||||
&payload.sensor_ref,
|
||||
payload.trigger_types.clone(),
|
||||
&state.jwt_config,
|
||||
Some(ttl_seconds),
|
||||
)?;
|
||||
|
||||
// Calculate expiration time
|
||||
let expires_at = chrono::Utc::now() + chrono::Duration::seconds(ttl_seconds);
|
||||
|
||||
let response = SensorTokenResponse {
|
||||
identity_id: identity.id,
|
||||
sensor_ref: payload.sensor_ref,
|
||||
token,
|
||||
expires_at: expires_at.to_rfc3339(),
|
||||
trigger_types: payload.trigger_types,
|
||||
};
|
||||
|
||||
Ok(Json(ApiResponse::new(response)))
|
||||
}
|
||||
391
crates/api/src/routes/events.rs
Normal file
391
crates/api/src/routes/events.rs
Normal file
@@ -0,0 +1,391 @@
|
||||
//! Event and Enforcement query API routes
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::sync::Arc;
|
||||
use utoipa::ToSchema;
|
||||
use validator::Validate;
|
||||
|
||||
use attune_common::{
|
||||
mq::{EventCreatedPayload, MessageEnvelope, MessageType},
|
||||
repositories::{
|
||||
event::{CreateEventInput, EnforcementRepository, EventRepository},
|
||||
trigger::TriggerRepository,
|
||||
Create, FindById, FindByRef, List,
|
||||
},
|
||||
};
|
||||
|
||||
use crate::auth::RequireAuth;
|
||||
use crate::{
|
||||
dto::{
|
||||
common::{PaginatedResponse, PaginationParams},
|
||||
event::{
|
||||
EnforcementQueryParams, EnforcementResponse, EnforcementSummary, EventQueryParams,
|
||||
EventResponse, EventSummary,
|
||||
},
|
||||
ApiResponse,
|
||||
},
|
||||
middleware::{ApiError, ApiResult},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
/// Request body for creating an event
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Validate, ToSchema)]
|
||||
pub struct CreateEventRequest {
|
||||
/// Trigger reference (e.g., "core.timer", "core.webhook")
|
||||
#[validate(length(min = 1))]
|
||||
#[schema(example = "core.timer")]
|
||||
pub trigger_ref: String,
|
||||
|
||||
/// Event payload data
|
||||
#[schema(value_type = Object, example = json!({"timestamp": "2024-01-13T10:30:00Z"}))]
|
||||
pub payload: Option<JsonValue>,
|
||||
|
||||
/// Event configuration
|
||||
#[schema(value_type = Object)]
|
||||
pub config: Option<JsonValue>,
|
||||
|
||||
/// Trigger instance ID (for correlation, often rule_id)
|
||||
#[schema(example = "rule_123")]
|
||||
pub trigger_instance_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Create a new event
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/events",
|
||||
tag = "events",
|
||||
request_body = CreateEventRequest,
|
||||
security(("bearer_auth" = [])),
|
||||
responses(
|
||||
(status = 201, description = "Event created successfully", body = ApiResponse<EventResponse>),
|
||||
(status = 400, description = "Validation error"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Trigger not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn create_event(
|
||||
user: RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(payload): Json<CreateEventRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Validate request
|
||||
payload
|
||||
.validate()
|
||||
.map_err(|e| ApiError::ValidationError(format!("Invalid event request: {}", e)))?;
|
||||
|
||||
// Lookup trigger by reference to get trigger ID
|
||||
let trigger = TriggerRepository::find_by_ref(&state.db, &payload.trigger_ref)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::NotFound(format!("Trigger '{}' not found", payload.trigger_ref))
|
||||
})?;
|
||||
|
||||
// Parse trigger_instance_id to extract rule ID (format: "rule_{id}")
|
||||
let (rule_id, rule_ref) = if let Some(instance_id) = &payload.trigger_instance_id {
|
||||
if let Some(id_str) = instance_id.strip_prefix("rule_") {
|
||||
if let Ok(rid) = id_str.parse::<i64>() {
|
||||
// Fetch rule reference from database
|
||||
let fetched_rule_ref: Option<String> =
|
||||
sqlx::query_scalar("SELECT ref FROM rule WHERE id = $1")
|
||||
.bind(rid)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
if let Some(rref) = fetched_rule_ref {
|
||||
tracing::debug!("Event associated with rule {} (id: {})", rref, rid);
|
||||
(Some(rid), Some(rref))
|
||||
} else {
|
||||
tracing::warn!("trigger_instance_id {} provided but rule not found", rid);
|
||||
(None, None)
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("Invalid rule ID in trigger_instance_id: {}", instance_id);
|
||||
(None, None)
|
||||
}
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"trigger_instance_id doesn't match rule format: {}",
|
||||
instance_id
|
||||
);
|
||||
(None, None)
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
// Determine source (sensor) from authenticated user if it's a sensor token
|
||||
use crate::auth::jwt::TokenType;
|
||||
let (source_id, source_ref) = match user.0.claims.token_type {
|
||||
TokenType::Sensor => {
|
||||
// Extract sensor reference from login
|
||||
let sensor_ref = user.0.claims.login.clone();
|
||||
|
||||
// Look up sensor by reference
|
||||
let sensor_id: Option<i64> = sqlx::query_scalar("SELECT id FROM sensor WHERE ref = $1")
|
||||
.bind(&sensor_ref)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
match sensor_id {
|
||||
Some(id) => {
|
||||
tracing::debug!("Event created by sensor {} (id: {})", sensor_ref, id);
|
||||
(Some(id), Some(sensor_ref))
|
||||
}
|
||||
None => {
|
||||
tracing::warn!("Sensor token for ref '{}' but sensor not found", sensor_ref);
|
||||
(None, Some(sensor_ref))
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => (None, None),
|
||||
};
|
||||
|
||||
// Create event input
|
||||
let input = CreateEventInput {
|
||||
trigger: Some(trigger.id),
|
||||
trigger_ref: payload.trigger_ref.clone(),
|
||||
config: payload.config,
|
||||
payload: payload.payload,
|
||||
source: source_id,
|
||||
source_ref,
|
||||
rule: rule_id,
|
||||
rule_ref,
|
||||
};
|
||||
|
||||
// Create the event
|
||||
let event = EventRepository::create(&state.db, input).await?;
|
||||
|
||||
// Publish EventCreated message to message queue if publisher is available
|
||||
if let Some(ref publisher) = state.publisher {
|
||||
let message_payload = EventCreatedPayload {
|
||||
event_id: event.id,
|
||||
trigger_id: event.trigger,
|
||||
trigger_ref: event.trigger_ref.clone(),
|
||||
sensor_id: event.source,
|
||||
sensor_ref: event.source_ref.clone(),
|
||||
payload: event.payload.clone().unwrap_or(serde_json::json!({})),
|
||||
config: event.config.clone(),
|
||||
};
|
||||
|
||||
let envelope = MessageEnvelope::new(MessageType::EventCreated, message_payload)
|
||||
.with_source("api-service");
|
||||
|
||||
if let Err(e) = publisher.publish_envelope(&envelope).await {
|
||||
tracing::warn!(
|
||||
"Failed to publish EventCreated message for event {}: {}",
|
||||
event.id,
|
||||
e
|
||||
);
|
||||
// Continue even if message publishing fails - event is already recorded
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"Published EventCreated message for event {} (trigger: {})",
|
||||
event.id,
|
||||
event.trigger_ref
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let response = ApiResponse::new(EventResponse::from(event));
|
||||
|
||||
Ok((StatusCode::CREATED, Json(response)))
|
||||
}
|
||||
|
||||
/// List all events with pagination and optional filters
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/events",
|
||||
tag = "events",
|
||||
params(EventQueryParams),
|
||||
security(("bearer_auth" = [])),
|
||||
responses(
|
||||
(status = 200, description = "List of events", body = PaginatedResponse<EventSummary>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn list_events(
|
||||
_user: RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<EventQueryParams>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Get events based on filters
|
||||
let events = if let Some(trigger_id) = query.trigger {
|
||||
// Filter by trigger ID
|
||||
EventRepository::find_by_trigger(&state.db, trigger_id).await?
|
||||
} else if let Some(trigger_ref) = &query.trigger_ref {
|
||||
// Filter by trigger reference
|
||||
EventRepository::find_by_trigger_ref(&state.db, trigger_ref).await?
|
||||
} else {
|
||||
// Get all events
|
||||
EventRepository::list(&state.db).await?
|
||||
};
|
||||
|
||||
// Apply additional filters in memory
|
||||
let mut filtered_events = events;
|
||||
|
||||
if let Some(source_id) = query.source {
|
||||
filtered_events.retain(|e| e.source == Some(source_id));
|
||||
}
|
||||
|
||||
// Calculate pagination
|
||||
let total = filtered_events.len() as u64;
|
||||
let start = query.offset() as usize;
|
||||
let end = (start + query.limit() as usize).min(filtered_events.len());
|
||||
|
||||
// Get paginated slice
|
||||
let paginated_events: Vec<EventSummary> = filtered_events[start..end]
|
||||
.iter()
|
||||
.map(|event| EventSummary::from(event.clone()))
|
||||
.collect();
|
||||
|
||||
// Convert query params to pagination params for response
|
||||
let pagination_params = PaginationParams {
|
||||
page: query.page,
|
||||
page_size: query.per_page,
|
||||
};
|
||||
|
||||
let response = PaginatedResponse::new(paginated_events, &pagination_params, total);
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Get a single event by ID
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/events/{id}",
|
||||
tag = "events",
|
||||
params(
|
||||
("id" = i64, Path, description = "Event ID")
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
responses(
|
||||
(status = 200, description = "Event details", body = ApiResponse<EventResponse>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Event not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn get_event(
|
||||
_user: RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
let event = EventRepository::find_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Event with ID {} not found", id)))?;
|
||||
|
||||
let response = ApiResponse::new(EventResponse::from(event));
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// List all enforcements with pagination and optional filters
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/enforcements",
|
||||
tag = "enforcements",
|
||||
params(EnforcementQueryParams),
|
||||
security(("bearer_auth" = [])),
|
||||
responses(
|
||||
(status = 200, description = "List of enforcements", body = PaginatedResponse<EnforcementSummary>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn list_enforcements(
|
||||
_user: RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<EnforcementQueryParams>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Get enforcements based on filters
|
||||
let enforcements = if let Some(status) = query.status {
|
||||
// Filter by status
|
||||
EnforcementRepository::find_by_status(&state.db, status).await?
|
||||
} else if let Some(rule_id) = query.rule {
|
||||
// Filter by rule ID
|
||||
EnforcementRepository::find_by_rule(&state.db, rule_id).await?
|
||||
} else if let Some(event_id) = query.event {
|
||||
// Filter by event ID
|
||||
EnforcementRepository::find_by_event(&state.db, event_id).await?
|
||||
} else {
|
||||
// Get all enforcements
|
||||
EnforcementRepository::list(&state.db).await?
|
||||
};
|
||||
|
||||
// Apply additional filters in memory
|
||||
let mut filtered_enforcements = enforcements;
|
||||
|
||||
if let Some(trigger_ref) = &query.trigger_ref {
|
||||
filtered_enforcements.retain(|e| e.trigger_ref == *trigger_ref);
|
||||
}
|
||||
|
||||
// Calculate pagination
|
||||
let total = filtered_enforcements.len() as u64;
|
||||
let start = query.offset() as usize;
|
||||
let end = (start + query.limit() as usize).min(filtered_enforcements.len());
|
||||
|
||||
// Get paginated slice
|
||||
let paginated_enforcements: Vec<EnforcementSummary> = filtered_enforcements[start..end]
|
||||
.iter()
|
||||
.map(|enforcement| EnforcementSummary::from(enforcement.clone()))
|
||||
.collect();
|
||||
|
||||
// Convert query params to pagination params for response
|
||||
let pagination_params = PaginationParams {
|
||||
page: query.page,
|
||||
page_size: query.per_page,
|
||||
};
|
||||
|
||||
let response = PaginatedResponse::new(paginated_enforcements, &pagination_params, total);
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Get a single enforcement by ID
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/enforcements/{id}",
|
||||
tag = "enforcements",
|
||||
params(
|
||||
("id" = i64, Path, description = "Enforcement ID")
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
responses(
|
||||
(status = 200, description = "Enforcement details", body = ApiResponse<EnforcementResponse>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Enforcement not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn get_enforcement(
|
||||
_user: RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
let enforcement = EnforcementRepository::find_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Enforcement with ID {} not found", id)))?;
|
||||
|
||||
let response = ApiResponse::new(EnforcementResponse::from(enforcement));
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Register event and enforcement routes
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/events", get(list_events).post(create_event))
|
||||
.route("/events/{id}", get(get_event))
|
||||
.route("/enforcements", get(list_enforcements))
|
||||
.route("/enforcements/{id}", get(get_enforcement))
|
||||
}
|
||||
529
crates/api/src/routes/executions.rs
Normal file
529
crates/api/src/routes/executions.rs
Normal file
@@ -0,0 +1,529 @@
|
||||
//! Execution management API routes
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::{
|
||||
sse::{Event, KeepAlive, Sse},
|
||||
IntoResponse,
|
||||
},
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use std::sync::Arc;
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
|
||||
use attune_common::models::enums::ExecutionStatus;
|
||||
use attune_common::mq::{ExecutionRequestedPayload, MessageEnvelope, MessageType};
|
||||
use attune_common::repositories::{
|
||||
action::ActionRepository,
|
||||
execution::{CreateExecutionInput, ExecutionRepository},
|
||||
Create, EnforcementRepository, FindById, FindByRef, List,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::middleware::RequireAuth,
|
||||
dto::{
|
||||
common::{PaginatedResponse, PaginationParams},
|
||||
execution::{
|
||||
CreateExecutionRequest, ExecutionQueryParams, ExecutionResponse, ExecutionSummary,
|
||||
},
|
||||
ApiResponse,
|
||||
},
|
||||
middleware::{ApiError, ApiResult},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
/// Create a new execution (manual execution)
|
||||
///
|
||||
/// This endpoint allows directly executing an action without a trigger or rule.
|
||||
/// The execution is queued and will be picked up by the executor service.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/executions/execute",
|
||||
tag = "executions",
|
||||
request_body = CreateExecutionRequest,
|
||||
responses(
|
||||
(status = 201, description = "Execution created and queued", body = ExecutionResponse),
|
||||
(status = 404, description = "Action not found"),
|
||||
(status = 400, description = "Invalid request"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn create_execution(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Json(request): Json<CreateExecutionRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Validate that the action exists
|
||||
let action = ActionRepository::find_by_ref(&state.db, &request.action_ref)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Action '{}' not found", request.action_ref)))?;
|
||||
|
||||
// Create execution input
|
||||
let execution_input = CreateExecutionInput {
|
||||
action: Some(action.id),
|
||||
action_ref: action.r#ref.clone(),
|
||||
config: request
|
||||
.parameters
|
||||
.as_ref()
|
||||
.and_then(|p| serde_json::from_value(p.clone()).ok()),
|
||||
parent: None,
|
||||
enforcement: None,
|
||||
executor: None,
|
||||
status: ExecutionStatus::Requested,
|
||||
result: None,
|
||||
workflow_task: None, // Non-workflow execution
|
||||
};
|
||||
|
||||
// Insert into database
|
||||
let created_execution = ExecutionRepository::create(&state.db, execution_input).await?;
|
||||
|
||||
// Publish ExecutionRequested message to queue
|
||||
let payload = ExecutionRequestedPayload {
|
||||
execution_id: created_execution.id,
|
||||
action_id: Some(action.id),
|
||||
action_ref: action.r#ref.clone(),
|
||||
parent_id: None,
|
||||
enforcement_id: None,
|
||||
config: request.parameters,
|
||||
};
|
||||
|
||||
let message = MessageEnvelope::new(MessageType::ExecutionRequested, payload)
|
||||
.with_source("api-service")
|
||||
.with_correlation_id(uuid::Uuid::new_v4());
|
||||
|
||||
if let Some(publisher) = &state.publisher {
|
||||
publisher.publish_envelope(&message).await.map_err(|e| {
|
||||
ApiError::InternalServerError(format!("Failed to publish message: {}", e))
|
||||
})?;
|
||||
}
|
||||
|
||||
let response = ExecutionResponse::from(created_execution);
|
||||
|
||||
Ok((StatusCode::CREATED, Json(ApiResponse::new(response))))
|
||||
}
|
||||
|
||||
/// List all executions with pagination and optional filters
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/executions",
|
||||
tag = "executions",
|
||||
params(ExecutionQueryParams),
|
||||
responses(
|
||||
(status = 200, description = "List of executions", body = PaginatedResponse<ExecutionSummary>),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn list_executions(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Query(query): Query<ExecutionQueryParams>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Get executions based on filters
|
||||
let executions = if let Some(status) = query.status {
|
||||
// Filter by status
|
||||
ExecutionRepository::find_by_status(&state.db, status).await?
|
||||
} else if let Some(enforcement_id) = query.enforcement {
|
||||
// Filter by enforcement
|
||||
ExecutionRepository::find_by_enforcement(&state.db, enforcement_id).await?
|
||||
} else {
|
||||
// Get all executions
|
||||
ExecutionRepository::list(&state.db).await?
|
||||
};
|
||||
|
||||
// Apply additional filters in memory (could be optimized with database queries)
|
||||
let mut filtered_executions = executions;
|
||||
|
||||
if let Some(action_ref) = &query.action_ref {
|
||||
filtered_executions.retain(|e| e.action_ref == *action_ref);
|
||||
}
|
||||
|
||||
if let Some(pack_name) = &query.pack_name {
|
||||
filtered_executions.retain(|e| {
|
||||
// action_ref format is "pack.action"
|
||||
e.action_ref.starts_with(&format!("{}.", pack_name))
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(result_search) = &query.result_contains {
|
||||
let search_lower = result_search.to_lowercase();
|
||||
filtered_executions.retain(|e| {
|
||||
if let Some(result) = &e.result {
|
||||
// Convert result to JSON string and search case-insensitively
|
||||
let result_str = serde_json::to_string(result).unwrap_or_default();
|
||||
result_str.to_lowercase().contains(&search_lower)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(parent_id) = query.parent {
|
||||
filtered_executions.retain(|e| e.parent == Some(parent_id));
|
||||
}
|
||||
|
||||
if let Some(executor_id) = query.executor {
|
||||
filtered_executions.retain(|e| e.executor == Some(executor_id));
|
||||
}
|
||||
|
||||
// Fetch enforcements for all executions to populate rule_ref and trigger_ref
|
||||
let enforcement_ids: Vec<i64> = filtered_executions
|
||||
.iter()
|
||||
.filter_map(|e| e.enforcement)
|
||||
.collect();
|
||||
|
||||
let enforcement_map: std::collections::HashMap<i64, _> = if !enforcement_ids.is_empty() {
|
||||
let enforcements = EnforcementRepository::list(&state.db).await?;
|
||||
enforcements.into_iter().map(|enf| (enf.id, enf)).collect()
|
||||
} else {
|
||||
std::collections::HashMap::new()
|
||||
};
|
||||
|
||||
// Filter by rule_ref if specified
|
||||
if let Some(rule_ref) = &query.rule_ref {
|
||||
filtered_executions.retain(|e| {
|
||||
e.enforcement
|
||||
.and_then(|enf_id| enforcement_map.get(&enf_id))
|
||||
.map(|enf| enf.rule_ref == *rule_ref)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by trigger_ref if specified
|
||||
if let Some(trigger_ref) = &query.trigger_ref {
|
||||
filtered_executions.retain(|e| {
|
||||
e.enforcement
|
||||
.and_then(|enf_id| enforcement_map.get(&enf_id))
|
||||
.map(|enf| enf.trigger_ref == *trigger_ref)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate pagination
|
||||
let total = filtered_executions.len() as u64;
|
||||
let start = query.offset() as usize;
|
||||
let end = (start + query.limit() as usize).min(filtered_executions.len());
|
||||
|
||||
// Get paginated slice and populate rule_ref/trigger_ref from enforcements
|
||||
let paginated_executions: Vec<ExecutionSummary> = filtered_executions[start..end]
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let mut summary = ExecutionSummary::from(e.clone());
|
||||
if let Some(enf_id) = e.enforcement {
|
||||
if let Some(enforcement) = enforcement_map.get(&enf_id) {
|
||||
summary.rule_ref = Some(enforcement.rule_ref.clone());
|
||||
summary.trigger_ref = Some(enforcement.trigger_ref.clone());
|
||||
}
|
||||
}
|
||||
summary
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Convert query params to pagination params for response
|
||||
let pagination_params = PaginationParams {
|
||||
page: query.page,
|
||||
page_size: query.per_page,
|
||||
};
|
||||
|
||||
let response = PaginatedResponse::new(paginated_executions, &pagination_params, total);
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Get a single execution by ID
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/executions/{id}",
|
||||
tag = "executions",
|
||||
params(
|
||||
("id" = i64, Path, description = "Execution ID")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Execution details", body = inline(ApiResponse<ExecutionResponse>)),
|
||||
(status = 404, description = "Execution not found")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_execution(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(id): Path<i64>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
let execution = ExecutionRepository::find_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Execution with ID {} not found", id)))?;
|
||||
|
||||
let response = ApiResponse::new(ExecutionResponse::from(execution));
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// List executions by status
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/executions/status/{status}",
|
||||
tag = "executions",
|
||||
params(
|
||||
("status" = String, Path, description = "Execution status (requested, scheduling, scheduled, running, completed, failed, canceling, cancelled, timeout, abandoned)"),
|
||||
PaginationParams
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "List of executions with specified status", body = PaginatedResponse<ExecutionSummary>),
|
||||
(status = 400, description = "Invalid status"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn list_executions_by_status(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(status_str): Path<String>,
|
||||
Query(pagination): Query<PaginationParams>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Parse status from string
|
||||
let status = match status_str.to_lowercase().as_str() {
|
||||
"requested" => attune_common::models::enums::ExecutionStatus::Requested,
|
||||
"scheduling" => attune_common::models::enums::ExecutionStatus::Scheduling,
|
||||
"scheduled" => attune_common::models::enums::ExecutionStatus::Scheduled,
|
||||
"running" => attune_common::models::enums::ExecutionStatus::Running,
|
||||
"completed" => attune_common::models::enums::ExecutionStatus::Completed,
|
||||
"failed" => attune_common::models::enums::ExecutionStatus::Failed,
|
||||
"canceling" => attune_common::models::enums::ExecutionStatus::Canceling,
|
||||
"cancelled" => attune_common::models::enums::ExecutionStatus::Cancelled,
|
||||
"timeout" => attune_common::models::enums::ExecutionStatus::Timeout,
|
||||
"abandoned" => attune_common::models::enums::ExecutionStatus::Abandoned,
|
||||
_ => {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"Invalid execution status: {}",
|
||||
status_str
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
// Get executions by status
|
||||
let executions = ExecutionRepository::find_by_status(&state.db, status).await?;
|
||||
|
||||
// Calculate pagination
|
||||
let total = executions.len() as u64;
|
||||
let start = ((pagination.page - 1) * pagination.limit()) as usize;
|
||||
let end = (start + pagination.limit() as usize).min(executions.len());
|
||||
|
||||
// Get paginated slice
|
||||
let paginated_executions: Vec<ExecutionSummary> = executions[start..end]
|
||||
.iter()
|
||||
.map(|e| ExecutionSummary::from(e.clone()))
|
||||
.collect();
|
||||
|
||||
let response = PaginatedResponse::new(paginated_executions, &pagination, total);
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// List executions by enforcement ID
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/executions/enforcement/{enforcement_id}",
|
||||
tag = "executions",
|
||||
params(
|
||||
("enforcement_id" = i64, Path, description = "Enforcement ID"),
|
||||
PaginationParams
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "List of executions for enforcement", body = PaginatedResponse<ExecutionSummary>),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn list_executions_by_enforcement(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(enforcement_id): Path<i64>,
|
||||
Query(pagination): Query<PaginationParams>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Get executions by enforcement
|
||||
let executions = ExecutionRepository::find_by_enforcement(&state.db, enforcement_id).await?;
|
||||
|
||||
// Calculate pagination
|
||||
let total = executions.len() as u64;
|
||||
let start = ((pagination.page - 1) * pagination.limit()) as usize;
|
||||
let end = (start + pagination.limit() as usize).min(executions.len());
|
||||
|
||||
// Get paginated slice
|
||||
let paginated_executions: Vec<ExecutionSummary> = executions[start..end]
|
||||
.iter()
|
||||
.map(|e| ExecutionSummary::from(e.clone()))
|
||||
.collect();
|
||||
|
||||
let response = PaginatedResponse::new(paginated_executions, &pagination, total);
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Get execution statistics
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/executions/stats",
|
||||
tag = "executions",
|
||||
responses(
|
||||
(status = 200, description = "Execution statistics", body = inline(Object)),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_execution_stats(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Get all executions (limited by repository to 1000)
|
||||
let executions = ExecutionRepository::list(&state.db).await?;
|
||||
|
||||
// Calculate statistics
|
||||
let total = executions.len();
|
||||
let completed = executions
|
||||
.iter()
|
||||
.filter(|e| e.status == attune_common::models::enums::ExecutionStatus::Completed)
|
||||
.count();
|
||||
let failed = executions
|
||||
.iter()
|
||||
.filter(|e| e.status == attune_common::models::enums::ExecutionStatus::Failed)
|
||||
.count();
|
||||
let running = executions
|
||||
.iter()
|
||||
.filter(|e| e.status == attune_common::models::enums::ExecutionStatus::Running)
|
||||
.count();
|
||||
let pending = executions
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
matches!(
|
||||
e.status,
|
||||
attune_common::models::enums::ExecutionStatus::Requested
|
||||
| attune_common::models::enums::ExecutionStatus::Scheduling
|
||||
| attune_common::models::enums::ExecutionStatus::Scheduled
|
||||
)
|
||||
})
|
||||
.count();
|
||||
|
||||
let stats = serde_json::json!({
|
||||
"total": total,
|
||||
"completed": completed,
|
||||
"failed": failed,
|
||||
"running": running,
|
||||
"pending": pending,
|
||||
"cancelled": executions.iter().filter(|e| e.status == attune_common::models::enums::ExecutionStatus::Cancelled).count(),
|
||||
"timeout": executions.iter().filter(|e| e.status == attune_common::models::enums::ExecutionStatus::Timeout).count(),
|
||||
"abandoned": executions.iter().filter(|e| e.status == attune_common::models::enums::ExecutionStatus::Abandoned).count(),
|
||||
});
|
||||
|
||||
let response = ApiResponse::new(stats);
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Create execution routes
|
||||
/// Stream execution updates via Server-Sent Events
|
||||
///
|
||||
/// This endpoint streams real-time updates for execution status changes.
|
||||
/// Optionally filter by execution_id to watch a specific execution.
|
||||
///
|
||||
/// Note: Authentication is done via `token` query parameter since EventSource
|
||||
/// doesn't support custom headers.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/executions/stream",
|
||||
tag = "executions",
|
||||
params(
|
||||
("execution_id" = Option<i64>, Query, description = "Optional execution ID to filter updates"),
|
||||
("token" = String, Query, description = "JWT access token for authentication")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "SSE stream of execution updates", content_type = "text/event-stream"),
|
||||
(status = 401, description = "Unauthorized - invalid or missing token"),
|
||||
)
|
||||
)]
|
||||
pub async fn stream_execution_updates(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<StreamExecutionParams>,
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, std::convert::Infallible>>>, ApiError> {
|
||||
// Validate token from query parameter
|
||||
use crate::auth::jwt::validate_token;
|
||||
|
||||
let token = params.token.as_ref().ok_or(ApiError::Unauthorized(
|
||||
"Missing authentication token".to_string(),
|
||||
))?;
|
||||
|
||||
validate_token(token, &state.jwt_config)
|
||||
.map_err(|_| ApiError::Unauthorized("Invalid authentication token".to_string()))?;
|
||||
let rx = state.broadcast_tx.subscribe();
|
||||
let stream = BroadcastStream::new(rx);
|
||||
|
||||
let filtered_stream = stream.filter_map(move |msg| {
|
||||
async move {
|
||||
match msg {
|
||||
Ok(notification) => {
|
||||
// Parse the notification as JSON
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(¬ification) {
|
||||
// Check if it's an execution update
|
||||
if let Some(entity_type) = value.get("entity_type").and_then(|v| v.as_str())
|
||||
{
|
||||
if entity_type == "execution" {
|
||||
// If filtering by execution_id, check if it matches
|
||||
if let Some(filter_id) = params.execution_id {
|
||||
if let Some(entity_id) =
|
||||
value.get("entity_id").and_then(|v| v.as_i64())
|
||||
{
|
||||
if entity_id != filter_id {
|
||||
return None; // Skip this event
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send the notification as an SSE event
|
||||
return Some(Ok(Event::default().data(notification)));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
Err(_) => None, // Skip broadcast errors
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Sse::new(filtered_stream).keep_alive(KeepAlive::default()))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct StreamExecutionParams {
|
||||
pub execution_id: Option<i64>,
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/executions", get(list_executions))
|
||||
.route("/executions/execute", axum::routing::post(create_execution))
|
||||
.route("/executions/stats", get(get_execution_stats))
|
||||
.route("/executions/stream", get(stream_execution_updates))
|
||||
.route("/executions/{id}", get(get_execution))
|
||||
.route(
|
||||
"/executions/status/{status}",
|
||||
get(list_executions_by_status),
|
||||
)
|
||||
.route(
|
||||
"/enforcements/{enforcement_id}/executions",
|
||||
get(list_executions_by_enforcement),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_execution_routes_structure() {
|
||||
// Just verify the router can be constructed
|
||||
let _router = routes();
|
||||
}
|
||||
}
|
||||
131
crates/api/src/routes/health.rs
Normal file
131
crates/api/src/routes/health.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
//! Health check endpoints
|
||||
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Health check response
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct HealthResponse {
|
||||
/// Service status
|
||||
#[schema(example = "ok")]
|
||||
pub status: String,
|
||||
/// Service version
|
||||
#[schema(example = "0.1.0")]
|
||||
pub version: String,
|
||||
/// Database connectivity status
|
||||
#[schema(example = "connected")]
|
||||
pub database: String,
|
||||
}
|
||||
|
||||
/// Basic health check endpoint
|
||||
///
|
||||
/// Returns 200 OK if the service is running
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/health",
|
||||
tag = "health",
|
||||
responses(
|
||||
(status = 200, description = "Service is healthy", body = inline(Object), example = json!({"status": "ok"}))
|
||||
)
|
||||
)]
|
||||
pub async fn health() -> impl IntoResponse {
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"status": "ok"
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/// Detailed health check endpoint
|
||||
///
|
||||
/// Checks database connectivity and returns detailed status
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/health/detailed",
|
||||
tag = "health",
|
||||
responses(
|
||||
(status = 200, description = "Service is healthy with details", body = HealthResponse),
|
||||
(status = 503, description = "Service unavailable", body = inline(Object))
|
||||
)
|
||||
)]
|
||||
pub async fn health_detailed(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
// Check database connectivity
|
||||
let db_status = match sqlx::query("SELECT 1").fetch_one(&state.db).await {
|
||||
Ok(_) => "connected",
|
||||
Err(e) => {
|
||||
tracing::error!("Database health check failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(serde_json::json!({
|
||||
"status": "error",
|
||||
"database": "disconnected",
|
||||
"error": "Database connectivity check failed"
|
||||
})),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let response = HealthResponse {
|
||||
status: "ok".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
database: db_status.to_string(),
|
||||
};
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Readiness check endpoint
|
||||
///
|
||||
/// Returns 200 OK if the service is ready to accept requests
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/health/ready",
|
||||
tag = "health",
|
||||
responses(
|
||||
(status = 200, description = "Service is ready"),
|
||||
(status = 503, description = "Service not ready")
|
||||
)
|
||||
)]
|
||||
pub async fn readiness(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
// Check if database is ready
|
||||
match sqlx::query("SELECT 1").fetch_one(&state.db).await {
|
||||
Ok(_) => Ok(StatusCode::OK),
|
||||
Err(e) => {
|
||||
tracing::error!("Readiness check failed: {}", e);
|
||||
Err(StatusCode::SERVICE_UNAVAILABLE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Liveness check endpoint
|
||||
///
|
||||
/// Returns 200 OK if the service process is alive
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/health/live",
|
||||
tag = "health",
|
||||
responses(
|
||||
(status = 200, description = "Service is alive")
|
||||
)
|
||||
)]
|
||||
pub async fn liveness() -> impl IntoResponse {
|
||||
StatusCode::OK
|
||||
}
|
||||
|
||||
/// Create health check router
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/health/detailed", get(health_detailed))
|
||||
.route("/health/ready", get(readiness))
|
||||
.route("/health/live", get(liveness))
|
||||
}
|
||||
507
crates/api/src/routes/inquiries.rs
Normal file
507
crates/api/src/routes/inquiries.rs
Normal file
@@ -0,0 +1,507 @@
|
||||
//! Inquiry management API routes
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use validator::Validate;
|
||||
|
||||
use attune_common::{
|
||||
mq::{InquiryRespondedPayload, MessageEnvelope, MessageType},
|
||||
repositories::{
|
||||
execution::ExecutionRepository,
|
||||
inquiry::{CreateInquiryInput, InquiryRepository, UpdateInquiryInput},
|
||||
Create, Delete, FindById, List, Update,
|
||||
},
|
||||
};
|
||||
|
||||
use crate::auth::RequireAuth;
|
||||
use crate::{
|
||||
dto::{
|
||||
common::{PaginatedResponse, PaginationParams},
|
||||
inquiry::{
|
||||
CreateInquiryRequest, InquiryQueryParams, InquiryRespondRequest, InquiryResponse,
|
||||
InquirySummary, UpdateInquiryRequest,
|
||||
},
|
||||
ApiResponse, SuccessResponse,
|
||||
},
|
||||
middleware::{ApiError, ApiResult},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
/// List all inquiries with pagination and optional filters
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/inquiries",
|
||||
tag = "inquiries",
|
||||
params(InquiryQueryParams),
|
||||
security(("bearer_auth" = [])),
|
||||
responses(
|
||||
(status = 200, description = "List of inquiries", body = PaginatedResponse<InquirySummary>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn list_inquiries(
|
||||
_user: RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<InquiryQueryParams>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Get inquiries based on filters
|
||||
let inquiries = if let Some(status) = query.status {
|
||||
// Filter by status
|
||||
InquiryRepository::find_by_status(&state.db, status).await?
|
||||
} else if let Some(execution_id) = query.execution {
|
||||
// Filter by execution
|
||||
InquiryRepository::find_by_execution(&state.db, execution_id).await?
|
||||
} else {
|
||||
// Get all inquiries
|
||||
InquiryRepository::list(&state.db).await?
|
||||
};
|
||||
|
||||
// Apply additional filters in memory
|
||||
let mut filtered_inquiries = inquiries;
|
||||
|
||||
if let Some(assigned_to) = query.assigned_to {
|
||||
filtered_inquiries.retain(|i| i.assigned_to == Some(assigned_to));
|
||||
}
|
||||
|
||||
// Calculate pagination
|
||||
let total = filtered_inquiries.len() as u64;
|
||||
let offset = query.offset.unwrap_or(0);
|
||||
let limit = query.limit.unwrap_or(50).min(500);
|
||||
let start = offset;
|
||||
let end = (start + limit).min(filtered_inquiries.len());
|
||||
|
||||
// Get paginated slice
|
||||
let paginated_inquiries: Vec<InquirySummary> = filtered_inquiries[start..end]
|
||||
.iter()
|
||||
.map(|inquiry| InquirySummary::from(inquiry.clone()))
|
||||
.collect();
|
||||
|
||||
// Convert to pagination params for response
|
||||
let pagination_params = PaginationParams {
|
||||
page: (offset / limit.max(1)) as u32 + 1,
|
||||
page_size: limit as u32,
|
||||
};
|
||||
|
||||
let response = PaginatedResponse::new(paginated_inquiries, &pagination_params, total);
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Get a single inquiry by ID
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/inquiries/{id}",
|
||||
tag = "inquiries",
|
||||
params(
|
||||
("id" = i64, Path, description = "Inquiry ID")
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
responses(
|
||||
(status = 200, description = "Inquiry details", body = ApiResponse<InquiryResponse>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Inquiry not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn get_inquiry(
|
||||
_user: RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
let inquiry = InquiryRepository::find_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Inquiry with ID {} not found", id)))?;
|
||||
|
||||
let response = ApiResponse::new(InquiryResponse::from(inquiry));
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// List inquiries by status
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/inquiries/status/{status}",
|
||||
tag = "inquiries",
|
||||
params(
|
||||
("status" = String, Path, description = "Inquiry status (pending, responded, timeout, canceled)"),
|
||||
PaginationParams
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
responses(
|
||||
(status = 200, description = "List of inquiries with specified status", body = PaginatedResponse<InquirySummary>),
|
||||
(status = 400, description = "Invalid status"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn list_inquiries_by_status(
|
||||
_user: RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(status_str): Path<String>,
|
||||
Query(pagination): Query<PaginationParams>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Parse status from string
|
||||
let status = match status_str.to_lowercase().as_str() {
|
||||
"pending" => attune_common::models::enums::InquiryStatus::Pending,
|
||||
"responded" => attune_common::models::enums::InquiryStatus::Responded,
|
||||
"timeout" => attune_common::models::enums::InquiryStatus::Timeout,
|
||||
"canceled" => attune_common::models::enums::InquiryStatus::Cancelled,
|
||||
_ => {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"Invalid inquiry status: '{}'. Valid values are: pending, responded, timeout, canceled",
|
||||
status_str
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let inquiries = InquiryRepository::find_by_status(&state.db, status).await?;
|
||||
|
||||
// Calculate pagination
|
||||
let total = inquiries.len() as u64;
|
||||
let start = ((pagination.page - 1) * pagination.limit()) as usize;
|
||||
let end = (start + pagination.limit() as usize).min(inquiries.len());
|
||||
|
||||
// Get paginated slice
|
||||
let paginated_inquiries: Vec<InquirySummary> = inquiries[start..end]
|
||||
.iter()
|
||||
.map(|inquiry| InquirySummary::from(inquiry.clone()))
|
||||
.collect();
|
||||
|
||||
let response = PaginatedResponse::new(paginated_inquiries, &pagination, total);
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// List inquiries for a specific execution
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/executions/{execution_id}/inquiries",
|
||||
tag = "inquiries",
|
||||
params(
|
||||
("execution_id" = i64, Path, description = "Execution ID"),
|
||||
PaginationParams
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
responses(
|
||||
(status = 200, description = "List of inquiries for execution", body = PaginatedResponse<InquirySummary>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Execution not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn list_inquiries_by_execution(
|
||||
_user: RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(execution_id): Path<i64>,
|
||||
Query(pagination): Query<PaginationParams>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Verify execution exists
|
||||
let _execution = ExecutionRepository::find_by_id(&state.db, execution_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::NotFound(format!("Execution with ID {} not found", execution_id))
|
||||
})?;
|
||||
|
||||
let inquiries = InquiryRepository::find_by_execution(&state.db, execution_id).await?;
|
||||
|
||||
// Calculate pagination
|
||||
let total = inquiries.len() as u64;
|
||||
let start = ((pagination.page - 1) * pagination.limit()) as usize;
|
||||
let end = (start + pagination.limit() as usize).min(inquiries.len());
|
||||
|
||||
// Get paginated slice
|
||||
let paginated_inquiries: Vec<InquirySummary> = inquiries[start..end]
|
||||
.iter()
|
||||
.map(|inquiry| InquirySummary::from(inquiry.clone()))
|
||||
.collect();
|
||||
|
||||
let response = PaginatedResponse::new(paginated_inquiries, &pagination, total);
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Create a new inquiry
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/inquiries",
|
||||
tag = "inquiries",
|
||||
request_body = CreateInquiryRequest,
|
||||
security(("bearer_auth" = [])),
|
||||
responses(
|
||||
(status = 201, description = "Inquiry created successfully", body = ApiResponse<InquiryResponse>),
|
||||
(status = 400, description = "Invalid request"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Execution not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn create_inquiry(
|
||||
_user: RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(request): Json<CreateInquiryRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Validate request
|
||||
request.validate()?;
|
||||
|
||||
// Verify execution exists
|
||||
let _execution = ExecutionRepository::find_by_id(&state.db, request.execution)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::NotFound(format!("Execution with ID {} not found", request.execution))
|
||||
})?;
|
||||
|
||||
// Create inquiry input
|
||||
let inquiry_input = CreateInquiryInput {
|
||||
execution: request.execution,
|
||||
prompt: request.prompt,
|
||||
response_schema: request.response_schema,
|
||||
assigned_to: request.assigned_to,
|
||||
status: attune_common::models::enums::InquiryStatus::Pending,
|
||||
response: None,
|
||||
timeout_at: request.timeout_at,
|
||||
};
|
||||
|
||||
let inquiry = InquiryRepository::create(&state.db, inquiry_input).await?;
|
||||
|
||||
let response = ApiResponse::with_message(
|
||||
InquiryResponse::from(inquiry),
|
||||
"Inquiry created successfully",
|
||||
);
|
||||
|
||||
Ok((StatusCode::CREATED, Json(response)))
|
||||
}
|
||||
|
||||
/// Update an existing inquiry
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/inquiries/{id}",
|
||||
tag = "inquiries",
|
||||
params(
|
||||
("id" = i64, Path, description = "Inquiry ID")
|
||||
),
|
||||
request_body = UpdateInquiryRequest,
|
||||
security(("bearer_auth" = [])),
|
||||
responses(
|
||||
(status = 200, description = "Inquiry updated successfully", body = ApiResponse<InquiryResponse>),
|
||||
(status = 400, description = "Invalid request"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Inquiry not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn update_inquiry(
|
||||
_user: RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
Json(request): Json<UpdateInquiryRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Validate request
|
||||
request.validate()?;
|
||||
|
||||
// Verify inquiry exists
|
||||
let _existing = InquiryRepository::find_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Inquiry with ID {} not found", id)))?;
|
||||
|
||||
// Create update input
|
||||
let update_input = UpdateInquiryInput {
|
||||
status: request.status,
|
||||
response: request.response,
|
||||
responded_at: None, // Let the database handle this if needed
|
||||
assigned_to: request.assigned_to,
|
||||
};
|
||||
|
||||
let updated_inquiry = InquiryRepository::update(&state.db, id, update_input).await?;
|
||||
|
||||
let response = ApiResponse::with_message(
|
||||
InquiryResponse::from(updated_inquiry),
|
||||
"Inquiry updated successfully",
|
||||
);
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Respond to an inquiry (user-facing endpoint)
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/inquiries/{id}/respond",
|
||||
tag = "inquiries",
|
||||
params(
|
||||
("id" = i64, Path, description = "Inquiry ID")
|
||||
),
|
||||
request_body = InquiryRespondRequest,
|
||||
security(("bearer_auth" = [])),
|
||||
responses(
|
||||
(status = 200, description = "Response submitted successfully", body = ApiResponse<InquiryResponse>),
|
||||
(status = 400, description = "Invalid request or inquiry cannot be responded to"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Not authorized to respond to this inquiry"),
|
||||
(status = 404, description = "Inquiry not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn respond_to_inquiry(
|
||||
user: RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
Json(request): Json<InquiryRespondRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Validate request
|
||||
request.validate()?;
|
||||
|
||||
// Verify inquiry exists and is in pending status
|
||||
let inquiry = InquiryRepository::find_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Inquiry with ID {} not found", id)))?;
|
||||
|
||||
// Check if inquiry is still pending
|
||||
if inquiry.status != attune_common::models::enums::InquiryStatus::Pending {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"Cannot respond to inquiry with status '{:?}'. Only pending inquiries can be responded to.",
|
||||
inquiry.status
|
||||
)));
|
||||
}
|
||||
|
||||
// Check if inquiry is assigned to this user (optional enforcement)
|
||||
if let Some(assigned_to) = inquiry.assigned_to {
|
||||
let user_id = user
|
||||
.0
|
||||
.identity_id()
|
||||
.map_err(|_| ApiError::InternalServerError("Invalid user identity".to_string()))?;
|
||||
if assigned_to != user_id {
|
||||
return Err(ApiError::Forbidden(
|
||||
"You are not authorized to respond to this inquiry".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if inquiry has timed out
|
||||
if let Some(timeout_at) = inquiry.timeout_at {
|
||||
if timeout_at < chrono::Utc::now() {
|
||||
// Update inquiry to timeout status
|
||||
let timeout_input = UpdateInquiryInput {
|
||||
status: Some(attune_common::models::enums::InquiryStatus::Timeout),
|
||||
response: None,
|
||||
responded_at: None,
|
||||
assigned_to: None,
|
||||
};
|
||||
let _ = InquiryRepository::update(&state.db, id, timeout_input).await?;
|
||||
|
||||
return Err(ApiError::BadRequest(
|
||||
"Inquiry has timed out and can no longer be responded to".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Validate response against response_schema if present
|
||||
// For now, just accept the response as-is
|
||||
|
||||
// Create update input with response
|
||||
let update_input = UpdateInquiryInput {
|
||||
status: Some(attune_common::models::enums::InquiryStatus::Responded),
|
||||
response: Some(request.response.clone()),
|
||||
responded_at: Some(chrono::Utc::now()),
|
||||
assigned_to: None,
|
||||
};
|
||||
|
||||
let updated_inquiry = InquiryRepository::update(&state.db, id, update_input).await?;
|
||||
|
||||
// Publish InquiryResponded message if publisher is available
|
||||
if let Some(publisher) = &state.publisher {
|
||||
let user_id = user
|
||||
.0
|
||||
.identity_id()
|
||||
.map_err(|_| ApiError::InternalServerError("Invalid user identity".to_string()))?;
|
||||
|
||||
let payload = InquiryRespondedPayload {
|
||||
inquiry_id: id,
|
||||
execution_id: inquiry.execution,
|
||||
response: request.response.clone(),
|
||||
responded_by: Some(user_id),
|
||||
responded_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let envelope =
|
||||
MessageEnvelope::new(MessageType::InquiryResponded, payload).with_source("api");
|
||||
|
||||
if let Err(e) = publisher.publish_envelope(&envelope).await {
|
||||
tracing::error!("Failed to publish InquiryResponded message: {}", e);
|
||||
// Don't fail the request - inquiry is already saved
|
||||
} else {
|
||||
tracing::info!("Published InquiryResponded message for inquiry {}", id);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("No publisher available to publish InquiryResponded message");
|
||||
}
|
||||
|
||||
let response = ApiResponse::with_message(
|
||||
InquiryResponse::from(updated_inquiry),
|
||||
"Response submitted successfully",
|
||||
);
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Delete an inquiry
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/inquiries/{id}",
|
||||
tag = "inquiries",
|
||||
params(
|
||||
("id" = i64, Path, description = "Inquiry ID")
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
responses(
|
||||
(status = 200, description = "Inquiry deleted successfully", body = SuccessResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Inquiry not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn delete_inquiry(
|
||||
_user: RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Verify inquiry exists
|
||||
let _inquiry = InquiryRepository::find_by_id(&state.db, id)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Inquiry with ID {} not found", id)))?;
|
||||
|
||||
// Delete the inquiry
|
||||
let deleted = InquiryRepository::delete(&state.db, id).await?;
|
||||
|
||||
if !deleted {
|
||||
return Err(ApiError::NotFound(format!(
|
||||
"Inquiry with ID {} not found",
|
||||
id
|
||||
)));
|
||||
}
|
||||
|
||||
let response = SuccessResponse::new("Inquiry deleted successfully");
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Register inquiry routes
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/inquiries", get(list_inquiries).post(create_inquiry))
|
||||
.route(
|
||||
"/inquiries/{id}",
|
||||
get(get_inquiry).put(update_inquiry).delete(delete_inquiry),
|
||||
)
|
||||
.route("/inquiries/status/{status}", get(list_inquiries_by_status))
|
||||
.route(
|
||||
"/executions/{execution_id}/inquiries",
|
||||
get(list_inquiries_by_execution),
|
||||
)
|
||||
.route("/inquiries/{id}/respond", post(respond_to_inquiry))
|
||||
}
|
||||
363
crates/api/src/routes/keys.rs
Normal file
363
crates/api/src/routes/keys.rs
Normal file
@@ -0,0 +1,363 @@
|
||||
//! Key/Secret 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::repositories::{
|
||||
key::{CreateKeyInput, KeyRepository, UpdateKeyInput},
|
||||
Create, Delete, List, Update,
|
||||
};
|
||||
|
||||
use crate::auth::RequireAuth;
|
||||
use crate::{
|
||||
dto::{
|
||||
common::{PaginatedResponse, PaginationParams},
|
||||
key::{CreateKeyRequest, KeyQueryParams, KeyResponse, KeySummary, UpdateKeyRequest},
|
||||
ApiResponse, SuccessResponse,
|
||||
},
|
||||
middleware::{ApiError, ApiResult},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
/// List all keys with pagination and optional filters (values redacted)
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/keys",
|
||||
tag = "secrets",
|
||||
params(KeyQueryParams),
|
||||
responses(
|
||||
(status = 200, description = "List of keys (values redacted)", body = PaginatedResponse<KeySummary>),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn list_keys(
|
||||
_user: RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<KeyQueryParams>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Get keys based on filters
|
||||
let keys = if let Some(owner_type) = query.owner_type {
|
||||
// Filter by owner type
|
||||
KeyRepository::find_by_owner_type(&state.db, owner_type).await?
|
||||
} else {
|
||||
// Get all keys
|
||||
KeyRepository::list(&state.db).await?
|
||||
};
|
||||
|
||||
// Apply additional filters in memory
|
||||
let mut filtered_keys = keys;
|
||||
|
||||
if let Some(owner) = &query.owner {
|
||||
filtered_keys.retain(|k| k.owner.as_ref() == Some(owner));
|
||||
}
|
||||
|
||||
// Calculate pagination
|
||||
let total = filtered_keys.len() as u64;
|
||||
let start = query.offset() as usize;
|
||||
let end = (start + query.limit() as usize).min(filtered_keys.len());
|
||||
|
||||
// Get paginated slice (values redacted in summary)
|
||||
let paginated_keys: Vec<KeySummary> = filtered_keys[start..end]
|
||||
.iter()
|
||||
.map(|key| KeySummary::from(key.clone()))
|
||||
.collect();
|
||||
|
||||
// Convert query params to pagination params for response
|
||||
let pagination_params = PaginationParams {
|
||||
page: query.page,
|
||||
page_size: query.per_page,
|
||||
};
|
||||
|
||||
let response = PaginatedResponse::new(paginated_keys, &pagination_params, total);
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Get a single key by reference (includes decrypted value)
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/keys/{ref}",
|
||||
tag = "secrets",
|
||||
params(
|
||||
("ref" = String, Path, description = "Key reference identifier")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Key details with decrypted value", body = inline(ApiResponse<KeyResponse>)),
|
||||
(status = 404, description = "Key not found")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_key(
|
||||
_user: RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(key_ref): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
let mut key = KeyRepository::find_by_ref(&state.db, &key_ref)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Key '{}' not found", key_ref)))?;
|
||||
|
||||
// Decrypt value if encrypted
|
||||
if key.encrypted {
|
||||
let encryption_key = state
|
||||
.config
|
||||
.security
|
||||
.encryption_key
|
||||
.as_ref()
|
||||
.ok_or_else(|| {
|
||||
ApiError::InternalServerError("Encryption key not configured on server".to_string())
|
||||
})?;
|
||||
|
||||
let decrypted_value =
|
||||
attune_common::crypto::decrypt(&key.value, encryption_key).map_err(|e| {
|
||||
tracing::error!("Failed to decrypt key '{}': {}", key_ref, e);
|
||||
ApiError::InternalServerError(format!("Failed to decrypt key: {}", e))
|
||||
})?;
|
||||
|
||||
key.value = decrypted_value;
|
||||
}
|
||||
|
||||
let response = ApiResponse::new(KeyResponse::from(key));
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Create a new key/secret
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/keys",
|
||||
tag = "secrets",
|
||||
request_body = CreateKeyRequest,
|
||||
responses(
|
||||
(status = 201, description = "Key created successfully", body = inline(ApiResponse<KeyResponse>)),
|
||||
(status = 400, description = "Validation error"),
|
||||
(status = 409, description = "Key with same ref already exists")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn create_key(
|
||||
_user: RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(request): Json<CreateKeyRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Validate request
|
||||
request.validate()?;
|
||||
|
||||
// Check if key with same ref already exists
|
||||
if let Some(_) = KeyRepository::find_by_ref(&state.db, &request.r#ref).await? {
|
||||
return Err(ApiError::Conflict(format!(
|
||||
"Key with ref '{}' already exists",
|
||||
request.r#ref
|
||||
)));
|
||||
}
|
||||
|
||||
// Encrypt value if requested
|
||||
let (value, encryption_key_hash) = if request.encrypted {
|
||||
let encryption_key = state
|
||||
.config
|
||||
.security
|
||||
.encryption_key
|
||||
.as_ref()
|
||||
.ok_or_else(|| {
|
||||
ApiError::BadRequest(
|
||||
"Cannot encrypt: encryption key not configured on server".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let encrypted_value = attune_common::crypto::encrypt(&request.value, encryption_key)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to encrypt key value: {}", e);
|
||||
ApiError::InternalServerError(format!("Failed to encrypt value: {}", e))
|
||||
})?;
|
||||
|
||||
let key_hash = attune_common::crypto::hash_encryption_key(encryption_key);
|
||||
|
||||
(encrypted_value, Some(key_hash))
|
||||
} else {
|
||||
// Store in plaintext (not recommended for sensitive data)
|
||||
(request.value.clone(), None)
|
||||
};
|
||||
|
||||
// Create key input
|
||||
let key_input = CreateKeyInput {
|
||||
r#ref: request.r#ref,
|
||||
owner_type: request.owner_type,
|
||||
owner: request.owner,
|
||||
owner_identity: request.owner_identity,
|
||||
owner_pack: request.owner_pack,
|
||||
owner_pack_ref: request.owner_pack_ref,
|
||||
owner_action: request.owner_action,
|
||||
owner_action_ref: request.owner_action_ref,
|
||||
owner_sensor: request.owner_sensor,
|
||||
owner_sensor_ref: request.owner_sensor_ref,
|
||||
name: request.name,
|
||||
encrypted: request.encrypted,
|
||||
encryption_key_hash,
|
||||
value,
|
||||
};
|
||||
|
||||
let mut key = KeyRepository::create(&state.db, key_input).await?;
|
||||
|
||||
// Return decrypted value in response
|
||||
if key.encrypted {
|
||||
let encryption_key = state.config.security.encryption_key.as_ref().unwrap();
|
||||
key.value = attune_common::crypto::decrypt(&key.value, encryption_key).map_err(|e| {
|
||||
tracing::error!("Failed to decrypt newly created key: {}", e);
|
||||
ApiError::InternalServerError(format!("Failed to decrypt value: {}", e))
|
||||
})?;
|
||||
}
|
||||
|
||||
let response = ApiResponse::with_message(KeyResponse::from(key), "Key created successfully");
|
||||
|
||||
Ok((StatusCode::CREATED, Json(response)))
|
||||
}
|
||||
|
||||
/// Update an existing key/secret
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/keys/{ref}",
|
||||
tag = "secrets",
|
||||
params(
|
||||
("ref" = String, Path, description = "Key reference identifier")
|
||||
),
|
||||
request_body = UpdateKeyRequest,
|
||||
responses(
|
||||
(status = 200, description = "Key updated successfully", body = inline(ApiResponse<KeyResponse>)),
|
||||
(status = 400, description = "Validation error"),
|
||||
(status = 404, description = "Key not found")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn update_key(
|
||||
_user: RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(key_ref): Path<String>,
|
||||
Json(request): Json<UpdateKeyRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Validate request
|
||||
request.validate()?;
|
||||
|
||||
// Verify key exists
|
||||
let existing = KeyRepository::find_by_ref(&state.db, &key_ref)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Key '{}' not found", key_ref)))?;
|
||||
|
||||
// Handle value update with encryption
|
||||
let (value, encrypted, encryption_key_hash) = if let Some(new_value) = request.value {
|
||||
let should_encrypt = request.encrypted.unwrap_or(existing.encrypted);
|
||||
|
||||
if should_encrypt {
|
||||
let encryption_key =
|
||||
state
|
||||
.config
|
||||
.security
|
||||
.encryption_key
|
||||
.as_ref()
|
||||
.ok_or_else(|| {
|
||||
ApiError::BadRequest(
|
||||
"Cannot encrypt: encryption key not configured on server".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let encrypted_value = attune_common::crypto::encrypt(&new_value, encryption_key)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to encrypt key value: {}", e);
|
||||
ApiError::InternalServerError(format!("Failed to encrypt value: {}", e))
|
||||
})?;
|
||||
|
||||
let key_hash = attune_common::crypto::hash_encryption_key(encryption_key);
|
||||
|
||||
(Some(encrypted_value), Some(should_encrypt), Some(key_hash))
|
||||
} else {
|
||||
(Some(new_value), Some(false), None)
|
||||
}
|
||||
} else {
|
||||
// No value update, but might be changing encryption status
|
||||
(None, request.encrypted, None)
|
||||
};
|
||||
|
||||
// Create update input
|
||||
let update_input = UpdateKeyInput {
|
||||
name: request.name,
|
||||
value,
|
||||
encrypted,
|
||||
encryption_key_hash,
|
||||
};
|
||||
|
||||
let mut updated_key = KeyRepository::update(&state.db, existing.id, update_input).await?;
|
||||
|
||||
// Return decrypted value in response
|
||||
if updated_key.encrypted {
|
||||
let encryption_key = state
|
||||
.config
|
||||
.security
|
||||
.encryption_key
|
||||
.as_ref()
|
||||
.ok_or_else(|| {
|
||||
ApiError::InternalServerError("Encryption key not configured on server".to_string())
|
||||
})?;
|
||||
|
||||
updated_key.value = attune_common::crypto::decrypt(&updated_key.value, encryption_key)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to decrypt updated key '{}': {}", key_ref, e);
|
||||
ApiError::InternalServerError(format!("Failed to decrypt value: {}", e))
|
||||
})?;
|
||||
}
|
||||
|
||||
let response =
|
||||
ApiResponse::with_message(KeyResponse::from(updated_key), "Key updated successfully");
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Delete a key/secret
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/keys/{ref}",
|
||||
tag = "secrets",
|
||||
params(
|
||||
("ref" = String, Path, description = "Key reference identifier")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Key deleted successfully", body = SuccessResponse),
|
||||
(status = 404, description = "Key not found")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete_key(
|
||||
_user: RequireAuth,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(key_ref): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Verify key exists
|
||||
let key = KeyRepository::find_by_ref(&state.db, &key_ref)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Key '{}' not found", key_ref)))?;
|
||||
|
||||
// Delete the key
|
||||
let deleted = KeyRepository::delete(&state.db, key.id).await?;
|
||||
|
||||
if !deleted {
|
||||
return Err(ApiError::NotFound(format!("Key '{}' not found", key_ref)));
|
||||
}
|
||||
|
||||
let response = SuccessResponse::new("Key deleted successfully");
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Register key/secret routes
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/keys", get(list_keys).post(create_key))
|
||||
.route(
|
||||
"/keys/{ref}",
|
||||
get(get_key).put(update_key).delete(delete_key),
|
||||
)
|
||||
}
|
||||
27
crates/api/src/routes/mod.rs
Normal file
27
crates/api/src/routes/mod.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
//! API route modules
|
||||
|
||||
pub mod actions;
|
||||
pub mod auth;
|
||||
pub mod events;
|
||||
pub mod executions;
|
||||
pub mod health;
|
||||
pub mod inquiries;
|
||||
pub mod keys;
|
||||
pub mod packs;
|
||||
pub mod rules;
|
||||
pub mod triggers;
|
||||
pub mod webhooks;
|
||||
pub mod workflows;
|
||||
|
||||
pub use actions::routes as action_routes;
|
||||
pub use auth::routes as auth_routes;
|
||||
pub use events::routes as event_routes;
|
||||
pub use executions::routes as execution_routes;
|
||||
pub use health::routes as health_routes;
|
||||
pub use inquiries::routes as inquiry_routes;
|
||||
pub use keys::routes as key_routes;
|
||||
pub use packs::routes as pack_routes;
|
||||
pub use rules::routes as rule_routes;
|
||||
pub use triggers::routes as trigger_routes;
|
||||
pub use webhooks::routes as webhook_routes;
|
||||
pub use workflows::routes as workflow_routes;
|
||||
1243
crates/api/src/routes/packs.rs
Normal file
1243
crates/api/src/routes/packs.rs
Normal file
File diff suppressed because it is too large
Load Diff
660
crates/api/src/routes/rules.rs
Normal file
660
crates/api/src/routes/rules.rs
Normal file
@@ -0,0 +1,660 @@
|
||||
//! Rule management API routes
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tracing::{info, warn};
|
||||
use validator::Validate;
|
||||
|
||||
use attune_common::mq::{
|
||||
MessageEnvelope, MessageType, RuleCreatedPayload, RuleDisabledPayload, RuleEnabledPayload,
|
||||
};
|
||||
use attune_common::repositories::{
|
||||
action::ActionRepository,
|
||||
pack::PackRepository,
|
||||
rule::{CreateRuleInput, RuleRepository, UpdateRuleInput},
|
||||
trigger::TriggerRepository,
|
||||
Create, Delete, FindByRef, List, Update,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::middleware::RequireAuth,
|
||||
dto::{
|
||||
common::{PaginatedResponse, PaginationParams},
|
||||
rule::{CreateRuleRequest, RuleResponse, RuleSummary, UpdateRuleRequest},
|
||||
ApiResponse, SuccessResponse,
|
||||
},
|
||||
middleware::{ApiError, ApiResult},
|
||||
state::AppState,
|
||||
validation::{validate_action_params, validate_trigger_params},
|
||||
};
|
||||
|
||||
/// List all rules with pagination
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/rules",
|
||||
tag = "rules",
|
||||
params(PaginationParams),
|
||||
responses(
|
||||
(status = 200, description = "List of rules", body = PaginatedResponse<RuleSummary>),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn list_rules(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Query(pagination): Query<PaginationParams>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Get all rules
|
||||
let rules = RuleRepository::list(&state.db).await?;
|
||||
|
||||
// Calculate pagination
|
||||
let total = rules.len() as u64;
|
||||
let start = ((pagination.page - 1) * pagination.limit()) as usize;
|
||||
let end = (start + pagination.limit() as usize).min(rules.len());
|
||||
|
||||
// Get paginated slice
|
||||
let paginated_rules: Vec<RuleSummary> = rules[start..end]
|
||||
.iter()
|
||||
.map(|r| RuleSummary::from(r.clone()))
|
||||
.collect();
|
||||
|
||||
let response = PaginatedResponse::new(paginated_rules, &pagination, total);
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// List enabled rules
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/rules/enabled",
|
||||
tag = "rules",
|
||||
params(PaginationParams),
|
||||
responses(
|
||||
(status = 200, description = "List of enabled rules", body = PaginatedResponse<RuleSummary>),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn list_enabled_rules(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Query(pagination): Query<PaginationParams>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Get enabled rules
|
||||
let rules = RuleRepository::find_enabled(&state.db).await?;
|
||||
|
||||
// Calculate pagination
|
||||
let total = rules.len() as u64;
|
||||
let start = ((pagination.page - 1) * pagination.limit()) as usize;
|
||||
let end = (start + pagination.limit() as usize).min(rules.len());
|
||||
|
||||
// Get paginated slice
|
||||
let paginated_rules: Vec<RuleSummary> = rules[start..end]
|
||||
.iter()
|
||||
.map(|r| RuleSummary::from(r.clone()))
|
||||
.collect();
|
||||
|
||||
let response = PaginatedResponse::new(paginated_rules, &pagination, total);
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// List rules by pack reference
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/packs/{pack_ref}/rules",
|
||||
tag = "rules",
|
||||
params(
|
||||
("pack_ref" = String, Path, description = "Pack reference"),
|
||||
PaginationParams
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "List of rules in pack", body = PaginatedResponse<RuleSummary>),
|
||||
(status = 404, description = "Pack not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn list_rules_by_pack(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(pack_ref): Path<String>,
|
||||
Query(pagination): Query<PaginationParams>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// 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)))?;
|
||||
|
||||
// Get rules for this pack
|
||||
let rules = RuleRepository::find_by_pack(&state.db, pack.id).await?;
|
||||
|
||||
// Calculate pagination
|
||||
let total = rules.len() as u64;
|
||||
let start = ((pagination.page - 1) * pagination.limit()) as usize;
|
||||
let end = (start + pagination.limit() as usize).min(rules.len());
|
||||
|
||||
// Get paginated slice
|
||||
let paginated_rules: Vec<RuleSummary> = rules[start..end]
|
||||
.iter()
|
||||
.map(|r| RuleSummary::from(r.clone()))
|
||||
.collect();
|
||||
|
||||
let response = PaginatedResponse::new(paginated_rules, &pagination, total);
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// List rules by action reference
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/actions/{action_ref}/rules",
|
||||
tag = "rules",
|
||||
params(
|
||||
("action_ref" = String, Path, description = "Action reference"),
|
||||
PaginationParams
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "List of rules using this action", body = PaginatedResponse<RuleSummary>),
|
||||
(status = 404, description = "Action not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn list_rules_by_action(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(action_ref): Path<String>,
|
||||
Query(pagination): Query<PaginationParams>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Verify action exists
|
||||
let action = ActionRepository::find_by_ref(&state.db, &action_ref)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Action '{}' not found", action_ref)))?;
|
||||
|
||||
// Get rules for this action
|
||||
let rules = RuleRepository::find_by_action(&state.db, action.id).await?;
|
||||
|
||||
// Calculate pagination
|
||||
let total = rules.len() as u64;
|
||||
let start = ((pagination.page - 1) * pagination.limit()) as usize;
|
||||
let end = (start + pagination.limit() as usize).min(rules.len());
|
||||
|
||||
// Get paginated slice
|
||||
let paginated_rules: Vec<RuleSummary> = rules[start..end]
|
||||
.iter()
|
||||
.map(|r| RuleSummary::from(r.clone()))
|
||||
.collect();
|
||||
|
||||
let response = PaginatedResponse::new(paginated_rules, &pagination, total);
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// List rules by trigger reference
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/triggers/{trigger_ref}/rules",
|
||||
tag = "rules",
|
||||
params(
|
||||
("trigger_ref" = String, Path, description = "Trigger reference"),
|
||||
PaginationParams
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "List of rules using this trigger", body = PaginatedResponse<RuleSummary>),
|
||||
(status = 404, description = "Trigger not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn list_rules_by_trigger(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(trigger_ref): Path<String>,
|
||||
Query(pagination): Query<PaginationParams>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Verify trigger exists
|
||||
let trigger = TriggerRepository::find_by_ref(&state.db, &trigger_ref)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?;
|
||||
|
||||
// Get rules for this trigger
|
||||
let rules = RuleRepository::find_by_trigger(&state.db, trigger.id).await?;
|
||||
|
||||
// Calculate pagination
|
||||
let total = rules.len() as u64;
|
||||
let start = ((pagination.page - 1) * pagination.limit()) as usize;
|
||||
let end = (start + pagination.limit() as usize).min(rules.len());
|
||||
|
||||
// Get paginated slice
|
||||
let paginated_rules: Vec<RuleSummary> = rules[start..end]
|
||||
.iter()
|
||||
.map(|r| RuleSummary::from(r.clone()))
|
||||
.collect();
|
||||
|
||||
let response = PaginatedResponse::new(paginated_rules, &pagination, total);
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Get a single rule by reference
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/rules/{ref}",
|
||||
tag = "rules",
|
||||
params(
|
||||
("ref" = String, Path, description = "Rule reference")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Rule details", body = ApiResponse<RuleResponse>),
|
||||
(status = 404, description = "Rule not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn get_rule(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(rule_ref): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
let rule = RuleRepository::find_by_ref(&state.db, &rule_ref)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Rule '{}' not found", rule_ref)))?;
|
||||
|
||||
let response = ApiResponse::new(RuleResponse::from(rule));
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Create a new rule
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/rules",
|
||||
tag = "rules",
|
||||
request_body = CreateRuleRequest,
|
||||
responses(
|
||||
(status = 201, description = "Rule created successfully", body = ApiResponse<RuleResponse>),
|
||||
(status = 400, description = "Invalid request"),
|
||||
(status = 404, description = "Pack, action, or trigger not found"),
|
||||
(status = 409, description = "Rule with same ref already exists"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn create_rule(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Json(request): Json<CreateRuleRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Validate request
|
||||
request.validate()?;
|
||||
|
||||
// Check if rule with same ref already exists
|
||||
if let Some(_) = RuleRepository::find_by_ref(&state.db, &request.r#ref).await? {
|
||||
return Err(ApiError::Conflict(format!(
|
||||
"Rule 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)))?;
|
||||
|
||||
// Verify action exists and get its ID
|
||||
let action = ActionRepository::find_by_ref(&state.db, &request.action_ref)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Action '{}' not found", request.action_ref)))?;
|
||||
|
||||
// Verify trigger exists and get its ID
|
||||
let trigger = TriggerRepository::find_by_ref(&state.db, &request.trigger_ref)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::NotFound(format!("Trigger '{}' not found", request.trigger_ref))
|
||||
})?;
|
||||
|
||||
// Validate trigger parameters against schema
|
||||
validate_trigger_params(&trigger, &request.trigger_params)?;
|
||||
|
||||
// Validate action parameters against schema
|
||||
validate_action_params(&action, &request.action_params)?;
|
||||
|
||||
// Create rule input
|
||||
let rule_input = CreateRuleInput {
|
||||
r#ref: request.r#ref,
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: request.label,
|
||||
description: request.description,
|
||||
action: action.id,
|
||||
action_ref: action.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
trigger_ref: trigger.r#ref.clone(),
|
||||
conditions: request.conditions,
|
||||
action_params: request.action_params,
|
||||
trigger_params: request.trigger_params,
|
||||
enabled: request.enabled,
|
||||
is_adhoc: true, // Rules created via API are ad-hoc (not from pack installation)
|
||||
};
|
||||
|
||||
let rule = RuleRepository::create(&state.db, rule_input).await?;
|
||||
|
||||
// Publish RuleCreated message to notify sensor service
|
||||
if let Some(ref publisher) = state.publisher {
|
||||
let payload = RuleCreatedPayload {
|
||||
rule_id: rule.id,
|
||||
rule_ref: rule.r#ref.clone(),
|
||||
trigger_id: Some(rule.trigger),
|
||||
trigger_ref: rule.trigger_ref.clone(),
|
||||
action_id: Some(rule.action),
|
||||
action_ref: rule.action_ref.clone(),
|
||||
trigger_params: Some(rule.trigger_params.clone()),
|
||||
enabled: rule.enabled,
|
||||
};
|
||||
|
||||
let envelope =
|
||||
MessageEnvelope::new(MessageType::RuleCreated, payload).with_source("api-service");
|
||||
|
||||
if let Err(e) = publisher.publish_envelope(&envelope).await {
|
||||
warn!(
|
||||
"Failed to publish RuleCreated message for rule {}: {}",
|
||||
rule.r#ref, e
|
||||
);
|
||||
} else {
|
||||
info!("Published RuleCreated message for rule {}", rule.r#ref);
|
||||
}
|
||||
}
|
||||
|
||||
let response = ApiResponse::with_message(RuleResponse::from(rule), "Rule created successfully");
|
||||
|
||||
Ok((StatusCode::CREATED, Json(response)))
|
||||
}
|
||||
|
||||
/// Update an existing rule
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/rules/{ref}",
|
||||
tag = "rules",
|
||||
params(
|
||||
("ref" = String, Path, description = "Rule reference")
|
||||
),
|
||||
request_body = UpdateRuleRequest,
|
||||
responses(
|
||||
(status = 200, description = "Rule updated successfully", body = ApiResponse<RuleResponse>),
|
||||
(status = 400, description = "Invalid request"),
|
||||
(status = 404, description = "Rule not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn update_rule(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(rule_ref): Path<String>,
|
||||
Json(request): Json<UpdateRuleRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Validate request
|
||||
request.validate()?;
|
||||
|
||||
// Check if rule exists
|
||||
let existing_rule = RuleRepository::find_by_ref(&state.db, &rule_ref)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Rule '{}' not found", rule_ref)))?;
|
||||
|
||||
// If action parameters are being updated, validate against the action's schema
|
||||
if let Some(ref action_params) = request.action_params {
|
||||
let action = ActionRepository::find_by_ref(&state.db, &existing_rule.action_ref)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::NotFound(format!("Action '{}' not found", existing_rule.action_ref))
|
||||
})?;
|
||||
validate_action_params(&action, action_params)?;
|
||||
}
|
||||
|
||||
// If trigger parameters are being updated, validate against the trigger's schema
|
||||
if let Some(ref trigger_params) = request.trigger_params {
|
||||
let trigger = TriggerRepository::find_by_ref(&state.db, &existing_rule.trigger_ref)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::NotFound(format!("Trigger '{}' not found", existing_rule.trigger_ref))
|
||||
})?;
|
||||
validate_trigger_params(&trigger, trigger_params)?;
|
||||
}
|
||||
|
||||
// Track if trigger params changed
|
||||
let trigger_params_changed = request.trigger_params.is_some()
|
||||
&& request.trigger_params != Some(existing_rule.trigger_params.clone());
|
||||
|
||||
// Create update input
|
||||
let update_input = UpdateRuleInput {
|
||||
label: request.label,
|
||||
description: request.description,
|
||||
conditions: request.conditions,
|
||||
action_params: request.action_params,
|
||||
trigger_params: request.trigger_params,
|
||||
enabled: request.enabled,
|
||||
};
|
||||
|
||||
let rule = RuleRepository::update(&state.db, existing_rule.id, update_input).await?;
|
||||
|
||||
// If the rule is enabled and trigger params changed, publish RuleEnabled message
|
||||
// to notify sensors to restart with new parameters
|
||||
if rule.enabled && trigger_params_changed {
|
||||
if let Some(ref publisher) = state.publisher {
|
||||
let payload = RuleEnabledPayload {
|
||||
rule_id: rule.id,
|
||||
rule_ref: rule.r#ref.clone(),
|
||||
trigger_ref: rule.trigger_ref.clone(),
|
||||
trigger_params: Some(rule.trigger_params.clone()),
|
||||
};
|
||||
|
||||
let envelope =
|
||||
MessageEnvelope::new(MessageType::RuleEnabled, payload).with_source("api-service");
|
||||
|
||||
if let Err(e) = publisher.publish_envelope(&envelope).await {
|
||||
warn!(
|
||||
"Failed to publish RuleEnabled message for updated rule {}: {}",
|
||||
rule.r#ref, e
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
"Published RuleEnabled message for updated rule {} (trigger params changed)",
|
||||
rule.r#ref
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let response = ApiResponse::with_message(RuleResponse::from(rule), "Rule updated successfully");
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Delete a rule
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/rules/{ref}",
|
||||
tag = "rules",
|
||||
params(
|
||||
("ref" = String, Path, description = "Rule reference")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Rule deleted successfully", body = SuccessResponse),
|
||||
(status = 404, description = "Rule not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn delete_rule(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(rule_ref): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Check if rule exists
|
||||
let rule = RuleRepository::find_by_ref(&state.db, &rule_ref)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Rule '{}' not found", rule_ref)))?;
|
||||
|
||||
// Delete the rule
|
||||
let deleted = RuleRepository::delete(&state.db, rule.id).await?;
|
||||
|
||||
if !deleted {
|
||||
return Err(ApiError::NotFound(format!("Rule '{}' not found", rule_ref)));
|
||||
}
|
||||
|
||||
let response = SuccessResponse::new(format!("Rule '{}' deleted successfully", rule_ref));
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Enable a rule
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/rules/{ref}/enable",
|
||||
tag = "rules",
|
||||
params(
|
||||
("ref" = String, Path, description = "Rule reference")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Rule enabled successfully", body = ApiResponse<RuleResponse>),
|
||||
(status = 404, description = "Rule not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn enable_rule(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(rule_ref): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Check if rule exists
|
||||
let existing_rule = RuleRepository::find_by_ref(&state.db, &rule_ref)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Rule '{}' not found", rule_ref)))?;
|
||||
|
||||
// Update rule to enabled
|
||||
let update_input = UpdateRuleInput {
|
||||
label: None,
|
||||
description: None,
|
||||
conditions: None,
|
||||
action_params: None,
|
||||
trigger_params: None,
|
||||
enabled: Some(true),
|
||||
};
|
||||
|
||||
let rule = RuleRepository::update(&state.db, existing_rule.id, update_input).await?;
|
||||
|
||||
// Publish RuleEnabled message to notify sensor service
|
||||
if let Some(ref publisher) = state.publisher {
|
||||
let payload = RuleEnabledPayload {
|
||||
rule_id: rule.id,
|
||||
rule_ref: rule.r#ref.clone(),
|
||||
trigger_ref: rule.trigger_ref.clone(),
|
||||
trigger_params: Some(rule.trigger_params.clone()),
|
||||
};
|
||||
|
||||
let envelope =
|
||||
MessageEnvelope::new(MessageType::RuleEnabled, payload).with_source("api-service");
|
||||
|
||||
if let Err(e) = publisher.publish_envelope(&envelope).await {
|
||||
warn!(
|
||||
"Failed to publish RuleEnabled message for rule {}: {}",
|
||||
rule.r#ref, e
|
||||
);
|
||||
} else {
|
||||
info!("Published RuleEnabled message for rule {}", rule.r#ref);
|
||||
}
|
||||
}
|
||||
|
||||
let response = ApiResponse::with_message(RuleResponse::from(rule), "Rule enabled successfully");
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Disable a rule
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/rules/{ref}/disable",
|
||||
tag = "rules",
|
||||
params(
|
||||
("ref" = String, Path, description = "Rule reference")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Rule disabled successfully", body = ApiResponse<RuleResponse>),
|
||||
(status = 404, description = "Rule not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn disable_rule(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(rule_ref): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Check if rule exists
|
||||
let existing_rule = RuleRepository::find_by_ref(&state.db, &rule_ref)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Rule '{}' not found", rule_ref)))?;
|
||||
|
||||
// Update rule to disabled
|
||||
let update_input = UpdateRuleInput {
|
||||
label: None,
|
||||
description: None,
|
||||
conditions: None,
|
||||
action_params: None,
|
||||
trigger_params: None,
|
||||
enabled: Some(false),
|
||||
};
|
||||
|
||||
let rule = RuleRepository::update(&state.db, existing_rule.id, update_input).await?;
|
||||
|
||||
// Publish RuleDisabled message to notify sensor service
|
||||
if let Some(ref publisher) = state.publisher {
|
||||
let payload = RuleDisabledPayload {
|
||||
rule_id: rule.id,
|
||||
rule_ref: rule.r#ref.clone(),
|
||||
trigger_ref: rule.trigger_ref.clone(),
|
||||
};
|
||||
|
||||
let envelope =
|
||||
MessageEnvelope::new(MessageType::RuleDisabled, payload).with_source("api-service");
|
||||
|
||||
if let Err(e) = publisher.publish_envelope(&envelope).await {
|
||||
warn!(
|
||||
"Failed to publish RuleDisabled message for rule {}: {}",
|
||||
rule.r#ref, e
|
||||
);
|
||||
} else {
|
||||
info!("Published RuleDisabled message for rule {}", rule.r#ref);
|
||||
}
|
||||
}
|
||||
|
||||
let response =
|
||||
ApiResponse::with_message(RuleResponse::from(rule), "Rule disabled successfully");
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Create rule routes
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/rules", get(list_rules).post(create_rule))
|
||||
.route("/rules/enabled", get(list_enabled_rules))
|
||||
.route(
|
||||
"/rules/{ref}",
|
||||
get(get_rule).put(update_rule).delete(delete_rule),
|
||||
)
|
||||
.route("/rules/{ref}/enable", post(enable_rule))
|
||||
.route("/rules/{ref}/disable", post(disable_rule))
|
||||
.route("/packs/{pack_ref}/rules", get(list_rules_by_pack))
|
||||
.route("/actions/{action_ref}/rules", get(list_rules_by_action))
|
||||
.route("/triggers/{trigger_ref}/rules", get(list_rules_by_trigger))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_rule_routes_structure() {
|
||||
// Just verify the router can be constructed
|
||||
let _router = routes();
|
||||
}
|
||||
}
|
||||
893
crates/api/src/routes/triggers.rs
Normal file
893
crates/api/src/routes/triggers.rs
Normal file
@@ -0,0 +1,893 @@
|
||||
//! Trigger and Sensor management API routes
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use validator::Validate;
|
||||
|
||||
use attune_common::repositories::{
|
||||
pack::PackRepository,
|
||||
runtime::RuntimeRepository,
|
||||
trigger::{
|
||||
CreateSensorInput, CreateTriggerInput, SensorRepository, TriggerRepository,
|
||||
UpdateSensorInput, UpdateTriggerInput,
|
||||
},
|
||||
Create, Delete, FindByRef, List, Update,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::middleware::RequireAuth,
|
||||
dto::{
|
||||
common::{PaginatedResponse, PaginationParams},
|
||||
trigger::{
|
||||
CreateSensorRequest, CreateTriggerRequest, SensorResponse, SensorSummary,
|
||||
TriggerResponse, TriggerSummary, UpdateSensorRequest, UpdateTriggerRequest,
|
||||
},
|
||||
ApiResponse, SuccessResponse,
|
||||
},
|
||||
middleware::{ApiError, ApiResult},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// TRIGGER ENDPOINTS
|
||||
// ============================================================================
|
||||
|
||||
/// List all triggers with pagination
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/triggers",
|
||||
tag = "triggers",
|
||||
params(PaginationParams),
|
||||
responses(
|
||||
(status = 200, description = "List of triggers", body = PaginatedResponse<TriggerSummary>),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn list_triggers(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Query(pagination): Query<PaginationParams>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Get all triggers
|
||||
let triggers = TriggerRepository::list(&state.db).await?;
|
||||
|
||||
// Calculate pagination
|
||||
let total = triggers.len() as u64;
|
||||
let start = ((pagination.page - 1) * pagination.limit()) as usize;
|
||||
let end = (start + pagination.limit() as usize).min(triggers.len());
|
||||
|
||||
// Get paginated slice
|
||||
let paginated_triggers: Vec<TriggerSummary> = triggers[start..end]
|
||||
.iter()
|
||||
.map(|t| TriggerSummary::from(t.clone()))
|
||||
.collect();
|
||||
|
||||
let response = PaginatedResponse::new(paginated_triggers, &pagination, total);
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// List enabled triggers
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/triggers/enabled",
|
||||
tag = "triggers",
|
||||
params(PaginationParams),
|
||||
responses(
|
||||
(status = 200, description = "List of enabled triggers", body = PaginatedResponse<TriggerSummary>),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn list_enabled_triggers(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Query(pagination): Query<PaginationParams>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Get enabled triggers
|
||||
let triggers = TriggerRepository::find_enabled(&state.db).await?;
|
||||
|
||||
// Calculate pagination
|
||||
let total = triggers.len() as u64;
|
||||
let start = ((pagination.page - 1) * pagination.limit()) as usize;
|
||||
let end = (start + pagination.limit() as usize).min(triggers.len());
|
||||
|
||||
// Get paginated slice
|
||||
let paginated_triggers: Vec<TriggerSummary> = triggers[start..end]
|
||||
.iter()
|
||||
.map(|t| TriggerSummary::from(t.clone()))
|
||||
.collect();
|
||||
|
||||
let response = PaginatedResponse::new(paginated_triggers, &pagination, total);
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// List triggers by pack reference
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/packs/{pack_ref}/triggers",
|
||||
tag = "triggers",
|
||||
params(
|
||||
("pack_ref" = String, Path, description = "Pack reference"),
|
||||
PaginationParams
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "List of triggers in pack", body = PaginatedResponse<TriggerSummary>),
|
||||
(status = 404, description = "Pack not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn list_triggers_by_pack(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(pack_ref): Path<String>,
|
||||
Query(pagination): Query<PaginationParams>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// 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)))?;
|
||||
|
||||
// Get triggers for this pack
|
||||
let triggers = TriggerRepository::find_by_pack(&state.db, pack.id).await?;
|
||||
|
||||
// Calculate pagination
|
||||
let total = triggers.len() as u64;
|
||||
let start = ((pagination.page - 1) * pagination.limit()) as usize;
|
||||
let end = (start + pagination.limit() as usize).min(triggers.len());
|
||||
|
||||
// Get paginated slice
|
||||
let paginated_triggers: Vec<TriggerSummary> = triggers[start..end]
|
||||
.iter()
|
||||
.map(|t| TriggerSummary::from(t.clone()))
|
||||
.collect();
|
||||
|
||||
let response = PaginatedResponse::new(paginated_triggers, &pagination, total);
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Get a single trigger by reference
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/triggers/{ref}",
|
||||
tag = "triggers",
|
||||
params(
|
||||
("ref" = String, Path, description = "Trigger reference")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Trigger details", body = ApiResponse<TriggerResponse>),
|
||||
(status = 404, description = "Trigger not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn get_trigger(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(trigger_ref): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
let trigger = TriggerRepository::find_by_ref(&state.db, &trigger_ref)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?;
|
||||
|
||||
let response = ApiResponse::new(TriggerResponse::from(trigger));
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Create a new trigger
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/triggers",
|
||||
tag = "triggers",
|
||||
request_body = CreateTriggerRequest,
|
||||
responses(
|
||||
(status = 201, description = "Trigger created successfully", body = ApiResponse<TriggerResponse>),
|
||||
(status = 400, description = "Invalid request"),
|
||||
(status = 404, description = "Pack not found"),
|
||||
(status = 409, description = "Trigger with same ref already exists"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn create_trigger(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Json(request): Json<CreateTriggerRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Validate request
|
||||
request.validate()?;
|
||||
|
||||
// Check if trigger with same ref already exists
|
||||
if let Some(_) = TriggerRepository::find_by_ref(&state.db, &request.r#ref).await? {
|
||||
return Err(ApiError::Conflict(format!(
|
||||
"Trigger with ref '{}' already exists",
|
||||
request.r#ref
|
||||
)));
|
||||
}
|
||||
|
||||
// If pack_ref is provided, verify pack exists and get its ID
|
||||
let (pack_id, pack_ref) = if let Some(ref pack_ref_str) = request.pack_ref {
|
||||
let pack = PackRepository::find_by_ref(&state.db, pack_ref_str)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Pack '{}' not found", pack_ref_str)))?;
|
||||
(Some(pack.id), Some(pack.r#ref.clone()))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
// Create trigger input
|
||||
let trigger_input = CreateTriggerInput {
|
||||
r#ref: request.r#ref,
|
||||
pack: pack_id,
|
||||
pack_ref,
|
||||
label: request.label,
|
||||
description: request.description,
|
||||
enabled: request.enabled,
|
||||
param_schema: request.param_schema,
|
||||
out_schema: request.out_schema,
|
||||
is_adhoc: true, // Triggers created via API are ad-hoc (not from pack installation)
|
||||
};
|
||||
|
||||
let trigger = TriggerRepository::create(&state.db, trigger_input).await?;
|
||||
|
||||
let response = ApiResponse::with_message(
|
||||
TriggerResponse::from(trigger),
|
||||
"Trigger created successfully",
|
||||
);
|
||||
|
||||
Ok((StatusCode::CREATED, Json(response)))
|
||||
}
|
||||
|
||||
/// Update an existing trigger
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/triggers/{ref}",
|
||||
tag = "triggers",
|
||||
params(
|
||||
("ref" = String, Path, description = "Trigger reference")
|
||||
),
|
||||
request_body = UpdateTriggerRequest,
|
||||
responses(
|
||||
(status = 200, description = "Trigger updated successfully", body = ApiResponse<TriggerResponse>),
|
||||
(status = 400, description = "Invalid request"),
|
||||
(status = 404, description = "Trigger not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn update_trigger(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(trigger_ref): Path<String>,
|
||||
Json(request): Json<UpdateTriggerRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Validate request
|
||||
request.validate()?;
|
||||
|
||||
// Check if trigger exists
|
||||
let existing_trigger = TriggerRepository::find_by_ref(&state.db, &trigger_ref)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?;
|
||||
|
||||
// Create update input
|
||||
let update_input = UpdateTriggerInput {
|
||||
label: request.label,
|
||||
description: request.description,
|
||||
enabled: request.enabled,
|
||||
param_schema: request.param_schema,
|
||||
out_schema: request.out_schema,
|
||||
};
|
||||
|
||||
let trigger = TriggerRepository::update(&state.db, existing_trigger.id, update_input).await?;
|
||||
|
||||
let response = ApiResponse::with_message(
|
||||
TriggerResponse::from(trigger),
|
||||
"Trigger updated successfully",
|
||||
);
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Delete a trigger
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/triggers/{ref}",
|
||||
tag = "triggers",
|
||||
params(
|
||||
("ref" = String, Path, description = "Trigger reference")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Trigger deleted successfully", body = SuccessResponse),
|
||||
(status = 404, description = "Trigger not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn delete_trigger(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(trigger_ref): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Check if trigger exists
|
||||
let trigger = TriggerRepository::find_by_ref(&state.db, &trigger_ref)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?;
|
||||
|
||||
// Delete the trigger
|
||||
let deleted = TriggerRepository::delete(&state.db, trigger.id).await?;
|
||||
|
||||
if !deleted {
|
||||
return Err(ApiError::NotFound(format!(
|
||||
"Trigger '{}' not found",
|
||||
trigger_ref
|
||||
)));
|
||||
}
|
||||
|
||||
let response = SuccessResponse::new(format!("Trigger '{}' deleted successfully", trigger_ref));
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Enable a trigger
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/triggers/{ref}/enable",
|
||||
tag = "triggers",
|
||||
params(
|
||||
("ref" = String, Path, description = "Trigger reference")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Trigger enabled successfully", body = ApiResponse<TriggerResponse>),
|
||||
(status = 404, description = "Trigger not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn enable_trigger(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(trigger_ref): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Check if trigger exists
|
||||
let existing_trigger = TriggerRepository::find_by_ref(&state.db, &trigger_ref)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?;
|
||||
|
||||
// Update trigger to enabled
|
||||
let update_input = UpdateTriggerInput {
|
||||
label: None,
|
||||
description: None,
|
||||
enabled: Some(true),
|
||||
param_schema: None,
|
||||
out_schema: None,
|
||||
};
|
||||
|
||||
let trigger = TriggerRepository::update(&state.db, existing_trigger.id, update_input).await?;
|
||||
|
||||
let response = ApiResponse::with_message(
|
||||
TriggerResponse::from(trigger),
|
||||
"Trigger enabled successfully",
|
||||
);
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Disable a trigger
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/triggers/{ref}/disable",
|
||||
tag = "triggers",
|
||||
params(
|
||||
("ref" = String, Path, description = "Trigger reference")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Trigger disabled successfully", body = ApiResponse<TriggerResponse>),
|
||||
(status = 404, description = "Trigger not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn disable_trigger(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(trigger_ref): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Check if trigger exists
|
||||
let existing_trigger = TriggerRepository::find_by_ref(&state.db, &trigger_ref)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?;
|
||||
|
||||
// Update trigger to disabled
|
||||
let update_input = UpdateTriggerInput {
|
||||
label: None,
|
||||
description: None,
|
||||
enabled: Some(false),
|
||||
param_schema: None,
|
||||
out_schema: None,
|
||||
};
|
||||
|
||||
let trigger = TriggerRepository::update(&state.db, existing_trigger.id, update_input).await?;
|
||||
|
||||
let response = ApiResponse::with_message(
|
||||
TriggerResponse::from(trigger),
|
||||
"Trigger disabled successfully",
|
||||
);
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SENSOR ENDPOINTS
|
||||
// ============================================================================
|
||||
|
||||
/// List all sensors with pagination
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/sensors",
|
||||
tag = "sensors",
|
||||
params(PaginationParams),
|
||||
responses(
|
||||
(status = 200, description = "List of sensors", body = PaginatedResponse<SensorSummary>),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn list_sensors(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Query(pagination): Query<PaginationParams>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Get all sensors
|
||||
let sensors = SensorRepository::list(&state.db).await?;
|
||||
|
||||
// Calculate pagination
|
||||
let total = sensors.len() as u64;
|
||||
let start = ((pagination.page - 1) * pagination.limit()) as usize;
|
||||
let end = (start + pagination.limit() as usize).min(sensors.len());
|
||||
|
||||
// Get paginated slice
|
||||
let paginated_sensors: Vec<SensorSummary> = sensors[start..end]
|
||||
.iter()
|
||||
.map(|s| SensorSummary::from(s.clone()))
|
||||
.collect();
|
||||
|
||||
let response = PaginatedResponse::new(paginated_sensors, &pagination, total);
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// List enabled sensors
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/sensors/enabled",
|
||||
tag = "sensors",
|
||||
params(PaginationParams),
|
||||
responses(
|
||||
(status = 200, description = "List of enabled sensors", body = PaginatedResponse<SensorSummary>),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn list_enabled_sensors(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Query(pagination): Query<PaginationParams>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Get enabled sensors
|
||||
let sensors = SensorRepository::find_enabled(&state.db).await?;
|
||||
|
||||
// Calculate pagination
|
||||
let total = sensors.len() as u64;
|
||||
let start = ((pagination.page - 1) * pagination.limit()) as usize;
|
||||
let end = (start + pagination.limit() as usize).min(sensors.len());
|
||||
|
||||
// Get paginated slice
|
||||
let paginated_sensors: Vec<SensorSummary> = sensors[start..end]
|
||||
.iter()
|
||||
.map(|s| SensorSummary::from(s.clone()))
|
||||
.collect();
|
||||
|
||||
let response = PaginatedResponse::new(paginated_sensors, &pagination, total);
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// List sensors by pack reference
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/packs/{pack_ref}/sensors",
|
||||
tag = "sensors",
|
||||
params(
|
||||
("pack_ref" = String, Path, description = "Pack reference"),
|
||||
PaginationParams
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "List of sensors in pack", body = PaginatedResponse<SensorSummary>),
|
||||
(status = 404, description = "Pack not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn list_sensors_by_pack(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(pack_ref): Path<String>,
|
||||
Query(pagination): Query<PaginationParams>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// 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)))?;
|
||||
|
||||
// Get sensors for this pack
|
||||
let sensors = SensorRepository::find_by_pack(&state.db, pack.id).await?;
|
||||
|
||||
// Calculate pagination
|
||||
let total = sensors.len() as u64;
|
||||
let start = ((pagination.page - 1) * pagination.limit()) as usize;
|
||||
let end = (start + pagination.limit() as usize).min(sensors.len());
|
||||
|
||||
// Get paginated slice
|
||||
let paginated_sensors: Vec<SensorSummary> = sensors[start..end]
|
||||
.iter()
|
||||
.map(|s| SensorSummary::from(s.clone()))
|
||||
.collect();
|
||||
|
||||
let response = PaginatedResponse::new(paginated_sensors, &pagination, total);
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// List sensors by trigger reference
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/triggers/{trigger_ref}/sensors",
|
||||
tag = "sensors",
|
||||
params(
|
||||
("trigger_ref" = String, Path, description = "Trigger reference"),
|
||||
PaginationParams
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "List of sensors for trigger", body = PaginatedResponse<SensorSummary>),
|
||||
(status = 404, description = "Trigger not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn list_sensors_by_trigger(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(trigger_ref): Path<String>,
|
||||
Query(pagination): Query<PaginationParams>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Verify trigger exists
|
||||
let trigger = TriggerRepository::find_by_ref(&state.db, &trigger_ref)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?;
|
||||
|
||||
// Get sensors for this trigger
|
||||
let sensors = SensorRepository::find_by_trigger(&state.db, trigger.id).await?;
|
||||
|
||||
// Calculate pagination
|
||||
let total = sensors.len() as u64;
|
||||
let start = ((pagination.page - 1) * pagination.limit()) as usize;
|
||||
let end = (start + pagination.limit() as usize).min(sensors.len());
|
||||
|
||||
// Get paginated slice
|
||||
let paginated_sensors: Vec<SensorSummary> = sensors[start..end]
|
||||
.iter()
|
||||
.map(|s| SensorSummary::from(s.clone()))
|
||||
.collect();
|
||||
|
||||
let response = PaginatedResponse::new(paginated_sensors, &pagination, total);
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Get a single sensor by reference
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/sensors/{ref}",
|
||||
tag = "sensors",
|
||||
params(
|
||||
("ref" = String, Path, description = "Sensor reference")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Sensor details", body = ApiResponse<SensorResponse>),
|
||||
(status = 404, description = "Sensor not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn get_sensor(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(sensor_ref): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
let sensor = SensorRepository::find_by_ref(&state.db, &sensor_ref)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Sensor '{}' not found", sensor_ref)))?;
|
||||
|
||||
let response = ApiResponse::new(SensorResponse::from(sensor));
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Create a new sensor
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/sensors",
|
||||
tag = "sensors",
|
||||
request_body = CreateSensorRequest,
|
||||
responses(
|
||||
(status = 201, description = "Sensor created successfully", body = ApiResponse<SensorResponse>),
|
||||
(status = 400, description = "Invalid request"),
|
||||
(status = 404, description = "Pack, runtime, or trigger not found"),
|
||||
(status = 409, description = "Sensor with same ref already exists"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn create_sensor(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Json(request): Json<CreateSensorRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Validate request
|
||||
request.validate()?;
|
||||
|
||||
// Check if sensor with same ref already exists
|
||||
if let Some(_) = SensorRepository::find_by_ref(&state.db, &request.r#ref).await? {
|
||||
return Err(ApiError::Conflict(format!(
|
||||
"Sensor 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)))?;
|
||||
|
||||
// Verify runtime exists and get its ID
|
||||
let runtime = RuntimeRepository::find_by_ref(&state.db, &request.runtime_ref)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::NotFound(format!("Runtime '{}' not found", request.runtime_ref))
|
||||
})?;
|
||||
|
||||
// Verify trigger exists and get its ID
|
||||
let trigger = TriggerRepository::find_by_ref(&state.db, &request.trigger_ref)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::NotFound(format!("Trigger '{}' not found", request.trigger_ref))
|
||||
})?;
|
||||
|
||||
// Create sensor input
|
||||
let sensor_input = CreateSensorInput {
|
||||
r#ref: request.r#ref,
|
||||
pack: Some(pack.id),
|
||||
pack_ref: Some(pack.r#ref.clone()),
|
||||
label: request.label,
|
||||
description: request.description,
|
||||
entrypoint: request.entrypoint,
|
||||
runtime: runtime.id,
|
||||
runtime_ref: runtime.r#ref.clone(),
|
||||
trigger: trigger.id,
|
||||
trigger_ref: trigger.r#ref.clone(),
|
||||
enabled: request.enabled,
|
||||
param_schema: request.param_schema,
|
||||
config: request.config,
|
||||
};
|
||||
|
||||
let sensor = SensorRepository::create(&state.db, sensor_input).await?;
|
||||
|
||||
let response =
|
||||
ApiResponse::with_message(SensorResponse::from(sensor), "Sensor created successfully");
|
||||
|
||||
Ok((StatusCode::CREATED, Json(response)))
|
||||
}
|
||||
|
||||
/// Update an existing sensor
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/sensors/{ref}",
|
||||
tag = "sensors",
|
||||
params(
|
||||
("ref" = String, Path, description = "Sensor reference")
|
||||
),
|
||||
request_body = UpdateSensorRequest,
|
||||
responses(
|
||||
(status = 200, description = "Sensor updated successfully", body = ApiResponse<SensorResponse>),
|
||||
(status = 400, description = "Invalid request"),
|
||||
(status = 404, description = "Sensor not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn update_sensor(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(sensor_ref): Path<String>,
|
||||
Json(request): Json<UpdateSensorRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Validate request
|
||||
request.validate()?;
|
||||
|
||||
// Check if sensor exists
|
||||
let existing_sensor = SensorRepository::find_by_ref(&state.db, &sensor_ref)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Sensor '{}' not found", sensor_ref)))?;
|
||||
|
||||
// Create update input
|
||||
let update_input = UpdateSensorInput {
|
||||
label: request.label,
|
||||
description: request.description,
|
||||
entrypoint: request.entrypoint,
|
||||
enabled: request.enabled,
|
||||
param_schema: request.param_schema,
|
||||
};
|
||||
|
||||
let sensor = SensorRepository::update(&state.db, existing_sensor.id, update_input).await?;
|
||||
|
||||
let response =
|
||||
ApiResponse::with_message(SensorResponse::from(sensor), "Sensor updated successfully");
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Delete a sensor
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/sensors/{ref}",
|
||||
tag = "sensors",
|
||||
params(
|
||||
("ref" = String, Path, description = "Sensor reference")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Sensor deleted successfully", body = SuccessResponse),
|
||||
(status = 404, description = "Sensor not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn delete_sensor(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(sensor_ref): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Check if sensor exists
|
||||
let sensor = SensorRepository::find_by_ref(&state.db, &sensor_ref)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Sensor '{}' not found", sensor_ref)))?;
|
||||
|
||||
// Delete the sensor
|
||||
let deleted = SensorRepository::delete(&state.db, sensor.id).await?;
|
||||
|
||||
if !deleted {
|
||||
return Err(ApiError::NotFound(format!(
|
||||
"Sensor '{}' not found",
|
||||
sensor_ref
|
||||
)));
|
||||
}
|
||||
|
||||
let response = SuccessResponse::new(format!("Sensor '{}' deleted successfully", sensor_ref));
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Enable a sensor
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/sensors/{ref}/enable",
|
||||
tag = "sensors",
|
||||
params(
|
||||
("ref" = String, Path, description = "Sensor reference")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Sensor enabled successfully", body = ApiResponse<SensorResponse>),
|
||||
(status = 404, description = "Sensor not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn enable_sensor(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(sensor_ref): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Check if sensor exists
|
||||
let existing_sensor = SensorRepository::find_by_ref(&state.db, &sensor_ref)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Sensor '{}' not found", sensor_ref)))?;
|
||||
|
||||
// Update sensor to enabled
|
||||
let update_input = UpdateSensorInput {
|
||||
label: None,
|
||||
description: None,
|
||||
entrypoint: None,
|
||||
enabled: Some(true),
|
||||
param_schema: None,
|
||||
};
|
||||
|
||||
let sensor = SensorRepository::update(&state.db, existing_sensor.id, update_input).await?;
|
||||
|
||||
let response =
|
||||
ApiResponse::with_message(SensorResponse::from(sensor), "Sensor enabled successfully");
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Disable a sensor
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/sensors/{ref}/disable",
|
||||
tag = "sensors",
|
||||
params(
|
||||
("ref" = String, Path, description = "Sensor reference")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Sensor disabled successfully", body = ApiResponse<SensorResponse>),
|
||||
(status = 404, description = "Sensor not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn disable_sensor(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(sensor_ref): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Check if sensor exists
|
||||
let existing_sensor = SensorRepository::find_by_ref(&state.db, &sensor_ref)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Sensor '{}' not found", sensor_ref)))?;
|
||||
|
||||
// Update sensor to disabled
|
||||
let update_input = UpdateSensorInput {
|
||||
label: None,
|
||||
description: None,
|
||||
entrypoint: None,
|
||||
enabled: Some(false),
|
||||
param_schema: None,
|
||||
};
|
||||
|
||||
let sensor = SensorRepository::update(&state.db, existing_sensor.id, update_input).await?;
|
||||
|
||||
let response =
|
||||
ApiResponse::with_message(SensorResponse::from(sensor), "Sensor disabled successfully");
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Create trigger and sensor routes
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
// Trigger routes
|
||||
.route("/triggers", get(list_triggers).post(create_trigger))
|
||||
.route("/triggers/enabled", get(list_enabled_triggers))
|
||||
.route(
|
||||
"/triggers/{ref}",
|
||||
get(get_trigger).put(update_trigger).delete(delete_trigger),
|
||||
)
|
||||
.route("/triggers/{ref}/enable", post(enable_trigger))
|
||||
.route("/triggers/{ref}/disable", post(disable_trigger))
|
||||
.route("/packs/{pack_ref}/triggers", get(list_triggers_by_pack))
|
||||
// Sensor routes
|
||||
.route("/sensors", get(list_sensors).post(create_sensor))
|
||||
.route("/sensors/enabled", get(list_enabled_sensors))
|
||||
.route(
|
||||
"/sensors/{ref}",
|
||||
get(get_sensor).put(update_sensor).delete(delete_sensor),
|
||||
)
|
||||
.route("/sensors/{ref}/enable", post(enable_sensor))
|
||||
.route("/sensors/{ref}/disable", post(disable_sensor))
|
||||
.route("/packs/{pack_ref}/sensors", get(list_sensors_by_pack))
|
||||
.route(
|
||||
"/triggers/{trigger_ref}/sensors",
|
||||
get(list_sensors_by_trigger),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_trigger_sensor_routes_structure() {
|
||||
// Just verify the router can be constructed
|
||||
let _router = routes();
|
||||
}
|
||||
}
|
||||
808
crates/api/src/routes/webhooks.rs
Normal file
808
crates/api/src/routes/webhooks.rs
Normal file
@@ -0,0 +1,808 @@
|
||||
//! Webhook management and receiver API routes
|
||||
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{Path, State},
|
||||
http::HeaderMap,
|
||||
response::IntoResponse,
|
||||
routing::post,
|
||||
Json, Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use attune_common::{
|
||||
mq::{EventCreatedPayload, MessageEnvelope, MessageType},
|
||||
repositories::{
|
||||
event::{CreateEventInput, EventRepository},
|
||||
trigger::{TriggerRepository, WebhookEventLogInput},
|
||||
Create, FindById, FindByRef,
|
||||
},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::middleware::RequireAuth,
|
||||
dto::{
|
||||
trigger::TriggerResponse,
|
||||
webhook::{WebhookReceiverRequest, WebhookReceiverResponse},
|
||||
ApiResponse,
|
||||
},
|
||||
middleware::{ApiError, ApiResult},
|
||||
state::AppState,
|
||||
webhook_security,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// WEBHOOK CONFIG HELPERS
|
||||
// ============================================================================
|
||||
|
||||
/// Helper to extract boolean value from webhook_config JSON using path notation
|
||||
fn get_webhook_config_bool(
|
||||
trigger: &attune_common::models::trigger::Trigger,
|
||||
path: &str,
|
||||
default: bool,
|
||||
) -> bool {
|
||||
let config = match &trigger.webhook_config {
|
||||
Some(c) => c,
|
||||
None => return default,
|
||||
};
|
||||
|
||||
let parts: Vec<&str> = path.split('/').collect();
|
||||
let mut current = config;
|
||||
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
if i == parts.len() - 1 {
|
||||
// Last part - extract value
|
||||
return current
|
||||
.get(part)
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(default);
|
||||
} else {
|
||||
// Intermediate part - navigate deeper
|
||||
current = match current.get(part) {
|
||||
Some(v) => v,
|
||||
None => return default,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
default
|
||||
}
|
||||
|
||||
/// Helper to extract string value from webhook_config JSON using path notation
|
||||
fn get_webhook_config_str(
|
||||
trigger: &attune_common::models::trigger::Trigger,
|
||||
path: &str,
|
||||
) -> Option<String> {
|
||||
let config = trigger.webhook_config.as_ref()?;
|
||||
|
||||
let parts: Vec<&str> = path.split('/').collect();
|
||||
let mut current = config;
|
||||
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
if i == parts.len() - 1 {
|
||||
// Last part - extract value
|
||||
return current
|
||||
.get(part)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
} else {
|
||||
// Intermediate part - navigate deeper
|
||||
current = current.get(part)?;
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Helper to extract i64 value from webhook_config JSON using path notation
|
||||
fn get_webhook_config_i64(
|
||||
trigger: &attune_common::models::trigger::Trigger,
|
||||
path: &str,
|
||||
) -> Option<i64> {
|
||||
let config = trigger.webhook_config.as_ref()?;
|
||||
|
||||
let parts: Vec<&str> = path.split('/').collect();
|
||||
let mut current = config;
|
||||
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
if i == parts.len() - 1 {
|
||||
// Last part - extract value
|
||||
return current.get(part).and_then(|v| v.as_i64());
|
||||
} else {
|
||||
// Intermediate part - navigate deeper
|
||||
current = current.get(part)?;
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Helper to extract array of strings from webhook_config JSON using path notation
|
||||
fn get_webhook_config_array(
|
||||
trigger: &attune_common::models::trigger::Trigger,
|
||||
path: &str,
|
||||
) -> Option<Vec<String>> {
|
||||
let config = trigger.webhook_config.as_ref()?;
|
||||
|
||||
let parts: Vec<&str> = path.split('/').collect();
|
||||
let mut current = config;
|
||||
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
if i == parts.len() - 1 {
|
||||
// Last part - extract array
|
||||
return current.get(part).and_then(|v| {
|
||||
v.as_array().map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|item| item.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
});
|
||||
} else {
|
||||
// Intermediate part - navigate deeper
|
||||
current = current.get(part)?;
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WEBHOOK MANAGEMENT ENDPOINTS
|
||||
// ============================================================================
|
||||
|
||||
/// Enable webhooks for a trigger
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/triggers/{ref}/webhooks/enable",
|
||||
tag = "webhooks",
|
||||
params(
|
||||
("ref" = String, Path, description = "Trigger reference (pack.name)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Webhooks enabled", body = TriggerResponse),
|
||||
(status = 404, description = "Trigger not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
("jwt" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn enable_webhook(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(trigger_ref): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// First, find the trigger by ref to get its ID
|
||||
let trigger = TriggerRepository::find_by_ref(&state.db, &trigger_ref)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?;
|
||||
|
||||
// Enable webhooks for this trigger
|
||||
let _webhook_info = TriggerRepository::enable_webhook(&state.db, trigger.id)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalServerError(e.to_string()))?;
|
||||
|
||||
// Fetch the updated trigger to return
|
||||
let updated_trigger = TriggerRepository::find_by_id(&state.db, trigger.id)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| ApiError::NotFound("Trigger not found after update".to_string()))?;
|
||||
|
||||
let response = TriggerResponse::from(updated_trigger);
|
||||
Ok(Json(ApiResponse::new(response)))
|
||||
}
|
||||
|
||||
/// Disable webhooks for a trigger
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/triggers/{ref}/webhooks/disable",
|
||||
tag = "webhooks",
|
||||
params(
|
||||
("ref" = String, Path, description = "Trigger reference (pack.name)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Webhooks disabled", body = TriggerResponse),
|
||||
(status = 404, description = "Trigger not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
("jwt" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn disable_webhook(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(trigger_ref): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// First, find the trigger by ref to get its ID
|
||||
let trigger = TriggerRepository::find_by_ref(&state.db, &trigger_ref)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?;
|
||||
|
||||
// Disable webhooks for this trigger
|
||||
TriggerRepository::disable_webhook(&state.db, trigger.id)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalServerError(e.to_string()))?;
|
||||
|
||||
// Fetch the updated trigger to return
|
||||
let updated_trigger = TriggerRepository::find_by_id(&state.db, trigger.id)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| ApiError::NotFound("Trigger not found after update".to_string()))?;
|
||||
|
||||
let response = TriggerResponse::from(updated_trigger);
|
||||
Ok(Json(ApiResponse::new(response)))
|
||||
}
|
||||
|
||||
/// Regenerate webhook key for a trigger
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/triggers/{ref}/webhooks/regenerate",
|
||||
tag = "webhooks",
|
||||
params(
|
||||
("ref" = String, Path, description = "Trigger reference (pack.name)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Webhook key regenerated", body = TriggerResponse),
|
||||
(status = 400, description = "Webhooks not enabled for this trigger"),
|
||||
(status = 404, description = "Trigger not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
("jwt" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn regenerate_webhook_key(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(trigger_ref): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// First, find the trigger by ref to get its ID
|
||||
let trigger = TriggerRepository::find_by_ref(&state.db, &trigger_ref)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Trigger '{}' not found", trigger_ref)))?;
|
||||
|
||||
// Check if webhooks are enabled
|
||||
if !trigger.webhook_enabled {
|
||||
return Err(ApiError::BadRequest(
|
||||
"Webhooks are not enabled for this trigger. Enable webhooks first.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Regenerate the webhook key
|
||||
let _regenerate_result = TriggerRepository::regenerate_webhook_key(&state.db, trigger.id)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalServerError(e.to_string()))?;
|
||||
|
||||
// Fetch the updated trigger to return
|
||||
let updated_trigger = TriggerRepository::find_by_id(&state.db, trigger.id)
|
||||
.await
|
||||
.map_err(|e| ApiError::InternalServerError(e.to_string()))?
|
||||
.ok_or_else(|| ApiError::NotFound("Trigger not found after update".to_string()))?;
|
||||
|
||||
let response = TriggerResponse::from(updated_trigger);
|
||||
Ok(Json(ApiResponse::new(response)))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WEBHOOK RECEIVER ENDPOINT
|
||||
// ============================================================================
|
||||
|
||||
/// Webhook receiver endpoint - receives webhook events and creates events
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/webhooks/{webhook_key}",
|
||||
tag = "webhooks",
|
||||
params(
|
||||
("webhook_key" = String, Path, description = "Webhook key")
|
||||
),
|
||||
request_body = WebhookReceiverRequest,
|
||||
responses(
|
||||
(status = 200, description = "Webhook received and event created", body = WebhookReceiverResponse),
|
||||
(status = 404, description = "Invalid webhook key"),
|
||||
(status = 429, description = "Rate limit exceeded"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn receive_webhook(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(webhook_key): Path<String>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
let start_time = Instant::now();
|
||||
|
||||
// Extract metadata from headers
|
||||
let source_ip = headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.or_else(|| headers.get("x-real-ip").and_then(|v| v.to_str().ok()))
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let user_agent = headers
|
||||
.get("user-agent")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let signature = headers
|
||||
.get("x-webhook-signature")
|
||||
.or_else(|| headers.get("x-hub-signature-256"))
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Parse JSON payload
|
||||
let payload: WebhookReceiverRequest = serde_json::from_slice(&body)
|
||||
.map_err(|e| ApiError::BadRequest(format!("Invalid JSON payload: {}", e)))?;
|
||||
|
||||
let payload_size_bytes = body.len() as i32;
|
||||
|
||||
// Look up trigger by webhook key
|
||||
let trigger = match TriggerRepository::find_by_webhook_key(&state.db, &webhook_key).await {
|
||||
Ok(Some(t)) => t,
|
||||
Ok(None) => {
|
||||
// Log failed attempt
|
||||
let _ = log_webhook_failure(
|
||||
&state,
|
||||
webhook_key.clone(),
|
||||
source_ip.clone(),
|
||||
user_agent.clone(),
|
||||
payload_size_bytes,
|
||||
404,
|
||||
"Invalid webhook key".to_string(),
|
||||
start_time,
|
||||
)
|
||||
.await;
|
||||
return Err(ApiError::NotFound("Invalid webhook key".to_string()));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = log_webhook_failure(
|
||||
&state,
|
||||
webhook_key.clone(),
|
||||
source_ip.clone(),
|
||||
user_agent.clone(),
|
||||
payload_size_bytes,
|
||||
500,
|
||||
e.to_string(),
|
||||
start_time,
|
||||
)
|
||||
.await;
|
||||
return Err(ApiError::InternalServerError(e.to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
// Verify webhooks are enabled
|
||||
if !trigger.webhook_enabled {
|
||||
let _ = log_webhook_event(
|
||||
&state,
|
||||
&trigger,
|
||||
&webhook_key,
|
||||
None,
|
||||
source_ip.clone(),
|
||||
user_agent.clone(),
|
||||
payload_size_bytes,
|
||||
400,
|
||||
Some("Webhooks not enabled for this trigger".to_string()),
|
||||
start_time,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
return Err(ApiError::BadRequest(
|
||||
"Webhooks are not enabled for this trigger".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Phase 3: Check payload size limit
|
||||
if let Some(limit_kb) = get_webhook_config_i64(&trigger, "payload_size_limit_kb") {
|
||||
let limit_bytes = limit_kb * 1024;
|
||||
if i64::from(payload_size_bytes) > limit_bytes {
|
||||
let _ = log_webhook_event(
|
||||
&state,
|
||||
&trigger,
|
||||
&webhook_key,
|
||||
None,
|
||||
source_ip.clone(),
|
||||
user_agent.clone(),
|
||||
payload_size_bytes,
|
||||
413,
|
||||
Some(format!(
|
||||
"Payload too large: {} bytes (limit: {} bytes)",
|
||||
payload_size_bytes, limit_bytes
|
||||
)),
|
||||
start_time,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"Payload too large. Maximum size: {} KB",
|
||||
limit_kb
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Check IP whitelist
|
||||
let ip_whitelist_enabled = get_webhook_config_bool(&trigger, "ip_whitelist/enabled", false);
|
||||
let ip_allowed = if ip_whitelist_enabled {
|
||||
if let Some(ref ip) = source_ip {
|
||||
if let Some(whitelist) = get_webhook_config_array(&trigger, "ip_whitelist/ips") {
|
||||
match webhook_security::check_ip_in_whitelist(ip, &whitelist) {
|
||||
Ok(allowed) => {
|
||||
if !allowed {
|
||||
let _ = log_webhook_event(
|
||||
&state,
|
||||
&trigger,
|
||||
&webhook_key,
|
||||
None,
|
||||
source_ip.clone(),
|
||||
user_agent.clone(),
|
||||
payload_size_bytes,
|
||||
403,
|
||||
Some("IP address not in whitelist".to_string()),
|
||||
start_time,
|
||||
None,
|
||||
false,
|
||||
Some(false),
|
||||
)
|
||||
.await;
|
||||
return Err(ApiError::Forbidden("IP address not allowed".to_string()));
|
||||
}
|
||||
Some(true)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("IP whitelist check error: {}", e);
|
||||
Some(false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Some(false)
|
||||
}
|
||||
} else {
|
||||
Some(false)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Phase 3: Check rate limit
|
||||
let rate_limit_enabled = get_webhook_config_bool(&trigger, "rate_limit/enabled", false);
|
||||
if rate_limit_enabled {
|
||||
if let (Some(max_requests), Some(window_seconds)) = (
|
||||
get_webhook_config_i64(&trigger, "rate_limit/requests"),
|
||||
get_webhook_config_i64(&trigger, "rate_limit/window_seconds"),
|
||||
) {
|
||||
// Note: Rate limit checking would need to be implemented with a time-series approach
|
||||
// For now, we skip this check as the repository function was removed
|
||||
let allowed = true; // TODO: Implement proper rate limiting
|
||||
|
||||
if !allowed {
|
||||
{
|
||||
let _ = log_webhook_event(
|
||||
&state,
|
||||
&trigger,
|
||||
&webhook_key,
|
||||
None,
|
||||
source_ip.clone(),
|
||||
user_agent.clone(),
|
||||
payload_size_bytes,
|
||||
429,
|
||||
Some("Rate limit exceeded".to_string()),
|
||||
start_time,
|
||||
None,
|
||||
true,
|
||||
ip_allowed,
|
||||
)
|
||||
.await;
|
||||
return Err(ApiError::TooManyRequests(format!(
|
||||
"Rate limit exceeded. Maximum {} requests per {} seconds",
|
||||
max_requests, window_seconds
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Verify HMAC signature
|
||||
let hmac_enabled = get_webhook_config_bool(&trigger, "hmac/enabled", false);
|
||||
let hmac_verified = if hmac_enabled {
|
||||
if let (Some(secret), Some(algorithm)) = (
|
||||
get_webhook_config_str(&trigger, "hmac/secret"),
|
||||
get_webhook_config_str(&trigger, "hmac/algorithm"),
|
||||
) {
|
||||
if let Some(sig) = signature {
|
||||
match webhook_security::verify_hmac_signature(&body, &sig, &secret, &algorithm) {
|
||||
Ok(valid) => {
|
||||
if !valid {
|
||||
let _ = log_webhook_event(
|
||||
&state,
|
||||
&trigger,
|
||||
&webhook_key,
|
||||
None,
|
||||
source_ip.clone(),
|
||||
user_agent.clone(),
|
||||
payload_size_bytes,
|
||||
401,
|
||||
Some("Invalid HMAC signature".to_string()),
|
||||
start_time,
|
||||
Some(false),
|
||||
false,
|
||||
ip_allowed,
|
||||
)
|
||||
.await;
|
||||
return Err(ApiError::Unauthorized(
|
||||
"Invalid webhook signature".to_string(),
|
||||
));
|
||||
}
|
||||
Some(true)
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = log_webhook_event(
|
||||
&state,
|
||||
&trigger,
|
||||
&webhook_key,
|
||||
None,
|
||||
source_ip.clone(),
|
||||
user_agent.clone(),
|
||||
payload_size_bytes,
|
||||
401,
|
||||
Some(format!("HMAC verification error: {}", e)),
|
||||
start_time,
|
||||
Some(false),
|
||||
false,
|
||||
ip_allowed,
|
||||
)
|
||||
.await;
|
||||
return Err(ApiError::Unauthorized(format!(
|
||||
"Signature verification failed: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let _ = log_webhook_event(
|
||||
&state,
|
||||
&trigger,
|
||||
&webhook_key,
|
||||
None,
|
||||
source_ip.clone(),
|
||||
user_agent.clone(),
|
||||
payload_size_bytes,
|
||||
401,
|
||||
Some("HMAC signature required but not provided".to_string()),
|
||||
start_time,
|
||||
Some(false),
|
||||
false,
|
||||
ip_allowed,
|
||||
)
|
||||
.await;
|
||||
return Err(ApiError::Unauthorized("Signature required".to_string()));
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Build config with webhook context metadata
|
||||
let mut config = serde_json::json!({
|
||||
"source": "webhook",
|
||||
"webhook_key": webhook_key,
|
||||
"received_at": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
|
||||
// Add optional metadata
|
||||
if let Some(headers) = payload.headers {
|
||||
config["headers"] = headers;
|
||||
}
|
||||
if let Some(ref ip) = source_ip {
|
||||
config["source_ip"] = serde_json::Value::String(ip.clone());
|
||||
}
|
||||
if let Some(ref ua) = user_agent {
|
||||
config["user_agent"] = serde_json::Value::String(ua.clone());
|
||||
}
|
||||
let hmac_enabled = get_webhook_config_bool(&trigger, "hmac/enabled", false);
|
||||
if hmac_enabled {
|
||||
config["hmac_verified"] = serde_json::Value::Bool(hmac_verified.unwrap_or(false));
|
||||
}
|
||||
|
||||
// Create event
|
||||
let event_input = CreateEventInput {
|
||||
trigger: Some(trigger.id),
|
||||
trigger_ref: trigger.r#ref.clone(),
|
||||
config: Some(config),
|
||||
payload: Some(payload.payload),
|
||||
source: None,
|
||||
source_ref: Some("webhook".to_string()),
|
||||
rule: None,
|
||||
rule_ref: None,
|
||||
};
|
||||
|
||||
let event = EventRepository::create(&state.db, event_input)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let _ = futures::executor::block_on(log_webhook_event(
|
||||
&state,
|
||||
&trigger,
|
||||
&webhook_key,
|
||||
None,
|
||||
source_ip.clone(),
|
||||
user_agent.clone(),
|
||||
payload_size_bytes,
|
||||
500,
|
||||
Some(format!("Failed to create event: {}", e)),
|
||||
start_time,
|
||||
hmac_verified,
|
||||
false,
|
||||
ip_allowed,
|
||||
));
|
||||
ApiError::InternalServerError(e.to_string())
|
||||
})?;
|
||||
|
||||
// Publish EventCreated message to message queue if publisher is available
|
||||
tracing::info!(
|
||||
"Webhook event {} created, attempting to publish EventCreated message",
|
||||
event.id
|
||||
);
|
||||
if let Some(ref publisher) = state.publisher {
|
||||
let message_payload = EventCreatedPayload {
|
||||
event_id: event.id,
|
||||
trigger_id: event.trigger,
|
||||
trigger_ref: event.trigger_ref.clone(),
|
||||
sensor_id: event.source,
|
||||
sensor_ref: event.source_ref.clone(),
|
||||
payload: event.payload.clone().unwrap_or(serde_json::json!({})),
|
||||
config: event.config.clone(),
|
||||
};
|
||||
|
||||
let envelope = MessageEnvelope::new(MessageType::EventCreated, message_payload)
|
||||
.with_source("api-webhook-receiver");
|
||||
|
||||
if let Err(e) = publisher.publish_envelope(&envelope).await {
|
||||
tracing::warn!(
|
||||
"Failed to publish EventCreated message for event {}: {}",
|
||||
event.id,
|
||||
e
|
||||
);
|
||||
// Continue even if message publishing fails - event is already recorded
|
||||
} else {
|
||||
tracing::info!(
|
||||
"Published EventCreated message for event {} (trigger: {})",
|
||||
event.id,
|
||||
event.trigger_ref
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"Publisher not available, cannot publish EventCreated message for event {}",
|
||||
event.id
|
||||
);
|
||||
}
|
||||
|
||||
// Log successful webhook
|
||||
let _ = log_webhook_event(
|
||||
&state,
|
||||
&trigger,
|
||||
&webhook_key,
|
||||
Some(event.id),
|
||||
source_ip.clone(),
|
||||
user_agent.clone(),
|
||||
payload_size_bytes,
|
||||
200,
|
||||
None,
|
||||
start_time,
|
||||
hmac_verified,
|
||||
false,
|
||||
ip_allowed,
|
||||
)
|
||||
.await;
|
||||
|
||||
let response = WebhookReceiverResponse {
|
||||
event_id: event.id,
|
||||
trigger_ref: trigger.r#ref.clone(),
|
||||
received_at: event.created,
|
||||
message: "Webhook received successfully".to_string(),
|
||||
};
|
||||
|
||||
Ok(Json(ApiResponse::new(response)))
|
||||
}
|
||||
|
||||
// Helper function to log webhook events
|
||||
async fn log_webhook_event(
|
||||
state: &AppState,
|
||||
trigger: &attune_common::models::trigger::Trigger,
|
||||
webhook_key: &str,
|
||||
event_id: Option<i64>,
|
||||
source_ip: Option<String>,
|
||||
user_agent: Option<String>,
|
||||
payload_size_bytes: i32,
|
||||
status_code: i32,
|
||||
error_message: Option<String>,
|
||||
start_time: Instant,
|
||||
hmac_verified: Option<bool>,
|
||||
rate_limited: bool,
|
||||
ip_allowed: Option<bool>,
|
||||
) -> Result<(), attune_common::error::Error> {
|
||||
let processing_time_ms = start_time.elapsed().as_millis() as i32;
|
||||
|
||||
let log_input = WebhookEventLogInput {
|
||||
trigger_id: trigger.id,
|
||||
trigger_ref: trigger.r#ref.clone(),
|
||||
webhook_key: webhook_key.to_string(),
|
||||
event_id,
|
||||
source_ip,
|
||||
user_agent,
|
||||
payload_size_bytes: Some(payload_size_bytes),
|
||||
headers: None, // Could be added if needed
|
||||
status_code,
|
||||
error_message,
|
||||
processing_time_ms: Some(processing_time_ms),
|
||||
hmac_verified,
|
||||
rate_limited,
|
||||
ip_allowed,
|
||||
};
|
||||
|
||||
TriggerRepository::log_webhook_event(&state.db, log_input).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Helper function to log failures when trigger is not found
|
||||
async fn log_webhook_failure(
|
||||
_state: &AppState,
|
||||
webhook_key: String,
|
||||
source_ip: Option<String>,
|
||||
user_agent: Option<String>,
|
||||
payload_size_bytes: i32,
|
||||
status_code: i32,
|
||||
error_message: String,
|
||||
start_time: Instant,
|
||||
) -> Result<(), attune_common::error::Error> {
|
||||
let processing_time_ms = start_time.elapsed().as_millis() as i32;
|
||||
|
||||
// We can't log to webhook_event_log without a trigger_id, so just log to tracing
|
||||
tracing::warn!(
|
||||
webhook_key = %webhook_key,
|
||||
source_ip = ?source_ip,
|
||||
user_agent = ?user_agent,
|
||||
payload_size_bytes = payload_size_bytes,
|
||||
status_code = status_code,
|
||||
error_message = %error_message,
|
||||
processing_time_ms = processing_time_ms,
|
||||
"Webhook request failed"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ROUTER
|
||||
// ============================================================================
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
// Webhook management routes (protected)
|
||||
.route("/triggers/{ref}/webhooks/enable", post(enable_webhook))
|
||||
.route("/triggers/{ref}/webhooks/disable", post(disable_webhook))
|
||||
.route(
|
||||
"/triggers/{ref}/webhooks/regenerate",
|
||||
post(regenerate_webhook_key),
|
||||
)
|
||||
// TODO: Add Phase 3 management endpoints for HMAC, rate limiting, IP whitelist
|
||||
// Webhook receiver route (public - no auth required)
|
||||
.route("/webhooks/{webhook_key}", post(receive_webhook))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_webhook_routes_structure() {
|
||||
let _router = routes();
|
||||
}
|
||||
}
|
||||
365
crates/api/src/routes/workflows.rs
Normal file
365
crates/api/src/routes/workflows.rs
Normal file
@@ -0,0 +1,365 @@
|
||||
//! Workflow 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::repositories::{
|
||||
pack::PackRepository,
|
||||
workflow::{
|
||||
CreateWorkflowDefinitionInput, UpdateWorkflowDefinitionInput, WorkflowDefinitionRepository,
|
||||
},
|
||||
Create, Delete, FindByRef, List, Update,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::middleware::RequireAuth,
|
||||
dto::{
|
||||
common::{PaginatedResponse, PaginationParams},
|
||||
workflow::{
|
||||
CreateWorkflowRequest, UpdateWorkflowRequest, WorkflowResponse, WorkflowSearchParams,
|
||||
WorkflowSummary,
|
||||
},
|
||||
ApiResponse, SuccessResponse,
|
||||
},
|
||||
middleware::{ApiError, ApiResult},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
/// List all workflows with pagination and filtering
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/workflows",
|
||||
tag = "workflows",
|
||||
params(PaginationParams, WorkflowSearchParams),
|
||||
responses(
|
||||
(status = 200, description = "List of workflows", body = PaginatedResponse<WorkflowSummary>),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn list_workflows(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Query(pagination): Query<PaginationParams>,
|
||||
Query(search_params): Query<WorkflowSearchParams>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Validate search params
|
||||
search_params.validate()?;
|
||||
|
||||
// Get workflows based on filters
|
||||
let mut workflows = if let Some(tags_str) = &search_params.tags {
|
||||
// Filter by tags
|
||||
let tags: Vec<&str> = tags_str.split(',').map(|s| s.trim()).collect();
|
||||
let mut results = Vec::new();
|
||||
for tag in tags {
|
||||
let mut tag_results = WorkflowDefinitionRepository::find_by_tag(&state.db, tag).await?;
|
||||
results.append(&mut tag_results);
|
||||
}
|
||||
// Remove duplicates by ID
|
||||
results.sort_by_key(|w| w.id);
|
||||
results.dedup_by_key(|w| w.id);
|
||||
results
|
||||
} else if search_params.enabled == Some(true) {
|
||||
// Filter by enabled status (only return enabled workflows)
|
||||
WorkflowDefinitionRepository::find_enabled(&state.db).await?
|
||||
} else {
|
||||
// Get all workflows
|
||||
WorkflowDefinitionRepository::list(&state.db).await?
|
||||
};
|
||||
|
||||
// Apply enabled filter if specified and not already filtered by it
|
||||
if let Some(enabled) = search_params.enabled {
|
||||
if search_params.tags.is_some() {
|
||||
// If we filtered by tags, also apply enabled filter
|
||||
workflows.retain(|w| w.enabled == enabled);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply search filter if provided
|
||||
if let Some(search_term) = &search_params.search {
|
||||
let search_lower = search_term.to_lowercase();
|
||||
workflows.retain(|w| {
|
||||
w.label.to_lowercase().contains(&search_lower)
|
||||
|| w.description
|
||||
.as_ref()
|
||||
.map(|d| d.to_lowercase().contains(&search_lower))
|
||||
.unwrap_or(false)
|
||||
});
|
||||
}
|
||||
|
||||
// Apply pack_ref filter if provided
|
||||
if let Some(pack_ref) = &search_params.pack_ref {
|
||||
workflows.retain(|w| w.pack_ref == *pack_ref);
|
||||
}
|
||||
|
||||
// Calculate pagination
|
||||
let total = workflows.len() as u64;
|
||||
let start = ((pagination.page - 1) * pagination.limit()) as usize;
|
||||
let end = (start + pagination.limit() as usize).min(workflows.len());
|
||||
|
||||
// Get paginated slice
|
||||
let paginated_workflows: Vec<WorkflowSummary> = workflows[start..end]
|
||||
.iter()
|
||||
.map(|w| WorkflowSummary::from(w.clone()))
|
||||
.collect();
|
||||
|
||||
let response = PaginatedResponse::new(paginated_workflows, &pagination, total);
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// List workflows by pack reference
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/packs/{pack_ref}/workflows",
|
||||
tag = "workflows",
|
||||
params(
|
||||
("pack_ref" = String, Path, description = "Pack reference identifier"),
|
||||
PaginationParams
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "List of workflows for pack", body = PaginatedResponse<WorkflowSummary>),
|
||||
(status = 404, description = "Pack not found")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn list_workflows_by_pack(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(pack_ref): Path<String>,
|
||||
Query(pagination): Query<PaginationParams>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// 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)))?;
|
||||
|
||||
// Get workflows for this pack
|
||||
let workflows = WorkflowDefinitionRepository::find_by_pack(&state.db, pack.id).await?;
|
||||
|
||||
// Calculate pagination
|
||||
let total = workflows.len() as u64;
|
||||
let start = ((pagination.page - 1) * pagination.limit()) as usize;
|
||||
let end = (start + pagination.limit() as usize).min(workflows.len());
|
||||
|
||||
// Get paginated slice
|
||||
let paginated_workflows: Vec<WorkflowSummary> = workflows[start..end]
|
||||
.iter()
|
||||
.map(|w| WorkflowSummary::from(w.clone()))
|
||||
.collect();
|
||||
|
||||
let response = PaginatedResponse::new(paginated_workflows, &pagination, total);
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Get a single workflow by reference
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/workflows/{ref}",
|
||||
tag = "workflows",
|
||||
params(
|
||||
("ref" = String, Path, description = "Workflow reference identifier")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Workflow details", body = inline(ApiResponse<WorkflowResponse>)),
|
||||
(status = 404, description = "Workflow not found")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_workflow(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(workflow_ref): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
let workflow = WorkflowDefinitionRepository::find_by_ref(&state.db, &workflow_ref)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Workflow '{}' not found", workflow_ref)))?;
|
||||
|
||||
let response = ApiResponse::new(WorkflowResponse::from(workflow));
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Create a new workflow
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/workflows",
|
||||
tag = "workflows",
|
||||
request_body = CreateWorkflowRequest,
|
||||
responses(
|
||||
(status = 201, description = "Workflow created successfully", body = inline(ApiResponse<WorkflowResponse>)),
|
||||
(status = 400, description = "Validation error"),
|
||||
(status = 404, description = "Pack not found"),
|
||||
(status = 409, description = "Workflow with same ref already exists")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn create_workflow(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Json(request): Json<CreateWorkflowRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Validate request
|
||||
request.validate()?;
|
||||
|
||||
// Check if workflow with same ref already exists
|
||||
if let Some(_) = WorkflowDefinitionRepository::find_by_ref(&state.db, &request.r#ref).await? {
|
||||
return Err(ApiError::Conflict(format!(
|
||||
"Workflow 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)))?;
|
||||
|
||||
// Create workflow input
|
||||
let workflow_input = CreateWorkflowDefinitionInput {
|
||||
r#ref: request.r#ref,
|
||||
pack: pack.id,
|
||||
pack_ref: pack.r#ref.clone(),
|
||||
label: request.label,
|
||||
description: request.description,
|
||||
version: request.version,
|
||||
param_schema: request.param_schema,
|
||||
out_schema: request.out_schema,
|
||||
definition: request.definition,
|
||||
tags: request.tags.unwrap_or_default(),
|
||||
enabled: request.enabled.unwrap_or(true),
|
||||
};
|
||||
|
||||
let workflow = WorkflowDefinitionRepository::create(&state.db, workflow_input).await?;
|
||||
|
||||
let response = ApiResponse::with_message(
|
||||
WorkflowResponse::from(workflow),
|
||||
"Workflow created successfully",
|
||||
);
|
||||
|
||||
Ok((StatusCode::CREATED, Json(response)))
|
||||
}
|
||||
|
||||
/// Update an existing workflow
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/workflows/{ref}",
|
||||
tag = "workflows",
|
||||
params(
|
||||
("ref" = String, Path, description = "Workflow reference identifier")
|
||||
),
|
||||
request_body = UpdateWorkflowRequest,
|
||||
responses(
|
||||
(status = 200, description = "Workflow updated successfully", body = inline(ApiResponse<WorkflowResponse>)),
|
||||
(status = 400, description = "Validation error"),
|
||||
(status = 404, description = "Workflow not found")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn update_workflow(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(workflow_ref): Path<String>,
|
||||
Json(request): Json<UpdateWorkflowRequest>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Validate request
|
||||
request.validate()?;
|
||||
|
||||
// Check if workflow exists
|
||||
let existing_workflow = WorkflowDefinitionRepository::find_by_ref(&state.db, &workflow_ref)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Workflow '{}' not found", workflow_ref)))?;
|
||||
|
||||
// Create update input
|
||||
let update_input = UpdateWorkflowDefinitionInput {
|
||||
label: request.label,
|
||||
description: request.description,
|
||||
version: request.version,
|
||||
param_schema: request.param_schema,
|
||||
out_schema: request.out_schema,
|
||||
definition: request.definition,
|
||||
tags: request.tags,
|
||||
enabled: request.enabled,
|
||||
};
|
||||
|
||||
let workflow =
|
||||
WorkflowDefinitionRepository::update(&state.db, existing_workflow.id, update_input).await?;
|
||||
|
||||
let response = ApiResponse::with_message(
|
||||
WorkflowResponse::from(workflow),
|
||||
"Workflow updated successfully",
|
||||
);
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Delete a workflow
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/workflows/{ref}",
|
||||
tag = "workflows",
|
||||
params(
|
||||
("ref" = String, Path, description = "Workflow reference identifier")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Workflow deleted successfully", body = SuccessResponse),
|
||||
(status = 404, description = "Workflow not found")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete_workflow(
|
||||
State(state): State<Arc<AppState>>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(workflow_ref): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
// Check if workflow exists
|
||||
let workflow = WorkflowDefinitionRepository::find_by_ref(&state.db, &workflow_ref)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound(format!("Workflow '{}' not found", workflow_ref)))?;
|
||||
|
||||
// Delete the workflow
|
||||
let deleted = WorkflowDefinitionRepository::delete(&state.db, workflow.id).await?;
|
||||
|
||||
if !deleted {
|
||||
return Err(ApiError::NotFound(format!(
|
||||
"Workflow '{}' not found",
|
||||
workflow_ref
|
||||
)));
|
||||
}
|
||||
|
||||
let response =
|
||||
SuccessResponse::new(format!("Workflow '{}' deleted successfully", workflow_ref));
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
/// Create workflow routes
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/workflows", get(list_workflows).post(create_workflow))
|
||||
.route(
|
||||
"/workflows/{ref}",
|
||||
get(get_workflow)
|
||||
.put(update_workflow)
|
||||
.delete(delete_workflow),
|
||||
)
|
||||
.route("/packs/{pack_ref}/workflows", get(list_workflows_by_pack))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_workflow_routes_structure() {
|
||||
// Just verify the router can be constructed
|
||||
let _router = routes();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user