Skip to main content

mockforge_http/
fixtures_api.rs

1//! Fixtures management API for hosted-mock deployments.
2//!
3//! The admin UI calls `/__mockforge/fixtures/*` for list/create/delete/
4//! download. Those routes live on the *admin* server (port 9080), which
5//! isn't exposed publicly on hosted-mock Fly machines — only port 3000
6//! is. So UI calls 404'd against the deployed instance. This module
7//! mounts an equivalent surface on the main HTTP app so it's reachable
8//! from the cloud-side admin UI through the deployed instance.
9//!
10//! ## Storage
11//!
12//! Fixtures are written as JSON files into `MOCKFORGE_FIXTURES_DIR`
13//! (default `/app/fixtures`). The startup-time `CustomFixtureLoader`
14//! already reads from there, so newly-uploaded fixtures take effect on
15//! the next deploy/restart. Live reload is a separate concern (would
16//! require rebuilding the OpenAPI registry mid-flight).
17//!
18//! ## Endpoints (mounted under `/__mockforge/fixtures`)
19//!
20//! - `GET    /                     → list fixtures
21//! - `POST   /                     → create or upsert by name
22//! - `GET    /{id}/download        → return raw JSON content
23//! - `DELETE /{id}                 → remove
24//! - `DELETE /bulk                 → remove many (ids in JSON body)
25
26use axum::extract::{Path as AxumPath, Query, State};
27use axum::http::header::CONTENT_TYPE;
28use axum::http::StatusCode;
29use axum::response::{IntoResponse, Response};
30use axum::routing::{delete, get};
31use axum::{Json, Router};
32use serde::{Deserialize, Serialize};
33use std::path::PathBuf;
34use tokio::fs;
35
36/// Configuration for the fixtures API. Cheap to clone.
37#[derive(Clone)]
38pub struct FixturesApiState {
39    /// Directory where fixture files live. Created on first write.
40    pub fixtures_dir: PathBuf,
41}
42
43impl FixturesApiState {
44    /// Construct from the same env var the startup-time loader reads.
45    /// Defaults to `/app/fixtures` (matching the Dockerfile path).
46    pub fn from_env() -> Self {
47        let dir =
48            std::env::var("MOCKFORGE_FIXTURES_DIR").unwrap_or_else(|_| "/app/fixtures".to_string());
49        Self {
50            fixtures_dir: PathBuf::from(dir),
51        }
52    }
53}
54
55/// Public list-shape entry returned by the fixtures API.
56#[derive(Debug, Serialize, Deserialize, Clone)]
57pub struct FixtureInfo {
58    /// Stable ID — derived from the filename (without `.json`).
59    pub id: String,
60    /// Human-readable name; usually equals id.
61    pub name: String,
62    /// HTTP method this fixture targets, if any.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub method: Option<String>,
65    /// Path the fixture matches, if any.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub path: Option<String>,
68    /// Free-form description.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub description: Option<String>,
71    /// Tags for organisation.
72    #[serde(default, skip_serializing_if = "Vec::is_empty")]
73    pub tags: Vec<String>,
74    /// File size in bytes.
75    pub size_bytes: u64,
76    /// Last modified time (Unix seconds).
77    pub modified_at: u64,
78}
79
80/// JSON body accepted by `POST /__mockforge/fixtures`.
81#[derive(Debug, Deserialize)]
82pub struct CreateFixturePayload {
83    /// Identifier (becomes the filename `{name}.json`). Required.
84    pub name: String,
85    /// HTTP method this fixture targets.
86    #[serde(default)]
87    pub method: Option<String>,
88    /// Path the fixture matches.
89    #[serde(default)]
90    pub path: Option<String>,
91    /// Free-form description.
92    #[serde(default)]
93    pub description: Option<String>,
94    /// Tags for organisation.
95    #[serde(default)]
96    pub tags: Vec<String>,
97    /// Response body (or arbitrary fixture payload).
98    pub content: serde_json::Value,
99}
100
101/// Persisted shape on disk. Includes the metadata + content together so
102/// the file is self-describing and round-trippable through download.
103#[derive(Debug, Serialize, Deserialize)]
104struct StoredFixture {
105    name: String,
106    #[serde(default)]
107    method: Option<String>,
108    #[serde(default)]
109    path: Option<String>,
110    #[serde(default)]
111    description: Option<String>,
112    #[serde(default)]
113    tags: Vec<String>,
114    content: serde_json::Value,
115}
116
117fn safe_id(name: &str) -> Option<String> {
118    // Defence-in-depth path traversal protection. We accept only
119    // [a-zA-Z0-9._-]; reject anything that could escape the directory.
120    if name.is_empty() || name.len() > 200 {
121        return None;
122    }
123    if name
124        .chars()
125        .any(|c| !(c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.'))
126    {
127        return None;
128    }
129    if name == "." || name == ".." || name.starts_with('.') {
130        return None;
131    }
132    Some(name.to_string())
133}
134
135async fn list_handler(State(state): State<FixturesApiState>) -> Response {
136    if let Err(e) = fs::create_dir_all(&state.fixtures_dir).await {
137        return io_error("create_dir_failed", &e.to_string());
138    }
139    let mut entries = match fs::read_dir(&state.fixtures_dir).await {
140        Ok(e) => e,
141        Err(e) => return io_error("read_dir_failed", &e.to_string()),
142    };
143    let mut out: Vec<FixtureInfo> = Vec::new();
144    while let Ok(Some(entry)) = entries.next_entry().await {
145        let path = entry.path();
146        if path.extension().and_then(|s| s.to_str()) != Some("json") {
147            continue;
148        }
149        let id = match path.file_stem().and_then(|s| s.to_str()) {
150            Some(s) => s.to_string(),
151            None => continue,
152        };
153        let metadata = match fs::metadata(&path).await {
154            Ok(m) => m,
155            Err(_) => continue,
156        };
157        let modified_at = metadata
158            .modified()
159            .ok()
160            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
161            .map(|d| d.as_secs())
162            .unwrap_or(0);
163        let stored: Option<StoredFixture> =
164            fs::read_to_string(&path).await.ok().and_then(|s| serde_json::from_str(&s).ok());
165        let info = match stored {
166            Some(s) => FixtureInfo {
167                id: id.clone(),
168                name: s.name,
169                method: s.method,
170                path: s.path,
171                description: s.description,
172                tags: s.tags,
173                size_bytes: metadata.len(),
174                modified_at,
175            },
176            None => FixtureInfo {
177                id: id.clone(),
178                name: id,
179                method: None,
180                path: None,
181                description: None,
182                tags: vec![],
183                size_bytes: metadata.len(),
184                modified_at,
185            },
186        };
187        out.push(info);
188    }
189    Json(out).into_response()
190}
191
192async fn create_handler(
193    State(state): State<FixturesApiState>,
194    Json(payload): Json<CreateFixturePayload>,
195) -> Response {
196    let Some(id) = safe_id(&payload.name) else {
197        return (
198            StatusCode::BAD_REQUEST,
199            Json(serde_json::json!({
200                "error": "invalid_name",
201                "message": "Fixture name must match [a-zA-Z0-9._-]{1,200} and not start with '.'",
202            })),
203        )
204            .into_response();
205    };
206    if let Err(e) = fs::create_dir_all(&state.fixtures_dir).await {
207        return io_error("create_dir_failed", &e.to_string());
208    }
209    let path = state.fixtures_dir.join(format!("{}.json", id));
210    let stored = StoredFixture {
211        name: payload.name.clone(),
212        method: payload.method,
213        path: payload.path,
214        description: payload.description,
215        tags: payload.tags,
216        content: payload.content,
217    };
218    let body = match serde_json::to_string_pretty(&stored) {
219        Ok(b) => b,
220        Err(e) => return io_error("serialize_failed", &e.to_string()),
221    };
222    if let Err(e) = fs::write(&path, body).await {
223        return io_error("write_failed", &e.to_string());
224    }
225    let metadata = fs::metadata(&path).await.ok();
226    let info = FixtureInfo {
227        id,
228        name: stored.name,
229        method: stored.method,
230        path: stored.path,
231        description: stored.description,
232        tags: stored.tags,
233        size_bytes: metadata.as_ref().map(|m| m.len()).unwrap_or(0),
234        modified_at: metadata
235            .and_then(|m| m.modified().ok())
236            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
237            .map(|d| d.as_secs())
238            .unwrap_or(0),
239    };
240    (StatusCode::CREATED, Json(info)).into_response()
241}
242
243async fn download_handler(
244    State(state): State<FixturesApiState>,
245    AxumPath(id): AxumPath<String>,
246) -> Response {
247    let Some(safe) = safe_id(&id) else {
248        return invalid_id();
249    };
250    let path = state.fixtures_dir.join(format!("{}.json", safe));
251    match fs::read_to_string(&path).await {
252        Ok(s) => (StatusCode::OK, [(CONTENT_TYPE, "application/json")], s).into_response(),
253        Err(_) => (
254            StatusCode::NOT_FOUND,
255            Json(serde_json::json!({
256                "error": "fixture_not_found",
257                "message": format!("No fixture with id '{}'", id),
258            })),
259        )
260            .into_response(),
261    }
262}
263
264async fn delete_handler(
265    State(state): State<FixturesApiState>,
266    AxumPath(id): AxumPath<String>,
267) -> Response {
268    let Some(safe) = safe_id(&id) else {
269        return invalid_id();
270    };
271    let path = state.fixtures_dir.join(format!("{}.json", safe));
272    match fs::remove_file(&path).await {
273        Ok(_) => StatusCode::NO_CONTENT.into_response(),
274        Err(e) if e.kind() == std::io::ErrorKind::NotFound => (
275            StatusCode::NOT_FOUND,
276            Json(serde_json::json!({
277                "error": "fixture_not_found",
278                "message": format!("No fixture with id '{}'", id),
279            })),
280        )
281            .into_response(),
282        Err(e) => io_error("remove_failed", &e.to_string()),
283    }
284}
285
286#[derive(Debug, Deserialize)]
287struct BulkDeletePayload {
288    ids: Vec<String>,
289}
290
291async fn delete_bulk_handler(
292    State(state): State<FixturesApiState>,
293    Json(payload): Json<BulkDeletePayload>,
294) -> Response {
295    let mut deleted = 0usize;
296    let mut skipped: Vec<String> = Vec::new();
297    for id in payload.ids {
298        let Some(safe) = safe_id(&id) else {
299            skipped.push(id);
300            continue;
301        };
302        let path = state.fixtures_dir.join(format!("{}.json", safe));
303        if fs::remove_file(&path).await.is_ok() {
304            deleted += 1;
305        } else {
306            skipped.push(id);
307        }
308    }
309    Json(serde_json::json!({
310        "deleted": deleted,
311        "skipped": skipped,
312    }))
313    .into_response()
314}
315
316#[derive(Debug, Deserialize)]
317struct DownloadQuery {
318    #[serde(default)]
319    _format: Option<String>,
320}
321
322fn invalid_id() -> Response {
323    (
324        StatusCode::BAD_REQUEST,
325        Json(serde_json::json!({
326            "error": "invalid_id",
327            "message": "Fixture id must match [a-zA-Z0-9._-]{1,200} and not start with '.'",
328        })),
329    )
330        .into_response()
331}
332
333fn io_error(code: &str, msg: &str) -> Response {
334    (
335        StatusCode::INTERNAL_SERVER_ERROR,
336        Json(serde_json::json!({
337            "error": code,
338            "message": msg,
339        })),
340    )
341        .into_response()
342}
343
344async fn download_with_query_handler(
345    state: State<FixturesApiState>,
346    id: AxumPath<String>,
347    Query(_q): Query<DownloadQuery>,
348) -> Response {
349    download_handler(state, id).await
350}
351
352/// Build the fixtures API router. Mount under `/__mockforge/fixtures`.
353pub fn fixtures_api_router(state: FixturesApiState) -> Router {
354    Router::new()
355        .route("/", get(list_handler).post(create_handler))
356        .route("/bulk", delete(delete_bulk_handler))
357        .route("/{id}", delete(delete_handler))
358        .route("/{id}/download", get(download_with_query_handler))
359        .with_state(state)
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use tempfile::tempdir;
366
367    fn state_for(dir: &std::path::Path) -> FixturesApiState {
368        FixturesApiState {
369            fixtures_dir: dir.to_path_buf(),
370        }
371    }
372
373    #[test]
374    fn safe_id_rejects_traversal() {
375        assert!(safe_id("../etc/passwd").is_none());
376        assert!(safe_id("hello/world").is_none());
377        assert!(safe_id(".hidden").is_none());
378        assert!(safe_id("").is_none());
379    }
380
381    #[test]
382    fn safe_id_accepts_normal_names() {
383        assert_eq!(safe_id("user-by-id"), Some("user-by-id".to_string()));
384        assert_eq!(safe_id("user_42.v2"), Some("user_42.v2".to_string()));
385    }
386
387    #[tokio::test]
388    async fn create_then_list_round_trips() {
389        let dir = tempdir().unwrap();
390        let st = state_for(dir.path());
391        let payload = CreateFixturePayload {
392            name: "users-list".to_string(),
393            method: Some("GET".to_string()),
394            path: Some("/users".to_string()),
395            description: Some("seed".to_string()),
396            tags: vec!["e2e".into()],
397            content: serde_json::json!([{"id": 1}]),
398        };
399        let resp = create_handler(State(st.clone()), Json(payload)).await;
400        assert_eq!(resp.status(), StatusCode::CREATED);
401
402        let listed = list_handler(State(st)).await;
403        assert_eq!(listed.status(), StatusCode::OK);
404        // Body is wrapped — pull bytes and verify name appears.
405        let body = axum::body::to_bytes(listed.into_body(), 64 * 1024).await.unwrap();
406        let s = std::str::from_utf8(&body).unwrap();
407        assert!(s.contains("users-list"));
408    }
409
410    #[tokio::test]
411    async fn delete_removes_and_subsequent_returns_404() {
412        let dir = tempdir().unwrap();
413        let st = state_for(dir.path());
414        let payload = CreateFixturePayload {
415            name: "doomed".to_string(),
416            method: None,
417            path: None,
418            description: None,
419            tags: vec![],
420            content: serde_json::json!({}),
421        };
422        let _ = create_handler(State(st.clone()), Json(payload)).await;
423        let resp = delete_handler(State(st.clone()), AxumPath("doomed".to_string())).await;
424        assert_eq!(resp.status(), StatusCode::NO_CONTENT);
425        let resp = delete_handler(State(st), AxumPath("doomed".to_string())).await;
426        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
427    }
428}