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                if let ReferenceOr::Item(schema) = schema_ref {
80                    schemas.insert(name.clone(), schema.clone());
81                }
82            }
83        }
84    }
85
86    schemas
87}
88
89/// Convert an OpenAPI schema to a VBR schema definition
90fn convert_schema_to_vbr(
91    schema_name: &str,
92    schema: Schema,
93    all_schemas: &HashMap<String, Schema>,
94) -> Result<VbrSchemaDefinition> {
95    let mut fields = Vec::new();
96    let mut primary_key = Vec::new();
97    let mut auto_generation = HashMap::new();
98
99    // Extract fields from schema
100    if let SchemaKind::Type(Type::Object(obj_type)) = &schema.schema_kind {
101        // Process properties (properties is directly an IndexMap, not Option)
102        for (field_name, field_schema_ref) in &obj_type.properties {
103            match field_schema_ref {
104                ReferenceOr::Item(field_schema) => {
105                    let field_def =
106                        convert_field_to_definition(field_name, field_schema, &obj_type.required)?;
107                    fields.push(field_def.clone());
108
109                    // Auto-detect primary key
110                    if is_primary_key_field(field_name, &field_def) {
111                        primary_key.push(field_name.clone());
112                        // Auto-generate UUID for primary keys if not specified
113                        if primary_key.len() == 1 && !auto_generation.contains_key(field_name) {
114                            auto_generation.insert(field_name.clone(), AutoGenerationRule::Uuid);
115                        }
116                    }
117
118                    // Auto-detect auto-generation rules
119                    if let Some(rule) = detect_auto_generation(field_name, field_schema) {
120                        auto_generation.insert(field_name.clone(), rule);
121                    }
122                }
123                ReferenceOr::Reference { reference } => {
124                    // Handle schema references - for now, treat as string
125                    // TODO: Resolve references properly
126                    let field_def =
127                        FieldDefinition::new(field_name.clone(), "string".to_string()).optional();
128                    fields.push(field_def);
129                }
130            }
131        }
132    } else {
133        return Err(Error::generic(format!(
134            "Schema '{}' is not an object type, cannot convert to entity",
135            schema_name
136        )));
137    }
138
139    // Default primary key if none detected
140    if primary_key.is_empty() {
141        // Try to find an "id" field
142        if fields.iter().any(|f| f.name == "id") {
143            primary_key.push("id".to_string());
144            auto_generation.insert("id".to_string(), AutoGenerationRule::Uuid);
145        } else {
146            // Create a default "id" field
147            primary_key.push("id".to_string());
148            fields.insert(
149                0,
150                FieldDefinition::new("id".to_string(), "string".to_string())
151                    .with_description("Auto-generated primary key".to_string()),
152            );
153            auto_generation.insert("id".to_string(), AutoGenerationRule::Uuid);
154        }
155    }
156
157    // Create base schema definition
158    let base_schema = SchemaDefinition {
159        name: schema_name.to_string(),
160        fields,
161        description: schema.schema_data.description.as_ref().map(|s| s.clone()),
162        metadata: HashMap::new(),
163        relationships: HashMap::new(),
164    };
165
166    // Create VBR schema definition
167    let vbr_schema = VbrSchemaDefinition::new(base_schema).with_primary_key(primary_key);
168
169    // Apply auto-generation rules
170    let mut final_schema = vbr_schema;
171    for (field, rule) in auto_generation {
172        final_schema = final_schema.with_auto_generation(field, rule);
173    }
174
175    Ok(final_schema)
176}
177
178/// Convert an OpenAPI schema field to a FieldDefinition
179fn convert_field_to_definition(
180    field_name: &str,
181    schema: &Schema,
182    required_fields: &[String],
183) -> Result<FieldDefinition> {
184    let required = required_fields.contains(&field_name.to_string());
185    let field_type = schema_type_to_string(schema)?;
186    let description = schema.schema_data.description.clone();
187
188    let mut field_def = FieldDefinition::new(field_name.to_string(), field_type);
189
190    if !required {
191        field_def = field_def.optional();
192    }
193
194    if let Some(desc) = description {
195        field_def = field_def.with_description(desc);
196    }
197
198    // Extract constraints from schema
199    if let SchemaKind::Type(Type::String(string_type)) = &schema.schema_kind {
200        if let Some(max_length) = string_type.max_length {
201            field_def = field_def.with_constraint("maxLength".to_string(), max_length.into());
202        }
203        if let Some(min_length) = string_type.min_length {
204            field_def = field_def.with_constraint("minLength".to_string(), min_length.into());
205        }
206        if let Some(pattern) = &string_type.pattern {
207            field_def = field_def.with_constraint("pattern".to_string(), pattern.clone().into());
208        }
209    } else if let SchemaKind::Type(Type::Integer(int_type)) = &schema.schema_kind {
210        if let Some(maximum) = int_type.maximum {
211            field_def = field_def.with_constraint("maximum".to_string(), maximum.into());
212        }
213        if let Some(minimum) = int_type.minimum {
214            field_def = field_def.with_constraint("minimum".to_string(), minimum.into());
215        }
216    } else if let SchemaKind::Type(Type::Number(num_type)) = &schema.schema_kind {
217        if let Some(maximum) = num_type.maximum {
218            field_def = field_def.with_constraint("maximum".to_string(), maximum.into());
219        }
220        if let Some(minimum) = num_type.minimum {
221            field_def = field_def.with_constraint("minimum".to_string(), minimum.into());
222        }
223    }
224
225    Ok(field_def)
226}
227
228/// Convert OpenAPI schema type to string representation
229fn schema_type_to_string(schema: &Schema) -> Result<String> {
230    match &schema.schema_kind {
231        SchemaKind::Type(Type::String(string_type)) => {
232            // Check for format (format is VariantOrUnknownOrEmpty, not Option)
233            match &string_type.format {
234                openapiv3::VariantOrUnknownOrEmpty::Item(fmt) => match fmt {
235                    openapiv3::StringFormat::Date => Ok("date".to_string()),
236                    openapiv3::StringFormat::DateTime => Ok("datetime".to_string()),
237                    _ => Ok("string".to_string()),
238                },
239                _ => Ok("string".to_string()),
240            }
241        }
242        SchemaKind::Type(Type::Integer(_)) => Ok("integer".to_string()),
243        SchemaKind::Type(Type::Number(_)) => Ok("number".to_string()),
244        SchemaKind::Type(Type::Boolean(_)) => Ok("boolean".to_string()),
245        SchemaKind::Type(Type::Array(_)) => Ok("array".to_string()),
246        SchemaKind::Type(Type::Object(_)) => Ok("object".to_string()),
247        _ => Ok("string".to_string()), // Default fallback
248    }
249}
250
251/// Check if a field is a primary key candidate
252fn is_primary_key_field(field_name: &str, field_def: &FieldDefinition) -> bool {
253    // Check common primary key names
254    let pk_names = ["id", "uuid", "_id", "pk"];
255    if pk_names.contains(&field_name.to_lowercase().as_str()) {
256        return true;
257    }
258
259    // Check if field is required and has a unique constraint
260    if field_def.required {
261        // Additional heuristics could be added here
262        false
263    } else {
264        false
265    }
266}
267
268/// Detect auto-generation rules for a field
269fn detect_auto_generation(field_name: &str, schema: &Schema) -> Option<AutoGenerationRule> {
270    let name_lower = field_name.to_lowercase();
271
272    // UUID fields
273    if name_lower.contains("uuid") || name_lower == "id" {
274        if let SchemaKind::Type(Type::String(string_type)) = &schema.schema_kind {
275            // Check if format indicates UUID (though StringFormat doesn't have Uuid variant,
276            // we check the field name instead)
277            if let openapiv3::VariantOrUnknownOrEmpty::Item(_) = &string_type.format {
278                // Format exists, but StringFormat doesn't have Uuid variant
279                // We'll rely on field name detection instead
280            }
281        }
282        // Default to UUID for id/uuid fields
283        return Some(AutoGenerationRule::Uuid);
284    }
285
286    // Timestamp fields
287    if name_lower.contains("timestamp")
288        || name_lower.contains("created_at")
289        || name_lower.contains("updated_at")
290    {
291        return Some(AutoGenerationRule::Timestamp);
292    }
293
294    // Date fields
295    if name_lower.contains("date") && !name_lower.contains("timestamp") {
296        if let SchemaKind::Type(Type::String(string_type)) = &schema.schema_kind {
297            if let openapiv3::VariantOrUnknownOrEmpty::Item(openapiv3::StringFormat::Date) =
298                &string_type.format
299            {
300                return Some(AutoGenerationRule::Date);
301            }
302        }
303    }
304
305    None
306}
307
308/// Auto-detect foreign key relationships
309fn detect_foreign_keys(
310    entity_name: &str,
311    vbr_schema: &mut VbrSchemaDefinition,
312    entity_names: &[String],
313    warnings: &mut Vec<String>,
314) {
315    for field in &vbr_schema.base.fields {
316        // Check if field name suggests a foreign key
317        if is_foreign_key_field(&field.name, &entity_names) {
318            if let Some(target_entity) = extract_target_entity(&field.name, &entity_names) {
319                // Check if foreign key already exists
320                if !vbr_schema.foreign_keys.iter().any(|fk| fk.field == field.name) {
321                    let fk = ForeignKeyDefinition {
322                        field: field.name.clone(),
323                        target_entity: target_entity.clone(),
324                        target_field: "id".to_string(), // Default to "id"
325                        on_delete: CascadeAction::NoAction,
326                        on_update: CascadeAction::NoAction,
327                    };
328                    vbr_schema.foreign_keys.push(fk);
329                }
330            }
331        }
332    }
333}
334
335/// Check if a field name suggests a foreign key
336fn is_foreign_key_field(field_name: &str, entity_names: &[String]) -> bool {
337    let name_lower = field_name.to_lowercase();
338
339    // Common foreign key patterns
340    if name_lower.ends_with("_id") {
341        return true;
342    }
343
344    // Check if field name matches an entity name (camelCase or snake_case)
345    for entity_name in entity_names {
346        let entity_lower = entity_name.to_lowercase();
347        // Match patterns like "userId", "user_id", "user"
348        if name_lower == entity_lower
349            || name_lower == format!("{}_id", entity_lower)
350            || name_lower == format!("{}id", entity_lower)
351        {
352            return true;
353        }
354    }
355
356    false
357}
358
359/// Extract target entity name from a foreign key field name
360fn extract_target_entity(field_name: &str, entity_names: &[String]) -> Option<String> {
361    let name_lower = field_name.to_lowercase();
362
363    // Remove common suffixes
364    let base_name = name_lower.trim_end_matches("_id").trim_end_matches("id").to_string();
365
366    // Find matching entity
367    for entity_name in entity_names {
368        let entity_lower = entity_name.to_lowercase();
369        if base_name == entity_lower || name_lower == format!("{}_id", entity_lower) {
370            return Some(entity_name.clone());
371        }
372    }
373
374    None
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use mockforge_core::openapi::OpenApiSpec;
381
382    #[test]
383    fn test_extract_schemas() {
384        let spec_json = serde_json::json!({
385            "openapi": "3.0.0",
386            "info": {
387                "title": "Test API",
388                "version": "1.0.0"
389            },
390            "components": {
391                "schemas": {
392                    "User": {
393                        "type": "object",
394                        "properties": {
395                            "id": {
396                                "type": "string",
397                                "format": "uuid"
398                            },
399                            "name": {
400                                "type": "string"
401                            },
402                            "email": {
403                                "type": "string",
404                                "format": "email"
405                            }
406                        },
407                        "required": ["id", "name", "email"]
408                    }
409                }
410            },
411            "paths": {}
412        });
413
414        let spec = OpenApiSpec::from_json(spec_json).unwrap();
415        let schemas = extract_schemas_from_openapi(&spec);
416
417        assert_eq!(schemas.len(), 1);
418        assert!(schemas.contains_key("User"));
419    }
420
421    #[test]
422    fn test_convert_schema_to_vbr() {
423        let spec_json = serde_json::json!({
424            "openapi": "3.0.0",
425            "info": {
426                "title": "Test API",
427                "version": "1.0.0"
428            },
429            "components": {
430                "schemas": {
431                    "User": {
432                        "type": "object",
433                        "properties": {
434                            "id": {
435                                "type": "string",
436                                "format": "uuid"
437                            },
438                            "name": {
439                                "type": "string"
440                            }
441                        },
442                        "required": ["id", "name"]
443                    }
444                }
445            },
446            "paths": {}
447        });
448
449        let spec = OpenApiSpec::from_json(spec_json).unwrap();
450        let schemas = extract_schemas_from_openapi(&spec);
451        let user_schema = schemas.get("User").unwrap();
452
453        let result = convert_schema_to_vbr("User", user_schema.clone(), &schemas);
454        assert!(result.is_ok());
455
456        let vbr_schema = result.unwrap();
457        assert_eq!(vbr_schema.primary_key, vec!["id"]);
458        assert_eq!(vbr_schema.base.fields.len(), 2);
459        assert!(vbr_schema.auto_generation.contains_key("id"));
460    }
461}