spikard_cli/codegen/
ts_schema.rs1use anyhow::Result;
2use heck::ToPascalCase;
3use serde_json::Value;
4
5#[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
14pub 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
33fn 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
95fn 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
233pub 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}