//! 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, SensorSearchFilters, TriggerRepository, TriggerSearchFilters, UpdateSensorInput, UpdateTriggerInput, }, Create, Delete, FindByRef, Patch, Update, }; use crate::{ auth::middleware::RequireAuth, dto::{ common::{PaginatedResponse, PaginationParams}, trigger::{ CreateSensorRequest, CreateTriggerRequest, SensorJsonPatch, SensorResponse, SensorSummary, TriggerJsonPatch, TriggerResponse, TriggerStringPatch, 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), (status = 500, description = "Internal server error") ) )] pub async fn list_triggers( State(state): State>, RequireAuth(_user): RequireAuth, Query(pagination): Query, ) -> ApiResult { let filters = TriggerSearchFilters { pack: None, enabled: None, limit: pagination.limit(), offset: pagination.offset(), }; let result = TriggerRepository::list_search(&state.db, &filters).await?; let paginated_triggers: Vec = result.rows.into_iter().map(TriggerSummary::from).collect(); let response = PaginatedResponse::new(paginated_triggers, &pagination, result.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), (status = 500, description = "Internal server error") ) )] pub async fn list_enabled_triggers( State(state): State>, RequireAuth(_user): RequireAuth, Query(pagination): Query, ) -> ApiResult { let filters = TriggerSearchFilters { pack: None, enabled: Some(true), limit: pagination.limit(), offset: pagination.offset(), }; let result = TriggerRepository::list_search(&state.db, &filters).await?; let paginated_triggers: Vec = result.rows.into_iter().map(TriggerSummary::from).collect(); let response = PaginatedResponse::new(paginated_triggers, &pagination, result.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), (status = 404, description = "Pack not found"), (status = 500, description = "Internal server error") ) )] pub async fn list_triggers_by_pack( State(state): State>, RequireAuth(_user): RequireAuth, Path(pack_ref): Path, Query(pagination): Query, ) -> ApiResult { // Verify pack exists let pack = PackRepository::find_by_ref(&state.db, &pack_ref) .await? .ok_or_else(|| ApiError::NotFound(format!("Pack '{}' not found", pack_ref)))?; let filters = TriggerSearchFilters { pack: Some(pack.id), enabled: None, limit: pagination.limit(), offset: pagination.offset(), }; let result = TriggerRepository::list_search(&state.db, &filters).await?; let paginated_triggers: Vec = result.rows.into_iter().map(TriggerSummary::from).collect(); let response = PaginatedResponse::new(paginated_triggers, &pagination, result.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), (status = 404, description = "Trigger not found"), (status = 500, description = "Internal server error") ) )] pub async fn get_trigger( State(state): State>, RequireAuth(_user): RequireAuth, Path(trigger_ref): Path, ) -> ApiResult { 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), (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>, RequireAuth(_user): RequireAuth, Json(request): Json, ) -> ApiResult { // Validate request request.validate()?; // Check if trigger with same ref already exists if TriggerRepository::find_by_ref(&state.db, &request.r#ref) .await? .is_some() { 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), (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>, RequireAuth(_user): RequireAuth, Path(trigger_ref): Path, Json(request): Json, ) -> ApiResult { // 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.map(|patch| match patch { TriggerStringPatch::Set(value) => Patch::Set(value), TriggerStringPatch::Clear => Patch::Clear, }), enabled: request.enabled, param_schema: request.param_schema.map(|patch| match patch { TriggerJsonPatch::Set(value) => Patch::Set(value), TriggerJsonPatch::Clear => Patch::Clear, }), out_schema: request.out_schema.map(|patch| match patch { TriggerJsonPatch::Set(value) => Patch::Set(value), TriggerJsonPatch::Clear => Patch::Clear, }), }; 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>, RequireAuth(_user): RequireAuth, Path(trigger_ref): Path, ) -> ApiResult { // 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), (status = 404, description = "Trigger not found"), (status = 500, description = "Internal server error") ) )] pub async fn enable_trigger( State(state): State>, RequireAuth(_user): RequireAuth, Path(trigger_ref): Path, ) -> ApiResult { // 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), (status = 404, description = "Trigger not found"), (status = 500, description = "Internal server error") ) )] pub async fn disable_trigger( State(state): State>, RequireAuth(_user): RequireAuth, Path(trigger_ref): Path, ) -> ApiResult { // 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), (status = 500, description = "Internal server error") ) )] pub async fn list_sensors( State(state): State>, RequireAuth(_user): RequireAuth, Query(pagination): Query, ) -> ApiResult { let filters = SensorSearchFilters { pack: None, trigger: None, enabled: None, limit: pagination.limit(), offset: pagination.offset(), }; let result = SensorRepository::list_search(&state.db, &filters).await?; let paginated_sensors: Vec = result.rows.into_iter().map(SensorSummary::from).collect(); let response = PaginatedResponse::new(paginated_sensors, &pagination, result.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), (status = 500, description = "Internal server error") ) )] pub async fn list_enabled_sensors( State(state): State>, RequireAuth(_user): RequireAuth, Query(pagination): Query, ) -> ApiResult { let filters = SensorSearchFilters { pack: None, trigger: None, enabled: Some(true), limit: pagination.limit(), offset: pagination.offset(), }; let result = SensorRepository::list_search(&state.db, &filters).await?; let paginated_sensors: Vec = result.rows.into_iter().map(SensorSummary::from).collect(); let response = PaginatedResponse::new(paginated_sensors, &pagination, result.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), (status = 404, description = "Pack not found"), (status = 500, description = "Internal server error") ) )] pub async fn list_sensors_by_pack( State(state): State>, RequireAuth(_user): RequireAuth, Path(pack_ref): Path, Query(pagination): Query, ) -> ApiResult { // Verify pack exists let pack = PackRepository::find_by_ref(&state.db, &pack_ref) .await? .ok_or_else(|| ApiError::NotFound(format!("Pack '{}' not found", pack_ref)))?; let filters = SensorSearchFilters { pack: Some(pack.id), trigger: None, enabled: None, limit: pagination.limit(), offset: pagination.offset(), }; let result = SensorRepository::list_search(&state.db, &filters).await?; let paginated_sensors: Vec = result.rows.into_iter().map(SensorSummary::from).collect(); let response = PaginatedResponse::new(paginated_sensors, &pagination, result.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), (status = 404, description = "Trigger not found"), (status = 500, description = "Internal server error") ) )] pub async fn list_sensors_by_trigger( State(state): State>, RequireAuth(_user): RequireAuth, Path(trigger_ref): Path, Query(pagination): Query, ) -> ApiResult { // 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)))?; let filters = SensorSearchFilters { pack: None, trigger: Some(trigger.id), enabled: None, limit: pagination.limit(), offset: pagination.offset(), }; let result = SensorRepository::list_search(&state.db, &filters).await?; let paginated_sensors: Vec = result.rows.into_iter().map(SensorSummary::from).collect(); let response = PaginatedResponse::new(paginated_sensors, &pagination, result.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), (status = 404, description = "Sensor not found"), (status = 500, description = "Internal server error") ) )] pub async fn get_sensor( State(state): State>, RequireAuth(_user): RequireAuth, Path(sensor_ref): Path, ) -> ApiResult { 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), (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>, RequireAuth(_user): RequireAuth, Json(request): Json, ) -> ApiResult { // Validate request request.validate()?; // Check if sensor with same ref already exists if SensorRepository::find_by_ref(&state.db, &request.r#ref) .await? .is_some() { 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(), runtime_version_constraint: None, 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), (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>, RequireAuth(_user): RequireAuth, Path(sensor_ref): Path, Json(request): Json, ) -> ApiResult { // 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.map(Patch::Set), entrypoint: request.entrypoint, runtime: None, runtime_ref: None, runtime_version_constraint: None, trigger: None, trigger_ref: None, enabled: request.enabled, param_schema: request.param_schema.map(|patch| match patch { SensorJsonPatch::Set(value) => Patch::Set(value), SensorJsonPatch::Clear => Patch::Clear, }), config: None, }; 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>, RequireAuth(_user): RequireAuth, Path(sensor_ref): Path, ) -> ApiResult { // 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), (status = 404, description = "Sensor not found"), (status = 500, description = "Internal server error") ) )] pub async fn enable_sensor( State(state): State>, RequireAuth(_user): RequireAuth, Path(sensor_ref): Path, ) -> ApiResult { // 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, runtime: None, runtime_ref: None, runtime_version_constraint: None, trigger: None, trigger_ref: None, enabled: Some(true), param_schema: None, config: 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), (status = 404, description = "Sensor not found"), (status = 500, description = "Internal server error") ) )] pub async fn disable_sensor( State(state): State>, RequireAuth(_user): RequireAuth, Path(sensor_ref): Path, ) -> ApiResult { // 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, runtime: None, runtime_ref: None, runtime_version_constraint: None, trigger: None, trigger_ref: None, enabled: Some(false), param_schema: None, config: 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> { 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(); } }