vika_cli/generator/zod_schema.rs
1use crate::error::Result;
2use crate::generator::swagger_parser::{get_schema_name_from_ref, resolve_ref};
3use crate::generator::utils::{sanitize_property_name, to_pascal_case};
4use crate::templates::context::ZodContext;
5use crate::templates::engine::TemplateEngine;
6use crate::templates::registry::TemplateId;
7use openapiv3::{OpenAPI, ReferenceOr, Schema, SchemaKind, Type};
8use std::collections::HashMap;
9
10pub struct ZodSchema {
11 pub content: String,
12}
13
14pub fn generate_zod_schemas(
15 openapi: &OpenAPI,
16 schemas: &HashMap<String, Schema>,
17 schema_names: &[String],
18) -> Result<Vec<ZodSchema>> {
19 generate_zod_schemas_with_registry(
20 openapi,
21 schemas,
22 schema_names,
23 &mut std::collections::HashMap::new(),
24 &[],
25 )
26}
27
28pub fn generate_zod_schemas_with_registry(
29 openapi: &OpenAPI,
30 schemas: &HashMap<String, Schema>,
31 schema_names: &[String],
32 enum_registry: &mut std::collections::HashMap<String, String>,
33 common_schemas: &[String],
34) -> Result<Vec<ZodSchema>> {
35 generate_zod_schemas_with_registry_and_engine(
36 openapi,
37 schemas,
38 schema_names,
39 enum_registry,
40 common_schemas,
41 None,
42 )
43}
44
45pub fn generate_zod_schemas_with_registry_and_engine(
46 openapi: &OpenAPI,
47 schemas: &HashMap<String, Schema>,
48 schema_names: &[String],
49 enum_registry: &mut std::collections::HashMap<String, String>,
50 common_schemas: &[String],
51 template_engine: Option<&TemplateEngine>,
52) -> Result<Vec<ZodSchema>> {
53 generate_zod_schemas_with_registry_and_engine_and_spec(
54 openapi,
55 schemas,
56 schema_names,
57 enum_registry,
58 common_schemas,
59 template_engine,
60 None,
61 )
62}
63
64pub fn generate_zod_schemas_with_registry_and_engine_and_spec(
65 openapi: &OpenAPI,
66 schemas: &HashMap<String, Schema>,
67 schema_names: &[String],
68 enum_registry: &mut std::collections::HashMap<String, String>,
69 common_schemas: &[String],
70 template_engine: Option<&TemplateEngine>,
71 spec_name: Option<&str>,
72) -> Result<Vec<ZodSchema>> {
73 let mut zod_schemas = Vec::new();
74 let mut processed = std::collections::HashSet::new();
75
76 for schema_name in schema_names {
77 if let Some(schema) = schemas.get(schema_name) {
78 generate_zod_for_schema(
79 openapi,
80 schema_name,
81 schema,
82 &mut zod_schemas,
83 &mut processed,
84 enum_registry,
85 None,
86 common_schemas,
87 template_engine,
88 spec_name,
89 )?;
90 }
91 }
92
93 Ok(zod_schemas)
94}
95
96#[allow(clippy::too_many_arguments)]
97fn generate_zod_for_schema(
98 openapi: &OpenAPI,
99 name: &str,
100 schema: &Schema,
101 zod_schemas: &mut Vec<ZodSchema>,
102 processed: &mut std::collections::HashSet<String>,
103 enum_registry: &mut std::collections::HashMap<String, String>,
104 parent_schema_name: Option<&str>,
105 common_schemas: &[String],
106 template_engine: Option<&TemplateEngine>,
107 spec_name: Option<&str>,
108) -> Result<()> {
109 if processed.contains(name) {
110 return Ok(());
111 }
112 processed.insert(name.to_string());
113
114 let schema_name = to_pascal_case(name);
115 let zod_def = schema_to_zod(
116 openapi,
117 schema,
118 zod_schemas,
119 processed,
120 0,
121 enum_registry,
122 None,
123 parent_schema_name,
124 common_schemas,
125 template_engine,
126 spec_name,
127 )?;
128
129 // Handle enums at top level (when schema itself is an enum)
130 // Note: For property-level enums, they're handled in schema_to_zod with context
131 if let SchemaKind::Type(Type::String(string_type)) = &schema.schema_kind {
132 if !string_type.enumeration.is_empty() {
133 let mut enum_values: Vec<String> = string_type
134 .enumeration
135 .iter()
136 .filter_map(|v| v.as_ref().cloned())
137 .collect();
138 if !enum_values.is_empty() {
139 enum_values.sort();
140 let enum_key = enum_values.join(",");
141 let schema_context_key = format!("schema_enum:{}", name);
142
143 // Use schema name if available, otherwise generate from values
144 let enum_name = if !name.is_empty() {
145 format!("{}Enum", to_pascal_case(name))
146 } else {
147 // Generate unique name from values
148 let value_hash: String = enum_values
149 .iter()
150 .take(3)
151 .map(|v| v.chars().next().unwrap_or('X'))
152 .collect();
153 format!("Enum{}", value_hash)
154 };
155
156 // Check if already generated in THIS zod_schemas collection
157 let schema_export = format!("export const {}Schema", enum_name);
158 let already_generated = zod_schemas
159 .iter()
160 .any(|s| s.content.contains(&schema_export));
161
162 if !already_generated {
163 // Store in registry (schema-specific + base key for reuse)
164 enum_registry.insert(schema_context_key, enum_name.clone());
165 if !enum_registry.contains_key(&enum_key) {
166 enum_registry.insert(enum_key.clone(), enum_name.clone());
167 }
168 if !name.is_empty() {
169 enum_registry.insert(format!("schema:{}", name), enum_name.clone());
170 }
171
172 if let Some(engine) = template_engine {
173 let context = ZodContext::enum_schema(
174 enum_name.clone(),
175 enum_values.clone(),
176 spec_name.map(|s| s.to_string()),
177 );
178 let content = engine.render(TemplateId::ZodEnum, &context)?;
179 zod_schemas.push(ZodSchema { content });
180 } else {
181 let enum_schema = generate_enum_zod(&enum_name, &enum_values);
182 zod_schemas.push(enum_schema);
183 }
184 }
185 return Ok(());
186 }
187 }
188 }
189
190 // Only create object schema if it's an object type
191 if matches!(&schema.schema_kind, SchemaKind::Type(Type::Object(_))) {
192 // Check if this is an empty object (should be a record type)
193 if let SchemaKind::Type(Type::Object(obj)) = &schema.schema_kind {
194 if obj.properties.is_empty() {
195 // Empty object with additionalProperties - use z.record() directly
196 if let Some(engine) = template_engine {
197 let description = schema.schema_data.description.clone();
198 let context = ZodContext::schema_with_annotation(
199 schema_name.clone(),
200 "z.record(z.string(), z.any())".to_string(),
201 description,
202 spec_name.map(|s| s.to_string()),
203 );
204 let content = engine.render(TemplateId::ZodSchema, &context)?;
205 zod_schemas.push(ZodSchema { content });
206 } else {
207 zod_schemas.push(ZodSchema {
208 content: format!(
209 "export const {}Schema: z.ZodType<any> = z.record(z.string(), z.any());",
210 schema_name
211 ),
212 });
213 }
214 } else {
215 // Regular object with properties
216 // Check if this schema has circular references (if it's already processed, it might be circular)
217 let has_circular_ref = zod_def.contains("z.lazy");
218 if let Some(engine) = template_engine {
219 let description = schema.schema_data.description.clone();
220 let zod_expr = format!("z.object({{\n{}\n}})", zod_def);
221 let context = if has_circular_ref {
222 ZodContext::schema_with_annotation(
223 schema_name.clone(),
224 zod_expr,
225 description,
226 spec_name.map(|s| s.to_string()),
227 )
228 } else {
229 ZodContext::schema(
230 schema_name.clone(),
231 zod_expr,
232 description,
233 spec_name.map(|s| s.to_string()),
234 )
235 };
236 let content = engine.render(TemplateId::ZodSchema, &context)?;
237 zod_schemas.push(ZodSchema { content });
238 } else {
239 zod_schemas.push(ZodSchema {
240 content: format!(
241 "export const {}Schema: z.ZodType<any> = z.object({{\n{}\n}});",
242 schema_name, zod_def
243 ),
244 });
245 }
246 }
247 }
248 }
249
250 Ok(())
251}
252
253#[allow(clippy::too_many_arguments)]
254fn schema_to_zod(
255 openapi: &OpenAPI,
256 schema: &Schema,
257 zod_schemas: &mut Vec<ZodSchema>,
258 processed: &mut std::collections::HashSet<String>,
259 indent: usize,
260 enum_registry: &mut std::collections::HashMap<String, String>,
261 context: Option<(&str, &str)>, // (property_name, parent_schema_name)
262 current_schema_name: Option<&str>, // Current schema being processed (for enum naming context)
263 common_schemas: &[String],
264 template_engine: Option<&TemplateEngine>,
265 spec_name: Option<&str>,
266) -> Result<String> {
267 // Prevent infinite recursion with a reasonable depth limit
268 if indent > 100 {
269 return Ok(format!("{}z.any()", " ".repeat(indent)));
270 }
271 let indent_str = " ".repeat(indent);
272
273 match &schema.schema_kind {
274 SchemaKind::Type(type_) => {
275 match type_ {
276 Type::String(string_type) => {
277 if !string_type.enumeration.is_empty() {
278 let mut enum_values: Vec<String> = string_type
279 .enumeration
280 .iter()
281 .filter_map(|v| v.as_ref().cloned())
282 .collect();
283 if !enum_values.is_empty() {
284 // Create a key from sorted enum values to check registry
285 enum_values.sort();
286 let enum_key = enum_values.join(",");
287
288 // For generic property names, include context in the key to avoid conflicts
289 // Only do this for truly generic names like status/type/state/kind
290 // For other generic identifiers (key/code/id/name), let them reuse based on values
291 let context_key = if let Some((prop_name, parent_schema)) = context {
292 let generic_names = ["status", "type", "state", "kind"];
293 if generic_names.contains(&prop_name.to_lowercase().as_str())
294 && !parent_schema.is_empty()
295 {
296 // Include parent schema in key for generic properties to avoid conflicts
297 format!("{}:{}", enum_key, parent_schema)
298 } else {
299 enum_key.clone()
300 }
301 } else {
302 enum_key.clone()
303 };
304
305 // Check if this enum already exists in registry
306 // First check context_key (for context-aware enums)
307 // Then check base enum_key to deduplicate enums with same values
308 // This ensures enums with the same values reuse the same enum name
309 let existing_enum_name = enum_registry
310 .get(&context_key)
311 .or_else(|| enum_registry.get(&enum_key))
312 .cloned();
313
314 if let Some(existing_name) = existing_enum_name {
315 // Store in registry with context_key for future lookups
316 enum_registry.insert(context_key.clone(), existing_name.clone());
317
318 // Check if enum schema has already been generated
319 // Must match the actual definition, not just a reference
320 let schema_already_generated = zod_schemas.iter().any(|s| {
321 let schema_name_pattern = format!("{}Schema", existing_name);
322 s.content.contains(&format!(
323 "export const {} =",
324 schema_name_pattern
325 ))
326 });
327
328 // If not generated yet, generate it now
329 if !schema_already_generated {
330 if let Some(engine) = template_engine {
331 let context = ZodContext::enum_schema(
332 existing_name.clone(),
333 enum_values.clone(),
334 spec_name.map(|s| s.to_string()),
335 );
336 let content =
337 engine.render(TemplateId::ZodEnum, &context)?;
338 zod_schemas.push(ZodSchema { content });
339 } else {
340 let enum_schema =
341 generate_enum_zod(&existing_name, &enum_values);
342 zod_schemas.push(enum_schema);
343 }
344 }
345
346 // Enums don't need z.lazy(), use directly
347 return Ok(format!("{}{}Schema", indent_str, existing_name));
348 }
349
350 // Generate meaningful enum name using context (property name + parent schema) or fallback
351 let enum_name = if let Some((prop_name, parent_schema)) = context {
352 // Use property name + parent schema for meaningful name to avoid conflicts
353 // For generic names like "status", use parent schema to differentiate
354 let prop_pascal = to_pascal_case(prop_name);
355
356 // If property name is generic (status, type, etc.), use parent schema
357 let generic_names = ["status", "type", "state", "kind"];
358 if generic_names.contains(&prop_name.to_lowercase().as_str())
359 && !parent_schema.is_empty()
360 {
361 let parent_pascal = to_pascal_case(parent_schema);
362 // Remove common suffixes from parent schema name
363 let parent_clean = parent_pascal
364 .trim_end_matches("ResponseDto")
365 .trim_end_matches("Dto")
366 .trim_end_matches("Response")
367 .to_string();
368
369 // Check if parent already contains the property name (e.g., "KycStatus" contains "Status")
370 // Use case-insensitive matching and check if property name is a suffix or contained
371 let prop_lower = prop_pascal.to_lowercase();
372 let parent_lower = parent_clean.to_lowercase();
373
374 // Check if parent ends with property name (e.g., "KycStatus" ends with "Status")
375 // or if property is contained in parent (case-insensitive)
376 if parent_lower.ends_with(&prop_lower)
377 || parent_lower.contains(&prop_lower)
378 {
379 // Parent already contains property name, just use parent + Enum
380 format!("{}Enum", parent_clean)
381 } else {
382 // Combine parent + property
383 format!("{}{}Enum", parent_clean, prop_pascal)
384 }
385 } else {
386 // For non-generic property names, check if parent schema name suggests a better enum name
387 // This helps with cases like "key" and "provider" in "AvailableProviderDto" both referring to provider enums
388 if !parent_schema.is_empty() {
389 let parent_pascal = to_pascal_case(parent_schema);
390 let parent_clean = parent_pascal
391 .trim_end_matches("ResponseDto")
392 .trim_end_matches("Dto")
393 .trim_end_matches("Response")
394 .to_string();
395
396 // If parent contains a word that matches the property conceptually, use that
397 // For example, "AvailableProviderDto" contains "Provider", so "key" property should use "ProviderEnum"
398
399 // Check if parent contains a more descriptive word (like "Provider" in "AvailableProvider")
400 // and the property is a generic identifier (like "key", "id", "name", "code")
401 let generic_identifiers =
402 ["key", "id", "name", "value", "code"];
403 if generic_identifiers
404 .contains(&prop_name.to_lowercase().as_str())
405 {
406 // For generic identifiers, always use parent schema to create unique enum names
407 // This prevents conflicts when different schemas have the same property name
408 // (e.g., CurrencyResponseDto.code vs OrderValidationErrorDto.code)
409 let meaningful_part = parent_clean
410 .trim_start_matches("Available")
411 .trim_start_matches("Get")
412 .trim_start_matches("Create")
413 .trim_start_matches("Update")
414 .trim_start_matches("Delete");
415
416 // Always include parent schema name for generic identifiers to avoid conflicts
417 if !meaningful_part.is_empty() {
418 format!("{}{}Enum", meaningful_part, prop_pascal)
419 } else {
420 format!("{}{}Enum", parent_clean, prop_pascal)
421 }
422 } else {
423 format!("{}Enum", prop_pascal)
424 }
425 } else {
426 format!("{}Enum", prop_pascal)
427 }
428 }
429 } else if !enum_values.is_empty() {
430 // Fallback: use first value to create name
431 let first_value = &enum_values[0];
432 let base_name = first_value
433 .chars()
434 .take(1)
435 .collect::<String>()
436 .to_uppercase()
437 + &first_value.chars().skip(1).collect::<String>();
438 format!("{}Enum", to_pascal_case(&base_name))
439 } else {
440 "UnknownEnum".to_string()
441 };
442
443 // Store in registry using context_key (includes context for generic properties)
444 // Also store with base enum_key for deduplication
445 enum_registry.insert(context_key.clone(), enum_name.clone());
446 enum_registry.insert(enum_key.clone(), enum_name.clone());
447
448 // Generate enum schema
449 if let Some(engine) = template_engine {
450 let context = ZodContext::enum_schema(
451 enum_name.clone(),
452 enum_values.clone(),
453 spec_name.map(|s| s.to_string()),
454 );
455 let content = engine.render(TemplateId::ZodEnum, &context)?;
456 zod_schemas.push(ZodSchema { content });
457 } else {
458 let enum_schema = generate_enum_zod(&enum_name, &enum_values);
459 zod_schemas.push(enum_schema);
460 }
461
462 // Enums don't need z.lazy(), use directly
463 return Ok(format!("{}{}Schema", indent_str, enum_name));
464 }
465 }
466 let mut zod_expr = format!("{}z.string()", indent_str);
467
468 // Add string constraints
469 if let Some(min_length) = string_type.min_length {
470 zod_expr = format!("{}.min({})", zod_expr, min_length);
471 }
472 if let Some(max_length) = string_type.max_length {
473 zod_expr = format!("{}.max({})", zod_expr, max_length);
474 }
475 if let Some(pattern) = &string_type.pattern {
476 // Escape regex pattern for JavaScript
477 let escaped_pattern = pattern.replace('\\', "\\\\").replace('"', "\\\"");
478 zod_expr = format!("{}.regex(/^{}$/)", zod_expr, escaped_pattern);
479 }
480 // Handle format - it's a VariantOrUnknownOrEmpty
481 // Try to extract format as string from both Item and Unknown variants
482 let format_str_opt = match &string_type.format {
483 openapiv3::VariantOrUnknownOrEmpty::Item(format) => {
484 // Convert enum variant to string using Debug
485 let debug_str = format!("{:?}", format);
486 // Extract format name (e.g., "Email" from "Email" or "StringFormat::Email")
487 let format_name = debug_str
488 .split("::")
489 .last()
490 .unwrap_or(&debug_str)
491 .to_lowercase();
492 Some(format_name)
493 }
494 openapiv3::VariantOrUnknownOrEmpty::Unknown(s) => Some(s.clone()),
495 _ => None,
496 };
497
498 if let Some(format_str) = format_str_opt {
499 match format_str.as_str() {
500 "email" => zod_expr = format!("{}.email()", zod_expr),
501 "uri" | "url" => zod_expr = format!("{}.url()", zod_expr),
502 "uuid" => zod_expr = format!("{}.uuid()", zod_expr),
503 "date-time" | "datetime" | "date_time" => {
504 zod_expr = format!("{}.datetime()", zod_expr)
505 }
506 _ => {}
507 }
508 }
509
510 Ok(zod_expr)
511 }
512 Type::Number(number_type) => {
513 let mut zod_expr = format!("{}z.number()", indent_str);
514
515 // Add number constraints
516 if let Some(minimum) = number_type.minimum {
517 zod_expr = format!("{}.min({})", zod_expr, minimum);
518 }
519 if let Some(maximum) = number_type.maximum {
520 zod_expr = format!("{}.max({})", zod_expr, maximum);
521 }
522 if let Some(multiple_of) = number_type.multiple_of {
523 zod_expr = format!("{}.multipleOf({})", zod_expr, multiple_of);
524 }
525
526 Ok(zod_expr)
527 }
528 Type::Integer(integer_type) => {
529 let mut zod_expr = format!("{}z.number().int()", indent_str);
530
531 // Add integer constraints
532 if let Some(minimum) = integer_type.minimum {
533 zod_expr = format!("{}.min({})", zod_expr, minimum);
534 }
535 if let Some(maximum) = integer_type.maximum {
536 zod_expr = format!("{}.max({})", zod_expr, maximum);
537 }
538 if let Some(multiple_of) = integer_type.multiple_of {
539 zod_expr = format!("{}.multipleOf({})", zod_expr, multiple_of);
540 }
541
542 Ok(zod_expr)
543 }
544 Type::Boolean(_) => Ok(format!("{}z.boolean()", indent_str)),
545 Type::Array(array_type) => {
546 let item_zod = if let Some(items) = &array_type.items {
547 match items {
548 ReferenceOr::Reference { reference } => {
549 if let Some(ref_name) = get_schema_name_from_ref(reference) {
550 // Check if this $ref points to a top-level enum schema
551 let schema_enum_key = format!("schema:{}", ref_name);
552 let enum_name_opt = enum_registry.get(&schema_enum_key);
553
554 let schema_ref = if let Some(enum_name) = enum_name_opt {
555 // It's an enum, use the enum schema name
556 if common_schemas.contains(&ref_name) {
557 format!("Common.{}Schema", enum_name)
558 } else {
559 format!("{}Schema", enum_name)
560 }
561 } else {
562 // Not an enum, use the schema name
563 if common_schemas.contains(&ref_name) {
564 format!("Common.{}Schema", to_pascal_case(&ref_name))
565 } else {
566 format!("{}Schema", to_pascal_case(&ref_name))
567 }
568 };
569
570 // If already processed, use lazy reference to avoid infinite recursion
571 if processed.contains(&ref_name) {
572 format!("{}z.lazy(() => {})", indent_str, schema_ref)
573 } else {
574 let resolved =
575 resolve_ref(openapi, reference).map_err(|e| {
576 crate::error::SchemaError::InvalidReference {
577 ref_path: format!("Failed to resolve: {}", e),
578 }
579 })?;
580 if let ReferenceOr::Item(item_schema) = resolved {
581 // Check if it's an object that needs to be extracted
582 if matches!(
583 &item_schema.schema_kind,
584 SchemaKind::Type(Type::Object(_))
585 ) {
586 generate_zod_for_schema(
587 openapi,
588 &ref_name,
589 &item_schema,
590 zod_schemas,
591 processed,
592 enum_registry,
593 None,
594 common_schemas,
595 template_engine,
596 spec_name,
597 )?;
598 format!(
599 "{}z.lazy(() => {})",
600 indent_str, schema_ref
601 )
602 } else {
603 schema_to_zod(
604 openapi,
605 &item_schema,
606 zod_schemas,
607 processed,
608 indent,
609 enum_registry,
610 context,
611 current_schema_name,
612 common_schemas,
613 template_engine,
614 spec_name,
615 )?
616 }
617 } else {
618 format!("{}z.lazy(() => {})", indent_str, schema_ref)
619 }
620 }
621 } else {
622 format!("{}z.any()", indent_str)
623 }
624 }
625 ReferenceOr::Item(item_schema) => {
626 // If it's an object, we need to generate it inline or as a separate schema
627 if matches!(
628 &item_schema.schema_kind,
629 SchemaKind::Type(Type::Object(_))
630 ) {
631 // Generate object fields
632 let object_fields = schema_to_zod(
633 openapi,
634 item_schema,
635 zod_schemas,
636 processed,
637 indent + 1,
638 enum_registry,
639 None,
640 current_schema_name,
641 common_schemas,
642 template_engine,
643 spec_name,
644 )?;
645 // Wrap in z.object()
646 format!(
647 "{}z.object({{\n{}\n{}}})",
648 indent_str, object_fields, indent_str
649 )
650 } else {
651 schema_to_zod(
652 openapi,
653 item_schema,
654 zod_schemas,
655 processed,
656 indent,
657 enum_registry,
658 None,
659 current_schema_name,
660 common_schemas,
661 template_engine,
662 spec_name,
663 )?
664 }
665 }
666 }
667 } else {
668 format!("{}z.any()", indent_str)
669 };
670
671 let mut array_zod = format!("{}z.array({})", indent_str, item_zod.trim_start());
672
673 // Add array constraints
674 if let Some(min_items) = array_type.min_items {
675 array_zod = format!("{}.min({})", array_zod, min_items);
676 }
677 if let Some(max_items) = array_type.max_items {
678 array_zod = format!("{}.max({})", array_zod, max_items);
679 }
680
681 Ok(array_zod)
682 }
683 Type::Object(object_type) => {
684 if !object_type.properties.is_empty() {
685 let mut fields = Vec::new();
686 // Get parent schema name from context if available, otherwise use current_schema_name parameter
687 // For object properties, use the current schema name as parent
688 let parent_schema_for_props = if let Some((_, parent)) = context {
689 if !parent.is_empty() {
690 parent.to_string()
691 } else if let Some(current) = current_schema_name {
692 current.to_string()
693 } else {
694 String::new()
695 }
696 } else if let Some(current) = current_schema_name {
697 current.to_string()
698 } else {
699 String::new()
700 };
701
702 for (prop_name, prop_schema_ref) in object_type.properties.iter() {
703 let prop_zod = match prop_schema_ref {
704 ReferenceOr::Reference { reference } => {
705 // For $ref properties, prefer enum schemas if the target is a top-level enum,
706 // otherwise use the referenced object schema (possibly lazily).
707 if let Some(ref_name) = get_schema_name_from_ref(reference) {
708 // If this $ref points to a top-level enum schema, use the enum schema directly
709 let schema_enum_key = format!("schema:{}", ref_name);
710 if let Some(enum_name) = enum_registry.get(&schema_enum_key)
711 {
712 // Clone enum_name to avoid borrow checker issues
713 let enum_name = enum_name.clone();
714
715 // Check if enum schema has already been generated
716 let schema_already_generated =
717 zod_schemas.iter().any(|s| {
718 s.content
719 .contains(&format!("{}Schema", enum_name))
720 });
721
722 // If not generated yet, generate it
723 if !schema_already_generated {
724 if !processed.contains(&ref_name) {
725 if let Ok(ReferenceOr::Item(ref_schema)) =
726 resolve_ref(openapi, reference)
727 {
728 generate_zod_for_schema(
729 openapi,
730 &ref_name,
731 &ref_schema,
732 zod_schemas,
733 processed,
734 enum_registry,
735 Some(&parent_schema_for_props),
736 common_schemas,
737 template_engine,
738 spec_name,
739 )?;
740 }
741 } else {
742 // Schema was processed but enum schema not generated
743 // This can happen if the enum was registered from TypeScript generation
744 // We need to resolve the schema and generate the enum schema
745 if let Ok(ReferenceOr::Item(ref_schema)) =
746 resolve_ref(openapi, reference)
747 {
748 // Extract enum values from the schema
749 if let SchemaKind::Type(Type::String(
750 string_type,
751 )) = &ref_schema.schema_kind
752 {
753 if !string_type.enumeration.is_empty() {
754 let mut enum_values: Vec<String> =
755 string_type
756 .enumeration
757 .iter()
758 .filter_map(|v| {
759 v.as_ref().cloned()
760 })
761 .collect();
762 if !enum_values.is_empty() {
763 enum_values.sort();
764 if let Some(engine) =
765 template_engine
766 {
767 let context =
768 ZodContext::enum_schema(
769 enum_name.clone(),
770 enum_values.clone(),
771 spec_name.map(
772 |s| {
773 s.to_string(
774 )
775 },
776 ),
777 );
778 let content = engine
779 .render(
780 TemplateId::ZodEnum,
781 &context,
782 )?;
783 zod_schemas.push(
784 ZodSchema { content },
785 );
786 } else {
787 let enum_schema =
788 generate_enum_zod(
789 &enum_name,
790 &enum_values,
791 );
792 zod_schemas
793 .push(enum_schema);
794 }
795 }
796 }
797 }
798 }
799 }
800 }
801
802 let schema_ref = if common_schemas.contains(&ref_name) {
803 format!("Common.{}Schema", enum_name)
804 } else {
805 format!("{}Schema", enum_name)
806 };
807 format!("{}{}", indent_str, schema_ref)
808 } else {
809 // Generate the referenced schema if not already processed
810 if !processed.contains(&ref_name) {
811 if let Ok(ReferenceOr::Item(ref_schema)) =
812 resolve_ref(openapi, reference)
813 {
814 // Generate both enum and object schemas
815 generate_zod_for_schema(
816 openapi,
817 &ref_name,
818 &ref_schema,
819 zod_schemas,
820 processed,
821 enum_registry,
822 Some(&parent_schema_for_props),
823 common_schemas,
824 template_engine,
825 spec_name,
826 )?;
827 }
828 }
829 // Check if this is an enum schema (even if not found in registry yet)
830 let schema_enum_key = format!("schema:{}", ref_name);
831 let enum_name_opt = enum_registry.get(&schema_enum_key);
832
833 if let Some(enum_name) = enum_name_opt {
834 // It's an enum, use the enum schema name directly (no lazy needed)
835 let schema_ref =
836 if common_schemas.contains(&ref_name) {
837 format!("Common.{}Schema", enum_name)
838 } else {
839 format!("{}Schema", enum_name)
840 };
841 format!("{}{}", indent_str, schema_ref)
842 } else {
843 // Not an enum, use the schema name with lazy
844 let schema_ref = if common_schemas
845 .contains(&ref_name)
846 {
847 format!(
848 "Common.{}Schema",
849 to_pascal_case(&ref_name)
850 )
851 } else {
852 format!("{}Schema", to_pascal_case(&ref_name))
853 };
854 format!(
855 "{}z.lazy(() => {})",
856 indent_str, schema_ref
857 )
858 }
859 }
860 } else {
861 format!("{}z.any()", indent_str)
862 }
863 }
864 ReferenceOr::Item(prop_schema) => schema_to_zod(
865 openapi,
866 prop_schema,
867 zod_schemas,
868 processed,
869 indent + 1,
870 enum_registry,
871 Some((prop_name, &parent_schema_for_props)),
872 current_schema_name,
873 common_schemas,
874 template_engine,
875 spec_name,
876 )?,
877 };
878
879 let required = object_type.required.contains(prop_name);
880
881 let nullable = prop_schema_ref
882 .as_item()
883 .map(|s| s.schema_data.nullable)
884 .unwrap_or(false);
885
886 let mut zod_expr = prop_zod.trim_start().to_string();
887 if nullable {
888 zod_expr = format!("{}.nullable()", zod_expr);
889 }
890 if !required {
891 zod_expr = format!("{}.optional()", zod_expr);
892 }
893
894 fields.push(format!(
895 "{}{}: {},",
896 " ".repeat(indent + 1),
897 sanitize_property_name(prop_name),
898 zod_expr
899 ));
900 }
901 Ok(fields.join("\n"))
902 } else {
903 Ok(format!("{}z.record(z.string(), z.any())", indent_str))
904 }
905 }
906 }
907 }
908 SchemaKind::Any(_) => Ok(format!("{}z.any()", indent_str)),
909 SchemaKind::OneOf { one_of, .. } => {
910 let mut variant_schemas = Vec::new();
911 for item in one_of {
912 match item {
913 ReferenceOr::Reference { reference } => {
914 if let Some(ref_name) = get_schema_name_from_ref(reference) {
915 // Check if this $ref points to a top-level enum schema
916 let schema_enum_key = format!("schema:{}", ref_name);
917 let enum_name_opt = enum_registry.get(&schema_enum_key);
918
919 let schema_ref = if let Some(enum_name) = enum_name_opt {
920 // It's an enum, use the enum schema name
921 if common_schemas.contains(&ref_name) {
922 format!("Common.{}Schema", enum_name)
923 } else {
924 format!("{}Schema", enum_name)
925 }
926 } else {
927 // Not an enum, use the schema name
928 if common_schemas.contains(&ref_name) {
929 format!("Common.{}Schema", to_pascal_case(&ref_name))
930 } else {
931 format!("{}Schema", to_pascal_case(&ref_name))
932 }
933 };
934 variant_schemas
935 .push(format!("{}z.lazy(() => {})", indent_str, schema_ref));
936 } else {
937 variant_schemas.push(format!("{}z.any()", indent_str));
938 }
939 }
940 ReferenceOr::Item(item_schema) => {
941 let item_zod = schema_to_zod(
942 openapi,
943 item_schema,
944 zod_schemas,
945 processed,
946 indent,
947 enum_registry,
948 None,
949 current_schema_name,
950 common_schemas,
951 template_engine,
952 spec_name,
953 )?;
954 variant_schemas.push(item_zod);
955 }
956 }
957 }
958 if variant_schemas.is_empty() {
959 Ok(format!("{}z.any()", indent_str))
960 } else {
961 Ok(format!(
962 "{}z.union([{}])",
963 indent_str,
964 variant_schemas.join(", ")
965 ))
966 }
967 }
968 SchemaKind::AllOf { all_of, .. } => {
969 let mut all_schemas = Vec::new();
970 for item in all_of {
971 match item {
972 ReferenceOr::Reference { reference } => {
973 if let Some(ref_name) = get_schema_name_from_ref(reference) {
974 let schema_ref = if common_schemas.contains(&ref_name) {
975 format!("Common.{}Schema", to_pascal_case(&ref_name))
976 } else {
977 format!("{}Schema", to_pascal_case(&ref_name))
978 };
979 all_schemas.push(format!("{}z.lazy(() => {})", indent_str, schema_ref));
980 } else {
981 all_schemas.push(format!("{}z.any()", indent_str));
982 }
983 }
984 ReferenceOr::Item(item_schema) => {
985 let item_zod = schema_to_zod(
986 openapi,
987 item_schema,
988 zod_schemas,
989 processed,
990 indent,
991 enum_registry,
992 None,
993 current_schema_name,
994 common_schemas,
995 template_engine,
996 spec_name,
997 )?;
998 all_schemas.push(item_zod);
999 }
1000 }
1001 }
1002 if all_schemas.is_empty() {
1003 Ok(format!("{}z.any()", indent_str))
1004 } else if all_schemas.len() == 1 {
1005 Ok(all_schemas[0].clone())
1006 } else {
1007 // AllOf represents intersection: all schemas must be satisfied
1008 // Zod uses .and() for intersection. Chain them: schema1.and(schema2).and(schema3)
1009 let mut result = all_schemas[0].clone();
1010 for schema in all_schemas.iter().skip(1) {
1011 result = format!("{}.and({})", result.trim(), schema.trim());
1012 }
1013 Ok(result)
1014 }
1015 }
1016 SchemaKind::AnyOf { any_of, .. } => {
1017 // AnyOf is treated same as OneOf (union)
1018 let mut variant_schemas = Vec::new();
1019 for item in any_of {
1020 match item {
1021 ReferenceOr::Reference { reference } => {
1022 if let Some(ref_name) = get_schema_name_from_ref(reference) {
1023 // Check if this $ref points to a top-level enum schema
1024 let schema_enum_key = format!("schema:{}", ref_name);
1025 let enum_name_opt = enum_registry.get(&schema_enum_key);
1026
1027 let schema_ref = if let Some(enum_name) = enum_name_opt {
1028 // It's an enum, use the enum schema name
1029 if common_schemas.contains(&ref_name) {
1030 format!("Common.{}Schema", enum_name)
1031 } else {
1032 format!("{}Schema", enum_name)
1033 }
1034 } else {
1035 // Not an enum, use the schema name
1036 if common_schemas.contains(&ref_name) {
1037 format!("Common.{}Schema", to_pascal_case(&ref_name))
1038 } else {
1039 format!("{}Schema", to_pascal_case(&ref_name))
1040 }
1041 };
1042 variant_schemas
1043 .push(format!("{}z.lazy(() => {})", indent_str, schema_ref));
1044 } else {
1045 variant_schemas.push(format!("{}z.any()", indent_str));
1046 }
1047 }
1048 ReferenceOr::Item(item_schema) => {
1049 let item_zod = schema_to_zod(
1050 openapi,
1051 item_schema,
1052 zod_schemas,
1053 processed,
1054 indent,
1055 enum_registry,
1056 None,
1057 current_schema_name,
1058 common_schemas,
1059 template_engine,
1060 spec_name,
1061 )?;
1062 variant_schemas.push(item_zod);
1063 }
1064 }
1065 }
1066 if variant_schemas.is_empty() {
1067 Ok(format!("{}z.any()", indent_str))
1068 } else {
1069 Ok(format!(
1070 "{}z.union([{}])",
1071 indent_str,
1072 variant_schemas.join(", ")
1073 ))
1074 }
1075 }
1076 SchemaKind::Not { .. } => Ok(format!("{}z.any()", indent_str)),
1077 }
1078}
1079
1080fn generate_enum_zod(name: &str, values: &[String]) -> ZodSchema {
1081 let enum_name = to_pascal_case(name);
1082 let enum_values = values
1083 .iter()
1084 .map(|v| format!("\"{}\"", v))
1085 .collect::<Vec<_>>()
1086 .join(", ");
1087
1088 ZodSchema {
1089 content: format!(
1090 "export const {}Schema = z.enum([{}]);",
1091 enum_name, enum_values
1092 ),
1093 }
1094}