Skip to main content

mockforge_http/
mockai_api.rs

1//! Standalone MockAI HTTP API.
2//!
3//! MockAI exists in the codebase but until now was only invoked from
4//! the OpenAPI route generator — i.e., a hosted mock with a spec got
5//! AI-augmented responses on its existing routes. There was no way to
6//! reach MockAI directly without a spec, which made it impossible to
7//! prototype an "AI persona" mock or hand-craft a one-off intelligent
8//! response without a full OpenAPI document.
9//!
10//! This module exposes the engine over HTTP. Same functional contract
11//! the internal callers use: a Request goes in (method/path/body/etc)
12//! and a Response comes out (status_code/body/headers).
13//!
14//! ## Endpoints
15//!
16//! - `POST /__mockforge/api/mockai/generate` — generate one response
17//! - `GET  /__mockforge/api/mockai/status`   — config / availability probe
18//!
19//! ## Auth / cost
20//!
21//! MockAI typically calls an LLM provider, so an API key needs to be
22//! configured at server start. The endpoint returns 503 with a clear
23//! reason if the engine isn't available — same surface the OpenAPI
24//! handler treats absence with.
25
26use axum::extract::{Json as AxumJson, State};
27use axum::http::StatusCode;
28use axum::response::{IntoResponse, Response};
29use axum::routing::{get, post};
30use axum::{Json, Router};
31use mockforge_core::intelligent_behavior::{
32    IntelligentBehaviorConfig, MockAI, Request as MockAiRequest, StatefulAiContext,
33};
34use serde::{Deserialize, Serialize};
35use std::collections::HashMap;
36use std::sync::Arc;
37use tokio::sync::RwLock;
38use uuid::Uuid;
39
40/// Cheap-to-clone shared state. Holds the existing MockAI instance built
41/// at server startup; the API handler reads through the RwLock so a
42/// future hot-reload of the model config (e.g., re-uploading a spec)
43/// doesn't require a router rebuild.
44#[derive(Clone)]
45pub struct MockAiApiState {
46    /// `None` when MockAI isn't configured for this deployment (no
47    /// API key, no model, etc.). Endpoint surfaces 503 in that case.
48    pub mockai: Option<Arc<RwLock<MockAI>>>,
49}
50
51impl MockAiApiState {
52    /// Construct from the same handle the router builder is given.
53    pub fn new(mockai: Option<Arc<RwLock<MockAI>>>) -> Self {
54        Self { mockai }
55    }
56}
57
58/// JSON body accepted by `POST /__mockforge/api/mockai/generate`.
59#[derive(Debug, Deserialize)]
60pub struct GenerateRequest {
61    /// HTTP method to associate with the synthesized request. Defaults
62    /// to GET — useful when the caller just wants "give me a believable
63    /// response shape for this resource."
64    #[serde(default = "default_method")]
65    pub method: String,
66    /// Resource path. Required.
67    pub path: String,
68    /// Optional JSON body (only meaningful for POST/PUT/PATCH).
69    #[serde(default)]
70    pub body: Option<serde_json::Value>,
71    /// Query params.
72    #[serde(default)]
73    pub query_params: HashMap<String, String>,
74    /// Headers to forward to MockAI.
75    #[serde(default)]
76    pub headers: HashMap<String, String>,
77    /// Optional caller-supplied session id so subsequent calls share
78    /// memory. A fresh UUID is generated when omitted.
79    #[serde(default)]
80    pub session_id: Option<String>,
81}
82
83fn default_method() -> String {
84    "GET".to_string()
85}
86
87/// JSON body returned from `POST /__mockforge/api/mockai/generate`.
88#[derive(Debug, Serialize)]
89pub struct GenerateResponseBody {
90    /// HTTP status code MockAI chose for the synthesised response.
91    pub status_code: u16,
92    /// Response payload.
93    pub body: serde_json::Value,
94    /// Response headers MockAI emitted.
95    pub headers: HashMap<String, String>,
96    /// Session id this call belongs to (echo of request, or freshly minted).
97    pub session_id: String,
98}
99
100async fn status_handler(State(state): State<MockAiApiState>) -> Response {
101    let available = state.mockai.is_some();
102    Json(serde_json::json!({
103        "available": available,
104        "reason": if available {
105            "MockAI is configured and ready"
106        } else {
107            "MockAI is not configured (missing API key or no model attached)"
108        },
109    }))
110    .into_response()
111}
112
113async fn generate_handler(
114    State(state): State<MockAiApiState>,
115    AxumJson(req): AxumJson<GenerateRequest>,
116) -> Response {
117    let Some(mockai) = state.mockai.clone() else {
118        return (
119            StatusCode::SERVICE_UNAVAILABLE,
120            Json(serde_json::json!({
121                "error": "mockai_unavailable",
122                "message": "MockAI is not configured. Set a provider API key (OPENAI_API_KEY) and redeploy.",
123            })),
124        )
125            .into_response();
126    };
127
128    if req.path.is_empty() {
129        return (
130            StatusCode::BAD_REQUEST,
131            Json(serde_json::json!({
132                "error": "missing_path",
133                "message": "`path` is required",
134            })),
135        )
136            .into_response();
137    }
138
139    let session_id = req.session_id.unwrap_or_else(|| Uuid::new_v4().to_string());
140    // The MockAI instance carries its own provider config from server
141    // start; we just need a context wrapper to track session state.
142    let context = StatefulAiContext::new(session_id.clone(), IntelligentBehaviorConfig::default());
143
144    let mockai_request = MockAiRequest {
145        method: req.method,
146        path: req.path,
147        body: req.body,
148        query_params: req.query_params,
149        headers: req.headers,
150    };
151
152    let guard = mockai.read().await;
153    let result = guard.generate_response(&mockai_request, &context).await;
154    drop(guard);
155
156    match result {
157        Ok(resp) => Json(GenerateResponseBody {
158            status_code: resp.status_code,
159            body: resp.body,
160            headers: resp.headers,
161            session_id,
162        })
163        .into_response(),
164        Err(e) => (
165            StatusCode::INTERNAL_SERVER_ERROR,
166            Json(serde_json::json!({
167                "error": "mockai_generate_failed",
168                "message": e.to_string(),
169            })),
170        )
171            .into_response(),
172    }
173}
174
175/// Build the MockAI standalone API router. Mount under
176/// `/__mockforge/api/mockai`.
177pub fn mockai_api_router(state: MockAiApiState) -> Router {
178    Router::new()
179        .route("/status", get(status_handler))
180        .route("/generate", post(generate_handler))
181        .with_state(state)
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[tokio::test]
189    async fn status_reports_unavailable_when_no_mockai() {
190        let state = MockAiApiState::new(None);
191        let resp = status_handler(State(state)).await;
192        assert_eq!(resp.status(), StatusCode::OK);
193        let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
194        let s = std::str::from_utf8(&body).unwrap();
195        assert!(s.contains("\"available\":false"));
196    }
197
198    #[tokio::test]
199    async fn generate_returns_503_when_no_mockai() {
200        let state = MockAiApiState::new(None);
201        let req = GenerateRequest {
202            method: "GET".into(),
203            path: "/users/42".into(),
204            body: None,
205            query_params: HashMap::new(),
206            headers: HashMap::new(),
207            session_id: None,
208        };
209        let resp = generate_handler(State(state), AxumJson(req)).await;
210        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
211    }
212
213    #[test]
214    fn default_method_is_get() {
215        assert_eq!(default_method(), "GET");
216    }
217}