Skip to main content

spikard_cli/codegen/
typescript.rs

1//! TypeScript code generation from `OpenAPI` schemas
2
3use super::NodeDtoStyle;
4use anyhow::Result;
5use heck::{ToPascalCase, ToSnakeCase};
6use openapiv3::{
7    OpenAPI, Operation, Parameter, ParameterSchemaOrContent, ReferenceOr, Schema, SchemaKind, StringFormat, Type,
8    VariantOrUnknownOrEmpty,
9};
10use std::collections::{HashMap, HashSet, VecDeque};
11
12pub struct TypeScriptGenerator {
13    spec: OpenAPI,
14    dto: NodeDtoStyle,
15}
16
17impl TypeScriptGenerator {
18    #[must_use]
19    pub const fn new(spec: OpenAPI, dto: NodeDtoStyle) -> Self {
20        Self { spec, dto }
21    }
22
23    pub fn generate(&self) -> Result<String> {
24        let mut output = String::new();
25
26        output.push_str(&self.generate_header());
27
28        output.push_str(&self.generate_schemas()?);
29
30        output.push_str(&self.generate_routes()?);
31
32        output.push_str(&self.generate_main());
33
34        Ok(output)
35    }
36
37    fn generate_header(&self) -> String {
38        match self.dto {
39            NodeDtoStyle::Zod => format!(
40                r#"// Generated by Spikard OpenAPI code generator
41// OpenAPI Version: {}
42// Title: {}
43// DO NOT EDIT - regenerate from OpenAPI schema
44
45import {{ route }} from "spikard";
46import type {{ Body, Path, Query, Request }} from "spikard";
47import {{ z }} from "zod";
48
49"#,
50                self.spec.openapi, self.spec.info.title
51            ),
52        }
53    }
54
55    fn generate_schemas(&self) -> Result<String> {
56        let mut output = String::new();
57        output.push_str("// Zod Schemas\n\n");
58
59        if let Some(components) = &self.spec.components {
60            let mut schemas_map: HashMap<String, Schema> = HashMap::new();
61            let mut schema_refs: HashMap<String, ReferenceOr<Schema>> = HashMap::new();
62
63            for (name, schema_ref) in &components.schemas {
64                schema_refs.insert(name.clone(), schema_ref.clone());
65                if let ReferenceOr::Item(schema) = schema_ref {
66                    schemas_map.insert(name.clone(), schema.clone());
67                }
68            }
69
70            let sorted_names = topological_sort_schemas(&schemas_map);
71
72            for name in sorted_names {
73                if let Some(ReferenceOr::Item(schema)) = schema_refs.get(&name) {
74                    output.push_str(&self.generate_zod_schema(&name, schema)?);
75                    output.push('\n');
76                }
77            }
78        }
79
80        Ok(output)
81    }
82
83    fn generate_zod_schema(&self, name: &str, schema: &Schema) -> Result<String> {
84        let schema_name = format!("{}Schema", name.to_pascal_case());
85        let type_name = name.to_pascal_case();
86        let mut output = String::new();
87
88        if let Some(description) = &schema.schema_data.description {
89            output.push_str(&format!("/** {description} */\n"));
90        }
91
92        let mut schema_expr = match &schema.schema_kind {
93            SchemaKind::Type(Type::Object(obj)) => {
94                let mut expr = String::from("z.object({\n");
95
96                for (prop_name, prop_schema_ref) in &obj.properties {
97                    let is_required = obj.required.contains(prop_name);
98                    let field_name = prop_name.to_snake_case();
99
100                    let zod_type = match prop_schema_ref {
101                        ReferenceOr::Item(prop_schema) => Self::schema_to_zod_type(prop_schema, !is_required),
102                        ReferenceOr::Reference { reference } => {
103                            let ref_name = reference.split('/').next_back().unwrap();
104                            let ref_schema = format!("{}Schema", ref_name.to_pascal_case());
105                            if is_required {
106                                ref_schema
107                            } else {
108                                format!("{ref_schema}.optional()")
109                            }
110                        }
111                    };
112
113                    expr.push_str(&format!("\t{field_name}: {zod_type},\n"));
114                }
115
116                expr.push_str("})");
117                expr
118            }
119            _ => "z.unknown()".to_string(),
120        };
121
122        if schema.schema_data.nullable {
123            schema_expr.push_str(".nullable()");
124        }
125
126        output.push_str(&format!("export const {schema_name} = {schema_expr};\n"));
127
128        output.push_str(&format!("\nexport type {type_name} = z.infer<typeof {schema_name}>;\n"));
129
130        Ok(output)
131    }
132
133    /// Extract type name from a schema reference or inline schema
134    fn extract_type_from_schema_ref(&self, schema_ref: &ReferenceOr<Schema>) -> String {
135        match schema_ref {
136            ReferenceOr::Reference { reference } => {
137                let ref_name = reference.split('/').next_back().unwrap();
138                ref_name.to_pascal_case()
139            }
140            ReferenceOr::Item(schema) => Self::schema_to_typescript_type(schema, false),
141        }
142    }
143
144    /// Extract TypeScript type from a schema reference or inline schema
145    fn extract_typescript_type_from_ref(&self, schema_ref: &ReferenceOr<Schema>) -> String {
146        match schema_ref {
147            ReferenceOr::Reference { reference } => {
148                let ref_name = reference.split('/').next_back().unwrap();
149                ref_name.to_pascal_case()
150            }
151            ReferenceOr::Item(schema) => Self::schema_to_typescript_type(schema, false),
152        }
153    }
154
155    /// Extract request body TypeScript type from operation
156    fn extract_request_body_type(&self, operation: &Operation) -> Option<String> {
157        operation.request_body.as_ref().and_then(|body_ref| match body_ref {
158            ReferenceOr::Item(request_body) => request_body.content.get("application/json").and_then(|media_type| {
159                media_type
160                    .schema
161                    .as_ref()
162                    .map(|schema_ref| self.extract_typescript_type_from_ref(schema_ref))
163            }),
164            ReferenceOr::Reference { reference } => {
165                let ref_name = reference.split('/').next_back().unwrap();
166                Some(ref_name.to_pascal_case())
167            }
168        })
169    }
170
171    /// Extract response type from operation (looks for 200/201 responses)
172    fn extract_response_type(&self, operation: &Operation) -> String {
173        use openapiv3::StatusCode;
174
175        let response = operation
176            .responses
177            .responses
178            .get(&StatusCode::Code(200))
179            .or_else(|| operation.responses.responses.get(&StatusCode::Code(201)))
180            .or_else(|| operation.responses.responses.get(&StatusCode::Range(2)));
181
182        if let Some(response_ref) = response {
183            match response_ref {
184                ReferenceOr::Item(response) => {
185                    if let Some(content) = response.content.get("application/json")
186                        && let Some(schema_ref) = &content.schema
187                    {
188                        return self.extract_type_from_schema_ref(schema_ref);
189                    }
190                }
191                ReferenceOr::Reference { reference } => {
192                    let ref_name = reference.split('/').next_back().unwrap();
193                    return ref_name.to_pascal_case();
194                }
195            }
196        }
197
198        "Record<string, unknown>".to_string()
199    }
200
201    fn schema_to_zod_type(schema: &Schema, optional: bool) -> String {
202        let mut base_type = match &schema.schema_kind {
203            SchemaKind::Type(Type::String(string_type)) => {
204                let enum_values = string_type
205                    .enumeration
206                    .iter()
207                    .flatten()
208                    .map(|value| format!("{value:?}"))
209                    .collect::<Vec<_>>();
210                if !enum_values.is_empty() {
211                    format!("z.enum([{}])", enum_values.join(", "))
212                } else {
213                    match &string_type.format {
214                        VariantOrUnknownOrEmpty::Item(StringFormat::Date) => "z.string()".to_string(),
215                        VariantOrUnknownOrEmpty::Item(StringFormat::DateTime) => "z.string().datetime()".to_string(),
216                        VariantOrUnknownOrEmpty::Unknown(format) if format == "email" => {
217                            "z.string().email()".to_string()
218                        }
219                        VariantOrUnknownOrEmpty::Unknown(format) if format == "uuid" => "z.string().uuid()".to_string(),
220                        _ => "z.string()".to_string(),
221                    }
222                }
223            }
224            SchemaKind::Type(Type::Number(_)) => "z.number()".to_string(),
225            SchemaKind::Type(Type::Integer(_)) => "z.number().int()".to_string(),
226            SchemaKind::Type(Type::Boolean(_)) => "z.boolean()".to_string(),
227            SchemaKind::Type(Type::Array(arr)) => {
228                let item_type = match &arr.items {
229                    Some(ReferenceOr::Item(item_schema)) => Self::schema_to_zod_type(item_schema, false),
230                    Some(ReferenceOr::Reference { reference }) => {
231                        let ref_name = reference.split('/').next_back().unwrap();
232                        format!("{}Schema", ref_name.to_pascal_case())
233                    }
234                    None => "z.record(z.string(), z.unknown())".to_string(),
235                };
236                format!("z.array({item_type})")
237            }
238            SchemaKind::Type(Type::Object(obj)) => {
239                if obj.properties.is_empty() {
240                    "z.record(z.string(), z.unknown())".to_string()
241                } else {
242                    let mut fields = String::from("z.object({\n");
243                    for (prop_name, prop_schema_ref) in &obj.properties {
244                        let is_required = obj.required.contains(prop_name);
245                        let field_name = prop_name.to_snake_case();
246                        let zod_type = match prop_schema_ref {
247                            ReferenceOr::Item(prop_schema) => Self::schema_to_zod_type(prop_schema, !is_required),
248                            ReferenceOr::Reference { reference } => {
249                                let ref_name = reference.split('/').next_back().unwrap();
250                                let ref_schema = format!("{}Schema", ref_name.to_pascal_case());
251                                if is_required {
252                                    ref_schema
253                                } else {
254                                    format!("{ref_schema}.optional()")
255                                }
256                            }
257                        };
258                        fields.push_str(&format!("\t{field_name}: {zod_type},\n"));
259                    }
260                    fields.push_str("})");
261                    fields
262                }
263            }
264            SchemaKind::OneOf { one_of } => {
265                let members = one_of
266                    .iter()
267                    .map(|schema_ref| match schema_ref {
268                        ReferenceOr::Item(item_schema) => Self::schema_to_zod_type(item_schema, false),
269                        ReferenceOr::Reference { reference } => {
270                            let ref_name = reference.split('/').next_back().unwrap();
271                            format!("{}Schema", ref_name.to_pascal_case())
272                        }
273                    })
274                    .collect::<Vec<_>>();
275                format!("z.union([{}])", members.join(", "))
276            }
277            SchemaKind::AnyOf { any_of } => {
278                let members = any_of
279                    .iter()
280                    .map(|schema_ref| match schema_ref {
281                        ReferenceOr::Item(item_schema) => Self::schema_to_zod_type(item_schema, false),
282                        ReferenceOr::Reference { reference } => {
283                            let ref_name = reference.split('/').next_back().unwrap();
284                            format!("{}Schema", ref_name.to_pascal_case())
285                        }
286                    })
287                    .collect::<Vec<_>>();
288                format!("z.union([{}])", members.join(", "))
289            }
290            SchemaKind::AllOf { all_of } => {
291                let members = all_of
292                    .iter()
293                    .map(|schema_ref| match schema_ref {
294                        ReferenceOr::Item(item_schema) => Self::schema_to_zod_type(item_schema, false),
295                        ReferenceOr::Reference { reference } => {
296                            let ref_name = reference.split('/').next_back().unwrap();
297                            format!("{}Schema", ref_name.to_pascal_case())
298                        }
299                    })
300                    .collect::<Vec<_>>();
301                match members.split_first() {
302                    Some((first, rest)) => rest
303                        .iter()
304                        .fold(first.clone(), |acc, member| format!("{acc}.and({member})")),
305                    None => "z.unknown()".to_string(),
306                }
307            }
308            _ => "z.unknown()".to_string(),
309        };
310
311        if schema.schema_data.nullable {
312            base_type.push_str(".nullable()");
313        }
314
315        if optional {
316            base_type.push_str(".optional()");
317        }
318
319        base_type
320    }
321
322    fn schema_to_typescript_type(schema: &Schema, optional: bool) -> String {
323        let mut base_type = match &schema.schema_kind {
324            SchemaKind::Type(Type::String(string_type)) => {
325                let enum_values = string_type
326                    .enumeration
327                    .iter()
328                    .flatten()
329                    .map(|value| format!("{value:?}"))
330                    .collect::<Vec<_>>();
331                if enum_values.is_empty() {
332                    "string".to_string()
333                } else {
334                    enum_values.join(" | ")
335                }
336            }
337            SchemaKind::Type(Type::Number(_) | Type::Integer(_)) => "number".to_string(),
338            SchemaKind::Type(Type::Boolean(_)) => "boolean".to_string(),
339            SchemaKind::Type(Type::Array(arr)) => {
340                let item_type = match &arr.items {
341                    Some(ReferenceOr::Item(item_schema)) => Self::schema_to_typescript_type(item_schema, false),
342                    Some(ReferenceOr::Reference { reference }) => {
343                        let ref_name = reference.split('/').next_back().unwrap();
344                        ref_name.to_pascal_case()
345                    }
346                    None => "Record<string, unknown>".to_string(),
347                };
348                let item_type = if item_type.contains(" | ") {
349                    format!("({item_type})")
350                } else {
351                    item_type
352                };
353                format!("{item_type}[]")
354            }
355            SchemaKind::Type(Type::Object(obj)) => {
356                if obj.properties.is_empty() {
357                    "Record<string, unknown>".to_string()
358                } else {
359                    let fields = obj
360                        .properties
361                        .iter()
362                        .map(|(prop_name, prop_schema_ref)| {
363                            let optional_marker = if obj.required.contains(prop_name) { "" } else { "?" };
364                            let prop_type = match prop_schema_ref {
365                                ReferenceOr::Item(prop_schema) => Self::schema_to_typescript_type(prop_schema, false),
366                                ReferenceOr::Reference { reference } => {
367                                    let ref_name = reference.split('/').next_back().unwrap();
368                                    ref_name.to_pascal_case()
369                                }
370                            };
371                            format!("{prop_name}{optional_marker}: {prop_type}")
372                        })
373                        .collect::<Vec<_>>()
374                        .join("; ");
375                    format!("{{ {fields} }}")
376                }
377            }
378            SchemaKind::OneOf { one_of } | SchemaKind::AnyOf { any_of: one_of } => one_of
379                .iter()
380                .map(|schema_ref| match schema_ref {
381                    ReferenceOr::Item(item_schema) => Self::schema_to_typescript_type(item_schema, false),
382                    ReferenceOr::Reference { reference } => {
383                        let ref_name = reference.split('/').next_back().unwrap();
384                        ref_name.to_pascal_case()
385                    }
386                })
387                .collect::<Vec<_>>()
388                .join(" | "),
389            SchemaKind::AllOf { all_of } => all_of
390                .iter()
391                .map(|schema_ref| match schema_ref {
392                    ReferenceOr::Item(item_schema) => Self::schema_to_typescript_type(item_schema, false),
393                    ReferenceOr::Reference { reference } => {
394                        let ref_name = reference.split('/').next_back().unwrap();
395                        ref_name.to_pascal_case()
396                    }
397                })
398                .collect::<Vec<_>>()
399                .join(" & "),
400            _ => "unknown".to_string(),
401        };
402
403        if schema.schema_data.nullable {
404            base_type.push_str(" | null");
405        }
406
407        if optional {
408            base_type.push_str(" | undefined");
409        }
410
411        base_type
412    }
413
414    fn generate_routes(&self) -> Result<String> {
415        let mut output = String::new();
416        output.push_str("\n// Route Handlers\n\n");
417
418        for (path, path_item_ref) in &self.spec.paths.paths {
419            let path_item = match path_item_ref {
420                ReferenceOr::Item(item) => item,
421                ReferenceOr::Reference { .. } => continue,
422            };
423
424            if let Some(op) = &path_item.get {
425                output.push_str(&self.generate_route_handler(path, "get", op)?);
426            }
427            if let Some(op) = &path_item.post {
428                output.push_str(&self.generate_route_handler(path, "post", op)?);
429            }
430            if let Some(op) = &path_item.put {
431                output.push_str(&self.generate_route_handler(path, "put", op)?);
432            }
433            if let Some(op) = &path_item.delete {
434                output.push_str(&self.generate_route_handler(path, "delete", op)?);
435            }
436            if let Some(op) = &path_item.patch {
437                output.push_str(&self.generate_route_handler(path, "patch", op)?);
438            }
439        }
440
441        Ok(output)
442    }
443
444    fn generate_route_handler(&self, path: &str, method: &str, operation: &Operation) -> Result<String> {
445        let mut output = String::new();
446
447        if let Some(summary) = &operation.summary {
448            output.push_str(&format!("/**\n * {summary}\n"));
449        } else {
450            output.push_str("/**\n");
451        }
452        output.push_str(&format!(" * Route: {} {}\n", method.to_uppercase(), path));
453        output.push_str(" */\n");
454
455        let func_name = operation
456            .operation_id
457            .as_ref()
458            .map(|id| id.to_snake_case())
459            .unwrap_or_else(|| {
460                format!(
461                    "{}_{}",
462                    method,
463                    path.replace('/', "_").replace(['{', '}'], "").trim_matches('_')
464                )
465            });
466
467        let mut path_params = Vec::new();
468        let mut query_params = Vec::new();
469
470        for param_ref in &operation.parameters {
471            if let ReferenceOr::Item(param) = param_ref {
472                match param {
473                    Parameter::Path { parameter_data, .. } => {
474                        let type_hint = Self::parameter_typescript_type(parameter_data);
475                        path_params.push((parameter_data.name.clone(), type_hint));
476                    }
477                    Parameter::Query { parameter_data, .. } => {
478                        let type_hint = Self::parameter_typescript_type(parameter_data);
479                        query_params.push((parameter_data.name.clone(), type_hint, parameter_data.required));
480                    }
481                    _ => {}
482                }
483            }
484        }
485
486        let body_type = self.extract_request_body_type(operation);
487
488        let return_type = self.extract_response_type(operation);
489
490        output.push_str(&format!("export function {func_name}(_request: Request"));
491
492        for (param_name, param_type) in &path_params {
493            output.push_str(&format!(", _{}: Path<{}>", param_name.to_snake_case(), param_type));
494        }
495
496        for (param_name, param_type, required) in &query_params {
497            if *required {
498                output.push_str(&format!(", _{}: Query<{}>", param_name.to_snake_case(), param_type));
499            } else {
500                output.push_str(&format!(
501                    ", _{}: Query<{} | undefined>",
502                    param_name.to_snake_case(),
503                    param_type
504                ));
505            }
506        }
507
508        if let Some(body_type) = &body_type {
509            output.push_str(&format!(", _body: Body<{body_type}>"));
510        }
511
512        output.push_str(&format!("): {return_type} {{\n"));
513
514        if let Some(desc) = &operation.description {
515            output.push_str(&format!("\t/**\n\t * {desc}\n\t */\n"));
516        }
517
518        output.push_str("\tthrow new Error(\"Implement this endpoint\");\n");
519        output.push_str("}\n");
520
521        output.push_str(&format!(
522            "route(\"{}\", {{ methods: [\"{}\"] }})({});\n\n",
523            path,
524            method.to_uppercase(),
525            func_name
526        ));
527
528        Ok(output)
529    }
530
531    fn generate_main(&self) -> String {
532        r"
533// Run the application
534// Note: Actual server setup depends on your runtime configuration
535"
536        .to_string()
537    }
538
539    fn parameter_typescript_type(parameter_data: &openapiv3::ParameterData) -> String {
540        match &parameter_data.format {
541            ParameterSchemaOrContent::Schema(schema_ref) => match schema_ref {
542                ReferenceOr::Item(schema) => Self::schema_to_typescript_type(schema, false),
543                ReferenceOr::Reference { reference } => {
544                    let ref_name = reference.split('/').next_back().unwrap();
545                    ref_name.to_pascal_case()
546                }
547            },
548            ParameterSchemaOrContent::Content(_) => "unknown".to_string(),
549        }
550    }
551}
552
553/// Extract all schema names that are referenced in a given schema
554fn extract_schema_dependencies(schema: &Schema) -> HashSet<String> {
555    let mut dependencies = HashSet::new();
556    extract_dependencies_recursive(schema, &mut dependencies);
557    dependencies
558}
559
560/// Recursively extract all $ref schema names from a schema
561fn extract_dependencies_recursive(schema: &Schema, deps: &mut HashSet<String>) {
562    match &schema.schema_kind {
563        SchemaKind::Type(Type::Object(obj)) => {
564            for (_prop_name, prop_schema_ref) in &obj.properties {
565                match prop_schema_ref {
566                    ReferenceOr::Reference { reference } => {
567                        if let Some(ref_name) = reference.split('/').next_back() {
568                            deps.insert(ref_name.to_string());
569                        }
570                    }
571                    ReferenceOr::Item(prop_schema) => {
572                        extract_dependencies_recursive(prop_schema, deps);
573                    }
574                }
575            }
576        }
577        SchemaKind::Type(Type::Array(arr)) => {
578            if let Some(items) = &arr.items {
579                match items {
580                    ReferenceOr::Reference { reference } => {
581                        if let Some(ref_name) = reference.split('/').next_back() {
582                            deps.insert(ref_name.to_string());
583                        }
584                    }
585                    ReferenceOr::Item(item_schema) => {
586                        extract_dependencies_recursive(item_schema, deps);
587                    }
588                }
589            }
590        }
591        _ => {}
592    }
593}
594
595/// Topologically sort schemas by their dependencies using Kahn's algorithm
596fn topological_sort_schemas(schemas: &HashMap<String, Schema>) -> Vec<String> {
597    let mut in_degree: HashMap<String, usize> = HashMap::new();
598    let mut graph: HashMap<String, Vec<String>> = HashMap::new();
599
600    for schema_name in schemas.keys() {
601        in_degree.insert(schema_name.clone(), 0);
602        graph.insert(schema_name.clone(), Vec::new());
603    }
604
605    for (schema_name, schema) in schemas {
606        let deps = extract_schema_dependencies(schema);
607        for dep in deps {
608            if schemas.contains_key(&dep) {
609                graph.entry(dep).or_default().push(schema_name.clone());
610                *in_degree.get_mut(schema_name).unwrap() += 1;
611            }
612        }
613    }
614
615    let mut queue: VecDeque<String> = in_degree
616        .iter()
617        .filter(|(_, deg)| **deg == 0)
618        .map(|(name, _)| name.clone())
619        .collect();
620
621    let mut result = Vec::new();
622
623    while let Some(node) = queue.pop_front() {
624        result.push(node.clone());
625
626        if let Some(neighbors) = graph.get(&node) {
627            for neighbor in neighbors {
628                let deg = in_degree.get_mut(neighbor).unwrap();
629                *deg -= 1;
630                if *deg == 0 {
631                    queue.push_back(neighbor.clone());
632                }
633            }
634        }
635    }
636
637    result
638}