1use std::collections::BTreeSet;
2
3use serde_json::Value;
4
5use crate::ConnectorDescription;
6
7pub fn sanitize_identifier(value: &str) -> String {
9 let mut result = String::new();
10 for (index, ch) in value.chars().enumerate() {
11 if (index == 0 && !(ch == '_' || ch == '$' || ch.is_ascii_alphabetic()))
12 || (index > 0 && !(ch == '_' || ch == '$' || ch.is_ascii_alphanumeric()))
13 {
14 result.push('_');
15 } else {
16 result.push(ch);
17 }
18 }
19 if result.is_empty() {
20 "_".to_string()
21 } else {
22 result
23 }
24}
25
26pub fn json_schema_to_type(schema: &Value) -> String {
28 convert(schema, schema, 0, &mut BTreeSet::new())
29}
30
31pub fn generate_types(description: &ConnectorDescription) -> String {
33 let mut result = String::new();
34 if let Some(instructions) = &description.instructions {
35 result.push_str(instructions);
36 result.push_str("\n\n");
37 }
38 for tool in &description.tools {
39 let type_name = pascal(&tool.name);
40 result.push_str(&format!(
41 "type {type_name}Input = {};\n",
42 json_schema_to_type(&tool.input_schema)
43 ));
44 if let Some(output) = &tool.output_schema {
45 result.push_str(&format!(
46 "type {type_name}Output = {};\n",
47 json_schema_to_type(output)
48 ));
49 }
50 }
51 result.push_str(&format!(
52 "declare const {}: {{\n",
53 sanitize_identifier(&description.name)
54 ));
55 for tool in &description.tools {
56 if let Some(doc) = &tool.description {
57 result.push_str(&format!(" /** {} */\n", escape_doc(doc)));
58 }
59 let type_name = pascal(&tool.name);
60 let output = tool
61 .output_schema
62 .as_ref()
63 .map(|_| format!("{type_name}Output"))
64 .unwrap_or_else(|| "unknown".to_string());
65 result.push_str(&format!(
66 " {}(args: {type_name}Input): Promise<{output}>;\n",
67 quote_property(&tool.name)
68 ));
69 }
70 result.push_str("};");
71 result
72}
73
74fn convert(schema: &Value, root: &Value, depth: usize, seen: &mut BTreeSet<String>) -> String {
75 if depth >= 20 {
76 return "unknown".to_string();
77 }
78 if let Some(reference) = schema.get("$ref").and_then(Value::as_str) {
79 if !seen.insert(reference.to_string()) {
80 return "unknown".to_string();
81 }
82 let resolved = resolve_ref(root, reference)
83 .map(|value| convert(value, root, depth + 1, seen))
84 .unwrap_or_else(|| "unknown".to_string());
85 seen.remove(reference);
86 return nullable(resolved, schema);
87 }
88 for (key, separator) in [("anyOf", " | "), ("oneOf", " | "), ("allOf", " & ")] {
89 if let Some(values) = schema.get(key).and_then(Value::as_array) {
90 let value = values
91 .iter()
92 .map(|value| convert(value, root, depth + 1, seen))
93 .collect::<Vec<_>>()
94 .join(separator);
95 return nullable(value, schema);
96 }
97 }
98 if let Some(values) = schema.get("enum").and_then(Value::as_array) {
99 return nullable(
100 values.iter().map(literal).collect::<Vec<_>>().join(" | "),
101 schema,
102 );
103 }
104 if let Some(value) = schema.get("const") {
105 return nullable(literal(value), schema);
106 }
107 let value = match schema.get("type") {
108 Some(Value::Array(types)) => types
109 .iter()
110 .map(|value| primitive(value.as_str().unwrap_or_default()))
111 .collect::<Vec<_>>()
112 .join(" | "),
113 Some(Value::String(kind)) if kind == "array" => {
114 if let Some(items) = schema.get("prefixItems").and_then(Value::as_array) {
115 format!(
116 "[{}]",
117 items
118 .iter()
119 .map(|item| convert(item, root, depth + 1, seen))
120 .collect::<Vec<_>>()
121 .join(", ")
122 )
123 } else if let Some(items) = schema.get("items").and_then(Value::as_array) {
124 format!(
125 "[{}]",
126 items
127 .iter()
128 .map(|item| convert(item, root, depth + 1, seen))
129 .collect::<Vec<_>>()
130 .join(", ")
131 )
132 } else {
133 let item = schema
134 .get("items")
135 .map(|item| convert(item, root, depth + 1, seen))
136 .unwrap_or_else(|| "unknown".to_string());
137 format!("({item})[]")
138 }
139 }
140 Some(Value::String(kind)) if kind == "object" || schema.get("properties").is_some() => {
141 object_type(schema, root, depth, seen)
142 }
143 Some(Value::String(kind)) => primitive(kind).to_string(),
144 _ => "unknown".to_string(),
145 };
146 nullable(value, schema)
147}
148
149fn object_type(schema: &Value, root: &Value, depth: usize, seen: &mut BTreeSet<String>) -> String {
150 let required = schema
151 .get("required")
152 .and_then(Value::as_array)
153 .into_iter()
154 .flatten()
155 .filter_map(Value::as_str)
156 .collect::<BTreeSet<_>>();
157 let mut fields = Vec::new();
158 if let Some(properties) = schema.get("properties").and_then(Value::as_object) {
159 for (name, value) in properties {
160 let optional = if required.contains(name.as_str()) {
161 ""
162 } else {
163 "?"
164 };
165 fields.push(format!(
166 " {}{optional}: {};",
167 quote_property(name),
168 convert(value, root, depth + 1, seen)
169 ));
170 }
171 }
172 if let Some(additional) = schema.get("additionalProperties") {
173 match additional {
174 Value::Bool(true) => fields.push(" [key: string]: unknown;".to_string()),
175 Value::Object(_) => fields.push(format!(
176 " [key: string]: {};",
177 convert(additional, root, depth + 1, seen)
178 )),
179 _ => {}
180 }
181 }
182 if fields.is_empty() {
183 if schema.get("additionalProperties") == Some(&Value::Bool(false)) {
184 "{}".to_string()
185 } else {
186 "Record<string, unknown>".to_string()
187 }
188 } else {
189 format!("{{\n{}\n}}", fields.join("\n"))
190 }
191}
192
193fn resolve_ref<'a>(root: &'a Value, reference: &str) -> Option<&'a Value> {
194 if reference == "#" {
195 return Some(root);
196 }
197 let pointer = reference.strip_prefix('#')?;
198 root.pointer(pointer)
199}
200
201fn nullable(value: String, schema: &Value) -> String {
202 if schema.get("nullable").and_then(Value::as_bool) == Some(true)
203 && value != "unknown"
204 && value != "never"
205 {
206 format!("{value} | null")
207 } else {
208 value
209 }
210}
211
212fn primitive(kind: &str) -> &'static str {
213 match kind {
214 "string" => "string",
215 "number" | "integer" => "number",
216 "boolean" => "boolean",
217 "null" => "null",
218 "array" => "unknown[]",
219 "object" => "Record<string, unknown>",
220 _ => "unknown",
221 }
222}
223
224fn literal(value: &Value) -> String {
225 serde_json::to_string(value).unwrap_or_else(|_| "unknown".to_string())
226}
227
228fn pascal(value: &str) -> String {
229 value
230 .split(|ch: char| !ch.is_ascii_alphanumeric())
231 .filter(|part| !part.is_empty())
232 .map(|part| {
233 let mut chars = part.chars();
234 chars
235 .next()
236 .map(|first| first.to_ascii_uppercase().to_string() + chars.as_str())
237 .unwrap_or_default()
238 })
239 .collect::<String>()
240}
241
242fn quote_property(value: &str) -> String {
243 let sanitized = sanitize_identifier(value);
244 if sanitized == value {
245 value.to_string()
246 } else {
247 serde_json::to_string(value).unwrap()
248 }
249}
250
251fn escape_doc(value: &str) -> String {
252 value.replace("*/", "*\\/").replace(['\r', '\n'], " ")
253}
254
255#[cfg(test)]
256mod tests {
257 use serde_json::json;
258
259 use super::*;
260
261 #[test]
262 fn converts_objects_unions_and_refs() {
263 let schema = json!({
264 "type": "object",
265 "properties": {
266 "id": {"type": "integer"},
267 "state": {"enum": ["open", "closed"]},
268 "owner": {"$ref": "#/$defs/user"}
269 },
270 "required": ["id"],
271 "$defs": {"user": {"type": "object", "properties": {"name": {"type": "string"}}}}
272 });
273 let output = json_schema_to_type(&schema);
274 assert!(output.starts_with("{\n"));
275 assert!(output.contains(" id: number;"));
276 assert!(output.contains(" state?: \"open\" | \"closed\";"));
277 assert!(output.contains(" owner?: {\n name?: string;\n};"));
278 }
279}