1use serde_json::Value;
26use std::sync::{OnceLock, RwLock};
27
28pub trait OpenApiClientGenerator: Send + Sync {
30 fn language(&self) -> &'static str;
32
33 fn variant(&self) -> &'static str;
35
36 fn generate(&self, spec: &Value) -> String;
38}
39
40static GENERATORS: RwLock<Vec<&'static dyn OpenApiClientGenerator>> = RwLock::new(Vec::new());
42static INITIALIZED: OnceLock<()> = OnceLock::new();
43
44pub fn register(generator: &'static dyn OpenApiClientGenerator) {
49 GENERATORS.write().unwrap().push(generator);
50}
51
52fn init_builtin() {
54 INITIALIZED.get_or_init(|| {
55 let mut generators = GENERATORS.write().unwrap();
56 static TS: TypeScriptFetch = TypeScriptFetch;
57 static PY: PythonUrllib = PythonUrllib;
58 static RS: RustUreq = RustUreq;
59 generators.push(&TS);
60 generators.push(&PY);
61 generators.push(&RS);
62 });
63}
64
65pub fn get_generator(lang: &str) -> Option<&'static dyn OpenApiClientGenerator> {
67 init_builtin();
68 let lang_lower = lang.to_lowercase();
69 GENERATORS
70 .read()
71 .unwrap()
72 .iter()
73 .find(|g| {
74 g.language() == lang_lower
75 || (lang_lower == "ts" && g.language() == "typescript")
76 || (lang_lower == "py" && g.language() == "python")
77 || (lang_lower == "rs" && g.language() == "rust")
78 })
79 .copied()
80}
81
82pub fn list_generators() -> Vec<(&'static str, &'static str)> {
84 init_builtin();
85 GENERATORS
86 .read()
87 .unwrap()
88 .iter()
89 .map(|g| (g.language(), g.variant()))
90 .collect()
91}
92
93pub fn find_generator(lang: &str) -> Option<Box<dyn OpenApiClientGenerator>> {
96 get_generator(lang).map(|g| Box::new(GeneratorWrapper(g)) as Box<dyn OpenApiClientGenerator>)
97}
98
99struct GeneratorWrapper(&'static dyn OpenApiClientGenerator);
100
101impl OpenApiClientGenerator for GeneratorWrapper {
102 fn language(&self) -> &'static str {
103 self.0.language()
104 }
105
106 fn variant(&self) -> &'static str {
107 self.0.variant()
108 }
109
110 fn generate(&self, spec: &Value) -> String {
111 self.0.generate(spec)
112 }
113}
114
115pub fn generators() -> Vec<Box<dyn OpenApiClientGenerator>> {
117 init_builtin();
118 GENERATORS
119 .read()
120 .unwrap()
121 .iter()
122 .map(|g| Box::new(GeneratorWrapper(*g)) as Box<dyn OpenApiClientGenerator>)
123 .collect()
124}
125
126struct TypeScriptFetch;
129
130impl OpenApiClientGenerator for TypeScriptFetch {
131 fn language(&self) -> &'static str {
132 "typescript"
133 }
134 fn variant(&self) -> &'static str {
135 "fetch"
136 }
137
138 fn generate(&self, spec: &Value) -> String {
139 let mut out = String::new();
140 out.push_str("// Auto-generated from OpenAPI spec\n");
141 out.push_str("// Uses fetch (built-in)\n\n");
142
143 if let Some(schemas) = spec
145 .pointer("/components/schemas")
146 .and_then(|s| s.as_object())
147 {
148 for (name, schema) in schemas {
149 out.push_str(&format!("export interface {} {{\n", name));
150 if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
151 let required: Vec<&str> = schema
152 .get("required")
153 .and_then(|r| r.as_array())
154 .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
155 .unwrap_or_default();
156 for (prop_name, prop) in props {
157 let ts_type = json_schema_to_ts(prop);
158 let opt = if required.contains(&prop_name.as_str()) {
159 ""
160 } else {
161 "?"
162 };
163 out.push_str(&format!(" {}{}: {};\n", prop_name, opt, ts_type));
164 }
165 }
166 out.push_str("}\n\n");
167 }
168 }
169
170 out.push_str("export class ApiClient {\n");
172 out.push_str(" constructor(private baseUrl = 'http://localhost:8080') {}\n\n");
173 out.push_str(" private async request<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T> {\n");
174 out.push_str(" const url = new URL(path, this.baseUrl);\n");
175 out.push_str(" if (params) {\n");
176 out.push_str(" for (const [k, v] of Object.entries(params)) {\n");
177 out.push_str(" if (v !== undefined) url.searchParams.set(k, String(v));\n");
178 out.push_str(" }\n");
179 out.push_str(" }\n");
180 out.push_str(" const res = await fetch(url.toString());\n");
181 out.push_str(" if (!res.ok) throw new Error(`HTTP ${res.status}`);\n");
182 out.push_str(" return await res.json() as T;\n");
183 out.push_str(" }\n\n");
184
185 if let Some(paths) = spec.get("paths").and_then(|p| p.as_object()) {
187 for (path, methods) in paths {
188 if let Some(op) = methods.get("get").and_then(|g| g.as_object()) {
189 let op_id = op
190 .get("operationId")
191 .and_then(|id| id.as_str())
192 .unwrap_or("unknown");
193 let params = op
194 .get("parameters")
195 .and_then(|p| p.as_array())
196 .map(|a| a.as_slice())
197 .unwrap_or(&[]);
198
199 let path_params: Vec<&str> = params
200 .iter()
201 .filter(|p| p.get("in").and_then(|i| i.as_str()) == Some("path"))
202 .filter_map(|p| p.get("name").and_then(|n| n.as_str()))
203 .collect();
204 let query_params: Vec<&str> = params
205 .iter()
206 .filter(|p| p.get("in").and_then(|i| i.as_str()) == Some("query"))
207 .filter_map(|p| p.get("name").and_then(|n| n.as_str()))
208 .collect();
209
210 let op_value = Value::Object(op.clone());
212 let resp_type = op_value
213 .pointer("/responses/200/content/application~1json/schema")
214 .map(json_schema_to_ts)
215 .unwrap_or_else(|| "void".to_string());
216
217 let mut args = Vec::new();
218 for p in &path_params {
219 args.push(format!("{}: string", p));
220 }
221 if !query_params.is_empty() {
222 let opts: Vec<String> = query_params
223 .iter()
224 .map(|p| format!("{}?: string | number", p))
225 .collect();
226 args.push(format!("options?: {{ {} }}", opts.join("; ")));
227 }
228
229 let url_template = path.replace('{', "${");
230 let call_params = if query_params.is_empty() {
231 ""
232 } else {
233 ", options"
234 };
235
236 out.push_str(&format!(
237 " async {}({}): Promise<{}> {{\n",
238 op_id,
239 args.join(", "),
240 resp_type
241 ));
242 out.push_str(&format!(
243 " return this.request<{}>(`{}`{});\n",
244 resp_type, url_template, call_params
245 ));
246 out.push_str(" }\n\n");
247 }
248 }
249 }
250
251 out.push_str("}\n");
252 out
253 }
254}
255
256struct PythonUrllib;
259
260impl OpenApiClientGenerator for PythonUrllib {
261 fn language(&self) -> &'static str {
262 "python"
263 }
264 fn variant(&self) -> &'static str {
265 "urllib"
266 }
267
268 fn generate(&self, spec: &Value) -> String {
269 let mut out = String::new();
270 out.push_str("# Auto-generated from OpenAPI spec\n");
271 out.push_str("# Uses urllib (stdlib)\n\n");
272 out.push_str("from dataclasses import dataclass\n");
273 out.push_str("from typing import Any, Optional\n");
274 out.push_str("from urllib.parse import urlencode\n");
275 out.push_str("from urllib.request import urlopen\n");
276 out.push_str("import json\n\n\n");
277
278 if let Some(schemas) = spec
280 .pointer("/components/schemas")
281 .and_then(|s| s.as_object())
282 {
283 for (name, schema) in schemas {
284 out.push_str("@dataclass\n");
285 out.push_str(&format!("class {}:\n", name));
286 if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
287 let required: Vec<&str> = schema
288 .get("required")
289 .and_then(|r| r.as_array())
290 .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
291 .unwrap_or_default();
292
293 for (prop_name, prop) in props {
295 if required.contains(&prop_name.as_str()) {
296 let py_type = json_schema_to_py(prop);
297 out.push_str(&format!(" {}: {}\n", prop_name, py_type));
298 }
299 }
300 for (prop_name, prop) in props {
302 if !required.contains(&prop_name.as_str()) {
303 let py_type = json_schema_to_py(prop);
304 out.push_str(&format!(
305 " {}: Optional[{}] = None\n",
306 prop_name, py_type
307 ));
308 }
309 }
310 if props.is_empty() {
311 out.push_str(" pass\n");
312 }
313 } else {
314 out.push_str(" pass\n");
315 }
316 out.push_str("\n\n");
317 }
318 }
319
320 out.push_str("class ApiClient:\n");
322 out.push_str(" def __init__(self, base_url: str = 'http://localhost:8080'):\n");
323 out.push_str(" self.base_url = base_url.rstrip('/')\n\n");
324 out.push_str(" def _request(self, path: str, params: Optional[dict] = None) -> dict:\n");
325 out.push_str(" url = f'{self.base_url}{path}'\n");
326 out.push_str(" if params:\n");
327 out.push_str(" filtered = {k: v for k, v in params.items() if v is not None}\n");
328 out.push_str(" if filtered:\n");
329 out.push_str(" url = f'{url}?{urlencode(filtered)}'\n");
330 out.push_str(" with urlopen(url) as response:\n");
331 out.push_str(" return json.load(response)\n\n");
332
333 if let Some(paths) = spec.get("paths").and_then(|p| p.as_object()) {
335 for (path, methods) in paths {
336 if let Some(op) = methods.get("get").and_then(|g| g.as_object()) {
337 let op_id = op
338 .get("operationId")
339 .and_then(|id| id.as_str())
340 .unwrap_or("unknown");
341 let params = op
342 .get("parameters")
343 .and_then(|p| p.as_array())
344 .map(|a| a.as_slice())
345 .unwrap_or(&[]);
346
347 let path_params: Vec<&str> = params
348 .iter()
349 .filter(|p| p.get("in").and_then(|i| i.as_str()) == Some("path"))
350 .filter_map(|p| p.get("name").and_then(|n| n.as_str()))
351 .collect();
352 let query_params: Vec<&str> = params
353 .iter()
354 .filter(|p| p.get("in").and_then(|i| i.as_str()) == Some("query"))
355 .filter_map(|p| p.get("name").and_then(|n| n.as_str()))
356 .collect();
357
358 let op_value = Value::Object(op.clone());
359 let resp_type = op_value
360 .pointer("/responses/200/content/application~1json/schema")
361 .map(json_schema_to_py)
362 .unwrap_or_else(|| "dict".to_string());
363
364 let mut args = vec!["self".to_string()];
365 for p in &path_params {
366 args.push(format!("{}: str", p));
367 }
368 if !query_params.is_empty() {
369 args.push("*".to_string());
370 for p in &query_params {
371 args.push(format!("{}: Optional[str] = None", p));
372 }
373 }
374
375 let url_template = path.replace('{', "{");
376 let params_dict = if query_params.is_empty() {
377 String::new()
378 } else {
379 let kv: Vec<_> = query_params
380 .iter()
381 .map(|p| format!("'{}': {}", p, p))
382 .collect();
383 format!(", {{{}}}", kv.join(", "))
384 };
385
386 out.push_str(&format!(
387 " def {}({}) -> {}:\n",
388 op_id,
389 args.join(", "),
390 resp_type
391 ));
392 out.push_str(&format!(
393 " data = self._request(f'{}'{})\n",
394 url_template, params_dict
395 ));
396 out.push_str(&format!(" return {}(**data)\n\n", resp_type));
397 }
398 }
399 }
400
401 out
402 }
403}
404
405struct RustUreq;
408
409impl OpenApiClientGenerator for RustUreq {
410 fn language(&self) -> &'static str {
411 "rust"
412 }
413 fn variant(&self) -> &'static str {
414 "ureq"
415 }
416
417 fn generate(&self, spec: &Value) -> String {
418 let mut out = String::new();
419 out.push_str("//! Auto-generated from OpenAPI spec\n");
420 out.push_str("//! Uses ureq (blocking HTTP)\n\n");
421 out.push_str("use serde::{Deserialize, Serialize};\n\n");
422
423 if let Some(schemas) = spec
425 .pointer("/components/schemas")
426 .and_then(|s| s.as_object())
427 {
428 for (name, schema) in schemas {
429 out.push_str("#[derive(Debug, Clone, Serialize, Deserialize)]\n");
430 out.push_str(&format!("pub struct {} {{\n", name));
431 if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
432 let required: Vec<&str> = schema
433 .get("required")
434 .and_then(|r| r.as_array())
435 .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
436 .unwrap_or_default();
437 for (prop_name, prop) in props {
438 let rust_type = json_schema_to_rust(prop);
439 let field_type = if required.contains(&prop_name.as_str()) {
440 rust_type
441 } else {
442 format!("Option<{}>", rust_type)
443 };
444 out.push_str(&format!(
445 " pub {}: {},\n",
446 to_snake_case(prop_name),
447 field_type
448 ));
449 }
450 }
451 out.push_str("}\n\n");
452 }
453 }
454
455 out.push_str("pub struct ApiClient {\n");
457 out.push_str(" base_url: String,\n");
458 out.push_str("}\n\n");
459
460 out.push_str("impl ApiClient {\n");
461 out.push_str(" pub fn new(base_url: impl Into<String>) -> Self {\n");
462 out.push_str(" Self { base_url: base_url.into() }\n");
463 out.push_str(" }\n\n");
464
465 if let Some(paths) = spec.get("paths").and_then(|p| p.as_object()) {
467 for (path, methods) in paths {
468 if let Some(op) = methods.get("get").and_then(|g| g.as_object()) {
469 let op_id = op
470 .get("operationId")
471 .and_then(|id| id.as_str())
472 .unwrap_or("unknown");
473 let params = op
474 .get("parameters")
475 .and_then(|p| p.as_array())
476 .map(|a| a.as_slice())
477 .unwrap_or(&[]);
478
479 let path_params: Vec<&str> = params
480 .iter()
481 .filter(|p| p.get("in").and_then(|i| i.as_str()) == Some("path"))
482 .filter_map(|p| p.get("name").and_then(|n| n.as_str()))
483 .collect();
484 let query_params: Vec<(&str, bool)> = params
485 .iter()
486 .filter(|p| p.get("in").and_then(|i| i.as_str()) == Some("query"))
487 .filter_map(|p| {
488 let name = p.get("name").and_then(|n| n.as_str())?;
489 let required =
490 p.get("required").and_then(|r| r.as_bool()).unwrap_or(false);
491 Some((name, required))
492 })
493 .collect();
494
495 let op_value = Value::Object(op.clone());
496 let resp_type = op_value
497 .pointer("/responses/200/content/application~1json/schema")
498 .map(json_schema_to_rust)
499 .unwrap_or_else(|| "()".to_string());
500
501 let mut args = Vec::new();
503 args.push("&self".to_string());
504 for p in &path_params {
505 args.push(format!("{}: &str", to_snake_case(p)));
506 }
507 for (p, required) in &query_params {
508 let param_type = if *required {
509 "&str".to_string()
510 } else {
511 "Option<&str>".to_string()
512 };
513 args.push(format!("{}: {}", to_snake_case(p), param_type));
514 }
515
516 out.push_str(&format!(
517 " pub fn {}({}) -> Result<{}, ureq::Error> {{\n",
518 to_snake_case(op_id),
519 args.join(", "),
520 resp_type
521 ));
522
523 let url_expr = if path_params.is_empty() {
525 format!("format!(\"{{}}{}\"", path)
526 } else {
527 let rust_path = path_params.iter().fold(path.to_string(), |acc, p| {
528 acc.replace(&format!("{{{}}}", p), &format!("{{{}}}", to_snake_case(p)))
529 });
530 format!("format!(\"{{}}{}\", ", rust_path)
531 };
532 out.push_str(&format!(" let url = {}self.base_url);\n", url_expr));
533
534 out.push_str(" let mut req = ureq::get(&url);\n");
536 for (p, required) in &query_params {
537 let snake = to_snake_case(p);
538 if *required {
539 out.push_str(&format!(
540 " req = req.query(\"{}\", {});\n",
541 p, snake
542 ));
543 } else {
544 out.push_str(&format!(
545 " if let Some(v) = {} {{ req = req.query(\"{}\", v); }}\n",
546 snake, p
547 ));
548 }
549 }
550
551 out.push_str(" let resp: ");
552 out.push_str(&resp_type);
553 out.push_str(" = req.call()?.into_json()?;\n");
554 out.push_str(" Ok(resp)\n");
555 out.push_str(" }\n\n");
556 }
557 }
558 }
559
560 out.push_str("}\n");
561 out
562 }
563}
564
565fn to_snake_case(s: &str) -> String {
566 let mut result = String::new();
567 for (i, c) in s.chars().enumerate() {
568 if c.is_uppercase() {
569 if i > 0 {
570 result.push('_');
571 }
572 result.push(c.to_lowercase().next().unwrap());
573 } else {
574 result.push(c);
575 }
576 }
577 result
578}
579
580fn json_schema_to_rust(schema: &Value) -> String {
581 if let Some(ref_path) = schema.get("$ref").and_then(|r| r.as_str()) {
582 return ref_path
583 .split('/')
584 .last()
585 .unwrap_or("serde_json::Value")
586 .to_string();
587 }
588
589 let type_val = schema.get("type");
590
591 if let Some(arr) = type_val.and_then(|t| t.as_array()) {
592 let types: Vec<&str> = arr.iter().filter_map(|v| v.as_str()).collect();
593 let non_null: Vec<_> = types.iter().filter(|t| **t != "null").collect();
594 if non_null.len() == 1 {
595 let base = type_str_to_rust(non_null[0]);
596 return format!("Option<{}>", base);
597 }
598 }
599
600 if let Some(type_str) = type_val.and_then(|t| t.as_str()) {
601 if type_str == "array" {
602 if let Some(items) = schema.get("items") {
603 return format!("Vec<{}>", json_schema_to_rust(items));
604 }
605 return "Vec<serde_json::Value>".to_string();
606 }
607 return type_str_to_rust(type_str);
608 }
609
610 "serde_json::Value".to_string()
611}
612
613fn type_str_to_rust(t: &str) -> String {
614 match t {
615 "string" => "String".to_string(),
616 "integer" => "i64".to_string(),
617 "number" => "f64".to_string(),
618 "boolean" => "bool".to_string(),
619 "object" => "serde_json::Value".to_string(),
620 _ => "serde_json::Value".to_string(),
621 }
622}
623
624fn json_schema_to_ts(schema: &Value) -> String {
627 if let Some(ref_path) = schema.get("$ref").and_then(|r| r.as_str()) {
628 return ref_path.split('/').last().unwrap_or("unknown").to_string();
629 }
630
631 let type_val = schema.get("type");
632
633 if let Some(arr) = type_val.and_then(|t| t.as_array()) {
634 let types: Vec<&str> = arr.iter().filter_map(|v| v.as_str()).collect();
635 let non_null: Vec<_> = types.iter().filter(|t| **t != "null").collect();
636 if non_null.len() == 1 {
637 let base = type_str_to_ts(non_null[0]);
638 return format!("{} | null", base);
639 }
640 }
641
642 if let Some(type_str) = type_val.and_then(|t| t.as_str()) {
643 if type_str == "array" {
644 if let Some(items) = schema.get("items") {
645 return format!("{}[]", json_schema_to_ts(items));
646 }
647 return "unknown[]".to_string();
648 }
649 return type_str_to_ts(type_str);
650 }
651
652 "unknown".to_string()
653}
654
655fn type_str_to_ts(t: &str) -> String {
656 match t {
657 "string" => "string".to_string(),
658 "integer" | "number" => "number".to_string(),
659 "boolean" => "boolean".to_string(),
660 "object" => "Record<string, unknown>".to_string(),
661 _ => "unknown".to_string(),
662 }
663}
664
665fn json_schema_to_py(schema: &Value) -> String {
666 if let Some(ref_path) = schema.get("$ref").and_then(|r| r.as_str()) {
667 return ref_path.split('/').last().unwrap_or("Any").to_string();
668 }
669
670 let type_val = schema.get("type");
671
672 if let Some(arr) = type_val.and_then(|t| t.as_array()) {
673 let types: Vec<&str> = arr.iter().filter_map(|v| v.as_str()).collect();
674 let non_null: Vec<_> = types.iter().filter(|t| **t != "null").collect();
675 if non_null.len() == 1 {
676 let base = type_str_to_py(non_null[0]);
677 return format!("Optional[{}]", base);
678 }
679 }
680
681 if let Some(type_str) = type_val.and_then(|t| t.as_str()) {
682 if type_str == "array" {
683 if let Some(items) = schema.get("items") {
684 return format!("list[{}]", json_schema_to_py(items));
685 }
686 return "list".to_string();
687 }
688 return type_str_to_py(type_str);
689 }
690
691 "Any".to_string()
692}
693
694fn type_str_to_py(t: &str) -> String {
695 match t {
696 "string" => "str".to_string(),
697 "integer" => "int".to_string(),
698 "number" => "float".to_string(),
699 "boolean" => "bool".to_string(),
700 "object" => "dict".to_string(),
701 _ => "Any".to_string(),
702 }
703}
704
705#[cfg(test)]
706mod tests {
707 use super::*;
708
709 #[test]
710 fn test_find_generator() {
711 assert!(find_generator("typescript").is_some());
712 assert!(find_generator("ts").is_some());
713 assert!(find_generator("python").is_some());
714 assert!(find_generator("py").is_some());
715 assert!(find_generator("rust").is_some());
716 assert!(find_generator("rs").is_some());
717 assert!(find_generator("unknown").is_none());
718 }
719
720 #[test]
721 fn test_list_generators() {
722 let gens = list_generators();
723 assert!(gens.iter().any(|(l, _)| *l == "typescript"));
724 assert!(gens.iter().any(|(l, _)| *l == "python"));
725 assert!(gens.iter().any(|(l, _)| *l == "rust"));
726 }
727
728 #[test]
729 fn test_to_snake_case() {
730 assert_eq!(to_snake_case("getUserById"), "get_user_by_id");
731 assert_eq!(to_snake_case("API"), "a_p_i");
732 assert_eq!(to_snake_case("simple"), "simple");
733 }
734}