Skip to main content

spikard_cli/codegen/
ts_schema.rs

1use anyhow::Result;
2use heck::ToPascalCase;
3use serde_json::Value;
4
5/// TypeScript + Zod fragments for a message DTO.
6#[derive(Debug, Clone)]
7pub struct TypeScriptDto {
8    pub schema_ident: String,
9    pub type_ident: String,
10    pub schema_declaration: String,
11    pub type_declaration: String,
12}
13
14/// Generate TypeScript + Zod declarations for a JSON Schema payload.
15pub fn generate_typescript_dto(message_name: &str, schema: &Value) -> Result<TypeScriptDto> {
16    let type_ident = format!("{}Message", camel_case(message_name));
17    let schema_ident = format!("{type_ident}Schema");
18
19    let zod_expr = schema_to_zod(schema, false);
20    let ts_type = schema_to_typescript(schema, false);
21
22    let schema_declaration = format!("const {schema_ident} = {zod_expr};\n");
23    let type_declaration = format!("type {type_ident} = {ts_type};\n");
24
25    Ok(TypeScriptDto {
26        schema_ident,
27        type_ident,
28        schema_declaration,
29        type_declaration,
30    })
31}
32
33/// Convert a JSON Schema to a TypeScript type expression.
34fn schema_to_typescript(schema: &Value, optional: bool) -> String {
35    let mut base = match detect_type(schema) {
36        Some("string") => "string".to_string(),
37        Some("number" | "integer") => "number".to_string(),
38        Some("boolean") => "boolean".to_string(),
39        Some("array") => {
40            if let Some(items) = schema.get("items") {
41                format!("{}[]", schema_to_typescript(items, false))
42            } else {
43                "unknown[]".to_string()
44            }
45        }
46        Some("object") => object_to_typescript(schema),
47        _ => {
48            if let Some(Value::Array(variants)) = schema.get("enum")
49                && !variants.is_empty()
50            {
51                return variants.iter().map(literal_type).collect::<Vec<_>>().join(" | ");
52            }
53            if let Some(constant) = schema.get("const") {
54                return literal_type(constant);
55            }
56            "Record<string, unknown>".to_string()
57        }
58    };
59
60    if optional {
61        base.push_str(" | undefined");
62    }
63
64    base
65}
66
67fn object_to_typescript(schema: &Value) -> String {
68    if let Some(additional) = schema.get("additionalProperties") {
69        if additional == &Value::Bool(true) {
70            return "Record<string, unknown>".to_string();
71        }
72        if let Value::Object(_) = additional {
73            return format!("Record<string, {}>", schema_to_typescript(additional, false));
74        }
75    }
76
77    let mut fields = Vec::new();
78    let required = required_set(schema);
79
80    if let Some(props) = schema.get("properties").and_then(|v| v.as_object()) {
81        for (name, subschema) in props {
82            let optional = !required.contains(name);
83            let ts_type = schema_to_typescript(subschema, optional);
84            fields.push(format!("  {}: {};", format_property(name), ts_type));
85        }
86    }
87
88    if fields.is_empty() {
89        "Record<string, unknown>".to_string()
90    } else {
91        format!("{{\n{}\n}}", fields.join("\n"))
92    }
93}
94
95/// Convert a JSON Schema to a Zod expression.
96fn schema_to_zod(schema: &Value, optional: bool) -> String {
97    let mut base = match detect_type(schema) {
98        Some("string") => {
99            if let Some(enum_values) = schema.get("enum") {
100                enum_literal(enum_values)
101            } else if let Some(constant) = schema.get("const") {
102                format!("z.literal({})", literal_value(constant))
103            } else {
104                "z.string()".to_string()
105            }
106        }
107        Some("number" | "integer") => "z.number()".to_string(),
108        Some("boolean") => "z.boolean()".to_string(),
109        Some("array") => {
110            if let Some(items) = schema.get("items") {
111                format!("z.array({})", schema_to_zod(items, false))
112            } else {
113                "z.array(z.unknown())".to_string()
114            }
115        }
116        Some("object") => object_to_zod(schema),
117        _ => {
118            if let Some(enum_values) = schema.get("enum")
119                && enum_values.is_array()
120            {
121                return enum_literal(enum_values);
122            }
123            if let Some(constant) = schema.get("const") {
124                return format!("z.literal({})", literal_value(constant));
125            }
126            "z.record(z.string(), z.unknown())".to_string()
127        }
128    };
129
130    if schema
131        .get("nullable")
132        .and_then(serde_json::Value::as_bool)
133        .unwrap_or(false)
134    {
135        base.push_str(".nullable()");
136    }
137
138    if optional {
139        base.push_str(".optional()");
140    }
141
142    base
143}
144
145fn object_to_zod(schema: &Value) -> String {
146    if matches!(schema.get("additionalProperties"), Some(Value::Bool(true))) {
147        return "z.record(z.string(), z.unknown())".to_string();
148    }
149
150    let mut fields = Vec::new();
151    let required = required_set(schema);
152
153    if let Some(props) = schema.get("properties").and_then(|v| v.as_object()) {
154        for (name, subschema) in props {
155            let optional = !required.contains(name);
156            let expr = schema_to_zod(subschema, optional);
157            fields.push(format!("  {}: {},", format_property(name), expr));
158        }
159    }
160
161    if fields.is_empty() {
162        "z.record(z.string(), z.unknown())".to_string()
163    } else {
164        format!("z.object({{\n{}\n}})", fields.join("\n"))
165    }
166}
167
168fn detect_type(schema: &Value) -> Option<&str> {
169    match schema.get("type") {
170        Some(Value::String(single)) => Some(single.as_str()),
171        Some(Value::Array(types)) => types.iter().filter_map(|value| value.as_str()).find(|ty| *ty != "null"),
172        _ => {
173            if schema.get("properties").is_some() {
174                Some("object")
175            } else if schema.get("items").is_some() {
176                Some("array")
177            } else {
178                None
179            }
180        }
181    }
182}
183
184fn required_set(schema: &Value) -> std::collections::HashSet<String> {
185    schema
186        .get("required")
187        .and_then(|v| v.as_array())
188        .map(|values| {
189            values
190                .iter()
191                .filter_map(|value| value.as_str().map(std::string::ToString::to_string))
192                .collect()
193        })
194        .unwrap_or_default()
195}
196
197fn enum_literal(values: &Value) -> String {
198    let mut literals = Vec::new();
199    if let Some(arr) = values.as_array() {
200        for value in arr {
201            literals.push(format!("z.literal({})", literal_value(value)));
202        }
203    }
204    if literals.is_empty() {
205        "z.unknown()".to_string()
206    } else if literals.len() == 1 {
207        literals.remove(0)
208    } else {
209        format!("z.union([{}])", literals.join(", "))
210    }
211}
212
213fn literal_value(value: &Value) -> String {
214    match value {
215        Value::String(s) => format!("{s:?}"),
216        Value::Number(num) => num.to_string(),
217        Value::Bool(b) => b.to_string(),
218        Value::Null => "null".to_string(),
219        other => serde_json::to_string(other).unwrap_or_else(|_| "null".to_string()),
220    }
221}
222
223fn literal_type(value: &Value) -> String {
224    match value {
225        Value::String(s) => format!("{s:?}"),
226        Value::Number(num) => num.to_string(),
227        Value::Bool(b) => b.to_string(),
228        Value::Null => "null".to_string(),
229        _ => "unknown".to_string(),
230    }
231}
232
233/// Convert a JSON value to a TypeScript literal expression.
234pub fn json_value_to_ts_literal(value: &Value) -> String {
235    match value {
236        Value::Object(map) => {
237            if map.is_empty() {
238                "{}".to_string()
239            } else {
240                let mut parts = Vec::new();
241                for (key, val) in map {
242                    parts.push(format!("{}: {}", format_property(key), json_value_to_ts_literal(val)));
243                }
244                format!("{{ {} }}", parts.join(", "))
245            }
246        }
247        Value::Array(items) => {
248            if items.is_empty() {
249                "[]".to_string()
250            } else {
251                let inner = items
252                    .iter()
253                    .map(json_value_to_ts_literal)
254                    .collect::<Vec<_>>()
255                    .join(", ");
256                format!("[{inner}]")
257            }
258        }
259        Value::String(s) => format!("{s:?}"),
260        Value::Number(num) => num.to_string(),
261        Value::Bool(b) => b.to_string(),
262        Value::Null => "null".to_string(),
263    }
264}
265
266fn camel_case(name: &str) -> String {
267    let converted = name
268        .chars()
269        .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { ' ' })
270        .collect::<String>()
271        .to_pascal_case();
272    if converted.is_empty() {
273        "Message".to_string()
274    } else {
275        converted
276    }
277}
278
279fn format_property(name: &str) -> String {
280    let valid_ident = name.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_');
281    if valid_ident {
282        name.to_string()
283    } else {
284        format!("{name:?}")
285    }
286}