ddex_builder/schema/
types.rs

1//! Type generation for TypeScript and Python from JSON Schema
2
3use super::*;
4use indexmap::IndexSet;
5
6impl SchemaGenerator {
7    /// Convert JSON Schema to TypeScript interface definition
8    pub(crate) fn schema_to_typescript(
9        &self,
10        name: &str,
11        schema: &JsonSchema,
12    ) -> Result<String, BuildError> {
13        let mut output = String::new();
14        let mut imports = IndexSet::new();
15
16        // Add JSDoc comment if description exists
17        if let Some(ref description) = schema.description {
18            output.push_str(&format!("/**\n * {}\n */\n", description));
19        }
20
21        match schema.schema_type.as_deref() {
22            Some("object") => {
23                output.push_str(&format!("export interface {} {{\n", name));
24
25                if let Some(ref properties) = schema.properties {
26                    let required = schema
27                        .required
28                        .as_ref()
29                        .map(|r| r.iter().collect::<IndexSet<_>>())
30                        .unwrap_or_default();
31
32                    for (prop_name, prop_schema) in properties {
33                        let optional = if required.contains(prop_name) {
34                            ""
35                        } else {
36                            "?"
37                        };
38                        let ts_type = self.schema_to_typescript_type(prop_schema, &mut imports)?;
39
40                        // Add property documentation
41                        if let Some(ref description) = prop_schema.description {
42                            output.push_str(&format!("  /** {} */\n", description));
43                        }
44
45                        output.push_str(&format!("  {}{}: {};\n", prop_name, optional, ts_type));
46                    }
47                }
48
49                output.push_str("}\n");
50            }
51            Some("array") => {
52                if let Some(ref items) = schema.items {
53                    let item_type = self.schema_to_typescript_type(items, &mut imports)?;
54                    output.push_str(&format!("export type {} = {}[];\n", name, item_type));
55                } else {
56                    output.push_str(&format!("export type {} = any[];\n", name));
57                }
58            }
59            _ => {
60                if let Some(ref enum_values) = schema.enum_values {
61                    let enum_variants: Vec<String> = enum_values
62                        .iter()
63                        .map(|v| match v {
64                            JsonValue::String(s) => format!("\"{}\"", s),
65                            JsonValue::Number(n) => n.to_string(),
66                            JsonValue::Bool(b) => b.to_string(),
67                            _ => "unknown".to_string(),
68                        })
69                        .collect();
70
71                    output.push_str(&format!(
72                        "export type {} = {};\n",
73                        name,
74                        enum_variants.join(" | ")
75                    ));
76                } else {
77                    let ts_type = self.schema_to_typescript_type(schema, &mut imports)?;
78                    output.push_str(&format!("export type {} = {};\n", name, ts_type));
79                }
80            }
81        }
82
83        // Add imports at the beginning if needed
84        if !imports.is_empty() {
85            let import_statements: Vec<String> = imports
86                .into_iter()
87                .map(|import| format!("import {{ {} }} from './types';", import))
88                .collect();
89            output = format!("{}\n\n{}", import_statements.join("\n"), output);
90        }
91
92        Ok(output)
93    }
94
95    /// Convert JSON Schema to TypeScript type string
96    fn schema_to_typescript_type(
97        &self,
98        schema: &JsonSchema,
99        imports: &mut IndexSet<String>,
100    ) -> Result<String, BuildError> {
101        // Handle references
102        if let Some(ref reference) = schema.reference {
103            if reference.starts_with("#/$defs/") {
104                let type_name = &reference[8..];
105                imports.insert(type_name.to_string());
106                return Ok(type_name.to_string());
107            }
108        }
109
110        // Handle type unions (anyOf, oneOf)
111        if let Some(ref any_of) = schema.any_of {
112            let union_types: Result<Vec<String>, BuildError> = any_of
113                .iter()
114                .map(|s| self.schema_to_typescript_type(s, imports))
115                .collect();
116            return Ok(format!("({})", union_types?.join(" | ")));
117        }
118
119        if let Some(ref one_of) = schema.one_of {
120            let union_types: Result<Vec<String>, BuildError> = one_of
121                .iter()
122                .map(|s| self.schema_to_typescript_type(s, imports))
123                .collect();
124            return Ok(format!("({})", union_types?.join(" | ")));
125        }
126
127        // Handle enum values
128        if let Some(ref enum_values) = schema.enum_values {
129            let variants: Vec<String> = enum_values
130                .iter()
131                .map(|v| match v {
132                    JsonValue::String(s) => format!("\"{}\"", s),
133                    JsonValue::Number(n) => n.to_string(),
134                    JsonValue::Bool(b) => b.to_string(),
135                    _ => "unknown".to_string(),
136                })
137                .collect();
138            return Ok(variants.join(" | "));
139        }
140
141        // Handle basic types
142        match schema.schema_type.as_deref() {
143            Some("string") => {
144                // Check for specific string formats
145                match schema.format.as_deref() {
146                    Some("date") => Ok("string /* date: YYYY-MM-DD */".to_string()),
147                    Some("date-time") => Ok("string /* date-time: ISO 8601 */".to_string()),
148                    Some("uri") => Ok("string /* URI */".to_string()),
149                    _ => {
150                        // Add pattern information if available
151                        if let Some(ref pattern) = schema.pattern {
152                            Ok(format!("string /* pattern: {} */", pattern))
153                        } else {
154                            Ok("string".to_string())
155                        }
156                    }
157                }
158            }
159            Some("number") => Ok("number".to_string()),
160            Some("integer") => Ok("number".to_string()),
161            Some("boolean") => Ok("boolean".to_string()),
162            Some("null") => Ok("null".to_string()),
163            Some("array") => {
164                if let Some(ref items) = schema.items {
165                    let item_type = self.schema_to_typescript_type(items, imports)?;
166                    Ok(format!("{}[]", item_type))
167                } else {
168                    Ok("any[]".to_string())
169                }
170            }
171            Some("object") => {
172                if let Some(ref properties) = schema.properties {
173                    let mut object_type = String::from("{\n");
174                    let required = schema
175                        .required
176                        .as_ref()
177                        .map(|r| r.iter().collect::<IndexSet<_>>())
178                        .unwrap_or_default();
179
180                    for (prop_name, prop_schema) in properties {
181                        let optional = if required.contains(prop_name) {
182                            ""
183                        } else {
184                            "?"
185                        };
186                        let prop_type = self.schema_to_typescript_type(prop_schema, imports)?;
187                        object_type
188                            .push_str(&format!("    {}{}: {};\n", prop_name, optional, prop_type));
189                    }
190
191                    object_type.push_str("  }");
192                    Ok(object_type)
193                } else {
194                    Ok("Record<string, any>".to_string())
195                }
196            }
197            _ => Ok("any".to_string()),
198        }
199    }
200
201    /// Convert JSON Schema to Python TypedDict definition
202    pub(crate) fn schema_to_python(
203        &self,
204        name: &str,
205        schema: &JsonSchema,
206    ) -> Result<String, BuildError> {
207        let mut output = String::new();
208        let mut imports = IndexSet::new();
209
210        // Add docstring if description exists
211        if let Some(ref description) = schema.description {
212            output.push_str(&format!("\"\"\"{}.\"\"\"\n", description));
213        }
214
215        match schema.schema_type.as_deref() {
216            Some("object") => {
217                // Determine if we need total=False for optional fields
218                let required = schema
219                    .required
220                    .as_ref()
221                    .map(|r| r.iter().collect::<IndexSet<_>>())
222                    .unwrap_or_default();
223                let has_optional = schema
224                    .properties
225                    .as_ref()
226                    .map(|props| props.keys().any(|k| !required.contains(k)))
227                    .unwrap_or(false);
228
229                let total_param = if has_optional { ", total=False" } else { "" };
230                output.push_str(&format!("class {}(TypedDict{}):\n", name, total_param));
231
232                if let Some(ref properties) = schema.properties {
233                    for (prop_name, prop_schema) in properties {
234                        let python_type = self.schema_to_python_type(prop_schema, &mut imports)?;
235                        let field_type = if required.contains(prop_name) || !has_optional {
236                            python_type
237                        } else {
238                            format!("Optional[{}]", python_type)
239                        };
240
241                        // Add field documentation
242                        if let Some(ref description) = prop_schema.description {
243                            output.push_str(&format!("    # {}\n", description));
244                        }
245
246                        output.push_str(&format!("    {}: {}\n", prop_name, field_type));
247                    }
248
249                    if properties.is_empty() {
250                        output.push_str("    pass\n");
251                    }
252                } else {
253                    output.push_str("    pass\n");
254                }
255            }
256            Some("array") => {
257                if let Some(ref items) = schema.items {
258                    let item_type = self.schema_to_python_type(items, &mut imports)?;
259                    output.push_str(&format!("{} = List[{}]\n", name, item_type));
260                } else {
261                    output.push_str(&format!("{} = List[Any]\n", name));
262                }
263            }
264            _ => {
265                if let Some(ref enum_values) = schema.enum_values {
266                    // Create a Literal type for enum values
267                    let enum_variants: Vec<String> = enum_values
268                        .iter()
269                        .map(|v| match v {
270                            JsonValue::String(s) => format!("\"{}\"", s),
271                            JsonValue::Number(n) => n.to_string(),
272                            JsonValue::Bool(b) => b.to_string(),
273                            _ => "None".to_string(),
274                        })
275                        .collect();
276
277                    output.push_str(&format!(
278                        "{} = Literal[{}]\n",
279                        name,
280                        enum_variants.join(", ")
281                    ));
282                } else {
283                    let python_type = self.schema_to_python_type(schema, &mut imports)?;
284                    output.push_str(&format!("{} = {}\n", name, python_type));
285                }
286            }
287        }
288
289        Ok(output)
290    }
291
292    /// Convert JSON Schema to Python type string
293    fn schema_to_python_type(
294        &self,
295        schema: &JsonSchema,
296        imports: &mut IndexSet<String>,
297    ) -> Result<String, BuildError> {
298        // Handle references
299        if let Some(ref reference) = schema.reference {
300            if reference.starts_with("#/$defs/") {
301                let type_name = &reference[8..];
302                return Ok(type_name.to_string());
303            }
304        }
305
306        // Handle type unions
307        if let Some(ref any_of) = schema.any_of {
308            let union_types: Result<Vec<String>, BuildError> = any_of
309                .iter()
310                .map(|s| self.schema_to_python_type(s, imports))
311                .collect();
312            return Ok(format!("Union[{}]", union_types?.join(", ")));
313        }
314
315        if let Some(ref one_of) = schema.one_of {
316            let union_types: Result<Vec<String>, BuildError> = one_of
317                .iter()
318                .map(|s| self.schema_to_python_type(s, imports))
319                .collect();
320            return Ok(format!("Union[{}]", union_types?.join(", ")));
321        }
322
323        // Handle enum values
324        if let Some(ref enum_values) = schema.enum_values {
325            let variants: Vec<String> = enum_values
326                .iter()
327                .map(|v| match v {
328                    JsonValue::String(s) => format!("\"{}\"", s),
329                    JsonValue::Number(n) => n.to_string(),
330                    JsonValue::Bool(b) => b.to_string(),
331                    _ => "None".to_string(),
332                })
333                .collect();
334            return Ok(format!("Literal[{}]", variants.join(", ")));
335        }
336
337        // Handle basic types
338        match schema.schema_type.as_deref() {
339            Some("string") => match schema.format.as_deref() {
340                Some("date") => Ok("str  # date: YYYY-MM-DD".to_string()),
341                Some("date-time") => Ok("datetime  # ISO 8601 datetime".to_string()),
342                Some("uri") => Ok("str  # URI".to_string()),
343                _ => Ok("str".to_string()),
344            },
345            Some("number") => Ok("float".to_string()),
346            Some("integer") => Ok("int".to_string()),
347            Some("boolean") => Ok("bool".to_string()),
348            Some("null") => Ok("None".to_string()),
349            Some("array") => {
350                if let Some(ref items) = schema.items {
351                    let item_type = self.schema_to_python_type(items, imports)?;
352                    Ok(format!("List[{}]", item_type))
353                } else {
354                    Ok("List[Any]".to_string())
355                }
356            }
357            Some("object") => {
358                if schema.properties.is_some() {
359                    // For inline objects, use Dict with type hints if possible
360                    Ok("Dict[str, Any]".to_string())
361                } else {
362                    Ok("Dict[str, Any]".to_string())
363                }
364            }
365            _ => Ok("Any".to_string()),
366        }
367    }
368
369    /// Generate OpenAPI specification from schema
370    pub fn generate_openapi_spec(&self, schema: &JsonSchema) -> Result<String, BuildError> {
371        let mut openapi = serde_json::json!({
372            "openapi": "3.0.3",
373            "info": {
374                "title": format!("DDEX Builder API - ERN {}", self.version_string()),
375                "description": format!("REST API for DDEX Builder with {} profile", self.profile_string()),
376                "version": "1.0.0"
377            },
378            "servers": [{
379                "url": "https://api.ddex-builder.example.com",
380                "description": "DDEX Builder API Server"
381            }],
382            "paths": {
383                "/build": {
384                    "post": {
385                        "summary": "Build DDEX message",
386                        "description": "Create a DDEX XML message from structured data",
387                        "requestBody": {
388                            "required": true,
389                            "content": {
390                                "application/json": {
391                                    "schema": {
392                                        "$ref": "#/components/schemas/BuildRequest"
393                                    }
394                                }
395                            }
396                        },
397                        "responses": {
398                            "200": {
399                                "description": "Successfully generated DDEX XML",
400                                "content": {
401                                    "application/xml": {
402                                        "schema": {
403                                            "type": "string"
404                                        }
405                                    }
406                                }
407                            },
408                            "400": {
409                                "description": "Invalid request data",
410                                "content": {
411                                    "application/json": {
412                                        "schema": {
413                                            "$ref": "#/components/schemas/Error"
414                                        }
415                                    }
416                                }
417                            }
418                        }
419                    }
420                },
421                "/validate": {
422                    "post": {
423                        "summary": "Validate DDEX data",
424                        "description": "Validate structured data against DDEX schema",
425                        "requestBody": {
426                            "required": true,
427                            "content": {
428                                "application/json": {
429                                    "schema": {
430                                        "$ref": "#/components/schemas/BuildRequest"
431                                    }
432                                }
433                            }
434                        },
435                        "responses": {
436                            "200": {
437                                "description": "Validation result",
438                                "content": {
439                                    "application/json": {
440                                        "schema": {
441                                            "$ref": "#/components/schemas/ValidationResult"
442                                        }
443                                    }
444                                }
445                            }
446                        }
447                    }
448                },
449                "/schema": {
450                    "get": {
451                        "summary": "Get JSON Schema",
452                        "description": "Retrieve the current JSON Schema for validation",
453                        "parameters": [{
454                            "name": "version",
455                            "in": "query",
456                            "description": "DDEX version",
457                            "schema": {
458                                "type": "string",
459                                "enum": ["4.1", "4.2", "4.3"]
460                            }
461                        }, {
462                            "name": "profile",
463                            "in": "query",
464                            "description": "Message profile",
465                            "schema": {
466                                "type": "string",
467                                "enum": ["AudioAlbum", "AudioSingle", "VideoAlbum", "VideoSingle", "Mixed"]
468                            }
469                        }],
470                        "responses": {
471                            "200": {
472                                "description": "JSON Schema",
473                                "content": {
474                                    "application/json": {
475                                        "schema": {
476                                            "type": "object"
477                                        }
478                                    }
479                                }
480                            }
481                        }
482                    }
483                }
484            },
485            "components": {
486                "schemas": {}
487            }
488        });
489
490        // Add schema definitions to components
491        if let Some(ref definitions) = schema.definitions {
492            let mut components_schemas = serde_json::Map::new();
493
494            for (name, def_schema) in definitions {
495                // Convert our JsonSchema to OpenAPI schema format
496                let openapi_schema = self.convert_to_openapi_schema(def_schema)?;
497                components_schemas.insert(name.clone(), openapi_schema);
498            }
499
500            // Add standard error schema
501            components_schemas.insert(
502                "Error".to_string(),
503                json!({
504                    "type": "object",
505                    "required": ["code", "message"],
506                    "properties": {
507                        "code": {
508                            "type": "string",
509                            "description": "Error code"
510                        },
511                        "message": {
512                            "type": "string",
513                            "description": "Error message"
514                        },
515                        "field": {
516                            "type": "string",
517                            "description": "Field that caused the error"
518                        }
519                    }
520                }),
521            );
522
523            // Add validation result schema
524            components_schemas.insert(
525                "ValidationResult".to_string(),
526                json!({
527                    "type": "object",
528                    "required": ["valid", "errors", "warnings"],
529                    "properties": {
530                        "valid": {
531                            "type": "boolean",
532                            "description": "Whether validation passed"
533                        },
534                        "errors": {
535                            "type": "array",
536                            "items": {"$ref": "#/components/schemas/Error"},
537                            "description": "Validation errors"
538                        },
539                        "warnings": {
540                            "type": "array",
541                            "items": {"$ref": "#/components/schemas/Error"},
542                            "description": "Validation warnings"
543                        }
544                    }
545                }),
546            );
547
548            openapi["components"]["schemas"] = JsonValue::Object(components_schemas);
549        }
550
551        serde_json::to_string_pretty(&openapi).map_err(|e| BuildError::InvalidFormat {
552            field: "openapi".to_string(),
553            message: format!("Failed to serialize OpenAPI spec: {}", e),
554        })
555    }
556
557    /// Convert our JsonSchema format to OpenAPI 3.0 schema format
558    fn convert_to_openapi_schema(&self, schema: &JsonSchema) -> Result<JsonValue, BuildError> {
559        let mut openapi_schema = serde_json::Map::new();
560
561        if let Some(ref title) = schema.title {
562            openapi_schema.insert("title".to_string(), JsonValue::String(title.clone()));
563        }
564
565        if let Some(ref description) = schema.description {
566            openapi_schema.insert(
567                "description".to_string(),
568                JsonValue::String(description.clone()),
569            );
570        }
571
572        if let Some(ref schema_type) = schema.schema_type {
573            openapi_schema.insert("type".to_string(), JsonValue::String(schema_type.clone()));
574        }
575
576        if let Some(ref properties) = schema.properties {
577            let mut openapi_properties = serde_json::Map::new();
578            for (name, prop_schema) in properties {
579                openapi_properties
580                    .insert(name.clone(), self.convert_to_openapi_schema(prop_schema)?);
581            }
582            openapi_schema.insert(
583                "properties".to_string(),
584                JsonValue::Object(openapi_properties),
585            );
586        }
587
588        if let Some(ref required) = schema.required {
589            let required_array: Vec<JsonValue> = required
590                .iter()
591                .map(|r| JsonValue::String(r.clone()))
592                .collect();
593            openapi_schema.insert("required".to_string(), JsonValue::Array(required_array));
594        }
595
596        if let Some(additional_properties) = schema.additional_properties {
597            openapi_schema.insert(
598                "additionalProperties".to_string(),
599                JsonValue::Bool(additional_properties),
600            );
601        }
602
603        if let Some(ref items) = schema.items {
604            openapi_schema.insert("items".to_string(), self.convert_to_openapi_schema(items)?);
605        }
606
607        if let Some(ref enum_values) = schema.enum_values {
608            openapi_schema.insert("enum".to_string(), JsonValue::Array(enum_values.clone()));
609        }
610
611        if let Some(ref pattern) = schema.pattern {
612            openapi_schema.insert("pattern".to_string(), JsonValue::String(pattern.clone()));
613        }
614
615        if let Some(ref format) = schema.format {
616            openapi_schema.insert("format".to_string(), JsonValue::String(format.clone()));
617        }
618
619        if let Some(min_length) = schema.min_length {
620            openapi_schema.insert(
621                "minLength".to_string(),
622                JsonValue::Number(min_length.into()),
623            );
624        }
625
626        if let Some(max_length) = schema.max_length {
627            openapi_schema.insert(
628                "maxLength".to_string(),
629                JsonValue::Number(max_length.into()),
630            );
631        }
632
633        if let Some(ref examples) = schema.examples {
634            openapi_schema.insert("examples".to_string(), JsonValue::Array(examples.clone()));
635        }
636
637        if let Some(ref reference) = schema.reference {
638            openapi_schema.insert("$ref".to_string(), JsonValue::String(reference.clone()));
639        }
640
641        Ok(JsonValue::Object(openapi_schema))
642    }
643}