1use 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
17pub use mockforge_foundation::intelligent_behavior::HttpExchange;
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct OpenApiGenerationConfig {
23 #[serde(default = "default_min_confidence")]
25 pub min_confidence: f64,
26
27 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#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ConfidenceScore {
47 pub value: f64,
49 pub reason: String,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct OpenApiGenerationMetadata {
56 pub requests_analyzed: usize,
58 pub paths_inferred: usize,
60 pub path_confidence: HashMap<String, ConfidenceScore>,
62 pub generated_at: DateTime<Utc>,
64 pub duration_ms: u64,
66}
67
68#[derive(Debug, Clone)]
70pub struct OpenApiGenerationResult {
71 pub spec: OpenApiSpec,
73 pub metadata: OpenApiGenerationMetadata,
75}
76
77pub struct OpenApiSpecGenerator {
79 llm_client: Option<LlmClient>,
81 #[allow(dead_code)]
83 config: OpenApiGenerationConfig,
84}
85
86impl OpenApiSpecGenerator {
87 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 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 let path_groups = self.group_by_path_pattern(&exchanges);
117
118 let normalized_paths = self.infer_path_parameters(&path_groups);
120
121 let schemas = self.infer_schemas(&exchanges).await?;
123
124 let spec = if let Some(ref llm_client) = self.llm_client {
126 self.generate_with_llm(&normalized_paths, &schemas, &exchanges, llm_client)
128 .await?
129 } else {
130 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 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 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 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 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 for (original_path, exchanges) in path_groups {
183 let segments: Vec<&str> = original_path.split('/').filter(|s| !s.is_empty()).collect();
184
185 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 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 normalized_segments
208 .push(format!("{{{}}}", self.infer_parameter_name(seg)));
209 is_parameter = true;
210 } else {
211 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 fn infer_parameter_name(&self, segment: &str) -> String {
231 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 "id".to_string()
241 }
242 }
243
244 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 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 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 #[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 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 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 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, max_tokens: 4000,
345 schema: None, seed: None,
347 };
348
349 let response = llm_client.generate(&request).await?;
351
352 let spec = OpenApiSpec::from_json(response)?;
355
356 Ok(spec)
357 }
358
359 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 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 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 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 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 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 for (path, path_exchanges) in normalized_paths {
449 let mut path_item = PathItem::default();
450
451 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 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 if let Some(ref mut components) = spec.components {
481 for (name, schema_value) in schemas {
482 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 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 fn create_operation_from_exchanges(&self, exchanges: &[&HttpExchange]) -> Result<Operation> {
502 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 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 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 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 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 let example_count = path_exchanges.len();
641 let example_ratio = (example_count as f64) / (exchanges.len() as f64);
642
643 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 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 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}