mockforge_http/
quick_mock.rs

1//! Quick Mock Mode
2//!
3//! Provides instant REST API mocking from JSON files with auto-route detection.
4//! Perfect for rapid prototyping and testing without configuration.
5//!
6//! # Features
7//! - Zero configuration setup
8//! - Auto-detection of routes from JSON keys
9//! - Dynamic data generation with tokens ($random, $faker, $ai)
10//! - Full CRUD operations on all detected resources
11//! - **Pagination** support with `page` and `limit` parameters
12//! - **Filtering** support with any field as query parameter
13//! - **Sorting** support with `sort=field:direction` syntax
14//!
15//! # Example
16//! ```json
17//! {
18//!   "users": [
19//!     {"id": "$random.uuid", "name": "$faker.name", "email": "$faker.email", "role": "admin"}
20//!   ],
21//!   "posts": [
22//!     {"id": "$random.int", "title": "Sample Post", "content": "$faker.paragraph"}
23//!   ]
24//! }
25//! ```
26//!
27//! ## Auto-generated Endpoints
28//! - GET /users - List all users
29//! - GET /users/:id - Get single user
30//! - POST /users - Create user
31//! - PUT /users/:id - Update user
32//! - DELETE /users/:id - Delete user
33//! - Same for /posts
34//!
35//! ## Query Parameters
36//! - `?page=2&limit=10` - Pagination (page is 1-indexed, limit 1-1000)
37//! - `?role=admin` - Filter by field value
38//! - `?sort=name:asc` - Sort by field (asc/desc)
39//! - `?role=admin&sort=name:desc&page=1&limit=20` - Combined filtering, sorting, and pagination
40//!
41//! ## Response Format
42//! List endpoints return:
43//! ```json
44//! {
45//!   "data": [...],
46//!   "pagination": {
47//!     "page": 1,
48//!     "limit": 50,
49//!     "total": 100,
50//!     "totalPages": 2,
51//!     "hasNext": true,
52//!     "hasPrev": false
53//!   }
54//! }
55//! ```
56
57use axum::{
58    extract::{Path, Query, State},
59    http::StatusCode,
60    response::Json,
61    routing::get,
62    Router,
63};
64use mockforge_data::token_resolver::TokenResolver;
65use serde::{Deserialize, Serialize};
66use serde_json::{json, Value};
67use std::collections::HashMap;
68use std::sync::Arc;
69use tokio::sync::RwLock;
70
71/// Query parameters for list endpoints
72#[derive(Debug, Deserialize, Serialize)]
73pub struct ListQueryParams {
74    /// Page number (1-indexed)
75    #[serde(default)]
76    page: Option<usize>,
77    /// Number of items per page
78    #[serde(default)]
79    limit: Option<usize>,
80    /// Sort field and direction (e.g., "name:asc", "created:desc")
81    #[serde(default)]
82    sort: Option<String>,
83    /// Filter parameters (dynamic, any field can be filtered)
84    #[serde(flatten)]
85    filters: HashMap<String, String>,
86}
87
88impl Default for ListQueryParams {
89    fn default() -> Self {
90        Self {
91            page: Some(1),
92            limit: Some(50),
93            sort: None,
94            filters: HashMap::new(),
95        }
96    }
97}
98
99/// Quick mock state holding the data store
100#[derive(Clone)]
101pub struct QuickMockState {
102    /// Data store: resource_name -> Vec<Value>
103    data: Arc<RwLock<HashMap<String, Vec<Value>>>>,
104    /// Token resolver for dynamic data generation
105    resolver: Arc<TokenResolver>,
106}
107
108impl QuickMockState {
109    /// Create a new quick mock state
110    pub fn new() -> Self {
111        Self {
112            data: Arc::new(RwLock::new(HashMap::new())),
113            resolver: Arc::new(TokenResolver::new()),
114        }
115    }
116
117    /// Initialize from JSON file data
118    pub async fn from_json(json_data: Value) -> Result<Self, String> {
119        let state = Self::new();
120
121        if let Value::Object(obj) = json_data {
122            let mut data = state.data.write().await;
123
124            for (key, value) in obj {
125                // Only process arrays at root level as resources
126                if let Value::Array(arr) = value {
127                    // Resolve tokens in the data
128                    let mut resolved_items = Vec::new();
129                    for item in arr {
130                        match state.resolver.resolve(&item).await {
131                            Ok(resolved) => resolved_items.push(resolved),
132                            Err(e) => {
133                                eprintln!("Warning: Failed to resolve tokens in {}: {}", key, e)
134                            }
135                        }
136                    }
137                    data.insert(key, resolved_items);
138                } else {
139                    // Single object resources
140                    match state.resolver.resolve(&value).await {
141                        Ok(resolved) => {
142                            data.insert(key, vec![resolved]);
143                        }
144                        Err(e) => eprintln!("Warning: Failed to resolve tokens in {}: {}", key, e),
145                    }
146                }
147            }
148        }
149
150        Ok(state)
151    }
152
153    /// Get all resource names
154    pub async fn resource_names(&self) -> Vec<String> {
155        let data = self.data.read().await;
156        data.keys().cloned().collect()
157    }
158}
159
160impl Default for QuickMockState {
161    fn default() -> Self {
162        Self::new()
163    }
164}
165
166/// Build a router with auto-detected routes from quick mock state
167pub async fn build_quick_router(state: QuickMockState) -> Router {
168    let resource_names = state.resource_names().await;
169    let mut router = Router::new();
170
171    for resource in resource_names {
172        // Create nested router for this resource
173        let resource_router = Router::new()
174            .route(
175                "/",
176                get({
177                    let resource = resource.clone();
178                    move |State(state): State<QuickMockState>,
179                          Query(params): Query<ListQueryParams>| {
180                        let resource = resource.clone();
181                        async move { list_handler_impl(state, resource, params).await }
182                    }
183                })
184                .post({
185                    let resource = resource.clone();
186                    move |State(state): State<QuickMockState>, Json(payload): Json<Value>| {
187                        let resource = resource.clone();
188                        async move { create_handler_impl(state, resource, payload).await }
189                    }
190                }),
191            )
192            .route(
193                "/{id}",
194                get({
195                    let resource = resource.clone();
196                    move |State(state): State<QuickMockState>, Path(id): Path<String>| {
197                        let resource = resource.clone();
198                        async move { get_handler_impl(state, resource, id).await }
199                    }
200                })
201                .put({
202                    let resource = resource.clone();
203                    move |State(state): State<QuickMockState>,
204                          Path(id): Path<String>,
205                          Json(payload): Json<Value>| {
206                        let resource = resource.clone();
207                        async move { update_handler_impl(state, resource, id, payload).await }
208                    }
209                })
210                .delete({
211                    let resource = resource.clone();
212                    move |State(state): State<QuickMockState>, Path(id): Path<String>| {
213                        let resource = resource.clone();
214                        async move { delete_handler_impl(state, resource, id).await }
215                    }
216                }),
217            );
218
219        router = router.nest(&format!("/{}", resource), resource_router);
220
221        println!("  ✓ Registered routes for /{}", resource);
222    }
223
224    // Add info endpoint
225    router = router.route("/__quick/info", get(info_handler));
226
227    router.with_state(state)
228}
229
230/// Implementation for listing all items in a resource with pagination, filtering, and sorting
231async fn list_handler_impl(
232    state: QuickMockState,
233    resource: String,
234    params: ListQueryParams,
235) -> Result<Json<Value>, StatusCode> {
236    let data = state.data.read().await;
237
238    if let Some(items) = data.get(&resource) {
239        let mut filtered_items: Vec<&Value> = items.iter().collect();
240
241        // Apply filters
242        for (key, value) in &params.filters {
243            // Skip pagination, sorting, and limit params
244            if key == "page" || key == "limit" || key == "sort" {
245                continue;
246            }
247
248            filtered_items.retain(|item| {
249                if let Some(field_value) = item.get(key) {
250                    // Support both string and other type comparisons
251                    match field_value {
252                        Value::String(s) => s.contains(value),
253                        Value::Number(n) => n.to_string() == *value,
254                        Value::Bool(b) => b.to_string() == *value,
255                        _ => false,
256                    }
257                } else {
258                    false
259                }
260            });
261        }
262
263        // Apply sorting
264        if let Some(sort) = &params.sort {
265            let parts: Vec<&str> = sort.split(':').collect();
266            let field = parts.first().unwrap_or(&"id");
267            let direction = parts.get(1).unwrap_or(&"asc");
268
269            filtered_items.sort_by(|a, b| {
270                let a_val = a.get(*field);
271                let b_val = b.get(*field);
272
273                let cmp = match (a_val, b_val) {
274                    (Some(Value::String(a)), Some(Value::String(b))) => a.cmp(b),
275                    (Some(Value::Number(a)), Some(Value::Number(b))) => {
276                        if let (Some(a_f), Some(b_f)) = (a.as_f64(), b.as_f64()) {
277                            a_f.partial_cmp(&b_f).unwrap_or(std::cmp::Ordering::Equal)
278                        } else {
279                            std::cmp::Ordering::Equal
280                        }
281                    }
282                    (Some(Value::Bool(a)), Some(Value::Bool(b))) => a.cmp(b),
283                    _ => std::cmp::Ordering::Equal,
284                };
285
286                if *direction == "desc" {
287                    cmp.reverse()
288                } else {
289                    cmp
290                }
291            });
292        }
293
294        // Get total count before pagination
295        let total = filtered_items.len();
296
297        // Apply pagination
298        let page = params.page.unwrap_or(1).max(1);
299        let limit = params.limit.unwrap_or(50).clamp(1, 1000);
300        let offset = (page - 1) * limit;
301
302        let paginated_items: Vec<Value> =
303            filtered_items.into_iter().skip(offset).take(limit).cloned().collect();
304
305        let total_pages = (total + limit - 1) / limit;
306
307        Ok(Json(json!({
308            "data": paginated_items,
309            "pagination": {
310                "page": page,
311                "limit": limit,
312                "total": total,
313                "totalPages": total_pages,
314                "hasNext": page < total_pages,
315                "hasPrev": page > 1
316            }
317        })))
318    } else {
319        Err(StatusCode::NOT_FOUND)
320    }
321}
322
323/// Implementation for getting a single item by ID
324async fn get_handler_impl(
325    state: QuickMockState,
326    resource: String,
327    id: String,
328) -> Result<Json<Value>, StatusCode> {
329    let data = state.data.read().await;
330
331    if let Some(items) = data.get(&resource) {
332        // Try to find by id field
333        for item in items {
334            if let Some(item_id) = item.get("id") {
335                // Support both string and number IDs
336                let matches = match item_id {
337                    Value::String(s) => s == &id,
338                    Value::Number(n) => n.to_string() == id,
339                    _ => false,
340                };
341
342                if matches {
343                    return Ok(Json(item.clone()));
344                }
345            }
346        }
347
348        // Try index-based access if no id field found
349        if let Ok(index) = id.parse::<usize>() {
350            if let Some(item) = items.get(index) {
351                return Ok(Json(item.clone()));
352            }
353        }
354
355        Err(StatusCode::NOT_FOUND)
356    } else {
357        Err(StatusCode::NOT_FOUND)
358    }
359}
360
361/// Implementation for creating a new item
362async fn create_handler_impl(
363    state: QuickMockState,
364    resource: String,
365    mut payload: Value,
366) -> Result<(StatusCode, Json<Value>), StatusCode> {
367    // Resolve tokens in the payload
368    payload = state
369        .resolver
370        .resolve(&payload)
371        .await
372        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
373
374    let mut data = state.data.write().await;
375
376    if let Some(items) = data.get_mut(&resource) {
377        // Auto-generate ID if not provided
378        if payload.get("id").is_none() {
379            let new_id = items.len() + 1;
380            if let Value::Object(obj) = &mut payload {
381                obj.insert("id".to_string(), json!(new_id));
382            }
383        }
384
385        items.push(payload.clone());
386        Ok((StatusCode::CREATED, Json(payload)))
387    } else {
388        // Create new resource
389        let mut new_payload = payload.clone();
390        if new_payload.get("id").is_none() {
391            if let Value::Object(obj) = &mut new_payload {
392                obj.insert("id".to_string(), json!(1));
393            }
394        }
395
396        data.insert(resource, vec![new_payload.clone()]);
397        Ok((StatusCode::CREATED, Json(new_payload)))
398    }
399}
400
401/// Implementation for updating an item
402async fn update_handler_impl(
403    state: QuickMockState,
404    resource: String,
405    id: String,
406    mut payload: Value,
407) -> Result<Json<Value>, StatusCode> {
408    // Resolve tokens in the payload
409    payload = state
410        .resolver
411        .resolve(&payload)
412        .await
413        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
414
415    let mut data = state.data.write().await;
416
417    if let Some(items) = data.get_mut(&resource) {
418        // Try to find and update by id field
419        for item in items.iter_mut() {
420            if let Some(item_id) = item.get("id") {
421                let matches = match item_id {
422                    Value::String(s) => s == &id,
423                    Value::Number(n) => n.to_string() == id,
424                    _ => false,
425                };
426
427                if matches {
428                    *item = payload.clone();
429                    return Ok(Json(payload));
430                }
431            }
432        }
433
434        // Try index-based update if no id field found
435        if let Ok(index) = id.parse::<usize>() {
436            if let Some(item) = items.get_mut(index) {
437                *item = payload.clone();
438                return Ok(Json(payload));
439            }
440        }
441
442        Err(StatusCode::NOT_FOUND)
443    } else {
444        Err(StatusCode::NOT_FOUND)
445    }
446}
447
448/// Implementation for deleting an item
449async fn delete_handler_impl(
450    state: QuickMockState,
451    resource: String,
452    id: String,
453) -> Result<StatusCode, StatusCode> {
454    let mut data = state.data.write().await;
455
456    if let Some(items) = data.get_mut(&resource) {
457        // Try to find and delete by id field
458        let original_len = items.len();
459        items.retain(|item| {
460            if let Some(item_id) = item.get("id") {
461                let matches = match item_id {
462                    Value::String(s) => s == &id,
463                    Value::Number(n) => n.to_string() == id,
464                    _ => false,
465                };
466                !matches
467            } else {
468                true
469            }
470        });
471
472        if items.len() < original_len {
473            return Ok(StatusCode::NO_CONTENT);
474        }
475
476        // Try index-based deletion if no id field found
477        if let Ok(index) = id.parse::<usize>() {
478            if index < items.len() {
479                items.remove(index);
480                return Ok(StatusCode::NO_CONTENT);
481            }
482        }
483
484        Err(StatusCode::NOT_FOUND)
485    } else {
486        Err(StatusCode::NOT_FOUND)
487    }
488}
489
490/// Handler for getting quick mock info
491async fn info_handler(State(state): State<QuickMockState>) -> Json<Value> {
492    let data = state.data.read().await;
493    let mut resources = HashMap::new();
494
495    for (name, items) in data.iter() {
496        resources.insert(
497            name.clone(),
498            json!({
499                "count": items.len(),
500                "endpoints": {
501                    "list": format!("GET /{}", name),
502                    "get": format!("GET /{}/:id or GET /{}/{{id}}", name, name),
503                    "create": format!("POST /{}", name),
504                    "update": format!("PUT /{}/:id or PUT /{}/{{id}}", name, name),
505                    "delete": format!("DELETE /{}/:id or DELETE /{}/{{id}}", name, name),
506                },
507                "queryParams": {
508                    "page": "Page number (1-indexed, default: 1)",
509                    "limit": "Items per page (1-1000, default: 50)",
510                    "sort": "Sort field and direction (e.g., name:asc, id:desc)",
511                    "filters": "Any field can be used as filter (e.g., ?role=admin&status=active)"
512                },
513                "examples": {
514                    "pagination": format!("GET /{}?page=2&limit=10", name),
515                    "filtering": format!("GET /{}?name=Alice", name),
516                    "sorting": format!("GET /{}?sort=name:asc", name),
517                    "combined": format!("GET /{}?role=admin&sort=name:desc&page=1&limit=20", name)
518                }
519            }),
520        );
521    }
522
523    Json(json!({
524        "mode": "quick",
525        "version": "1.1.0",
526        "features": [
527            "CRUD operations",
528            "Pagination",
529            "Filtering",
530            "Sorting",
531            "Dynamic token resolution ($random, $faker, $ai)"
532        ],
533        "resources": resources,
534        "info": "/__quick/info (this endpoint)"
535    }))
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541    use axum::body::Body;
542    use axum::http::{Request, StatusCode};
543    use tower::ServiceExt;
544
545    #[tokio::test]
546    async fn test_quick_mock_from_json() {
547        let json_data = json!({
548            "users": [
549                {"id": 1, "name": "Alice"},
550                {"id": 2, "name": "Bob"}
551            ],
552            "posts": [
553                {"id": 1, "title": "First Post"}
554            ]
555        });
556
557        let state = QuickMockState::from_json(json_data).await.unwrap();
558        let resource_names = state.resource_names().await;
559
560        assert_eq!(resource_names.len(), 2);
561        assert!(resource_names.contains(&"users".to_string()));
562        assert!(resource_names.contains(&"posts".to_string()));
563    }
564
565    #[tokio::test]
566    async fn test_list_handler() {
567        let json_data = json!({
568            "users": [
569                {"id": 1, "name": "Alice"},
570                {"id": 2, "name": "Bob"}
571            ]
572        });
573
574        let state = QuickMockState::from_json(json_data).await.unwrap();
575        let router = build_quick_router(state).await;
576
577        let response = router
578            .oneshot(Request::builder().uri("/users").body(Body::empty()).unwrap())
579            .await
580            .unwrap();
581
582        assert_eq!(response.status(), StatusCode::OK);
583    }
584
585    #[tokio::test]
586    async fn test_create_handler() {
587        let json_data = json!({
588            "users": []
589        });
590
591        let state = QuickMockState::from_json(json_data).await.unwrap();
592        let router = build_quick_router(state).await;
593
594        let response = router
595            .oneshot(
596                Request::builder()
597                    .method("POST")
598                    .uri("/users")
599                    .header("content-type", "application/json")
600                    .body(Body::from(r#"{"name":"Charlie"}"#))
601                    .unwrap(),
602            )
603            .await
604            .unwrap();
605
606        assert_eq!(response.status(), StatusCode::CREATED);
607    }
608
609    #[tokio::test]
610    async fn test_pagination() {
611        let json_data = json!({
612            "users": [
613                {"id": 1, "name": "Alice"},
614                {"id": 2, "name": "Bob"},
615                {"id": 3, "name": "Charlie"},
616                {"id": 4, "name": "David"},
617                {"id": 5, "name": "Eve"}
618            ]
619        });
620
621        let state = QuickMockState::from_json(json_data).await.unwrap();
622        let router = build_quick_router(state).await;
623
624        // Test first page with limit
625        let response = router
626            .clone()
627            .oneshot(Request::builder().uri("/users?page=1&limit=2").body(Body::empty()).unwrap())
628            .await
629            .unwrap();
630
631        assert_eq!(response.status(), StatusCode::OK);
632
633        // Test second page
634        let response = router
635            .oneshot(Request::builder().uri("/users?page=2&limit=2").body(Body::empty()).unwrap())
636            .await
637            .unwrap();
638
639        assert_eq!(response.status(), StatusCode::OK);
640    }
641
642    #[tokio::test]
643    async fn test_filtering() {
644        let json_data = json!({
645            "users": [
646                {"id": 1, "name": "Alice", "role": "admin"},
647                {"id": 2, "name": "Bob", "role": "user"},
648                {"id": 3, "name": "Charlie", "role": "admin"}
649            ]
650        });
651
652        let state = QuickMockState::from_json(json_data).await.unwrap();
653        let router = build_quick_router(state).await;
654
655        let response = router
656            .oneshot(Request::builder().uri("/users?role=admin").body(Body::empty()).unwrap())
657            .await
658            .unwrap();
659
660        assert_eq!(response.status(), StatusCode::OK);
661    }
662
663    #[tokio::test]
664    async fn test_sorting() {
665        let json_data = json!({
666            "users": [
667                {"id": 3, "name": "Charlie"},
668                {"id": 1, "name": "Alice"},
669                {"id": 2, "name": "Bob"}
670            ]
671        });
672
673        let state = QuickMockState::from_json(json_data).await.unwrap();
674        let router = build_quick_router(state).await;
675
676        // Test ascending sort
677        let response = router
678            .clone()
679            .oneshot(Request::builder().uri("/users?sort=name:asc").body(Body::empty()).unwrap())
680            .await
681            .unwrap();
682
683        assert_eq!(response.status(), StatusCode::OK);
684
685        // Test descending sort
686        let response = router
687            .oneshot(Request::builder().uri("/users?sort=name:desc").body(Body::empty()).unwrap())
688            .await
689            .unwrap();
690
691        assert_eq!(response.status(), StatusCode::OK);
692    }
693
694    #[tokio::test]
695    async fn test_get_by_id() {
696        let json_data = json!({
697            "users": [
698                {"id": 1, "name": "Alice"},
699                {"id": 2, "name": "Bob"}
700            ]
701        });
702
703        let state = QuickMockState::from_json(json_data).await.unwrap();
704        let router = build_quick_router(state).await;
705
706        let response = router
707            .oneshot(Request::builder().uri("/users/1").body(Body::empty()).unwrap())
708            .await
709            .unwrap();
710
711        assert_eq!(response.status(), StatusCode::OK);
712    }
713
714    #[tokio::test]
715    async fn test_update_handler() {
716        let json_data = json!({
717            "users": [
718                {"id": 1, "name": "Alice"}
719            ]
720        });
721
722        let state = QuickMockState::from_json(json_data).await.unwrap();
723        let router = build_quick_router(state).await;
724
725        let response = router
726            .oneshot(
727                Request::builder()
728                    .method("PUT")
729                    .uri("/users/1")
730                    .header("content-type", "application/json")
731                    .body(Body::from(r#"{"id":1,"name":"Alice Updated"}"#))
732                    .unwrap(),
733            )
734            .await
735            .unwrap();
736
737        assert_eq!(response.status(), StatusCode::OK);
738    }
739
740    #[tokio::test]
741    async fn test_delete_handler() {
742        let json_data = json!({
743            "users": [
744                {"id": 1, "name": "Alice"}
745            ]
746        });
747
748        let state = QuickMockState::from_json(json_data).await.unwrap();
749        let router = build_quick_router(state).await;
750
751        let response = router
752            .oneshot(
753                Request::builder().method("DELETE").uri("/users/1").body(Body::empty()).unwrap(),
754            )
755            .await
756            .unwrap();
757
758        assert_eq!(response.status(), StatusCode::NO_CONTENT);
759    }
760
761    #[tokio::test]
762    async fn test_combined_query_params() {
763        let json_data = json!({
764            "users": [
765                {"id": 1, "name": "Alice", "role": "admin"},
766                {"id": 2, "name": "Bob", "role": "user"},
767                {"id": 3, "name": "Charlie", "role": "admin"},
768                {"id": 4, "name": "David", "role": "user"}
769            ]
770        });
771
772        let state = QuickMockState::from_json(json_data).await.unwrap();
773        let router = build_quick_router(state).await;
774
775        // Test combined filtering, sorting, and pagination
776        let response = router
777            .oneshot(
778                Request::builder()
779                    .uri("/users?role=admin&sort=name:asc&page=1&limit=10")
780                    .body(Body::empty())
781                    .unwrap(),
782            )
783            .await
784            .unwrap();
785
786        assert_eq!(response.status(), StatusCode::OK);
787    }
788}