re-uploading work

This commit is contained in:
2026-02-04 17:46:30 -06:00
commit 3b14c65998
1388 changed files with 381262 additions and 0 deletions

View 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();
}
}

View 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)))
}

View 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))
}

View 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>(&notification) {
// 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();
}
}

View 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))
}

View 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))
}

View 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),
)
}

View 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;

File diff suppressed because it is too large Load Diff

View 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();
}
}

View 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();
}
}

View 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();
}
}

View 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();
}
}