Skip to main content

normalize_openapi/
lib.rs

1//! OpenAPI client code generation.
2//!
3//! Trait-based design allows multiple implementations per language/framework.
4//!
5//! # Extensibility
6//!
7//! Users can register custom generators via [`register()`]:
8//!
9//! ```ignore
10//! use normalize_openapi::{OpenApiClientGenerator, register};
11//! use serde_json::Value;
12//!
13//! struct MyGenerator;
14//!
15//! impl OpenApiClientGenerator for MyGenerator {
16//!     fn language(&self) -> &'static str { "mylang" }
17//!     fn variant(&self) -> &'static str { "myvariant" }
18//!     fn generate(&self, spec: &Value) -> String { /* ... */ }
19//! }
20//!
21//! // Register before first use
22//! register(&MyGenerator);
23//! ```
24
25use serde_json::Value;
26use std::sync::{OnceLock, RwLock};
27
28/// A code generator for a specific language/framework.
29pub trait OpenApiClientGenerator: Send + Sync {
30    /// Language name (e.g., "typescript", "python")
31    fn language(&self) -> &'static str;
32
33    /// Framework/variant name (e.g., "fetch", "axios", "urllib")
34    fn variant(&self) -> &'static str;
35
36    /// Generate client code from OpenAPI JSON.
37    fn generate(&self, spec: &Value) -> String;
38}
39
40/// Global registry of generator plugins.
41static GENERATORS: RwLock<Vec<&'static dyn OpenApiClientGenerator>> = RwLock::new(Vec::new());
42static INITIALIZED: OnceLock<()> = OnceLock::new();
43
44/// Register a custom generator plugin.
45///
46/// Call this before any generation operations to add custom generators.
47/// Built-in generators are registered automatically on first use.
48pub fn register(generator: &'static dyn OpenApiClientGenerator) {
49    // normalize-syntax-allow: rust/unwrap-in-impl - mutex poison on a global registry is unrecoverable
50    GENERATORS.write().unwrap().push(generator);
51}
52
53/// Initialize built-in generators (called automatically on first use).
54fn init_builtin() {
55    INITIALIZED.get_or_init(|| {
56        // normalize-syntax-allow: rust/unwrap-in-impl - mutex poison on a global registry is unrecoverable
57        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
67/// Get a generator by language from the global registry (returns first match).
68pub fn get_generator(lang: &str) -> Option<&'static dyn OpenApiClientGenerator> {
69    init_builtin();
70    let lang_lower = lang.to_lowercase();
71    // normalize-syntax-allow: rust/unwrap-in-impl - mutex poison on a global registry is unrecoverable
72    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
85/// List all available generators (language, variant) from the global registry.
86pub fn list_generators() -> Vec<(&'static str, &'static str)> {
87    init_builtin();
88    // normalize-syntax-allow: rust/unwrap-in-impl - mutex poison on a global registry is unrecoverable
89    GENERATORS
90        .read()
91        .unwrap()
92        .iter()
93        .map(|g| (g.language(), g.variant()))
94        .collect()
95}
96
97// Backwards-compatible aliases
98/// Find a generator by language (alias for get_generator, returns Box for compatibility).
99pub 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
119/// Registry of available generators (returns boxed generators for compatibility).
120pub fn generators() -> Vec<Box<dyn OpenApiClientGenerator>> {
121    init_builtin();
122    // normalize-syntax-allow: rust/unwrap-in-impl - mutex poison on a global registry is unrecoverable
123    GENERATORS
124        .read()
125        .unwrap()
126        .iter()
127        .map(|g| Box::new(GeneratorWrapper(*g)) as Box<dyn OpenApiClientGenerator>)
128        .collect()
129}
130
131// --- TypeScript (fetch) ---
132
133struct 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        // Generate interfaces from schemas
149        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        // Generate client class
176        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        // Generate methods from paths
191        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                    // Response type from nested path
216                    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
261// --- Python (urllib) ---
262
263struct 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        // Generate dataclasses from schemas
284        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                    // Required fields first
299                    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                    // Optional fields
306                    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        // Generate client class
326        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        // Generate methods from paths
339        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
409// --- Rust (ureq) ---
410
411struct 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        // Generate structs from schemas
428        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        // Generate client struct
460        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        // Generate methods from paths
470        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                    // Build function signature
506                    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                    // Build URL with path params
528                    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                    // Build request
539                    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            // normalize-syntax-allow: rust/unwrap-in-impl - char::to_lowercase() always yields at least one char
577            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
629// --- Helpers ---
630
631fn 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}