1use 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#[derive(Clone)]
37pub struct FixturesApiState {
38 pub fixtures_dir: PathBuf,
40}
41
42impl FixturesApiState {
43 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#[derive(Debug, Serialize, Deserialize, Clone)]
56pub struct FixtureInfo {
57 pub id: String,
59 pub name: String,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub method: Option<String>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub path: Option<String>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub description: Option<String>,
70 #[serde(default, skip_serializing_if = "Vec::is_empty")]
72 pub tags: Vec<String>,
73 pub size_bytes: u64,
75 pub modified_at: u64,
77}
78
79#[derive(Debug, Deserialize)]
81pub struct CreateFixturePayload {
82 pub name: String,
84 #[serde(default)]
86 pub method: Option<String>,
87 #[serde(default)]
89 pub path: Option<String>,
90 #[serde(default)]
92 pub description: Option<String>,
93 #[serde(default)]
95 pub tags: Vec<String>,
96 pub content: serde_json::Value,
98}
99
100#[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 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
352pub 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 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}