1use 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#[derive(Clone)]
38pub struct FixturesApiState {
39 pub fixtures_dir: PathBuf,
41}
42
43impl FixturesApiState {
44 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#[derive(Debug, Serialize, Deserialize, Clone)]
57pub struct FixtureInfo {
58 pub id: String,
60 pub name: String,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub method: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub path: Option<String>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub description: Option<String>,
71 #[serde(default, skip_serializing_if = "Vec::is_empty")]
73 pub tags: Vec<String>,
74 pub size_bytes: u64,
76 pub modified_at: u64,
78}
79
80#[derive(Debug, Deserialize)]
82pub struct CreateFixturePayload {
83 pub name: String,
85 #[serde(default)]
87 pub method: Option<String>,
88 #[serde(default)]
90 pub path: Option<String>,
91 #[serde(default)]
93 pub description: Option<String>,
94 #[serde(default)]
96 pub tags: Vec<String>,
97 pub content: serde_json::Value,
99}
100
101#[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 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
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}