Skip to main content

mockforge_intelligence/voice/
spec_generator.rs

1//! OpenAPI spec generator from parsed voice commands
2//!
3//! This module generates OpenAPI 3.0 specifications from parsed voice commands
4//! using the existing OpenApiSpec infrastructure.
5
6use mockforge_foundation::Result;
7use mockforge_openapi::OpenApiSpec;
8use openapiv3::*;
9use serde_json::Value;
10
11use super::command_parser::{EndpointRequirement, ModelRequirement, ParsedCommand};
12
13/// Voice spec generator that creates OpenAPI specs from parsed commands
14pub struct VoiceSpecGenerator;
15
16impl VoiceSpecGenerator {
17    /// Create a new voice spec generator
18    pub fn new() -> Self {
19        Self
20    }
21
22    /// Generate OpenAPI spec from a parsed command
23    pub async fn generate_spec(&self, parsed: &ParsedCommand) -> Result<OpenApiSpec> {
24        // Create base OpenAPI structure
25        let mut spec = OpenAPI {
26            openapi: "3.0.3".to_string(),
27            info: Info {
28                title: parsed.title.clone(),
29                version: "1.0.0".to_string(),
30                description: Some(parsed.description.clone()),
31                ..Default::default()
32            },
33            paths: Paths {
34                paths: indexmap::IndexMap::new(),
35                ..Default::default()
36            },
37            components: Some(Components {
38                schemas: indexmap::IndexMap::new(),
39                ..Default::default()
40            }),
41            ..Default::default()
42        };
43
44        // Generate schemas from models
45        if let Some(ref mut components) = spec.components {
46            for model in &parsed.models {
47                let schema = self.model_to_schema(model);
48                components.schemas.insert(model.name.clone(), ReferenceOr::Item(schema));
49            }
50        }
51
52        // Generate paths from endpoints
53        for endpoint in &parsed.endpoints {
54            self.add_endpoint_to_spec(&mut spec, endpoint, &parsed.models)?;
55        }
56
57        // Convert to OpenApiSpec
58        let spec_json = serde_json::to_value(&spec)?;
59        OpenApiSpec::from_json(spec_json)
60    }
61
62    /// Generate OpenAPI spec by merging with existing spec (for conversational mode)
63    pub async fn merge_spec(
64        &self,
65        existing: &OpenApiSpec,
66        parsed: &ParsedCommand,
67    ) -> Result<OpenApiSpec> {
68        // Start with existing spec
69        let mut spec_json = serde_json::to_value(&existing.spec)?;
70
71        // Add new endpoints
72        for endpoint in &parsed.endpoints {
73            self.add_endpoint_to_json(&mut spec_json, endpoint, &parsed.models)?;
74        }
75
76        // Add new models/schemas
77        if let Some(components) = spec_json.get_mut("components") {
78            if let Some(schemas) = components.get_mut("schemas") {
79                for model in &parsed.models {
80                    let schema = self.model_to_schema(model);
81                    let schema_value = serde_json::to_value(&schema)?;
82                    schemas[model.name.clone()] = schema_value;
83                }
84            }
85        }
86
87        OpenApiSpec::from_json(spec_json)
88    }
89
90    /// Convert a model requirement to an OpenAPI schema
91    fn model_to_schema(&self, model: &ModelRequirement) -> Schema {
92        let mut properties = indexmap::IndexMap::new();
93        let mut required = Vec::new();
94
95        for field in &model.fields {
96            let schema_data = SchemaData {
97                title: Some(field.name.clone()),
98                description: Some(field.description.clone()),
99                ..Default::default()
100            };
101
102            let schema_kind = match field.r#type.as_str() {
103                "string" => SchemaKind::Type(Type::String(StringType::default())),
104                "number" => SchemaKind::Type(Type::Number(NumberType {
105                    format: VariantOrUnknownOrEmpty::Empty,
106                    minimum: None,
107                    maximum: None,
108                    exclusive_minimum: false,
109                    exclusive_maximum: false,
110                    multiple_of: None,
111                    enumeration: vec![],
112                })),
113                "integer" => SchemaKind::Type(Type::Integer(IntegerType {
114                    format: VariantOrUnknownOrEmpty::Empty,
115                    minimum: None,
116                    maximum: None,
117                    exclusive_minimum: false,
118                    exclusive_maximum: false,
119                    multiple_of: None,
120                    enumeration: vec![],
121                })),
122                "boolean" => SchemaKind::Type(Type::Boolean(BooleanType {
123                    enumeration: vec![],
124                })),
125                "array" => SchemaKind::Type(Type::Array(ArrayType {
126                    items: Some(ReferenceOr::Item(Box::new(Schema {
127                        schema_data: SchemaData::default(),
128                        schema_kind: SchemaKind::Type(Type::String(StringType::default())),
129                    }))),
130                    min_items: None,
131                    max_items: None,
132                    unique_items: false,
133                })),
134                "object" => SchemaKind::Type(Type::Object(ObjectType {
135                    properties: indexmap::IndexMap::new(),
136                    required: vec![],
137                    additional_properties: None,
138                    ..Default::default()
139                })),
140                _ => SchemaKind::Type(Type::String(StringType::default())),
141            };
142
143            properties.insert(
144                field.name.clone(),
145                ReferenceOr::Item(Box::new(Schema {
146                    schema_data,
147                    schema_kind,
148                })),
149            );
150
151            if field.required {
152                required.push(field.name.clone());
153            }
154        }
155
156        Schema {
157            schema_data: SchemaData {
158                title: Some(model.name.clone()),
159                ..Default::default()
160            },
161            schema_kind: SchemaKind::Type(Type::Object(ObjectType {
162                properties,
163                required,
164                additional_properties: None,
165                ..Default::default()
166            })),
167        }
168    }
169
170    /// Add an endpoint to the OpenAPI spec
171    fn add_endpoint_to_spec(
172        &self,
173        spec: &mut OpenAPI,
174        endpoint: &EndpointRequirement,
175        models: &[ModelRequirement],
176    ) -> Result<()> {
177        // Get or create path item
178        let path_item = spec
179            .paths
180            .paths
181            .entry(endpoint.path.clone())
182            .or_insert_with(|| ReferenceOr::Item(PathItem::default()));
183
184        let path_item = match path_item {
185            ReferenceOr::Item(item) => item,
186            ReferenceOr::Reference { reference } => {
187                tracing::warn!(
188                    "Skipping path '{}': uses $ref '{}' which cannot be modified in-place",
189                    endpoint.path,
190                    reference
191                );
192                return Ok(());
193            }
194        };
195
196        // Create operation
197        let mut operation = Operation {
198            summary: Some(endpoint.description.clone()),
199            description: Some(endpoint.description.clone()),
200            ..Default::default()
201        };
202
203        // Add request body if present
204        if let Some(ref request_body) = endpoint.request_body {
205            operation.request_body = Some(ReferenceOr::Item(RequestBody {
206                description: None,
207                content: {
208                    let mut content = indexmap::IndexMap::new();
209                    let schema = if let Some(ref schema) = request_body.schema {
210                        self.json_value_to_schema(schema)
211                    } else {
212                        // Default to object schema
213                        Schema {
214                            schema_data: SchemaData::default(),
215                            schema_kind: SchemaKind::Type(Type::Object(ObjectType {
216                                properties: indexmap::IndexMap::new(),
217                                required: vec![],
218                                additional_properties: None,
219                                ..Default::default()
220                            })),
221                        }
222                    };
223
224                    content.insert(
225                        "application/json".to_string(),
226                        MediaType {
227                            schema: Some(ReferenceOr::Item(schema)),
228                            ..Default::default()
229                        },
230                    );
231                    content
232                },
233                required: !request_body.required.is_empty(),
234                extensions: indexmap::IndexMap::new(),
235            }));
236        }
237
238        // Add response
239        if let Some(ref response) = endpoint.response {
240            let _status_code = response.status.to_string();
241            let is_array = response.is_array;
242            let schema = if let Some(ref schema_value) = response.schema {
243                self.json_value_to_schema(schema_value)
244            } else if is_array {
245                // Array response - try to infer from endpoint path
246                let item_schema = self.infer_schema_from_path(&endpoint.path, models);
247                Schema {
248                    schema_data: SchemaData::default(),
249                    schema_kind: SchemaKind::Type(Type::Array(ArrayType {
250                        items: Some(ReferenceOr::Item(Box::new(item_schema))),
251                        min_items: None,
252                        max_items: None,
253                        unique_items: false,
254                    })),
255                }
256            } else {
257                // Single object response
258                self.infer_schema_from_path(&endpoint.path, models)
259            };
260
261            operation.responses = Responses {
262                responses: {
263                    let mut responses = indexmap::IndexMap::new();
264                    let status =
265                        StatusCode::Code(response.status.to_string().parse::<u16>().unwrap_or(200));
266                    responses.insert(
267                        status,
268                        ReferenceOr::Item(Response {
269                            description: format!("{} response", endpoint.method),
270                            content: {
271                                let mut content = indexmap::IndexMap::new();
272                                content.insert(
273                                    "application/json".to_string(),
274                                    MediaType {
275                                        schema: Some(ReferenceOr::Item(schema)),
276                                        ..Default::default()
277                                    },
278                                );
279                                content
280                            },
281                            ..Default::default()
282                        }),
283                    );
284                    responses
285                },
286                ..Default::default()
287            };
288        } else {
289            // Default 200 response
290            operation.responses = Responses {
291                responses: {
292                    let mut responses = indexmap::IndexMap::new();
293                    responses.insert(
294                        StatusCode::Code(200),
295                        ReferenceOr::Item(Response {
296                            description: "Success".to_string(),
297                            ..Default::default()
298                        }),
299                    );
300                    responses
301                },
302                ..Default::default()
303            };
304        }
305
306        // Add operation to path item based on method
307        match endpoint.method.to_uppercase().as_str() {
308            "GET" => path_item.get = Some(operation),
309            "POST" => path_item.post = Some(operation),
310            "PUT" => path_item.put = Some(operation),
311            "DELETE" => path_item.delete = Some(operation),
312            "PATCH" => path_item.patch = Some(operation),
313            _ => {
314                return Err(mockforge_foundation::Error::internal(format!(
315                    "Unsupported HTTP method: {}",
316                    endpoint.method
317                )));
318            }
319        }
320
321        Ok(())
322    }
323
324    /// Add endpoint to JSON spec (for merging)
325    fn add_endpoint_to_json(
326        &self,
327        spec_json: &mut Value,
328        endpoint: &EndpointRequirement,
329        models: &[ModelRequirement],
330    ) -> Result<()> {
331        let paths = spec_json
332            .get_mut("paths")
333            .and_then(|p| p.as_object_mut())
334            .ok_or_else(|| mockforge_foundation::Error::internal("Invalid spec JSON structure"))?;
335
336        let path_item = paths
337            .entry(endpoint.path.clone())
338            .or_insert_with(|| Value::Object(serde_json::Map::new()));
339
340        let path_obj = path_item
341            .as_object_mut()
342            .ok_or_else(|| mockforge_foundation::Error::internal("Invalid path item"))?;
343
344        // Create operation object
345        let mut operation = serde_json::Map::new();
346        operation.insert("summary".to_string(), Value::String(endpoint.description.clone()));
347        operation.insert("description".to_string(), Value::String(endpoint.description.clone()));
348
349        // Add request body if present
350        if let Some(ref request_body) = endpoint.request_body {
351            let mut req_body = serde_json::Map::new();
352            if let Some(ref schema) = request_body.schema {
353                req_body.insert("content".to_string(), {
354                    let mut content = serde_json::Map::new();
355                    content.insert(
356                        "application/json".to_string(),
357                        Value::Object({
358                            let mut media_type = serde_json::Map::new();
359                            media_type.insert("schema".to_string(), schema.clone());
360                            media_type
361                        }),
362                    );
363                    Value::Object(content)
364                });
365            }
366            operation.insert("requestBody".to_string(), Value::Object(req_body));
367        }
368
369        // Add response
370        let mut responses = serde_json::Map::new();
371        let status_code = endpoint
372            .response
373            .as_ref()
374            .map(|r| r.status.to_string())
375            .unwrap_or_else(|| "200".to_string());
376
377        let mut response_obj = serde_json::Map::new();
378        response_obj.insert("description".to_string(), Value::String("Success".to_string()));
379
380        if endpoint.response.as_ref().map(|r| r.is_array).unwrap_or(false) {
381            let schema = self.infer_schema_from_path(&endpoint.path, models);
382            let schema_value = serde_json::to_value(&schema)?;
383            response_obj.insert(
384                "content".to_string(),
385                Value::Object({
386                    let mut content = serde_json::Map::new();
387                    content.insert(
388                        "application/json".to_string(),
389                        Value::Object({
390                            let mut media_type = serde_json::Map::new();
391                            media_type.insert(
392                                "schema".to_string(),
393                                Value::Object({
394                                    let mut array_schema = serde_json::Map::new();
395                                    array_schema.insert(
396                                        "type".to_string(),
397                                        Value::String("array".to_string()),
398                                    );
399                                    array_schema.insert("items".to_string(), schema_value);
400                                    array_schema
401                                }),
402                            );
403                            media_type
404                        }),
405                    );
406                    content
407                }),
408            );
409        }
410
411        responses.insert(status_code, Value::Object(response_obj));
412        operation.insert("responses".to_string(), Value::Object(responses));
413
414        // Add to path item
415        path_obj.insert(endpoint.method.to_lowercase(), Value::Object(operation));
416
417        Ok(())
418    }
419
420    /// Infer schema from endpoint path (e.g., /api/products -> Product model)
421    fn infer_schema_from_path(&self, path: &str, models: &[ModelRequirement]) -> Schema {
422        // Try to find a model that matches the path
423        // e.g., /api/products -> Product model
424        let path_lower = path.to_lowercase();
425        for model in models {
426            let model_lower = model.name.to_lowercase();
427            if path_lower.contains(&model_lower) {
428                return self.model_to_schema(model);
429            }
430        }
431
432        // Default to generic object schema
433        Schema {
434            schema_data: SchemaData::default(),
435            schema_kind: SchemaKind::Type(Type::Object(ObjectType {
436                properties: indexmap::IndexMap::new(),
437                ..Default::default()
438            })),
439        }
440    }
441
442    /// Convert JSON value to OpenAPI schema
443    #[allow(clippy::only_used_in_recursion)]
444    fn json_value_to_schema(&self, value: &Value) -> Schema {
445        match value {
446            Value::Object(obj) => {
447                let mut properties = indexmap::IndexMap::new();
448                for (key, val) in obj {
449                    properties.insert(
450                        key.clone(),
451                        ReferenceOr::Item(Box::new(self.json_value_to_schema(val))),
452                    );
453                }
454                Schema {
455                    schema_data: SchemaData::default(),
456                    schema_kind: SchemaKind::Type(Type::Object(ObjectType {
457                        properties,
458                        required: vec![],
459                        additional_properties: None,
460                        ..Default::default()
461                    })),
462                }
463            }
464            Value::Array(arr) => {
465                let item_schema = if arr.is_empty() {
466                    Schema {
467                        schema_data: SchemaData::default(),
468                        schema_kind: SchemaKind::Type(Type::String(StringType::default())),
469                    }
470                } else {
471                    self.json_value_to_schema(&arr[0])
472                };
473                Schema {
474                    schema_data: SchemaData::default(),
475                    schema_kind: SchemaKind::Type(Type::Array(ArrayType {
476                        items: Some(ReferenceOr::Item(Box::new(item_schema))),
477                        min_items: None,
478                        max_items: None,
479                        unique_items: false,
480                    })),
481                }
482            }
483            Value::String(_) => Schema {
484                schema_data: SchemaData::default(),
485                schema_kind: SchemaKind::Type(Type::String(StringType::default())),
486            },
487            Value::Number(n) => {
488                if n.is_i64() {
489                    Schema {
490                        schema_data: SchemaData::default(),
491                        schema_kind: SchemaKind::Type(Type::Integer(IntegerType {
492                            format: VariantOrUnknownOrEmpty::Empty,
493                            minimum: None,
494                            maximum: None,
495                            exclusive_minimum: false,
496                            exclusive_maximum: false,
497                            multiple_of: None,
498                            enumeration: vec![],
499                        })),
500                    }
501                } else {
502                    Schema {
503                        schema_data: SchemaData::default(),
504                        schema_kind: SchemaKind::Type(Type::Number(NumberType {
505                            format: VariantOrUnknownOrEmpty::Empty,
506                            minimum: None,
507                            maximum: None,
508                            exclusive_minimum: false,
509                            exclusive_maximum: false,
510                            multiple_of: None,
511                            enumeration: vec![],
512                        })),
513                    }
514                }
515            }
516            Value::Bool(_) => Schema {
517                schema_data: SchemaData::default(),
518                schema_kind: SchemaKind::Type(Type::Boolean(BooleanType {
519                    enumeration: vec![],
520                })),
521            },
522            Value::Null => Schema {
523                schema_data: SchemaData::default(),
524                schema_kind: SchemaKind::Type(Type::String(StringType::default())),
525            },
526        }
527    }
528}
529
530impl Default for VoiceSpecGenerator {
531    fn default() -> Self {
532        Self::new()
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539    use crate::voice::command_parser::{
540        EndpointRequirement, FieldRequirement, ModelRequirement, ParsedCommand, ResponseRequirement,
541    };
542
543    #[test]
544    fn test_voice_spec_generator_new() {
545        let generator = VoiceSpecGenerator::new();
546        // Just verify it can be created
547        let _ = generator;
548    }
549
550    #[test]
551    fn test_voice_spec_generator_default() {
552        let generator = VoiceSpecGenerator;
553        // Just verify it can be created
554        let _ = generator;
555    }
556
557    #[tokio::test]
558    async fn test_generate_spec_basic() {
559        let generator = VoiceSpecGenerator::new();
560        let parsed = ParsedCommand {
561            api_type: "test".to_string(),
562            title: "Test API".to_string(),
563            description: "A test API".to_string(),
564            endpoints: vec![],
565            models: vec![],
566            relationships: vec![],
567            sample_counts: std::collections::HashMap::new(),
568            flows: vec![],
569        };
570
571        let spec = generator.generate_spec(&parsed).await.unwrap();
572        assert_eq!(spec.title(), "Test API");
573    }
574
575    #[tokio::test]
576    async fn test_generate_spec_with_model() {
577        let generator = VoiceSpecGenerator::new();
578        let model = ModelRequirement {
579            name: "Product".to_string(),
580            fields: vec![
581                FieldRequirement {
582                    name: "id".to_string(),
583                    r#type: "integer".to_string(),
584                    description: "Product ID".to_string(),
585                    required: true,
586                },
587                FieldRequirement {
588                    name: "name".to_string(),
589                    r#type: "string".to_string(),
590                    description: "Product name".to_string(),
591                    required: true,
592                },
593            ],
594        };
595
596        let parsed = ParsedCommand {
597            api_type: "e-commerce".to_string(),
598            title: "Shop API".to_string(),
599            description: "E-commerce API".to_string(),
600            endpoints: vec![],
601            models: vec![model],
602            relationships: vec![],
603            sample_counts: std::collections::HashMap::new(),
604            flows: vec![],
605        };
606
607        let spec = generator.generate_spec(&parsed).await.unwrap();
608        assert_eq!(spec.title(), "Shop API");
609    }
610
611    #[tokio::test]
612    async fn test_generate_spec_with_endpoint() {
613        let generator = VoiceSpecGenerator::new();
614        let endpoint = EndpointRequirement {
615            path: "/api/products".to_string(),
616            method: "GET".to_string(),
617            description: "Get products".to_string(),
618            request_body: None,
619            response: Some(ResponseRequirement {
620                status: 200,
621                schema: None,
622                is_array: true,
623                count: None,
624            }),
625        };
626
627        let parsed = ParsedCommand {
628            api_type: "e-commerce".to_string(),
629            title: "Shop API".to_string(),
630            description: "E-commerce API".to_string(),
631            endpoints: vec![endpoint],
632            models: vec![],
633            relationships: vec![],
634            sample_counts: std::collections::HashMap::new(),
635            flows: vec![],
636        };
637
638        let spec = generator.generate_spec(&parsed).await.unwrap();
639        assert_eq!(spec.title(), "Shop API");
640    }
641
642    #[tokio::test]
643    async fn test_merge_spec() {
644        let generator = VoiceSpecGenerator::new();
645
646        // Create existing spec
647        let existing_json = serde_json::json!({
648            "openapi": "3.0.3",
649            "info": {
650                "title": "Existing API",
651                "version": "1.0.0"
652            },
653            "paths": {},
654            "components": {
655                "schemas": {}
656            }
657        });
658        let existing = OpenApiSpec::from_json(existing_json).unwrap();
659
660        // Create parsed command with new endpoint
661        let parsed = ParsedCommand {
662            api_type: "test".to_string(),
663            title: "New API".to_string(),
664            description: "New API description".to_string(),
665            endpoints: vec![],
666            models: vec![],
667            relationships: vec![],
668            sample_counts: std::collections::HashMap::new(),
669            flows: vec![],
670        };
671
672        let merged = generator.merge_spec(&existing, &parsed).await.unwrap();
673        assert_eq!(merged.title(), "Existing API"); // Title should remain from existing
674    }
675}