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