mockforge_vbr/
openapi.rs

1//! OpenAPI integration for VBR
2//!
3//! This module provides functionality to automatically generate VBR entities
4//! and CRUD operations from OpenAPI 3.x specifications.
5
6use crate::schema::{AutoGenerationRule, CascadeAction, ForeignKeyDefinition, VbrSchemaDefinition};
7use crate::{Error, Result};
8use mockforge_core::openapi::OpenApiSpec;
9use mockforge_data::schema::{FieldDefinition, SchemaDefinition};
10use openapiv3::{ReferenceOr, Schema, SchemaKind, Type};
11use std::collections::HashMap;
12
13/// Result of converting an OpenAPI spec to VBR entities
14#[derive(Debug)]
15pub struct OpenApiConversionResult {
16    /// Successfully converted entities
17    pub entities: Vec<(String, VbrSchemaDefinition)>,
18    /// Warnings encountered during conversion
19    pub warnings: Vec<String>,
20}
21
22/// Convert an OpenAPI specification to VBR entities
23///
24/// This function automatically:
25/// - Extracts schemas from `components/schemas`
26/// - Detects primary keys (fields named "id", "uuid", or marked as required)
27/// - Detects foreign keys (fields ending in "_id" or following naming conventions)
28/// - Converts OpenAPI schema types to VBR field definitions
29///
30/// # Arguments
31/// * `spec` - The OpenAPI specification to convert
32///
33/// # Returns
34/// Conversion result with entities and any warnings
35pub fn convert_openapi_to_vbr(spec: &OpenApiSpec) -> Result<OpenApiConversionResult> {
36    let mut entities = Vec::new();
37    let mut warnings = Vec::new();
38
39    // Extract schemas from components
40    let schemas = extract_schemas_from_openapi(spec);
41
42    if schemas.is_empty() {
43        warnings.push("No schemas found in OpenAPI components/schemas".to_string());
44        return Ok(OpenApiConversionResult { entities, warnings });
45    }
46
47    // Convert each schema to a VBR entity
48    // Collect schema names first to avoid borrow issues
49    let schema_names: Vec<String> = schemas.keys().cloned().collect();
50    for schema_name in schema_names {
51        let schema = schemas.get(&schema_name).unwrap().clone();
52        match convert_schema_to_vbr(&schema_name, schema, &schemas) {
53            Ok(vbr_schema) => {
54                entities.push((schema_name.clone(), vbr_schema));
55            }
56            Err(e) => {
57                warnings.push(format!("Failed to convert schema '{}': {}", schema_name, e));
58            }
59        }
60    }
61
62    // Auto-detect foreign keys based on field names and schema references
63    // Collect entity names first to avoid borrow conflicts
64    let entity_names: Vec<String> = entities.iter().map(|(n, _)| n.clone()).collect();
65    for (entity_name, vbr_schema) in &mut entities {
66        detect_foreign_keys(entity_name, vbr_schema, &entity_names, &mut warnings);
67    }
68
69    Ok(OpenApiConversionResult { entities, warnings })
70}
71
72/// Extract all schemas from OpenAPI components
73fn extract_schemas_from_openapi(spec: &OpenApiSpec) -> HashMap<String, Schema> {
74    let mut schemas = HashMap::new();
75
76    if let Some(components) = &spec.spec.components {
77        if !components.schemas.is_empty() {
78            for (name, schema_ref) in &components.schemas {
79                match schema_ref {
80                    ReferenceOr::Item(schema) => {
81                        schemas.insert(name.clone(), schema.clone());
82                    }
83                    ReferenceOr::Reference { reference } => {
84                        // Resolve nested reference
85                        if let Some(resolved) = spec.get_schema(reference) {
86                            schemas.insert(name.clone(), resolved.schema);
87                        }
88                    }
89                }
90            }
91        }
92    }
93
94    schemas
95}
96
97/// Resolve a schema reference to an actual schema
98///
99/// Handles references like "#/components/schemas/User" by looking up
100/// the schema in the provided schemas HashMap.
101fn resolve_schema_reference(
102    reference: &str,
103    all_schemas: &HashMap<String, Schema>,
104) -> Option<Schema> {
105    // Extract schema name from reference (e.g., "#/components/schemas/User" -> "User")
106    let schema_name = reference.strip_prefix("#/components/schemas/")?;
107    all_schemas.get(schema_name).cloned()
108}
109
110/// Convert an OpenAPI schema to a VBR schema definition
111fn convert_schema_to_vbr(
112    schema_name: &str,
113    schema: Schema,
114    all_schemas: &HashMap<String, Schema>,
115) -> Result<VbrSchemaDefinition> {
116    let mut fields = Vec::new();
117    let mut primary_key = Vec::new();
118    let mut auto_generation = HashMap::new();
119
120    // Extract fields from schema
121    if let SchemaKind::Type(Type::Object(obj_type)) = &schema.schema_kind {
122        // Process properties (properties is directly an IndexMap, not Option)
123        for (field_name, field_schema_ref) in &obj_type.properties {
124            match field_schema_ref {
125                ReferenceOr::Item(field_schema) => {
126                    let field_def =
127                        convert_field_to_definition(field_name, field_schema, &obj_type.required)?;
128                    fields.push(field_def.clone());
129
130                    // Auto-detect primary key
131                    if is_primary_key_field(field_name, &field_def) {
132                        primary_key.push(field_name.clone());
133                        // Auto-generate UUID for primary keys if not specified
134                        if primary_key.len() == 1 && !auto_generation.contains_key(field_name) {
135                            auto_generation.insert(field_name.clone(), AutoGenerationRule::Uuid);
136                        }
137                    }
138
139                    // Auto-detect auto-generation rules
140                    if let Some(rule) = detect_auto_generation(field_name, field_schema) {
141                        auto_generation.insert(field_name.clone(), rule);
142                    }
143                }
144                ReferenceOr::Reference { reference } => {
145                    // Resolve schema reference
146                    if let Some(resolved_schema) = resolve_schema_reference(reference, all_schemas)
147                    {
148                        // Recursively convert the resolved schema
149                        match convert_field_to_definition(
150                            field_name,
151                            &resolved_schema,
152                            &obj_type.required,
153                        ) {
154                            Ok(field_def) => {
155                                fields.push(field_def.clone());
156
157                                // Auto-detect primary key
158                                if is_primary_key_field(field_name, &field_def) {
159                                    primary_key.push(field_name.clone());
160                                    if primary_key.len() == 1
161                                        && !auto_generation.contains_key(field_name)
162                                    {
163                                        auto_generation
164                                            .insert(field_name.clone(), AutoGenerationRule::Uuid);
165                                    }
166                                }
167
168                                // Auto-detect auto-generation rules
169                                if let Some(rule) =
170                                    detect_auto_generation(field_name, &resolved_schema)
171                                {
172                                    auto_generation.insert(field_name.clone(), rule);
173                                }
174                            }
175                            Err(e) => {
176                                // If conversion fails, fall back to string type
177                                let field_def =
178                                    FieldDefinition::new(field_name.clone(), "string".to_string())
179                                        .optional();
180                                fields.push(field_def);
181                            }
182                        }
183                    } else {
184                        // Reference not found, treat as string
185                        let field_def =
186                            FieldDefinition::new(field_name.clone(), "string".to_string())
187                                .optional();
188                        fields.push(field_def);
189                    }
190                }
191            }
192        }
193    } else {
194        return Err(Error::generic(format!(
195            "Schema '{}' is not an object type, cannot convert to entity",
196            schema_name
197        )));
198    }
199
200    // Default primary key if none detected
201    if primary_key.is_empty() {
202        // Try to find an "id" field
203        if fields.iter().any(|f| f.name == "id") {
204            primary_key.push("id".to_string());
205            auto_generation.insert("id".to_string(), AutoGenerationRule::Uuid);
206        } else {
207            // Create a default "id" field
208            primary_key.push("id".to_string());
209            fields.insert(
210                0,
211                FieldDefinition::new("id".to_string(), "string".to_string())
212                    .with_description("Auto-generated primary key".to_string()),
213            );
214            auto_generation.insert("id".to_string(), AutoGenerationRule::Uuid);
215        }
216    }
217
218    // Create base schema definition
219    let base_schema = SchemaDefinition {
220        name: schema_name.to_string(),
221        fields,
222        description: schema.schema_data.description.clone(),
223        metadata: HashMap::new(),
224        relationships: HashMap::new(),
225    };
226
227    // Create VBR schema definition
228    let vbr_schema = VbrSchemaDefinition::new(base_schema).with_primary_key(primary_key);
229
230    // Apply auto-generation rules
231    let mut final_schema = vbr_schema;
232    for (field, rule) in auto_generation {
233        final_schema = final_schema.with_auto_generation(field, rule);
234    }
235
236    Ok(final_schema)
237}
238
239/// Convert an OpenAPI schema field to a FieldDefinition
240fn convert_field_to_definition(
241    field_name: &str,
242    schema: &Schema,
243    required_fields: &[String],
244) -> Result<FieldDefinition> {
245    let required = required_fields.contains(&field_name.to_string());
246    let field_type = schema_type_to_string(schema)?;
247    let description = schema.schema_data.description.clone();
248
249    let mut field_def = FieldDefinition::new(field_name.to_string(), field_type);
250
251    if !required {
252        field_def = field_def.optional();
253    }
254
255    if let Some(desc) = description {
256        field_def = field_def.with_description(desc);
257    }
258
259    // Extract constraints from schema
260    if let SchemaKind::Type(Type::String(string_type)) = &schema.schema_kind {
261        if let Some(max_length) = string_type.max_length {
262            field_def = field_def.with_constraint("maxLength".to_string(), max_length.into());
263        }
264        if let Some(min_length) = string_type.min_length {
265            field_def = field_def.with_constraint("minLength".to_string(), min_length.into());
266        }
267        if let Some(pattern) = &string_type.pattern {
268            field_def = field_def.with_constraint("pattern".to_string(), pattern.clone().into());
269        }
270    } else if let SchemaKind::Type(Type::Integer(int_type)) = &schema.schema_kind {
271        if let Some(maximum) = int_type.maximum {
272            field_def = field_def.with_constraint("maximum".to_string(), maximum.into());
273        }
274        if let Some(minimum) = int_type.minimum {
275            field_def = field_def.with_constraint("minimum".to_string(), minimum.into());
276        }
277    } else if let SchemaKind::Type(Type::Number(num_type)) = &schema.schema_kind {
278        if let Some(maximum) = num_type.maximum {
279            field_def = field_def.with_constraint("maximum".to_string(), maximum.into());
280        }
281        if let Some(minimum) = num_type.minimum {
282            field_def = field_def.with_constraint("minimum".to_string(), minimum.into());
283        }
284    }
285
286    Ok(field_def)
287}
288
289/// Convert OpenAPI schema type to string representation
290fn schema_type_to_string(schema: &Schema) -> Result<String> {
291    match &schema.schema_kind {
292        SchemaKind::Type(Type::String(string_type)) => {
293            // Check for format (format is VariantOrUnknownOrEmpty, not Option)
294            match &string_type.format {
295                openapiv3::VariantOrUnknownOrEmpty::Item(fmt) => match fmt {
296                    openapiv3::StringFormat::Date => Ok("date".to_string()),
297                    openapiv3::StringFormat::DateTime => Ok("datetime".to_string()),
298                    _ => Ok("string".to_string()),
299                },
300                _ => Ok("string".to_string()),
301            }
302        }
303        SchemaKind::Type(Type::Integer(_)) => Ok("integer".to_string()),
304        SchemaKind::Type(Type::Number(_)) => Ok("number".to_string()),
305        SchemaKind::Type(Type::Boolean(_)) => Ok("boolean".to_string()),
306        SchemaKind::Type(Type::Array(_)) => Ok("array".to_string()),
307        SchemaKind::Type(Type::Object(_)) => Ok("object".to_string()),
308        _ => Ok("string".to_string()), // Default fallback
309    }
310}
311
312/// Check if a field is a primary key candidate
313fn is_primary_key_field(field_name: &str, field_def: &FieldDefinition) -> bool {
314    // Check common primary key names
315    let pk_names = ["id", "uuid", "_id", "pk"];
316    if pk_names.contains(&field_name.to_lowercase().as_str()) {
317        return true;
318    }
319
320    // Check if field is required and has a unique constraint
321    if field_def.required {
322        // Additional heuristics could be added here
323        false
324    } else {
325        false
326    }
327}
328
329/// Detect auto-generation rules for a field
330fn detect_auto_generation(field_name: &str, schema: &Schema) -> Option<AutoGenerationRule> {
331    let name_lower = field_name.to_lowercase();
332
333    // UUID fields
334    if name_lower.contains("uuid") || name_lower == "id" {
335        if let SchemaKind::Type(Type::String(string_type)) = &schema.schema_kind {
336            // Check if format indicates UUID (though StringFormat doesn't have Uuid variant,
337            // we check the field name instead)
338            if let openapiv3::VariantOrUnknownOrEmpty::Item(_) = &string_type.format {
339                // Format exists, but StringFormat doesn't have Uuid variant
340                // We'll rely on field name detection instead
341            }
342        }
343        // Default to UUID for id/uuid fields
344        return Some(AutoGenerationRule::Uuid);
345    }
346
347    // Timestamp fields
348    if name_lower.contains("timestamp")
349        || name_lower.contains("created_at")
350        || name_lower.contains("updated_at")
351    {
352        return Some(AutoGenerationRule::Timestamp);
353    }
354
355    // Date fields
356    if name_lower.contains("date") && !name_lower.contains("timestamp") {
357        if let SchemaKind::Type(Type::String(string_type)) = &schema.schema_kind {
358            if let openapiv3::VariantOrUnknownOrEmpty::Item(openapiv3::StringFormat::Date) =
359                &string_type.format
360            {
361                return Some(AutoGenerationRule::Date);
362            }
363        }
364    }
365
366    None
367}
368
369/// Auto-detect foreign key relationships
370fn detect_foreign_keys(
371    entity_name: &str,
372    vbr_schema: &mut VbrSchemaDefinition,
373    entity_names: &[String],
374    warnings: &mut Vec<String>,
375) {
376    for field in &vbr_schema.base.fields {
377        // Check if field name suggests a foreign key
378        if is_foreign_key_field(&field.name, entity_names) {
379            if let Some(target_entity) = extract_target_entity(&field.name, entity_names) {
380                // Check if foreign key already exists
381                if !vbr_schema.foreign_keys.iter().any(|fk| fk.field == field.name) {
382                    let fk = ForeignKeyDefinition {
383                        field: field.name.clone(),
384                        target_entity: target_entity.clone(),
385                        target_field: "id".to_string(), // Default to "id"
386                        on_delete: CascadeAction::NoAction,
387                        on_update: CascadeAction::NoAction,
388                    };
389                    vbr_schema.foreign_keys.push(fk);
390                }
391            }
392        }
393    }
394}
395
396/// Check if a field name suggests a foreign key
397fn is_foreign_key_field(field_name: &str, entity_names: &[String]) -> bool {
398    let name_lower = field_name.to_lowercase();
399
400    // Common foreign key patterns
401    if name_lower.ends_with("_id") {
402        return true;
403    }
404
405    // Check if field name matches an entity name (camelCase or snake_case)
406    for entity_name in entity_names {
407        let entity_lower = entity_name.to_lowercase();
408        // Match patterns like "userId", "user_id", "user"
409        if name_lower == entity_lower
410            || name_lower == format!("{}_id", entity_lower)
411            || name_lower == format!("{}id", entity_lower)
412        {
413            return true;
414        }
415    }
416
417    false
418}
419
420/// Extract target entity name from a foreign key field name
421fn extract_target_entity(field_name: &str, entity_names: &[String]) -> Option<String> {
422    let name_lower = field_name.to_lowercase();
423
424    // Remove common suffixes
425    let base_name = name_lower.trim_end_matches("_id").trim_end_matches("id").to_string();
426
427    // Find matching entity
428    for entity_name in entity_names {
429        let entity_lower = entity_name.to_lowercase();
430        if base_name == entity_lower || name_lower == format!("{}_id", entity_lower) {
431            return Some(entity_name.clone());
432        }
433    }
434
435    None
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use mockforge_core::openapi::OpenApiSpec;
442
443    #[test]
444    fn test_extract_schemas() {
445        let spec_json = serde_json::json!({
446            "openapi": "3.0.0",
447            "info": {
448                "title": "Test API",
449                "version": "1.0.0"
450            },
451            "components": {
452                "schemas": {
453                    "User": {
454                        "type": "object",
455                        "properties": {
456                            "id": {
457                                "type": "string",
458                                "format": "uuid"
459                            },
460                            "name": {
461                                "type": "string"
462                            },
463                            "email": {
464                                "type": "string",
465                                "format": "email"
466                            }
467                        },
468                        "required": ["id", "name", "email"]
469                    }
470                }
471            },
472            "paths": {}
473        });
474
475        let spec = OpenApiSpec::from_json(spec_json).unwrap();
476        let schemas = extract_schemas_from_openapi(&spec);
477
478        assert_eq!(schemas.len(), 1);
479        assert!(schemas.contains_key("User"));
480    }
481
482    #[test]
483    fn test_convert_schema_to_vbr() {
484        let spec_json = serde_json::json!({
485            "openapi": "3.0.0",
486            "info": {
487                "title": "Test API",
488                "version": "1.0.0"
489            },
490            "components": {
491                "schemas": {
492                    "User": {
493                        "type": "object",
494                        "properties": {
495                            "id": {
496                                "type": "string",
497                                "format": "uuid"
498                            },
499                            "name": {
500                                "type": "string"
501                            }
502                        },
503                        "required": ["id", "name"]
504                    }
505                }
506            },
507            "paths": {}
508        });
509
510        let spec = OpenApiSpec::from_json(spec_json).unwrap();
511        let schemas = extract_schemas_from_openapi(&spec);
512        let user_schema = schemas.get("User").unwrap();
513
514        let result = convert_schema_to_vbr("User", user_schema.clone(), &schemas);
515        assert!(result.is_ok());
516
517        let vbr_schema = result.unwrap();
518        assert_eq!(vbr_schema.primary_key, vec!["id"]);
519        assert_eq!(vbr_schema.base.fields.len(), 2);
520        assert!(vbr_schema.auto_generation.contains_key("id"));
521    }
522}