elif_openapi/
schema.rs

1use crate::{
2    error::{OpenApiError, OpenApiResult},
3    specification::Schema,
4};
5use serde_json::Value;
6use std::collections::HashMap;
7
8/// Schema generator for converting Rust types to OpenAPI schemas
9pub struct SchemaGenerator {
10    /// Generated schemas cache
11    schemas: HashMap<String, Schema>,
12    /// Configuration options
13    config: SchemaConfig,
14}
15
16/// Configuration for schema generation
17#[derive(Debug, Clone)]
18pub struct SchemaConfig {
19    /// Generate nullable schemas for Option<T>
20    pub nullable_optional: bool,
21    /// Include example values
22    pub include_examples: bool,
23    /// Custom type mappings
24    pub custom_mappings: HashMap<String, Schema>,
25}
26
27/// Type information for schema generation
28#[derive(Debug, Clone)]
29pub struct TypeSchema {
30    /// Type name
31    pub name: String,
32    /// Generated schema
33    pub schema: Schema,
34    /// Dependencies (other types this type references)
35    pub dependencies: Vec<String>,
36}
37
38impl SchemaGenerator {
39    /// Create a new schema generator
40    pub fn new(config: SchemaConfig) -> Self {
41        Self {
42            schemas: HashMap::new(),
43            config,
44        }
45    }
46
47    /// Generate schema for a Rust type
48    pub fn generate_schema(&mut self, type_name: &str) -> OpenApiResult<Schema> {
49        // Check cache first
50        if let Some(schema) = self.schemas.get(type_name) {
51            return Ok(schema.clone());
52        }
53
54        // Check custom mappings
55        if let Some(schema) = self.config.custom_mappings.get(type_name) {
56            self.schemas.insert(type_name.to_string(), schema.clone());
57            return Ok(schema.clone());
58        }
59
60        // Generate schema based on type
61        let schema = self.generate_schema_for_type(type_name)?;
62        self.schemas.insert(type_name.to_string(), schema.clone());
63        Ok(schema)
64    }
65
66    /// Generate schema for primitive types, collections, and custom types
67    fn generate_schema_for_type(&self, type_name: &str) -> OpenApiResult<Schema> {
68        match type_name {
69            // String types
70            "String" | "str" | "&str" => Ok(Schema {
71                schema_type: Some("string".to_string()),
72                ..Default::default()
73            }),
74
75            // Numeric types
76            "i8" | "i16" | "i32" => Ok(Schema {
77                schema_type: Some("integer".to_string()),
78                format: Some("int32".to_string()),
79                ..Default::default()
80            }),
81            "i64" => Ok(Schema {
82                schema_type: Some("integer".to_string()),
83                format: Some("int64".to_string()),
84                ..Default::default()
85            }),
86            "u8" | "u16" | "u32" => Ok(Schema {
87                schema_type: Some("integer".to_string()),
88                format: Some("int32".to_string()),
89                minimum: Some(0.0),
90                ..Default::default()
91            }),
92            "u64" => Ok(Schema {
93                schema_type: Some("integer".to_string()),
94                format: Some("int64".to_string()),
95                minimum: Some(0.0),
96                ..Default::default()
97            }),
98            "f32" => Ok(Schema {
99                schema_type: Some("number".to_string()),
100                format: Some("float".to_string()),
101                ..Default::default()
102            }),
103            "f64" => Ok(Schema {
104                schema_type: Some("number".to_string()),
105                format: Some("double".to_string()),
106                ..Default::default()
107            }),
108
109            // Boolean type
110            "bool" => Ok(Schema {
111                schema_type: Some("boolean".to_string()),
112                ..Default::default()
113            }),
114
115            // UUID type
116            "Uuid" => Ok(Schema {
117                schema_type: Some("string".to_string()),
118                format: Some("uuid".to_string()),
119                ..Default::default()
120            }),
121
122            // DateTime types
123            "DateTime" | "DateTime<Utc>" => Ok(Schema {
124                schema_type: Some("string".to_string()),
125                format: Some("date-time".to_string()),
126                ..Default::default()
127            }),
128            "NaiveDate" => Ok(Schema {
129                schema_type: Some("string".to_string()),
130                format: Some("date".to_string()),
131                ..Default::default()
132            }),
133
134            // Handle generic types
135            type_name if type_name.starts_with("Option<") => {
136                let inner_type = self.extract_generic_type(type_name, "Option")?;
137                let mut schema = self.generate_schema_for_type(&inner_type)?;
138                if self.config.nullable_optional {
139                    schema.nullable = Some(true);
140                }
141                Ok(schema)
142            }
143            type_name if type_name.starts_with("Vec<") => {
144                let inner_type = self.extract_generic_type(type_name, "Vec")?;
145                let items_schema = self.generate_schema_for_type(&inner_type)?;
146                Ok(Schema {
147                    schema_type: Some("array".to_string()),
148                    items: Some(Box::new(items_schema)),
149                    ..Default::default()
150                })
151            }
152            type_name if type_name.starts_with("HashMap<") => {
153                // For simplicity, assume HashMap<String, V>
154                let value_type = self.extract_hashmap_value_type(type_name)?;
155                let value_schema = self.generate_schema_for_type(&value_type)?;
156                Ok(Schema {
157                    schema_type: Some("object".to_string()),
158                    additional_properties: Some(Box::new(value_schema)),
159                    ..Default::default()
160                })
161            }
162
163            // Custom types - create reference
164            _ => Ok(Schema {
165                reference: Some(format!("#/components/schemas/{}", type_name)),
166                ..Default::default()
167            }),
168        }
169    }
170
171    /// Extract generic type parameter (e.g., "T" from "Option<T>")
172    fn extract_generic_type(&self, type_name: &str, wrapper: &str) -> OpenApiResult<String> {
173        let start = wrapper.len() + 1; // +1 for '<'
174        let end = type_name.len() - 1; // -1 for '>'
175
176        if start >= end {
177            return Err(OpenApiError::schema_error(format!(
178                "Invalid generic type: {}",
179                type_name
180            )));
181        }
182
183        Ok(type_name[start..end].to_string())
184    }
185
186    /// Extract value type from HashMap<K, V>
187    fn extract_hashmap_value_type(&self, type_name: &str) -> OpenApiResult<String> {
188        // Simple implementation - assumes HashMap<String, ValueType>
189        let inner = type_name
190            .strip_prefix("HashMap<")
191            .and_then(|s| s.strip_suffix(">"))
192            .ok_or_else(|| {
193                OpenApiError::schema_error(format!("Invalid HashMap type: {}", type_name))
194            })?;
195
196        let parts: Vec<&str> = inner.split(',').collect();
197        if parts.len() != 2 {
198            return Err(OpenApiError::schema_error(format!(
199                "Invalid HashMap type: {}",
200                type_name
201            )));
202        }
203
204        Ok(parts[1].trim().to_string())
205    }
206
207    /// Generate schema for a struct with fields
208    pub fn generate_struct_schema(
209        &mut self,
210        struct_name: &str,
211        fields: &[(String, String, Option<String>)], // (name, type, description)
212    ) -> OpenApiResult<Schema> {
213        let mut properties = HashMap::new();
214        let mut required = Vec::new();
215        let mut dependencies = Vec::new();
216
217        for (field_name, field_type, description) in fields {
218            let mut field_schema = self.generate_schema(field_type)?;
219
220            if let Some(desc) = description {
221                field_schema.description = Some(desc.clone());
222            }
223
224            // Check if field is optional
225            if !field_type.starts_with("Option<") {
226                required.push(field_name.clone());
227            }
228
229            properties.insert(field_name.clone(), field_schema);
230
231            // Track dependencies
232            if !self.is_primitive_type(field_type) {
233                dependencies.push(field_type.clone());
234            }
235        }
236
237        let schema = Schema {
238            schema_type: Some("object".to_string()),
239            properties,
240            required,
241            ..Default::default()
242        };
243
244        self.schemas.insert(struct_name.to_string(), schema.clone());
245        Ok(schema)
246    }
247
248    /// Generate schema for an enum
249    pub fn generate_enum_schema(
250        &mut self,
251        enum_name: &str,
252        variants: &[String],
253    ) -> OpenApiResult<Schema> {
254        let enum_values: Vec<Value> = variants.iter().map(|v| Value::String(v.clone())).collect();
255
256        let schema = Schema {
257            schema_type: Some("string".to_string()),
258            enum_values,
259            ..Default::default()
260        };
261
262        self.schemas.insert(enum_name.to_string(), schema.clone());
263        Ok(schema)
264    }
265
266    /// Check if a type is primitive
267    fn is_primitive_type(&self, type_name: &str) -> bool {
268        matches!(
269            type_name,
270            "String"
271                | "str"
272                | "&str"
273                | "i8"
274                | "i16"
275                | "i32"
276                | "i64"
277                | "u8"
278                | "u16"
279                | "u32"
280                | "u64"
281                | "f32"
282                | "f64"
283                | "bool"
284                | "Uuid"
285                | "DateTime"
286                | "DateTime<Utc>"
287                | "NaiveDate"
288        ) || type_name.starts_with("Option<")
289            || type_name.starts_with("Vec<")
290            || type_name.starts_with("HashMap<")
291    }
292
293    /// Get all generated schemas
294    pub fn get_schemas(&self) -> &HashMap<String, Schema> {
295        &self.schemas
296    }
297
298    /// Clear schema cache
299    pub fn clear_cache(&mut self) {
300        self.schemas.clear();
301    }
302}
303
304impl Default for SchemaConfig {
305    fn default() -> Self {
306        Self {
307            nullable_optional: true,
308            include_examples: true,
309            custom_mappings: HashMap::new(),
310        }
311    }
312}
313
314impl SchemaConfig {
315    /// Create new configuration
316    pub fn new() -> Self {
317        Self::default()
318    }
319
320    /// Set nullable option handling
321    pub fn with_nullable_optional(mut self, nullable: bool) -> Self {
322        self.nullable_optional = nullable;
323        self
324    }
325
326    /// Set example inclusion
327    pub fn with_examples(mut self, include: bool) -> Self {
328        self.include_examples = include;
329        self
330    }
331
332    /// Add custom type mapping
333    pub fn with_custom_mapping(mut self, type_name: &str, schema: Schema) -> Self {
334        self.custom_mappings.insert(type_name.to_string(), schema);
335        self
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    #[test]
344    fn test_primitive_schema_generation() {
345        let mut generator = SchemaGenerator::new(SchemaConfig::default());
346
347        let string_schema = generator.generate_schema("String").unwrap();
348        assert_eq!(string_schema.schema_type, Some("string".to_string()));
349
350        let int_schema = generator.generate_schema("i32").unwrap();
351        assert_eq!(int_schema.schema_type, Some("integer".to_string()));
352        assert_eq!(int_schema.format, Some("int32".to_string()));
353
354        let bool_schema = generator.generate_schema("bool").unwrap();
355        assert_eq!(bool_schema.schema_type, Some("boolean".to_string()));
356    }
357
358    #[test]
359    fn test_optional_schema_generation() {
360        let mut generator = SchemaGenerator::new(SchemaConfig::default());
361
362        let optional_string_schema = generator.generate_schema("Option<String>").unwrap();
363        assert_eq!(
364            optional_string_schema.schema_type,
365            Some("string".to_string())
366        );
367        assert_eq!(optional_string_schema.nullable, Some(true));
368    }
369
370    #[test]
371    fn test_array_schema_generation() {
372        let mut generator = SchemaGenerator::new(SchemaConfig::default());
373
374        let array_schema = generator.generate_schema("Vec<String>").unwrap();
375        assert_eq!(array_schema.schema_type, Some("array".to_string()));
376        assert!(array_schema.items.is_some());
377
378        let items = array_schema.items.unwrap();
379        assert_eq!(items.schema_type, Some("string".to_string()));
380    }
381
382    #[test]
383    fn test_struct_schema_generation() {
384        let mut generator = SchemaGenerator::new(SchemaConfig::default());
385
386        let fields = vec![
387            ("id".to_string(), "i32".to_string(), None),
388            (
389                "name".to_string(),
390                "String".to_string(),
391                Some("User name".to_string()),
392            ),
393            ("email".to_string(), "Option<String>".to_string(), None),
394        ];
395
396        let schema = generator.generate_struct_schema("User", &fields).unwrap();
397        assert_eq!(schema.schema_type, Some("object".to_string()));
398        assert_eq!(schema.properties.len(), 3);
399        assert_eq!(schema.required.len(), 2); // id and name are required
400        assert!(schema.properties.contains_key("id"));
401        assert!(schema.properties.contains_key("name"));
402        assert!(schema.properties.contains_key("email"));
403    }
404
405    #[test]
406    fn test_tuple_schema_representation() {
407        // Test that tuples are represented correctly for OpenAPI 3.0
408        // This test ensures we don't use oneOf incorrectly for tuples
409
410        // Create a mock tuple schema similar to what the derive macro should generate
411        let tuple_schema = crate::specification::Schema {
412            schema_type: Some("array".to_string()),
413            title: Some("TestTuple".to_string()),
414            description: Some("A tuple with 2 fields in fixed order: (String, i32). Note: OpenAPI 3.0 cannot precisely represent tuple types - this is a generic array representation.".to_string()),
415            items: Some(Box::new(crate::specification::Schema {
416                description: Some("Tuple element (type varies by position)".to_string()),
417                ..Default::default()
418            })),
419            ..Default::default()
420        };
421
422        // Verify the schema is structured correctly
423        assert_eq!(tuple_schema.schema_type, Some("array".to_string()));
424        assert!(tuple_schema.description.is_some());
425        assert!(tuple_schema
426            .description
427            .as_ref()
428            .unwrap()
429            .contains("fixed order"));
430        assert!(tuple_schema
431            .description
432            .as_ref()
433            .unwrap()
434            .contains("OpenAPI 3.0 cannot precisely represent"));
435
436        // Verify items doesn't use oneOf (which would be incorrect)
437        assert!(tuple_schema.items.is_some());
438        let items = tuple_schema.items.as_ref().unwrap();
439        assert!(items.one_of.is_empty()); // Should NOT use oneOf for tuples
440    }
441}