1use mockforge_foundation::Result;
7use mockforge_openapi::OpenApiSpec;
8use openapiv3::*;
9use serde_json::Value;
10
11use super::command_parser::{EndpointRequirement, ModelRequirement, ParsedCommand};
12
13pub struct VoiceSpecGenerator;
15
16impl VoiceSpecGenerator {
17 pub fn new() -> Self {
19 Self
20 }
21
22 pub async fn generate_spec(&self, parsed: &ParsedCommand) -> Result<OpenApiSpec> {
24 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 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 for endpoint in &parsed.endpoints {
54 self.add_endpoint_to_spec(&mut spec, endpoint, &parsed.models)?;
55 }
56
57 let spec_json = serde_json::to_value(&spec)?;
59 OpenApiSpec::from_json(spec_json)
60 }
61
62 pub async fn merge_spec(
64 &self,
65 existing: &OpenApiSpec,
66 parsed: &ParsedCommand,
67 ) -> Result<OpenApiSpec> {
68 let mut spec_json = serde_json::to_value(&existing.spec)?;
70
71 for endpoint in &parsed.endpoints {
73 self.add_endpoint_to_json(&mut spec_json, endpoint, &parsed.models)?;
74 }
75
76 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 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 fn add_endpoint_to_spec(
172 &self,
173 spec: &mut OpenAPI,
174 endpoint: &EndpointRequirement,
175 models: &[ModelRequirement],
176 ) -> Result<()> {
177 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 let mut operation = Operation {
198 summary: Some(endpoint.description.clone()),
199 description: Some(endpoint.description.clone()),
200 ..Default::default()
201 };
202
203 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 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 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 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 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 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 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 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 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 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 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 path_obj.insert(endpoint.method.to_lowercase(), Value::Object(operation));
416
417 Ok(())
418 }
419
420 fn infer_schema_from_path(&self, path: &str, models: &[ModelRequirement]) -> Schema {
422 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 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 #[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 let _ = generator;
548 }
549
550 #[test]
551 fn test_voice_spec_generator_default() {
552 let generator = VoiceSpecGenerator;
553 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 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 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"); }
675}