Skip to main content

mockforge_intelligence/intelligent_behavior/
openapi_generator.rs

1//! OpenAPI specification generator from recorded traffic
2//!
3//! This module analyzes recorded API traffic and generates OpenAPI 3.0 specifications
4//! using pattern detection and LLM inference.
5
6use super::config::BehaviorModelConfig;
7use super::llm_client::LlmClient;
8use super::types::LlmGenerationRequest;
9use chrono::{DateTime, Utc};
10use mockforge_foundation::Result;
11use mockforge_openapi::spec::OpenApiSpec;
12use openapiv3::*;
13use serde::{Deserialize, Serialize};
14use serde_json::{json, Value};
15use std::collections::HashMap;
16
17// `HttpExchange` is re-exported from `mockforge_foundation::intelligent_behavior`.
18pub use mockforge_foundation::intelligent_behavior::HttpExchange;
19
20/// Configuration for OpenAPI spec generation
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct OpenApiGenerationConfig {
23    /// Minimum confidence score for including inferred paths (0.0 to 1.0)
24    #[serde(default = "default_min_confidence")]
25    pub min_confidence: f64,
26
27    /// Behavior model config for LLM inference
28    pub behavior_model: Option<BehaviorModelConfig>,
29}
30
31fn default_min_confidence() -> f64 {
32    0.7
33}
34
35impl Default for OpenApiGenerationConfig {
36    fn default() -> Self {
37        Self {
38            min_confidence: default_min_confidence(),
39            behavior_model: None,
40        }
41    }
42}
43
44/// Confidence score for an inferred OpenAPI element
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ConfidenceScore {
47    /// Confidence value (0.0 to 1.0)
48    pub value: f64,
49    /// Reason for the confidence score
50    pub reason: String,
51}
52
53/// Metadata about the generated OpenAPI spec
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct OpenApiGenerationMetadata {
56    /// Number of requests analyzed
57    pub requests_analyzed: usize,
58    /// Number of paths inferred
59    pub paths_inferred: usize,
60    /// Confidence scores per path
61    pub path_confidence: HashMap<String, ConfidenceScore>,
62    /// Timestamp of generation
63    pub generated_at: DateTime<Utc>,
64    /// Generation duration in milliseconds
65    pub duration_ms: u64,
66}
67
68/// Result of OpenAPI spec generation
69#[derive(Debug, Clone)]
70pub struct OpenApiGenerationResult {
71    /// Generated OpenAPI specification
72    pub spec: OpenApiSpec,
73    /// Generation metadata
74    pub metadata: OpenApiGenerationMetadata,
75}
76
77/// OpenAPI specification generator from recorded traffic
78pub struct OpenApiSpecGenerator {
79    /// LLM client for AI-assisted generation
80    llm_client: Option<LlmClient>,
81    /// Configuration
82    #[allow(dead_code)]
83    config: OpenApiGenerationConfig,
84}
85
86impl OpenApiSpecGenerator {
87    /// Create a new OpenAPI spec generator
88    pub fn new(config: OpenApiGenerationConfig) -> Self {
89        let llm_client = config.behavior_model.as_ref().map(|bm| LlmClient::new(bm.clone()));
90
91        Self { llm_client, config }
92    }
93
94    /// Generate OpenAPI spec from HTTP exchanges
95    ///
96    /// This method:
97    /// 1. Groups requests by path patterns (normalize paths with parameters)
98    /// 2. Analyzes request/response schemas using JSON schema inference
99    /// 3. Uses LLM to infer OpenAPI spec structure from patterns
100    /// 4. Generates paths, operations, schemas, and examples
101    pub async fn generate_from_exchanges(
102        &self,
103        exchanges: Vec<HttpExchange>,
104    ) -> Result<OpenApiGenerationResult> {
105        let start_time = Utc::now();
106
107        if exchanges.is_empty() {
108            return Err(mockforge_foundation::Error::internal(
109                "No HTTP exchanges provided for OpenAPI generation",
110            ));
111        }
112
113        tracing::info!("Analyzing {} HTTP exchanges for OpenAPI generation", exchanges.len());
114
115        // 1. Group requests by path patterns
116        let path_groups = self.group_by_path_pattern(&exchanges);
117
118        // 2. Infer path parameters
119        let normalized_paths = self.infer_path_parameters(&path_groups);
120
121        // 3. Extract schemas from request/response bodies
122        let schemas = self.infer_schemas(&exchanges).await?;
123
124        // 4. Generate OpenAPI spec structure
125        let spec = if let Some(ref llm_client) = self.llm_client {
126            // Use LLM for AI-assisted generation
127            self.generate_with_llm(&normalized_paths, &schemas, &exchanges, llm_client)
128                .await?
129        } else {
130            // Fallback to pattern-based generation
131            self.generate_pattern_based(&normalized_paths, &schemas, &exchanges).await?
132        };
133
134        let duration_ms = (Utc::now() - start_time).num_milliseconds() as u64;
135
136        // 5. Calculate confidence scores
137        let path_confidence = self.calculate_confidence_scores(&normalized_paths, &exchanges);
138
139        let metadata = OpenApiGenerationMetadata {
140            requests_analyzed: exchanges.len(),
141            paths_inferred: normalized_paths.len(),
142            path_confidence,
143            generated_at: start_time,
144            duration_ms,
145        };
146
147        Ok(OpenApiGenerationResult { spec, metadata })
148    }
149
150    /// Group exchanges by path pattern
151    pub fn group_by_path_pattern<'a>(
152        &self,
153        exchanges: &'a [HttpExchange],
154    ) -> HashMap<String, Vec<&'a HttpExchange>> {
155        let mut groups: HashMap<String, Vec<&HttpExchange>> = HashMap::new();
156
157        for exchange in exchanges {
158            let path = &exchange.path;
159            groups.entry(path.clone()).or_default().push(exchange);
160        }
161
162        groups
163    }
164
165    /// Infer path parameters from path patterns
166    ///
167    /// Detects patterns like `/api/users/123` and `/api/users/456` and normalizes
168    /// them to `/api/users/{id}`.
169    pub fn infer_path_parameters<'a>(
170        &self,
171        path_groups: &HashMap<String, Vec<&'a HttpExchange>>,
172    ) -> HashMap<String, Vec<&'a HttpExchange>> {
173        let mut normalized: HashMap<String, Vec<&HttpExchange>> = HashMap::new();
174
175        // Group paths by their base pattern
176        let _path_segments: Vec<Vec<String>> = path_groups
177            .keys()
178            .map(|path| path.split('/').filter(|s| !s.is_empty()).map(|s| s.to_string()).collect())
179            .collect();
180
181        // Find common patterns
182        for (original_path, exchanges) in path_groups {
183            let segments: Vec<&str> = original_path.split('/').filter(|s| !s.is_empty()).collect();
184
185            // Try to find similar paths
186            let mut normalized_path = original_path.clone();
187            for other_path in path_groups.keys() {
188                if other_path == original_path {
189                    continue;
190                }
191
192                let other_segments: Vec<&str> =
193                    other_path.split('/').filter(|s| !s.is_empty()).collect();
194
195                if segments.len() == other_segments.len() {
196                    // Check if paths differ only in the last segment (likely an ID)
197                    let mut normalized_segments: Vec<String> = Vec::new();
198                    let mut is_parameter = false;
199
200                    for (i, (seg, other_seg)) in
201                        segments.iter().zip(other_segments.iter()).enumerate()
202                    {
203                        if seg == other_seg {
204                            normalized_segments.push(seg.to_string());
205                        } else if i == segments.len() - 1 {
206                            // Last segment differs - likely a parameter
207                            normalized_segments
208                                .push(format!("{{{}}}", self.infer_parameter_name(seg)));
209                            is_parameter = true;
210                        } else {
211                            // Different in middle - not a match
212                            break;
213                        }
214                    }
215
216                    if is_parameter {
217                        normalized_path = format!("/{}", normalized_segments.join("/"));
218                        break;
219                    }
220                }
221            }
222
223            normalized.entry(normalized_path).or_default().extend(exchanges);
224        }
225
226        normalized
227    }
228
229    /// Infer parameter name from path segment
230    fn infer_parameter_name(&self, segment: &str) -> String {
231        // Try to detect common patterns
232        if segment.chars().all(|c| c.is_ascii_digit()) {
233            "id".to_string()
234        } else if segment.starts_with("user") || segment.contains("user") {
235            "userId".to_string()
236        } else if segment.starts_with("order") || segment.contains("order") {
237            "orderId".to_string()
238        } else {
239            // Default: use singular form or generic name
240            "id".to_string()
241        }
242    }
243
244    /// Infer JSON schemas from request/response bodies
245    pub async fn infer_schemas(
246        &self,
247        exchanges: &[HttpExchange],
248    ) -> Result<HashMap<String, Value>> {
249        let mut schemas: HashMap<String, Value> = HashMap::new();
250
251        for exchange in exchanges {
252            // Parse request body if present
253            if let Some(ref body) = exchange.body {
254                if exchange.body_encoding == "utf8" {
255                    if let Ok(json_value) = serde_json::from_str::<Value>(body) {
256                        let schema = self.json_to_schema(&json_value);
257                        schemas.insert("RequestBody".to_string(), schema);
258                    }
259                }
260            }
261
262            // Parse response body if present
263            if let Some(ref body) = exchange.response_body {
264                if exchange.response_body_encoding.as_deref() == Some("utf8") {
265                    if let Ok(json_value) = serde_json::from_str::<Value>(body) {
266                        let schema = self.json_to_schema(&json_value);
267                        schemas.insert("ResponseBody".to_string(), schema);
268                    }
269                }
270            }
271        }
272
273        Ok(schemas)
274    }
275
276    /// Convert JSON value to JSON Schema
277    #[allow(clippy::only_used_in_recursion)]
278    pub fn json_to_schema(&self, value: &Value) -> Value {
279        match value {
280            Value::Null => json!({"type": "null"}),
281            Value::Bool(_) => json!({"type": "boolean"}),
282            Value::Number(n) => {
283                if n.is_i64() {
284                    json!({"type": "integer"})
285                } else {
286                    json!({"type": "number"})
287                }
288            }
289            Value::String(_) => json!({"type": "string"}),
290            Value::Array(arr) => {
291                if let Some(first) = arr.first() {
292                    json!({
293                        "type": "array",
294                        "items": self.json_to_schema(first)
295                    })
296                } else {
297                    json!({"type": "array"})
298                }
299            }
300            Value::Object(obj) => {
301                let mut properties = serde_json::Map::new();
302                let mut required = Vec::new();
303
304                for (key, val) in obj {
305                    properties.insert(key.clone(), self.json_to_schema(val));
306                    // Only mark non-null fields as required.
307                    // Null values indicate the field is optional.
308                    if !val.is_null() {
309                        required.push(key.clone());
310                    }
311                }
312
313                if required.is_empty() {
314                    json!({
315                        "type": "object",
316                        "properties": properties
317                    })
318                } else {
319                    json!({
320                        "type": "object",
321                        "properties": properties,
322                        "required": required
323                    })
324                }
325            }
326        }
327    }
328
329    /// Generate OpenAPI spec using LLM inference
330    async fn generate_with_llm(
331        &self,
332        normalized_paths: &HashMap<String, Vec<&HttpExchange>>,
333        schemas: &HashMap<String, Value>,
334        exchanges: &[HttpExchange],
335        llm_client: &LlmClient,
336    ) -> Result<OpenApiSpec> {
337        // Build prompt for LLM
338        let prompt = self.build_llm_prompt(normalized_paths, schemas, exchanges);
339
340        let request = LlmGenerationRequest {
341            system_prompt: "You are an expert at generating OpenAPI 3.0 specifications from API traffic patterns. Generate valid, well-structured OpenAPI specs.".to_string(),
342            user_prompt: prompt,
343            temperature: 0.3, // Lower temperature for more consistent output
344            max_tokens: 4000,
345            schema: None, // No schema constraint for OpenAPI generation
346            seed: None,
347        };
348
349        // Generate spec using LLM
350        let response = llm_client.generate(&request).await?;
351
352        // Parse response as OpenAPI spec
353        // The LLM should return a JSON object that can be converted to OpenAPI
354        let spec = OpenApiSpec::from_json(response)?;
355
356        Ok(spec)
357    }
358
359    /// Build LLM prompt from traffic patterns
360    fn build_llm_prompt(
361        &self,
362        normalized_paths: &HashMap<String, Vec<&HttpExchange>>,
363        schemas: &HashMap<String, Value>,
364        exchanges: &[HttpExchange],
365    ) -> String {
366        let mut prompt = String::from(
367            "Generate an OpenAPI 3.0 specification from the following API traffic patterns:\n\n",
368        );
369
370        // Add path patterns
371        prompt.push_str("## Paths and Methods:\n");
372        for (path, path_exchanges) in normalized_paths {
373            let methods: Vec<String> = path_exchanges
374                .iter()
375                .map(|e| e.method.clone())
376                .collect::<std::collections::HashSet<_>>()
377                .into_iter()
378                .collect();
379            prompt.push_str(&format!("- {}: {}\n", path, methods.join(", ")));
380        }
381
382        // Add sample request/response examples
383        prompt.push_str("\n## Sample Exchanges:\n");
384        for (i, exchange) in exchanges.iter().take(10).enumerate() {
385            prompt.push_str(&format!("\n### Exchange {}\n", i + 1));
386            prompt.push_str(&format!("Method: {}\n", exchange.method));
387            prompt.push_str(&format!("Path: {}\n", exchange.path));
388            if let Some(ref body) = exchange.body {
389                if exchange.body_encoding == "utf8" {
390                    prompt.push_str(&format!("Request Body: {}\n", body));
391                }
392            }
393            if let Some(status) = exchange.status_code {
394                prompt.push_str(&format!("Status: {}\n", status));
395                if let Some(ref body) = exchange.response_body {
396                    if exchange.response_body_encoding.as_deref() == Some("utf8") {
397                        prompt.push_str(&format!("Response Body: {}\n", body));
398                    }
399                }
400            }
401        }
402
403        // Add inferred schemas
404        if !schemas.is_empty() {
405            prompt.push_str("\n## Inferred Schemas:\n");
406            prompt.push_str(&serde_json::to_string_pretty(schemas).unwrap_or_default());
407        }
408
409        prompt.push_str("\n\nGenerate a complete OpenAPI 3.0 specification in JSON format with:");
410        prompt.push_str("\n- info section with title and version");
411        prompt.push_str("\n- paths section with all detected endpoints");
412        prompt.push_str("\n- components/schemas section with request/response schemas");
413        prompt.push_str("\n- proper HTTP methods, status codes, and content types");
414
415        prompt
416    }
417
418    /// Generate OpenAPI spec using pattern-based inference (fallback)
419    async fn generate_pattern_based(
420        &self,
421        normalized_paths: &HashMap<String, Vec<&HttpExchange>>,
422        schemas: &HashMap<String, Value>,
423        _exchanges: &[HttpExchange],
424    ) -> Result<OpenApiSpec> {
425        // Create a basic OpenAPI 3.0 spec structure
426        let mut spec = OpenAPI {
427            openapi: "3.0.3".to_string(),
428            info: Info {
429                title: "Generated API".to_string(),
430                version: "1.0.0".to_string(),
431                description: Some(
432                    "OpenAPI specification generated from recorded traffic".to_string(),
433                ),
434                ..Default::default()
435            },
436            paths: Paths {
437                paths: indexmap::IndexMap::new(),
438                ..Default::default()
439            },
440            components: Some(Components {
441                schemas: indexmap::IndexMap::new(),
442                ..Default::default()
443            }),
444            ..Default::default()
445        };
446
447        // Add paths
448        for (path, path_exchanges) in normalized_paths {
449            let mut path_item = PathItem::default();
450
451            // Group by method
452            let mut method_groups: HashMap<String, Vec<&HttpExchange>> = HashMap::new();
453            for exchange in path_exchanges {
454                method_groups.entry(exchange.method.clone()).or_default().push(exchange);
455            }
456
457            // Add operations for each method
458            for (method, method_exchanges) in method_groups {
459                let operation = self.create_operation_from_exchanges(&method_exchanges)?;
460
461                match method.as_str() {
462                    "GET" => path_item.get = Some(operation),
463                    "POST" => path_item.post = Some(operation),
464                    "PUT" => path_item.put = Some(operation),
465                    "DELETE" => path_item.delete = Some(operation),
466                    "PATCH" => path_item.patch = Some(operation),
467                    "HEAD" => path_item.head = Some(operation),
468                    "OPTIONS" => path_item.options = Some(operation),
469                    "TRACE" => path_item.trace = Some(operation),
470                    other => {
471                        tracing::debug!(method = other, path = %path, "Skipping unsupported HTTP method");
472                    }
473                }
474            }
475
476            spec.paths.paths.insert(path.clone(), ReferenceOr::Item(path_item));
477        }
478
479        // Add schemas to components
480        if let Some(ref mut components) = spec.components {
481            for (name, schema_value) in schemas {
482                // Convert JSON Schema to OpenAPI Schema
483                // This is a simplified conversion
484                if let Ok(schema) = serde_json::from_value::<Schema>(schema_value.clone()) {
485                    components.schemas.insert(name.clone(), ReferenceOr::Item(schema));
486                }
487            }
488        }
489
490        // Create raw document for serialization
491        let raw_document = serde_json::to_value(&spec)?;
492
493        Ok(OpenApiSpec {
494            spec,
495            file_path: None,
496            raw_document: Some(raw_document),
497        })
498    }
499
500    /// Create OpenAPI operation from exchanges
501    fn create_operation_from_exchanges(&self, exchanges: &[&HttpExchange]) -> Result<Operation> {
502        // Use the first exchange as a template
503        let first = exchanges
504            .first()
505            .ok_or_else(|| mockforge_foundation::Error::internal("No exchanges provided"))?;
506
507        let mut operation = Operation {
508            summary: Some(format!("{} {}", first.method, first.path)),
509            ..Default::default()
510        };
511
512        // Add responses
513        let mut responses = Responses::default();
514        for exchange in exchanges {
515            if let Some(status_code) = exchange.status_code {
516                let status = StatusCode::Code(status_code as u16);
517                let mut response_obj = Response::default();
518
519                // Add content if response has body
520                if let Some(ref body) = exchange.response_body {
521                    if exchange.response_body_encoding.as_deref() == Some("utf8") {
522                        if let Ok(json_value) = serde_json::from_str::<Value>(body) {
523                            let mut content = indexmap::IndexMap::new();
524                            let mut media_type = MediaType::default();
525
526                            // Convert JSON Schema to OpenAPI Schema
527                            // For now, create a basic object schema
528                            // A full conversion would require parsing the JSON Schema structure
529                            let schema = match json_value {
530                                Value::Object(_) => Schema {
531                                    schema_data: SchemaData::default(),
532                                    schema_kind: SchemaKind::Type(Type::Object(ObjectType {
533                                        properties: indexmap::IndexMap::new(),
534                                        required: vec![],
535                                        additional_properties: None,
536                                        ..Default::default()
537                                    })),
538                                },
539                                Value::Array(_) => Schema {
540                                    schema_data: SchemaData::default(),
541                                    schema_kind: SchemaKind::Type(Type::Array(ArrayType {
542                                        items: None,
543                                        min_items: None,
544                                        max_items: None,
545                                        unique_items: false,
546                                    })),
547                                },
548                                Value::String(_) => Schema {
549                                    schema_data: SchemaData::default(),
550                                    schema_kind: SchemaKind::Type(Type::String(StringType {
551                                        enumeration: vec![],
552                                        min_length: None,
553                                        max_length: None,
554                                        pattern: None,
555                                        format: VariantOrUnknownOrEmpty::Empty,
556                                    })),
557                                },
558                                Value::Number(n) => {
559                                    if n.is_f64() {
560                                        Schema {
561                                            schema_data: SchemaData::default(),
562                                            schema_kind: SchemaKind::Type(Type::Number(
563                                                NumberType {
564                                                    minimum: None,
565                                                    maximum: None,
566                                                    exclusive_minimum: false,
567                                                    exclusive_maximum: false,
568                                                    multiple_of: None,
569                                                    enumeration: vec![],
570                                                    format: VariantOrUnknownOrEmpty::Empty,
571                                                },
572                                            )),
573                                        }
574                                    } else {
575                                        Schema {
576                                            schema_data: SchemaData::default(),
577                                            schema_kind: SchemaKind::Type(Type::Integer(
578                                                IntegerType {
579                                                    minimum: None,
580                                                    maximum: None,
581                                                    exclusive_minimum: false,
582                                                    exclusive_maximum: false,
583                                                    multiple_of: None,
584                                                    enumeration: vec![],
585                                                    format: VariantOrUnknownOrEmpty::Item(
586                                                        IntegerFormat::Int64,
587                                                    ),
588                                                },
589                                            )),
590                                        }
591                                    }
592                                }
593                                Value::Bool(_) => Schema {
594                                    schema_data: SchemaData::default(),
595                                    schema_kind: SchemaKind::Type(Type::Boolean(BooleanType {
596                                        enumeration: vec![],
597                                    })),
598                                },
599                                Value::Null => Schema {
600                                    schema_data: SchemaData::default(),
601                                    schema_kind: SchemaKind::Type(Type::Object(ObjectType {
602                                        properties: indexmap::IndexMap::new(),
603                                        required: vec![],
604                                        additional_properties: None,
605                                        ..Default::default()
606                                    })),
607                                },
608                            };
609
610                            media_type.schema = Some(ReferenceOr::Item(schema));
611                            content.insert("application/json".to_string(), media_type);
612                            response_obj.content = content;
613                        }
614                    }
615                }
616
617                responses.responses.insert(status, ReferenceOr::Item(response_obj));
618            }
619        }
620
621        operation.responses = responses;
622
623        Ok(operation)
624    }
625
626    /// Calculate confidence scores for inferred paths
627    pub fn calculate_confidence_scores(
628        &self,
629        normalized_paths: &HashMap<String, Vec<&HttpExchange>>,
630        exchanges: &[HttpExchange],
631    ) -> HashMap<String, ConfidenceScore> {
632        let mut scores = HashMap::new();
633
634        for (path, path_exchanges) in normalized_paths {
635            // Confidence based on:
636            // 1. Number of examples (more = higher confidence)
637            // 2. Consistency of status codes
638            // 3. Presence of request/response bodies
639
640            let example_count = path_exchanges.len();
641            let example_ratio = (example_count as f64) / (exchanges.len() as f64);
642
643            // Check status code consistency
644            let status_codes: Vec<i32> =
645                path_exchanges.iter().filter_map(|e| e.status_code).collect();
646            let unique_statuses =
647                status_codes.iter().collect::<std::collections::HashSet<_>>().len();
648            let consistency = if unique_statuses <= 2 { 1.0 } else { 0.7 };
649
650            // Check for request/response bodies
651            let has_bodies =
652                path_exchanges.iter().any(|e| e.body.is_some() || e.response_body.is_some());
653            let body_score = if has_bodies { 1.0 } else { 0.5 };
654
655            // Calculate overall confidence
656            let confidence = (example_ratio * 0.4 + consistency * 0.3 + body_score * 0.3).min(1.0);
657
658            let reason = format!(
659                "Based on {} examples ({}% of total), {} unique status codes, {}",
660                example_count,
661                (example_ratio * 100.0) as u32,
662                unique_statuses,
663                if has_bodies {
664                    "with request/response bodies"
665                } else {
666                    "without bodies"
667                }
668            );
669
670            scores.insert(
671                path.clone(),
672                ConfidenceScore {
673                    value: confidence,
674                    reason,
675                },
676            );
677        }
678
679        scores
680    }
681}
682
683#[cfg(test)]
684mod tests {
685    use super::*;
686
687    #[test]
688    fn test_infer_parameter_name() {
689        let generator = OpenApiSpecGenerator::new(OpenApiGenerationConfig::default());
690        assert_eq!(generator.infer_parameter_name("123"), "id");
691        assert_eq!(generator.infer_parameter_name("user123"), "userId");
692    }
693
694    #[test]
695    fn test_json_to_schema() {
696        let generator = OpenApiSpecGenerator::new(OpenApiGenerationConfig::default());
697        let json = json!({"name": "test", "age": 25});
698        let schema = generator.json_to_schema(&json);
699        assert!(schema.get("type").is_some());
700        assert_eq!(schema["type"], "object");
701    }
702}