Skip to main content

prospero_api/
automations.rs

1//! REST endpoints for automations (#220).
2//!
3//! Scheduling and signature checking belong to `prospero-core`'s
4//! `AutomationEngine`; these handlers only resolve the route, hand over the
5//! request, and shape the reply — the one-directional `api → core` rule of
6//! ADR 0006.
7//!
8//! One thing is deliberately *not* delegated: the trigger endpoint reads the
9//! **raw** request body. Axum's `Json` extractor would give us a parsed value,
10//! and re-serializing that produces different bytes than the sender signed, so
11//! every honest caller's signature would fail to verify.
12
13use axum::Json;
14use axum::body::Bytes;
15use axum::extract::{Path, Query, State};
16use axum::http::{HeaderMap, StatusCode};
17use prospero_core::automation::{
18    Automation, AutomationRun, CreateAutomationBody, CreatedAutomationResponse, FiredResponse,
19    RunSource, SetEnabledBody,
20};
21use serde::Deserialize;
22
23use crate::AppState;
24use crate::error::ApiError;
25
26/// Header carrying a webhook's HMAC signature, `sha256=<hex>`.
27pub const SIGNATURE_HEADER: &str = "x-prospero-signature";
28
29/// Runs returned by `GET /api/automations/{id}/runs` when none is asked for.
30const RUNS_DEFAULT: usize = 50;
31
32/// Query for the run-history endpoint.
33#[derive(Debug, Default, Deserialize)]
34pub struct RunsQuery {
35    /// How many runs to return, newest first.
36    pub limit: Option<usize>,
37}
38
39/// The engine, or a `405` explaining that this backend has no shared config
40/// store to hold automations in.
41fn engine(
42    st: &AppState,
43) -> Result<&std::sync::Arc<prospero_core::automation::AutomationEngine>, ApiError> {
44    st.automations.as_ref().ok_or_else(|| {
45        ApiError::MethodNotAllowed(
46            "automations need a shared config store; run with --database-url \
47             (or the local backend) to enable them"
48                .to_string(),
49        )
50    })
51}
52
53/// `GET /api/automations` — every configured automation.
54pub async fn list_automations(
55    State(st): State<AppState>,
56) -> Result<Json<Vec<Automation>>, ApiError> {
57    let stored = engine(&st)?.list_automations().await?;
58    // Project to the wire type, which structurally has no secret to leak.
59    Ok(Json(stored.into_iter().map(|s| s.automation).collect()))
60}
61
62/// `POST /api/automations` — create one, returning a webhook key if it has a
63/// webhook trigger.
64pub async fn create_automation(
65    State(st): State<AppState>,
66    Json(body): Json<CreateAutomationBody>,
67) -> Result<(StatusCode, Json<CreatedAutomationResponse>), ApiError> {
68    let created = engine(&st)?.create_automation(body).await?;
69    Ok((StatusCode::CREATED, Json(created)))
70}
71
72/// `DELETE /api/automations/{id}` — remove it and its run history.
73pub async fn delete_automation(
74    State(st): State<AppState>,
75    Path(id): Path<String>,
76) -> Result<StatusCode, ApiError> {
77    if engine(&st)?.delete_automation(&id).await? {
78        Ok(StatusCode::NO_CONTENT)
79    } else {
80        Err(ApiError::NotFound(format!("automation not found: {id}")))
81    }
82}
83
84/// `PUT /api/automations/{id}/enabled` — enable or disable without deleting.
85pub async fn set_enabled(
86    State(st): State<AppState>,
87    Path(id): Path<String>,
88    Json(body): Json<SetEnabledBody>,
89) -> Result<StatusCode, ApiError> {
90    engine(&st)?
91        .set_automation_enabled(&id, body.enabled)
92        .await?;
93    Ok(StatusCode::NO_CONTENT)
94}
95
96/// `GET /api/automations/{id}/runs` — recent runs, newest first.
97pub async fn list_runs(
98    State(st): State<AppState>,
99    Path(id): Path<String>,
100    Query(q): Query<RunsQuery>,
101) -> Result<Json<Vec<AutomationRun>>, ApiError> {
102    let runs = engine(&st)?
103        .automation_runs(&id, q.limit.unwrap_or(RUNS_DEFAULT))
104        .await?;
105    Ok(Json(runs))
106}
107
108/// `POST /api/automations/{id}/run` — fire it now, whatever its trigger.
109pub async fn run_now(
110    State(st): State<AppState>,
111    Path(id): Path<String>,
112) -> Result<Json<FiredResponse>, ApiError> {
113    let run = engine(&st)?
114        .fire_automation(&id, RunSource::Manual, None)
115        .await?;
116    Ok(Json(FiredResponse { run }))
117}
118
119/// `POST /api/automations/{id}/trigger` — an inbound signed webhook.
120///
121/// Open in the scope table on purpose: the signature over the body *is* the
122/// credential. A sender holding the automation's key can fire that one
123/// automation and nothing else, which is why this needs no bearer token and
124/// must never accept one in place of a signature.
125pub async fn trigger(
126    State(st): State<AppState>,
127    Path(id): Path<String>,
128    headers: HeaderMap,
129    body: Bytes,
130) -> Result<Json<FiredResponse>, ApiError> {
131    let signature = headers
132        .get(SIGNATURE_HEADER)
133        .and_then(|v| v.to_str().ok())
134        .map(str::to_string);
135    let run = engine(&st)?
136        .trigger_webhook(&id, &body, signature.as_deref())
137        .await?;
138    Ok(Json(FiredResponse { run }))
139}