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::StatusCode;
28use axum::response::{IntoResponse, Response};
29use axum::routing::{delete, get};
30use axum::{Json, Router};
31use serde::{Deserialize, Serialize};
32use std::path::PathBuf;
33use tokio::fs;
34
35/// Configuration for the fixtures API. Cheap to clone.
36#[derive(Clone)]
37pub struct FixturesApiState {
38    /// Directory where fixture files live. Created on first write.
39    pub fixtures_dir: PathBuf,
40}
41
42impl FixturesApiState {
43    /// Construct from the same env var the startup-time loader reads.
44    /// Defaults to `/app/fixtures` (matching the Dockerfile path).
45    pub fn from_env() -> Self {
46        let dir =
47            std::env::var("MOCKFORGE_FIXTURES_DIR").unwrap_or_else(|_| "/app/fixtures".to_string());
48        Self {
49            fixtures_dir: PathBuf::from(dir),
50        }
51    }
52}
53
54/// Public list-shape entry returned by the fixtures API.
55#[derive(Debug, Serialize, Deserialize, Clone)]
56pub struct FixtureInfo {
57    /// Stable ID — derived from the filename (without `.json`).
58    pub id: String,
59    /// Human-readable name; usually equals id.
60    pub name: String,
61    /// HTTP method this fixture targets, if any.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub method: Option<String>,
64    /// Path the fixture matches, if any.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub path: Option<String>,
67    /// Free-form description.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub description: Option<String>,
70    /// Tags for organisation.
71    #[serde(default, skip_serializing_if = "Vec::is_empty")]
72    pub tags: Vec<String>,
73    /// File size in bytes.
74    pub size_bytes: u64,
75    /// Last modified time (Unix seconds).
76    pub modified_at: u64,
77}
78
79/// JSON body accepted by `POST /__mockforge/fixtures`.
80#[derive(Debug, Deserialize)]
81pub struct CreateFixturePayload {
82    /// Identifier (becomes the filename `{name}.json`). Required.
83    pub name: String,
84    /// HTTP method this fixture targets.
85    #[serde(default)]
86    pub method: Option<String>,
87    /// Path the fixture matches.
88    #[serde(default)]
89    pub path: Option<String>,
90    /// Free-form description.
91    #[serde(default)]
92    pub description: Option<String>,
93    /// Tags for organisation.
94    #[serde(default)]
95    pub tags: Vec<String>,
96    /// Response body (or arbitrary fixture payload).
97    pub content: serde_json::Value,
98}
99
100/// Persisted shape on disk. Includes the metadata + content together so
101/// the file is self-describing and round-trippable through download.
102#[derive(Debug, Serialize, Deserialize)]
103struct StoredFixture {
104    name: String,
105    #[serde(default)]
106    method: Option<String>,
107    #[serde(default)]
108    path: Option<String>,
109    #[serde(default)]
110    description: Option<String>,
111    #[serde(default)]
112    tags: Vec<String>,
113    content: serde_json::Value,
114}
115
116fn safe_id(name: &str) -> Option<String> {
117    // Defence-in-depth path traversal protection. We accept only
118    // [a-zA-Z0-9._-]; reject anything that could escape the directory.
119    if name.is_empty() || name.len() > 200 {
120        return None;
121    }
122    if name
123        .chars()
124        .any(|c| !(c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.'))
125    {
126        return None;
127    }
128    if name == "." || name == ".." || name.starts_with('.') {
129        return None;
130    }
131    Some(name.to_string())
132}
133
134async fn list_handler(State(state): State<FixturesApiState>) -> Response {
135    if let Err(e) = fs::create_dir_all(&state.fixtures_dir).await {
136        return io_error("create_dir_failed", &e.to_string());
137    }
138    let mut entries = match fs::read_dir(&state.fixtures_dir).await {
139        Ok(e) => e,
140        Err(e) => return io_error("read_dir_failed", &e.to_string()),
141    };
142    let mut out: Vec<FixtureInfo> = Vec::new();
143    while let Ok(Some(entry)) = entries.next_entry().await {
144        let path = entry.path();
145        if path.extension().and_then(|s| s.to_str()) != Some("json") {
146            continue;
147        }
148        let id = match path.file_stem().and_then(|s| s.to_str()) {
149            Some(s) => s.to_string(),
150            None => continue,
151        };
152        let metadata = match fs::metadata(&path).await {
153            Ok(m) => m,
154            Err(_) => continue,
155        };
156        let modified_at = metadata
157            .modified()
158            .ok()
159            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
160            .map(|d| d.as_secs())
161            .unwrap_or(0);
162        let stored: Option<StoredFixture> =
163            fs::read_to_string(&path).await.ok().and_then(|s| serde_json::from_str(&s).ok());
164        let info = match stored {
165            Some(s) => FixtureInfo {
166                id: id.clone(),
167                name: s.name,
168                method: s.method,
169                path: s.path,
170                description: s.description,
171                tags: s.tags,
172                size_bytes: metadata.len(),
173                modified_at,
174            },
175            None => FixtureInfo {
176                id: id.clone(),
177                name: id,
178                method: None,
179                path: None,
180                description: None,
181                tags: vec![],
182                size_bytes: metadata.len(),
183                modified_at,
184            },
185        };
186        out.push(info);
187    }
188    Json(out).into_response()
189}
190
191async fn create_handler(
192    State(state): State<FixturesApiState>,
193    Json(payload): Json<CreateFixturePayload>,
194) -> Response {
195    let Some(id) = safe_id(&payload.name) else {
196        return (
197            StatusCode::BAD_REQUEST,
198            Json(serde_json::json!({
199                "error": "invalid_name",
200                "message": "Fixture name must match [a-zA-Z0-9._-]{1,200} and not start with '.'",
201            })),
202        )
203            .into_response();
204    };
205    if let Err(e) = fs::create_dir_all(&state.fixtures_dir).await {
206        return io_error("create_dir_failed", &e.to_string());
207    }
208    let path = state.fixtures_dir.join(format!("{}.json", id));
209    let stored = StoredFixture {
210        name: payload.name.clone(),
211        method: payload.method,
212        path: payload.path,
213        description: payload.description,
214        tags: payload.tags,
215        content: payload.content,
216    };
217    let body = match serde_json::to_string_pretty(&stored) {
218        Ok(b) => b,
219        Err(e) => return io_error("serialize_failed", &e.to_string()),
220    };
221    if let Err(e) = fs::write(&path, body).await {
222        return io_error("write_failed", &e.to_string());
223    }
224    let metadata = fs::metadata(&path).await.ok();
225    let info = FixtureInfo {
226        id,
227        name: stored.name,
228        method: stored.method,
229        path: stored.path,
230        description: stored.description,
231        tags: stored.tags,
232        size_bytes: metadata.as_ref().map(|m| m.len()).unwrap_or(0),
233        modified_at: metadata
234            .and_then(|m| m.modified().ok())
235            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
236            .map(|d| d.as_secs())
237            .unwrap_or(0),
238    };
239    (StatusCode::CREATED, Json(info)).into_response()
240}
241
242async fn download_handler(
243    State(state): State<FixturesApiState>,
244    AxumPath(id): AxumPath<String>,
245) -> Response {
246    let Some(safe) = safe_id(&id) else {
247        return invalid_id();
248    };
249    let path = state.fixtures_dir.join(format!("{}.json", safe));
250    match fs::read_to_string(&path).await {
251        Ok(s) => (StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "application/json")], s)
252            .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}