mockforge_core/
ai_response.rs

1//! AI-assisted response generation for dynamic mock endpoints
2//!
3//! This module provides configuration and utilities for generating
4//! dynamic mock responses using LLMs based on request context.
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::HashMap;
9
10/// AI response generation mode
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12#[serde(rename_all = "lowercase")]
13pub enum AiResponseMode {
14    /// Static response (no AI)
15    Static,
16    /// Generate response using LLM
17    Intelligent,
18    /// Use static template enhanced with LLM
19    Hybrid,
20}
21
22impl Default for AiResponseMode {
23    fn default() -> Self {
24        Self::Static
25    }
26}
27
28/// Configuration for AI-assisted response generation per endpoint
29/// This is parsed from the `x-mockforge-ai` OpenAPI extension
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct AiResponseConfig {
32    /// Whether AI response generation is enabled
33    #[serde(default)]
34    pub enabled: bool,
35
36    /// Response generation mode
37    #[serde(default)]
38    pub mode: AiResponseMode,
39
40    /// Prompt template for LLM generation
41    /// Supports template variables: {{body.field}}, {{path.param}}, {{query.param}}, {{headers.name}}
42    pub prompt: Option<String>,
43
44    /// Additional context for generation
45    pub context: Option<String>,
46
47    /// Temperature for LLM (0.0 to 2.0)
48    #[serde(default = "default_temperature")]
49    pub temperature: f32,
50
51    /// Max tokens for LLM response
52    #[serde(default = "default_max_tokens")]
53    pub max_tokens: usize,
54
55    /// Schema that the response should conform to (JSON Schema)
56    pub schema: Option<Value>,
57
58    /// Enable caching for identical requests
59    #[serde(default = "default_true")]
60    pub cache_enabled: bool,
61}
62
63fn default_temperature() -> f32 {
64    0.7
65}
66
67fn default_max_tokens() -> usize {
68    1024
69}
70
71fn default_true() -> bool {
72    true
73}
74
75impl Default for AiResponseConfig {
76    fn default() -> Self {
77        Self {
78            enabled: false,
79            mode: AiResponseMode::Static,
80            prompt: None,
81            context: None,
82            temperature: default_temperature(),
83            max_tokens: default_max_tokens(),
84            schema: None,
85            cache_enabled: true,
86        }
87    }
88}
89
90impl AiResponseConfig {
91    /// Create a new AI response configuration
92    pub fn new(enabled: bool, mode: AiResponseMode, prompt: String) -> Self {
93        Self {
94            enabled,
95            mode,
96            prompt: Some(prompt),
97            ..Default::default()
98        }
99    }
100
101    /// Check if AI generation is enabled and configured
102    pub fn is_active(&self) -> bool {
103        self.enabled && self.mode != AiResponseMode::Static && self.prompt.is_some()
104    }
105}
106
107/// Request context for prompt template expansion
108#[derive(Debug, Clone, Default)]
109pub struct RequestContext {
110    /// HTTP method (GET, POST, etc.)
111    pub method: String,
112    /// Request path
113    pub path: String,
114    /// Path parameters
115    pub path_params: HashMap<String, Value>,
116    /// Query parameters
117    pub query_params: HashMap<String, Value>,
118    /// Request headers
119    pub headers: HashMap<String, Value>,
120    /// Request body (if JSON)
121    pub body: Option<Value>,
122    /// Multipart form fields (for multipart/form-data requests)
123    pub multipart_fields: HashMap<String, Value>,
124    /// Multipart file uploads (filename -> file path)
125    pub multipart_files: HashMap<String, String>,
126}
127
128impl RequestContext {
129    /// Create a new request context
130    pub fn new(method: String, path: String) -> Self {
131        Self {
132            method,
133            path,
134            ..Default::default()
135        }
136    }
137
138    /// Set path parameters
139    pub fn with_path_params(mut self, params: HashMap<String, Value>) -> Self {
140        self.path_params = params;
141        self
142    }
143
144    /// Set query parameters
145    pub fn with_query_params(mut self, params: HashMap<String, Value>) -> Self {
146        self.query_params = params;
147        self
148    }
149
150    /// Set headers
151    pub fn with_headers(mut self, headers: HashMap<String, Value>) -> Self {
152        self.headers = headers;
153        self
154    }
155
156    /// Set body
157    pub fn with_body(mut self, body: Value) -> Self {
158        self.body = Some(body);
159        self
160    }
161
162    /// Set multipart form fields
163    pub fn with_multipart_fields(mut self, fields: HashMap<String, Value>) -> Self {
164        self.multipart_fields = fields;
165        self
166    }
167
168    /// Set multipart file uploads
169    pub fn with_multipart_files(mut self, files: HashMap<String, String>) -> Self {
170        self.multipart_files = files;
171        self
172    }
173}
174
175/// Expand template variables in a prompt string using request context
176///
177/// **Note**: This function has been moved to `mockforge-template-expansion` crate.
178/// Use `mockforge_template_expansion::expand_prompt_template` for the full implementation.
179///
180/// This backward-compatible version performs basic template expansion for common patterns.
181/// It handles `{{method}}`, `{{path}}`, `{{query.*}}`, `{{path.*}}`, `{{headers.*}}`, and `{{body.*}}`.
182///
183/// For new code, import directly from the template expansion crate:
184/// ```rust
185/// use mockforge_template_expansion::expand_prompt_template;
186/// ```
187#[deprecated(note = "Use mockforge_template_expansion::expand_prompt_template instead")]
188pub fn expand_prompt_template(template: &str, context: &RequestContext) -> String {
189    let mut result = template.to_string();
190
191    // Normalize {{request.*}} prefix to standard format
192    result = result
193        .replace("{{request.query.", "{{query.")
194        .replace("{{request.path.", "{{path.")
195        .replace("{{request.headers.", "{{headers.")
196        .replace("{{request.body.", "{{body.")
197        .replace("{{request.method}}", "{{method}}")
198        .replace("{{request.path}}", "{{path}}");
199
200    // Replace {{method}}
201    result = result.replace("{{method}}", &context.method);
202
203    // Replace {{path}}
204    result = result.replace("{{path}}", &context.path);
205
206    // Replace {{path.*}} variables
207    for (key, val) in &context.path_params {
208        let placeholder = format!("{{{{path.{key}}}}}");
209        let replacement = value_to_string(val);
210        result = result.replace(&placeholder, &replacement);
211    }
212
213    // Replace {{query.*}} variables
214    for (key, val) in &context.query_params {
215        let placeholder = format!("{{{{query.{key}}}}}");
216        let replacement = value_to_string(val);
217        result = result.replace(&placeholder, &replacement);
218    }
219
220    // Replace {{headers.*}} variables
221    for (key, val) in &context.headers {
222        let placeholder = format!("{{{{headers.{key}}}}}");
223        let replacement = value_to_string(val);
224        result = result.replace(&placeholder, &replacement);
225    }
226
227    // Replace {{body.*}} variables
228    if let Some(body) = &context.body {
229        if let Some(obj) = body.as_object() {
230            for (key, val) in obj {
231                let placeholder = format!("{{{{body.{key}}}}}");
232                let replacement = value_to_string(val);
233                result = result.replace(&placeholder, &replacement);
234            }
235        }
236    }
237
238    // Replace {{multipart.*}} variables
239    for (key, val) in &context.multipart_fields {
240        let placeholder = format!("{{{{multipart.{key}}}}}");
241        let replacement = value_to_string(val);
242        result = result.replace(&placeholder, &replacement);
243    }
244
245    result
246}
247
248/// Helper to convert a JSON value to a string representation
249fn value_to_string(val: &serde_json::Value) -> String {
250    match val {
251        serde_json::Value::String(s) => s.clone(),
252        serde_json::Value::Number(n) => n.to_string(),
253        serde_json::Value::Bool(b) => b.to_string(),
254        serde_json::Value::Null => "null".to_string(),
255        _ => serde_json::to_string(val).unwrap_or_default(),
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use mockforge_template_expansion::{
263        expand_prompt_template, RequestContext as TemplateRequestContext,
264    };
265    use serde_json::json;
266
267    // Helper to convert core RequestContext to template expansion RequestContext
268    fn to_template_context(context: &RequestContext) -> TemplateRequestContext {
269        TemplateRequestContext {
270            method: context.method.clone(),
271            path: context.path.clone(),
272            path_params: context.path_params.clone(),
273            query_params: context.query_params.clone(),
274            headers: context.headers.clone(),
275            body: context.body.clone(),
276            multipart_fields: context.multipart_fields.clone(),
277            multipart_files: context.multipart_files.clone(),
278        }
279    }
280
281    #[test]
282    fn test_ai_response_config_default() {
283        let config = AiResponseConfig::default();
284        assert!(!config.enabled);
285        assert_eq!(config.mode, AiResponseMode::Static);
286        assert!(!config.is_active());
287    }
288
289    #[test]
290    fn test_ai_response_config_is_active() {
291        let config =
292            AiResponseConfig::new(true, AiResponseMode::Intelligent, "Test prompt".to_string());
293        assert!(config.is_active());
294
295        let config_disabled = AiResponseConfig {
296            enabled: false,
297            mode: AiResponseMode::Intelligent,
298            prompt: Some("Test".to_string()),
299            ..Default::default()
300        };
301        assert!(!config_disabled.is_active());
302    }
303
304    #[test]
305    fn test_request_context_builder() {
306        let mut path_params = HashMap::new();
307        path_params.insert("id".to_string(), json!("123"));
308
309        let context = RequestContext::new("POST".to_string(), "/users/123".to_string())
310            .with_path_params(path_params)
311            .with_body(json!({"name": "John"}));
312
313        assert_eq!(context.method, "POST");
314        assert_eq!(context.path, "/users/123");
315        assert_eq!(context.path_params.get("id"), Some(&json!("123")));
316        assert_eq!(context.body, Some(json!({"name": "John"})));
317    }
318
319    #[test]
320    fn test_expand_prompt_template_basic() {
321        let context = RequestContext::new("GET".to_string(), "/users".to_string());
322        let template = "Method: {{method}}, Path: {{path}}";
323        let template_context = to_template_context(&context);
324        let expanded = expand_prompt_template(template, &template_context);
325        assert_eq!(expanded, "Method: GET, Path: /users");
326    }
327
328    #[test]
329    fn test_expand_prompt_template_body() {
330        let body = json!({
331            "message": "Hello",
332            "user": "Alice"
333        });
334        let context = RequestContext::new("POST".to_string(), "/chat".to_string()).with_body(body);
335
336        let template = "User {{body.user}} says: {{body.message}}";
337        let template_context = to_template_context(&context);
338        let expanded = expand_prompt_template(template, &template_context);
339        assert_eq!(expanded, "User Alice says: Hello");
340    }
341
342    #[test]
343    fn test_expand_prompt_template_path_params() {
344        let mut path_params = HashMap::new();
345        path_params.insert("id".to_string(), json!("456"));
346        path_params.insert("name".to_string(), json!("test"));
347
348        let context = RequestContext::new("GET".to_string(), "/users/456".to_string())
349            .with_path_params(path_params);
350
351        let template = "Get user {{path.id}} with name {{path.name}}";
352        let template_context = to_template_context(&context);
353        let expanded = expand_prompt_template(template, &template_context);
354        assert_eq!(expanded, "Get user 456 with name test");
355    }
356
357    #[test]
358    fn test_expand_prompt_template_query_params() {
359        let mut query_params = HashMap::new();
360        query_params.insert("search".to_string(), json!("term"));
361        query_params.insert("limit".to_string(), json!(10));
362
363        let context = RequestContext::new("GET".to_string(), "/search".to_string())
364            .with_query_params(query_params);
365
366        let template = "Search for {{query.search}} with limit {{query.limit}}";
367        let template_context = to_template_context(&context);
368        let expanded = expand_prompt_template(template, &template_context);
369        assert_eq!(expanded, "Search for term with limit 10");
370    }
371
372    #[test]
373    fn test_expand_prompt_template_headers() {
374        let mut headers = HashMap::new();
375        headers.insert("user-agent".to_string(), json!("TestClient/1.0"));
376
377        let context =
378            RequestContext::new("GET".to_string(), "/api".to_string()).with_headers(headers);
379
380        let template = "Request from {{headers.user-agent}}";
381        let template_context = to_template_context(&context);
382        let expanded = expand_prompt_template(template, &template_context);
383        assert_eq!(expanded, "Request from TestClient/1.0");
384    }
385
386    #[test]
387    fn test_expand_prompt_template_complex() {
388        let mut path_params = HashMap::new();
389        path_params.insert("id".to_string(), json!("789"));
390
391        let mut query_params = HashMap::new();
392        query_params.insert("format".to_string(), json!("json"));
393
394        let body = json!({"action": "update", "value": 42});
395
396        let context = RequestContext::new("PUT".to_string(), "/api/items/789".to_string())
397            .with_path_params(path_params)
398            .with_query_params(query_params)
399            .with_body(body);
400
401        let template = "{{method}} item {{path.id}} with action {{body.action}} and value {{body.value}} in format {{query.format}}";
402        let template_context = to_template_context(&context);
403        let expanded = expand_prompt_template(template, &template_context);
404        assert_eq!(expanded, "PUT item 789 with action update and value 42 in format json");
405    }
406}