1use 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 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 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 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 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 ¶meter_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
553fn extract_schema_dependencies(schema: &Schema) -> HashSet<String> {
555 let mut dependencies = HashSet::new();
556 extract_dependencies_recursive(schema, &mut dependencies);
557 dependencies
558}
559
560fn 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
595fn 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}