Skip to main content

mockforge_http/
time_travel_api.rs

1//! Time-travel runtime API for hosted-mock deployments.
2//!
3//! Time-travel routes existed on the *admin* server (port 9080) via
4//! `mockforge-ui::time_travel_handlers`. Hosted-mock Fly machines only
5//! expose port 3000 publicly, so the admin server's routes were
6//! unreachable from outside the container — operators couldn't set or
7//! advance virtual time on a deployed mock without redeploying.
8//!
9//! This module mirrors the same surface on the main HTTP app. Handlers
10//! talk to a local `OnceLock<Arc<TimeTravelManager>>`; serve.rs
11//! initialises it alongside the existing admin-server init so both
12//! paths see the same manager (the inner `Arc` is shared).
13//!
14//! ## Endpoints (mounted under `/__mockforge/time-travel`)
15//!
16//! - `GET    /status            → current clock status
17//! - `POST   /enable            → start virtual clock at given time
18//! - `POST   /disable           → stop virtual clock
19//! - `POST   /advance           → advance virtual time by a duration
20//! - `POST   /set               → set virtual time to a specific instant
21//! - `POST   /scale             → set time scale factor (e.g., 60.0 = 1min/sec)
22//! - `POST   /reset             → reset to real time
23//!
24//! Scheduled responses, scenarios, and cron jobs are intentionally not
25//! mirrored here — they're only useful from the admin UI flow that
26//! already exists, and adding them here would more than double the
27//! surface area for a marginal gain.
28
29use axum::extract::Json as AxumJson;
30use axum::http::StatusCode;
31use axum::response::{IntoResponse, Response};
32use axum::routing::{get, post};
33use axum::{Json, Router};
34use chrono::{DateTime, Duration, Utc};
35use mockforge_core::TimeTravelManager;
36use serde::{Deserialize, Serialize};
37use std::sync::Arc;
38use std::sync::OnceLock;
39
40/// Process-wide handle to the active TimeTravelManager. Set once at
41/// server startup (see `init_time_travel_manager`); read by every
42/// request handler in this module.
43static MANAGER: OnceLock<Arc<TimeTravelManager>> = OnceLock::new();
44
45/// Register a TimeTravelManager for use by the HTTP-port time-travel
46/// API. Idempotent — subsequent calls are no-ops.
47pub fn init_time_travel_manager(manager: Arc<TimeTravelManager>) {
48    let _ = MANAGER.set(manager);
49}
50
51fn manager() -> Option<Arc<TimeTravelManager>> {
52    MANAGER.get().cloned()
53}
54
55#[derive(Debug, Deserialize)]
56struct EnableRequest {
57    /// Time to anchor at. Defaults to now.
58    #[serde(default)]
59    time: Option<DateTime<Utc>>,
60    /// Optional scale factor — 1.0 = real-time, 60.0 = 1min per real second.
61    #[serde(default)]
62    scale: Option<f64>,
63}
64
65#[derive(Debug, Deserialize)]
66struct AdvanceRequest {
67    /// e.g. "2h", "30m", "10s", "1d", "1week".
68    duration: String,
69}
70
71#[derive(Debug, Deserialize)]
72struct SetTimeRequest {
73    time: DateTime<Utc>,
74}
75
76#[derive(Debug, Deserialize)]
77struct ScaleRequest {
78    scale: f64,
79}
80
81fn not_initialised() -> Response {
82    (
83        StatusCode::NOT_FOUND,
84        Json(serde_json::json!({
85            "error": "time_travel_not_initialised",
86            "message": "TimeTravelManager hasn't been registered on this server",
87        })),
88    )
89        .into_response()
90}
91
92#[derive(Debug, Serialize)]
93struct OkResponse<S> {
94    success: bool,
95    status: S,
96}
97
98async fn status_handler() -> Response {
99    let Some(m) = manager() else {
100        return not_initialised();
101    };
102    Json(m.clock().status()).into_response()
103}
104
105async fn enable_handler(AxumJson(req): AxumJson<EnableRequest>) -> Response {
106    let Some(m) = manager() else {
107        return not_initialised();
108    };
109    let time = req.time.unwrap_or_else(Utc::now);
110    m.enable_and_set(time);
111    if let Some(scale) = req.scale {
112        m.set_scale(scale);
113    }
114    Json(OkResponse {
115        success: true,
116        status: m.clock().status(),
117    })
118    .into_response()
119}
120
121async fn disable_handler() -> Response {
122    let Some(m) = manager() else {
123        return not_initialised();
124    };
125    m.disable();
126    Json(OkResponse {
127        success: true,
128        status: m.clock().status(),
129    })
130    .into_response()
131}
132
133async fn advance_handler(AxumJson(req): AxumJson<AdvanceRequest>) -> Response {
134    let Some(m) = manager() else {
135        return not_initialised();
136    };
137    match parse_duration(&req.duration) {
138        Ok(dur) => {
139            m.advance(dur);
140            Json(OkResponse {
141                success: true,
142                status: m.clock().status(),
143            })
144            .into_response()
145        }
146        Err(e) => (
147            StatusCode::BAD_REQUEST,
148            Json(serde_json::json!({
149                "error": "invalid_duration",
150                "message": e,
151            })),
152        )
153            .into_response(),
154    }
155}
156
157async fn set_handler(AxumJson(req): AxumJson<SetTimeRequest>) -> Response {
158    let Some(m) = manager() else {
159        return not_initialised();
160    };
161    m.clock().set_time(req.time);
162    Json(OkResponse {
163        success: true,
164        status: m.clock().status(),
165    })
166    .into_response()
167}
168
169async fn scale_handler(AxumJson(req): AxumJson<ScaleRequest>) -> Response {
170    let Some(m) = manager() else {
171        return not_initialised();
172    };
173    m.set_scale(req.scale);
174    Json(OkResponse {
175        success: true,
176        status: m.clock().status(),
177    })
178    .into_response()
179}
180
181async fn reset_handler() -> Response {
182    let Some(m) = manager() else {
183        return not_initialised();
184    };
185    m.clock().reset();
186    Json(OkResponse {
187        success: true,
188        status: m.clock().status(),
189    })
190    .into_response()
191}
192
193/// Parse a duration string like "2h", "30m", "10s", "1d", "1week".
194/// Mirrors the admin-server parser; kept here so this module doesn't
195/// reach across crates for a 30-line helper.
196fn parse_duration(s: &str) -> Result<Duration, String> {
197    let s = s.trim().trim_start_matches('+').trim_start_matches('-');
198    if s.is_empty() {
199        return Err("empty duration".to_string());
200    }
201
202    // Multi-character unit suffixes first, longest match wins. Avoids
203    // "1ms" being read as "1m" + "s".
204    type DurationCtor = fn(i64) -> Duration;
205    let units: &[(&str, DurationCtor)] = &[
206        ("weeks", |n| Duration::days(n * 7)),
207        ("week", |n| Duration::days(n * 7)),
208        ("days", Duration::days),
209        ("day", Duration::days),
210        ("hours", Duration::hours),
211        ("hour", Duration::hours),
212        ("minutes", Duration::minutes),
213        ("minute", Duration::minutes),
214        ("seconds", Duration::seconds),
215        ("second", Duration::seconds),
216        ("ms", Duration::milliseconds),
217        ("d", Duration::days),
218        ("h", Duration::hours),
219        ("m", Duration::minutes),
220        ("s", Duration::seconds),
221    ];
222
223    for (suffix, ctor) in units {
224        if let Some(num_str) = s.strip_suffix(suffix) {
225            let num_str = num_str.trim();
226            let n: i64 =
227                num_str.parse().map_err(|e| format!("invalid number '{}': {}", num_str, e))?;
228            return Ok(ctor(n));
229        }
230    }
231
232    Err(format!("unknown duration suffix in '{}'; expected w/d/h/m/s/ms", s))
233}
234
235/// Build the time-travel runtime router. Mount under `/__mockforge/time-travel`.
236pub fn time_travel_router() -> Router {
237    Router::new()
238        .route("/status", get(status_handler))
239        .route("/enable", post(enable_handler))
240        .route("/disable", post(disable_handler))
241        .route("/advance", post(advance_handler))
242        .route("/set", post(set_handler))
243        .route("/scale", post(scale_handler))
244        .route("/reset", post(reset_handler))
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn parse_duration_basic_units() {
253        assert_eq!(parse_duration("30s").unwrap(), Duration::seconds(30));
254        assert_eq!(parse_duration("5m").unwrap(), Duration::minutes(5));
255        assert_eq!(parse_duration("2h").unwrap(), Duration::hours(2));
256        assert_eq!(parse_duration("3d").unwrap(), Duration::days(3));
257    }
258
259    #[test]
260    fn parse_duration_weeks() {
261        assert_eq!(parse_duration("1week").unwrap(), Duration::days(7));
262        assert_eq!(parse_duration("2weeks").unwrap(), Duration::days(14));
263    }
264
265    #[test]
266    fn parse_duration_milliseconds() {
267        assert_eq!(parse_duration("250ms").unwrap(), Duration::milliseconds(250));
268    }
269
270    #[test]
271    fn parse_duration_relative_prefix() {
272        assert_eq!(parse_duration("+1h").unwrap(), Duration::hours(1));
273        assert_eq!(parse_duration("-30m").unwrap(), Duration::minutes(30));
274    }
275
276    #[test]
277    fn parse_duration_rejects_empty() {
278        assert!(parse_duration("").is_err());
279        assert!(parse_duration("   ").is_err());
280    }
281
282    #[test]
283    fn parse_duration_rejects_unknown_suffix() {
284        assert!(parse_duration("5fortnights").is_err());
285        assert!(parse_duration("12").is_err());
286    }
287}