use axum::{
extract::{Path, Query, State},
http::{header, StatusCode},
response::IntoResponse,
Json,
};
use serde::Deserialize;
use crate::error::AppError;
use crate::global_lock::{LockScope, LockStatus};
use super::flags::Flag;
use super::ical::{svc_ical, svc_ical_routine};
use super::model::{
CleanupResponse, CreateRoutineRequest, IcalFeedQuery, Routine, RoutineListQuery,
RoutineResponse, RoutineStore, UpdateRoutineRequest,
};
use super::service::{
svc_cleanup, svc_create, svc_create_flag, svc_delete, svc_get, svc_list, svc_list_flags,
svc_logs, svc_resolve_flag, svc_trigger, svc_trigger_scheduled, svc_update,
};
#[derive(Deserialize, utoipa::ToSchema)]
pub struct CreateFlagRequest {
#[serde(rename = "type")]
pub flag_type: String,
pub description: String,
pub scope: String,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct LockRequest {
pub scope: String,
}
#[derive(Deserialize, utoipa::IntoParams)]
pub struct UnlockQuery {
pub scope: String,
}
#[utoipa::path(get, path = "/routines/lock",
responses((status = 200, body = LockStatus)))]
pub async fn get_lock_status() -> Json<LockStatus> {
Json(crate::global_lock::lock_status())
}
#[utoipa::path(post, path = "/routines/lock",
request_body = LockRequest,
responses((status = 200, body = LockStatus), (status = 400, description = "Unknown scope"), (status = 500, description = "IO error")))]
pub async fn lock(
State(store): State<RoutineStore>,
Json(body): Json<LockRequest>,
) -> Result<Json<LockStatus>, AppError> {
let scope = parse_lock_scope(&body.scope)?;
crate::global_lock::set_lock(scope, true).map_err(|_| AppError::Internal)?;
if let Err(sync_err) = crate::sync::routines::sync_routines_to_crontab(&store) {
log::warn!("crontab sync after HTTP lock failed: {sync_err}");
}
Ok(Json(crate::global_lock::lock_status()))
}
#[utoipa::path(delete, path = "/routines/lock",
params(UnlockQuery),
responses((status = 200, body = LockStatus), (status = 400, description = "Unknown scope"), (status = 500, description = "IO error")))]
pub async fn unlock(
State(store): State<RoutineStore>,
Query(query): Query<UnlockQuery>,
) -> Result<Json<LockStatus>, AppError> {
let scopes: Vec<LockScope> = if query.scope == "all" {
vec![LockScope::Shared, LockScope::Local]
} else {
vec![parse_lock_scope(&query.scope)?]
};
for scope in scopes {
crate::global_lock::set_lock(scope, false).map_err(|_| AppError::Internal)?;
}
if let Err(sync_err) = crate::sync::routines::sync_routines_to_crontab(&store) {
log::warn!("crontab sync after HTTP unlock failed: {sync_err}");
}
Ok(Json(crate::global_lock::lock_status()))
}
fn parse_lock_scope(scope: &str) -> Result<LockScope, AppError> {
match scope {
"shared" => Ok(LockScope::Shared),
"local" => Ok(LockScope::Local),
other => Err(AppError::BadRequest(format!(
"unknown scope {other:?}; use \"shared\" or \"local\""
))),
}
}
#[utoipa::path(post, path = "/routines",
request_body = CreateRoutineRequest,
responses((status = 201, body = RoutineResponse), (status = 400, description = "Invalid cron expression")))]
pub async fn create(
State(store): State<RoutineStore>,
Json(body): Json<CreateRoutineRequest>,
) -> Result<(StatusCode, Json<RoutineResponse>), AppError> {
Ok((StatusCode::CREATED, Json(svc_create(&store, body)?)))
}
#[utoipa::path(get, path = "/routines",
params(RoutineListQuery),
responses((status = 200, body = Vec<RoutineResponse>)))]
pub async fn list(
State(store): State<RoutineStore>,
Query(query): Query<RoutineListQuery>,
) -> Json<Vec<RoutineResponse>> {
Json(svc_list(&store, &query))
}
#[utoipa::path(get, path = "/agents",
responses((status = 200, body = Vec<String>, description = "Available agent names")))]
pub async fn list_agents() -> Json<Vec<String>> {
Json(super::available_agents())
}
#[utoipa::path(get, path = "/routines/{id}",
params(("id" = String, Path, description = "Routine UUID")),
responses((status = 200, body = RoutineResponse), (status = 404, description = "Not found")))]
pub async fn get(
State(store): State<RoutineStore>,
Path(id): Path<String>,
) -> Result<Json<RoutineResponse>, AppError> {
Ok(Json(svc_get(&store, &id)?))
}
#[utoipa::path(patch, path = "/routines/{id}",
params(("id" = String, Path, description = "Routine UUID")),
request_body = UpdateRoutineRequest,
responses((status = 200, body = RoutineResponse), (status = 400, description = "Invalid"), (status = 404, description = "Not found")))]
pub async fn update(
State(store): State<RoutineStore>,
Path(id): Path<String>,
Json(body): Json<UpdateRoutineRequest>,
) -> Result<Json<RoutineResponse>, AppError> {
Ok(Json(svc_update(&store, &id, body)?))
}
#[utoipa::path(put, path = "/routines/{id}",
params(("id" = String, Path, description = "Routine UUID")),
request_body = UpdateRoutineRequest,
responses((status = 200, body = RoutineResponse), (status = 400, description = "Invalid"), (status = 404, description = "Not found")))]
pub async fn replace(
state: State<RoutineStore>,
path: Path<String>,
body: Json<UpdateRoutineRequest>,
) -> Result<Json<RoutineResponse>, AppError> {
update(state, path, body).await
}
#[utoipa::path(delete, path = "/routines/{id}",
params(("id" = String, Path, description = "Routine UUID")),
responses((status = 200, body = RoutineResponse), (status = 404, description = "Not found")))]
pub async fn delete(
State(store): State<RoutineStore>,
Path(id): Path<String>,
) -> Result<Json<RoutineResponse>, AppError> {
Ok(Json(svc_delete(&store, &id)?))
}
#[utoipa::path(post, path = "/routines/{id}/trigger",
params(("id" = String, Path, description = "Routine UUID")),
responses((status = 200, body = Routine), (status = 404, description = "Not found")))]
pub async fn trigger(
State(store): State<RoutineStore>,
Path(id): Path<String>,
) -> Result<Json<Routine>, AppError> {
Ok(Json(svc_trigger(&store, &id)?))
}
#[utoipa::path(post, path = "/routines/{id}/scheduled-trigger",
params(("id" = String, Path, description = "Routine UUID")),
responses((status = 200, body = Routine), (status = 404, description = "Not found")))]
pub async fn scheduled_trigger(
State(store): State<RoutineStore>,
Path(id): Path<String>,
) -> Result<Json<Routine>, AppError> {
Ok(Json(svc_trigger_scheduled(&store, &id)?))
}
#[utoipa::path(get, path = "/routines.ics",
params(IcalFeedQuery),
responses((status = 200, description = "iCalendar (text/calendar) feed of upcoming routine fire times")))]
pub async fn ical_feed(
State(store): State<RoutineStore>,
Query(query): Query<IcalFeedQuery>,
) -> impl IntoResponse {
let body = match query.routine.as_deref() {
Some(id) => svc_ical_routine(&store, id),
None => svc_ical(&store),
};
(
[(header::CONTENT_TYPE, "text/calendar; charset=utf-8")],
body,
)
}
#[utoipa::path(post, path = "/routines/cleanup",
responses((status = 200, body = CleanupResponse, description = "Number of workbenches removed")))]
pub async fn cleanup(State(store): State<RoutineStore>) -> Json<CleanupResponse> {
Json(svc_cleanup(&store))
}
#[utoipa::path(post, path = "/routines/{id}/flags",
params(("id" = String, Path, description = "Routine UUID")),
request_body = CreateFlagRequest,
responses((status = 201, body = Flag), (status = 400, description = "Invalid type/description/scope"), (status = 404, description = "Not found")))]
pub async fn create_flag(
State(store): State<RoutineStore>,
Path(id): Path<String>,
Json(body): Json<CreateFlagRequest>,
) -> Result<(StatusCode, Json<Flag>), AppError> {
let flag = svc_create_flag(&store, &id, &body.flag_type, &body.description, &body.scope)?;
Ok((StatusCode::CREATED, Json(flag)))
}
#[utoipa::path(get, path = "/routines/{id}/flags",
params(("id" = String, Path, description = "Routine UUID")),
responses((status = 200, body = Vec<Flag>), (status = 404, description = "Not found")))]
pub async fn list_flags(
State(store): State<RoutineStore>,
Path(id): Path<String>,
) -> Result<Json<Vec<Flag>>, AppError> {
Ok(Json(svc_list_flags(&store, &id)?))
}
#[utoipa::path(delete, path = "/routines/{id}/flags/{filename}",
params(
("id" = String, Path, description = "Routine UUID"),
("filename" = String, Path, description = "Flag filename, as returned by create/list"),
),
responses((status = 204, description = "Resolved"), (status = 404, description = "Not found")))]
pub async fn resolve_flag(
State(store): State<RoutineStore>,
Path((id, filename)): Path<(String, String)>,
) -> Result<StatusCode, AppError> {
svc_resolve_flag(&store, &id, &filename)?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(get, path = "/routines/{id}/logs",
params(("id" = String, Path, description = "Routine UUID")),
responses((status = 200, description = "Log file contents as plain text"), (status = 404, description = "Not found")))]
pub async fn get_logs(
State(store): State<RoutineStore>,
Path(id): Path<String>,
) -> Result<String, AppError> {
svc_logs(&store, &id)
}
#[cfg(test)]
#[path = "handlers_tests.rs"]
mod handlers_tests;