Skip to main content

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