1use super::RustDtoStyle;
4use crate::codegen::common::{TargetLanguage, sanitize_identifier_snake_case};
5use anyhow::Result;
6use heck::{ToPascalCase, ToSnakeCase};
7use openapiv3::{
8 IntegerFormat, OpenAPI, Operation, ReferenceOr, Schema, SchemaKind, StringFormat, Type, VariantOrUnknownOrEmpty,
9};
10use std::collections::{BTreeSet, HashSet};
11
12#[derive(Debug, Clone)]
13struct RustFieldSpec {
14 original_name: String,
15 field_name: String,
16 type_hint: String,
17 required: bool,
18}
19
20pub struct RustGenerator {
21 spec: OpenAPI,
22 style: RustDtoStyle,
23}
24
25impl RustGenerator {
26 #[must_use]
27 pub const fn new(spec: OpenAPI, style: RustDtoStyle) -> Self {
28 Self { spec, style }
29 }
30
31 pub fn generate(&self) -> Result<String> {
32 let mut output = String::new();
33 match self.style {
34 RustDtoStyle::SerdeStruct => {}
35 }
36
37 output.push_str(&self.generate_header());
38
39 output.push_str(&self.generate_models()?);
40 output.push_str(&self.generate_operation_models()?);
41
42 let (handlers, registrations) = self.generate_handlers()?;
43 output.push_str(&handlers);
44 output.push_str(&self.generate_builder(®istrations));
45
46 Ok(output)
47 }
48
49 fn generate_header(&self) -> String {
50 let mut imports = vec![
51 "App".to_string(),
52 "AppError".to_string(),
53 "HandlerResult".to_string(),
54 "RequestContext".to_string(),
55 ];
56 imports.extend(self.route_builder_imports());
57 format!(
58 r"// Generated by Spikard OpenAPI code generator
59// OpenAPI Version: {}
60// Title: {}
61// DO NOT EDIT - regenerate from OpenAPI schema
62
63use axum::body::Body;
64use axum::http::StatusCode;
65use axum::http::Response as HttpResponse;
66use schemars::JsonSchema;
67use serde::{{Deserialize, Serialize}};
68use spikard::{{{}}};
69
70",
71 self.spec.openapi,
72 self.spec.info.title,
73 imports.join(", ")
74 )
75 }
76
77 fn route_builder_imports(&self) -> Vec<String> {
78 let mut builders = BTreeSet::new();
79
80 for path_item_ref in self.spec.paths.paths.values() {
81 let ReferenceOr::Item(path_item) = path_item_ref else {
82 continue;
83 };
84
85 if path_item.get.is_some() {
86 builders.insert("get".to_string());
87 }
88 if path_item.post.is_some() {
89 builders.insert("post".to_string());
90 }
91 if path_item.put.is_some() {
92 builders.insert("put".to_string());
93 }
94 if path_item.patch.is_some() {
95 builders.insert("patch".to_string());
96 }
97 if path_item.delete.is_some() {
98 builders.insert("delete".to_string());
99 }
100 }
101
102 builders.into_iter().collect()
103 }
104
105 fn generate_models(&self) -> Result<String> {
106 let mut output = String::new();
107 output.push_str("// Schema Models\n\n");
108
109 if let Some(components) = &self.spec.components {
110 for (name, schema_ref) in &components.schemas {
111 match schema_ref {
112 ReferenceOr::Item(schema) => {
113 output.push_str(&self.generate_model_struct(name, schema)?);
114 output.push('\n');
115 }
116 ReferenceOr::Reference { .. } => {
117 continue;
118 }
119 }
120 }
121 }
122
123 Ok(output)
124 }
125
126 fn generate_operation_models(&self) -> Result<String> {
127 let mut output = String::new();
128 let mut emitted = HashSet::new();
129
130 for path_item_ref in self.spec.paths.paths.values() {
131 let ReferenceOr::Item(path_item) = path_item_ref else {
132 continue;
133 };
134
135 for operation in [
136 path_item.get.as_ref(),
137 path_item.post.as_ref(),
138 path_item.put.as_ref(),
139 path_item.delete.as_ref(),
140 path_item.patch.as_ref(),
141 ]
142 .into_iter()
143 .flatten()
144 {
145 if let Some((name, schema)) = self.request_body_inline_model(operation)
146 && emitted.insert(name.clone())
147 {
148 output.push_str(&self.generate_inline_operation_model(&name, schema)?);
149 output.push('\n');
150 }
151
152 if let Some((name, schema)) = self.response_body_inline_model(operation)
153 && emitted.insert(name.clone())
154 {
155 output.push_str(&self.generate_inline_operation_model(&name, schema)?);
156 output.push('\n');
157 }
158 }
159 }
160
161 Ok(output)
162 }
163
164 fn generate_model_struct(&self, name: &str, schema: &Schema) -> Result<String> {
165 let struct_name = name.to_pascal_case();
166 self.generate_named_struct_recursive(&struct_name, schema)
167 }
168
169 fn generate_named_struct_recursive(&self, struct_name: &str, schema: &Schema) -> Result<String> {
170 let mut output = String::new();
171 let mut properties = Vec::new();
172 self.collect_object_properties(schema, &mut properties);
173
174 if let Some(description) = &schema.schema_data.description {
175 output.push_str(&render_doc_comment(description, 0));
176 }
177
178 for (prop_name, prop_schema_ref, _required) in &properties {
179 match prop_schema_ref {
180 ReferenceOr::Item(prop_schema) => match &prop_schema.schema_kind {
181 SchemaKind::Type(Type::Object(obj)) if !obj.properties.is_empty() => {
182 let nested_name = format!("{struct_name}{}", prop_name.to_pascal_case());
183 output.push_str(&self.generate_named_struct_recursive(&nested_name, prop_schema)?);
184 output.push('\n');
185 }
186 SchemaKind::Type(Type::Array(arr)) => {
187 if let Some(ReferenceOr::Item(item_schema)) = &arr.items
188 && let SchemaKind::Type(Type::Object(item_obj)) = &item_schema.schema_kind
189 && !item_obj.properties.is_empty()
190 {
191 let nested_name = format!("{struct_name}{}Item", prop_name.to_pascal_case());
192 output.push_str(&self.generate_named_struct_recursive(&nested_name, item_schema)?);
193 output.push('\n');
194 }
195 }
196 _ => {}
197 },
198 ReferenceOr::Reference { .. } => {}
199 }
200 }
201
202 output.push_str("#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]\n");
203 output.push_str(&format!("pub struct {struct_name} {{\n"));
204
205 let fields = self.collect_struct_fields(struct_name, &properties);
206 if fields.is_empty() {
207 output.push_str(" // Empty struct\n");
208 } else {
209 for field in fields {
210 if !field.required {
211 output.push_str(" #[serde(skip_serializing_if = \"Option::is_none\")]\n");
212 }
213 if field.field_name != field.original_name {
214 output.push_str(&format!(" #[serde(rename = \"{}\")]\n", field.original_name));
215 }
216
217 output.push_str(&format!(" pub {}: {},\n", field.field_name, field.type_hint));
218 }
219 }
220
221 output.push_str("}\n");
222
223 Ok(output)
224 }
225
226 fn collect_object_properties(
227 &self,
228 schema: &Schema,
229 properties: &mut Vec<(String, ReferenceOr<Box<Schema>>, bool)>,
230 ) {
231 match &schema.schema_kind {
232 SchemaKind::Type(Type::Object(obj)) => {
233 for (prop_name, prop_schema_ref) in &obj.properties {
234 if properties
235 .iter()
236 .any(|(existing_name, _, _)| existing_name == prop_name)
237 {
238 continue;
239 }
240 properties.push((
241 prop_name.clone(),
242 prop_schema_ref.clone(),
243 obj.required.contains(prop_name),
244 ));
245 }
246 }
247 SchemaKind::AllOf { all_of } => {
248 for schema_ref in all_of {
249 match schema_ref {
250 ReferenceOr::Item(schema) => self.collect_object_properties(schema, properties),
251 ReferenceOr::Reference { reference } => {
252 if let Some(schema) = self.resolve_schema_reference(reference) {
253 self.collect_object_properties(schema, properties);
254 }
255 }
256 }
257 }
258 }
259 _ => {}
260 }
261 }
262
263 fn collect_struct_fields(
264 &self,
265 struct_name: &str,
266 properties: &[(String, ReferenceOr<Box<Schema>>, bool)],
267 ) -> Vec<RustFieldSpec> {
268 properties
269 .iter()
270 .map(|(prop_name, prop_schema_ref, is_required)| {
271 let field_name = sanitize_rust_identifier(prop_name);
272 let type_hint = match prop_schema_ref {
273 ReferenceOr::Item(prop_schema) => {
274 self.inline_field_type(struct_name, prop_name, prop_schema, *is_required)
275 }
276 ReferenceOr::Reference { reference } => {
277 let ref_name = reference.split('/').next_back().unwrap();
278 let base_type = ref_name.to_pascal_case();
279 if *is_required {
280 base_type
281 } else {
282 format!("Option<{base_type}>")
283 }
284 }
285 };
286
287 RustFieldSpec {
288 original_name: prop_name.clone(),
289 field_name,
290 type_hint,
291 required: *is_required,
292 }
293 })
294 .collect()
295 }
296
297 fn inline_field_type(&self, struct_name: &str, prop_name: &str, schema: &Schema, required: bool) -> String {
298 let base_type = match &schema.schema_kind {
299 SchemaKind::Type(Type::Object(obj)) if !obj.properties.is_empty() => {
300 format!("{struct_name}{}", prop_name.to_pascal_case())
301 }
302 SchemaKind::Type(Type::Array(arr)) => {
303 if let Some(ReferenceOr::Item(item_schema)) = &arr.items
304 && let SchemaKind::Type(Type::Object(item_obj)) = &item_schema.schema_kind
305 && !item_obj.properties.is_empty()
306 {
307 format!("Vec<{struct_name}{}Item>", prop_name.to_pascal_case())
308 } else {
309 Self::schema_to_rust_type(schema, false)
310 }
311 }
312 _ => Self::schema_to_rust_type(schema, false),
313 };
314
315 if required {
316 base_type
317 } else {
318 format!("Option<{base_type}>")
319 }
320 }
321
322 fn resolve_schema_reference<'a>(&'a self, reference: &str) -> Option<&'a Schema> {
323 let name = reference.split('/').next_back()?;
324 self.spec
325 .components
326 .as_ref()?
327 .schemas
328 .get(name)
329 .and_then(|schema_ref| match schema_ref {
330 ReferenceOr::Item(schema) => Some(schema),
331 ReferenceOr::Reference { .. } => None,
332 })
333 }
334
335 fn extract_type_from_schema_ref(&self, schema_ref: &ReferenceOr<Schema>) -> String {
337 match schema_ref {
338 ReferenceOr::Reference { reference } => {
339 let ref_name = reference.split('/').next_back().unwrap();
340 ref_name.to_pascal_case()
341 }
342 ReferenceOr::Item(schema) => Self::schema_to_rust_type(schema, false),
343 }
344 }
345
346 fn extract_request_body_type(&self, operation: &Operation) -> Option<String> {
348 operation.request_body.as_ref().and_then(|body_ref| match body_ref {
349 ReferenceOr::Item(request_body) => request_body.content.get("application/json").and_then(|media_type| {
350 media_type.schema.as_ref().map(|schema_ref| match schema_ref {
351 ReferenceOr::Reference { .. } => self.extract_type_from_schema_ref(schema_ref),
352 ReferenceOr::Item(schema) => self
353 .request_body_inline_model(operation)
354 .map_or_else(|| Self::schema_to_rust_type(schema, false), |(name, _)| name),
355 })
356 }),
357 ReferenceOr::Reference { reference } => {
358 let ref_name = reference.split('/').next_back().unwrap();
359 Some(ref_name.to_pascal_case())
360 }
361 })
362 }
363
364 fn extract_response_type(&self, operation: &Operation) -> Option<String> {
366 use openapiv3::StatusCode;
367
368 let response = operation
369 .responses
370 .responses
371 .get(&StatusCode::Code(200))
372 .or_else(|| operation.responses.responses.get(&StatusCode::Code(201)))
373 .or_else(|| operation.responses.responses.get(&StatusCode::Range(2)));
374
375 if let Some(response_ref) = response {
376 match response_ref {
377 ReferenceOr::Item(response) => {
378 if let Some(content) = response.content.get("application/json")
379 && let Some(schema_ref) = &content.schema
380 {
381 return Some(match schema_ref {
382 ReferenceOr::Reference { .. } => self.extract_type_from_schema_ref(schema_ref),
383 ReferenceOr::Item(schema) => self
384 .response_body_inline_model(operation)
385 .map_or_else(|| Self::schema_to_rust_type(schema, false), |(name, _)| name),
386 });
387 }
388 }
389 ReferenceOr::Reference { reference } => {
390 let ref_name = reference.split('/').next_back().unwrap();
391 return Some(ref_name.to_pascal_case());
392 }
393 }
394 }
395
396 None
397 }
398
399 fn request_body_inline_model<'a>(&self, operation: &'a Operation) -> Option<(String, &'a Schema)> {
400 let operation_id = operation.operation_id.as_ref()?;
401 let body_ref = operation.request_body.as_ref()?;
402 let ReferenceOr::Item(request_body) = body_ref else {
403 return None;
404 };
405 let media_type = request_body.content.get("application/json")?;
406 let schema_ref = media_type.schema.as_ref()?;
407 let ReferenceOr::Item(schema) = schema_ref else {
408 return None;
409 };
410 if Self::schema_needs_named_inline_type(schema) {
411 Some((format!("{}RequestBody", operation_id.to_pascal_case()), schema))
412 } else {
413 None
414 }
415 }
416
417 fn response_body_inline_model<'a>(&self, operation: &'a Operation) -> Option<(String, &'a Schema)> {
418 use openapiv3::StatusCode;
419
420 let operation_id = operation.operation_id.as_ref()?;
421 let response_ref = operation
422 .responses
423 .responses
424 .get(&StatusCode::Code(200))
425 .or_else(|| operation.responses.responses.get(&StatusCode::Code(201)))
426 .or_else(|| operation.responses.responses.get(&StatusCode::Range(2)))?;
427 let ReferenceOr::Item(response) = response_ref else {
428 return None;
429 };
430 let content = response.content.get("application/json")?;
431 let schema_ref = content.schema.as_ref()?;
432 let ReferenceOr::Item(schema) = schema_ref else {
433 return None;
434 };
435 if Self::schema_needs_named_inline_type(schema) {
436 Some((format!("{}ResponseBody", operation_id.to_pascal_case()), schema))
437 } else {
438 None
439 }
440 }
441
442 fn schema_needs_named_inline_type(schema: &Schema) -> bool {
443 matches!(
444 schema.schema_kind,
445 SchemaKind::Type(Type::Object(_))
446 | SchemaKind::AllOf { .. }
447 | SchemaKind::OneOf { .. }
448 | SchemaKind::AnyOf { .. }
449 )
450 }
451
452 fn generate_inline_operation_model(&self, name: &str, schema: &Schema) -> Result<String> {
453 match &schema.schema_kind {
454 SchemaKind::OneOf { one_of } | SchemaKind::AnyOf { any_of: one_of } => {
455 let mut output = String::new();
456 output.push_str("#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]\n");
457 output.push_str("#[serde(untagged)]\n");
458 output.push_str(&format!("pub enum {name} {{\n"));
459
460 for (index, variant) in one_of.iter().enumerate() {
461 let (variant_name, variant_type) = match variant {
462 ReferenceOr::Reference { reference } => {
463 let ref_name = reference.split('/').next_back().unwrap();
464 (ref_name.to_pascal_case(), ref_name.to_pascal_case())
465 }
466 ReferenceOr::Item(item_schema) => (
467 format!("Variant{}", index + 1),
468 Self::schema_to_rust_type(item_schema, false),
469 ),
470 };
471 output.push_str(&format!(" {variant_name}({variant_type}),\n"));
472 }
473
474 output.push_str("}\n");
475 Ok(output)
476 }
477 _ => self.generate_model_struct(name, schema),
478 }
479 }
480
481 fn schema_to_rust_type(schema: &Schema, optional: bool) -> String {
482 let base_type = match &schema.schema_kind {
483 SchemaKind::Type(Type::String(string_type)) => match &string_type.format {
484 VariantOrUnknownOrEmpty::Item(StringFormat::Date) => "chrono::NaiveDate".to_string(),
485 VariantOrUnknownOrEmpty::Item(StringFormat::DateTime) => "chrono::DateTime<chrono::Utc>".to_string(),
486 VariantOrUnknownOrEmpty::Unknown(format) if format == "uuid" => "uuid::Uuid".to_string(),
487 _ => "String".to_string(),
488 },
489 SchemaKind::Type(Type::Number(_)) => "f64".to_string(),
490 SchemaKind::Type(Type::Integer(int_type)) => match &int_type.format {
491 VariantOrUnknownOrEmpty::Item(IntegerFormat::Int32) => "i32".to_string(),
492 VariantOrUnknownOrEmpty::Item(IntegerFormat::Int64) => "i64".to_string(),
493 _ => "i64".to_string(),
494 },
495 SchemaKind::Type(Type::Boolean(_)) => "bool".to_string(),
496 SchemaKind::Type(Type::Array(arr)) => {
497 let item_type = match &arr.items {
498 Some(ReferenceOr::Item(item_schema)) => Self::schema_to_rust_type(item_schema, false),
499 Some(ReferenceOr::Reference { reference }) => {
500 let ref_name = reference.split('/').next_back().unwrap();
501 ref_name.to_pascal_case()
502 }
503 None => "serde_json::Value".to_string(),
504 };
505 format!("Vec<{item_type}>")
506 }
507 SchemaKind::Type(Type::Object(_)) => "serde_json::Value".to_string(),
508 _ => "serde_json::Value".to_string(),
509 };
510
511 if optional {
512 format!("Option<{base_type}>")
513 } else {
514 base_type
515 }
516 }
517
518 fn generate_handlers(&self) -> Result<(String, String)> {
519 let mut handlers = String::from(
520 "
521// Route Handlers
522
523",
524 );
525 let mut registrations = String::new();
526
527 for (path, path_item_ref) in &self.spec.paths.paths {
528 let path_item = match path_item_ref {
529 ReferenceOr::Item(item) => item,
530 ReferenceOr::Reference { .. } => continue,
531 };
532
533 if let Some(op) = &path_item.get {
534 self.append_handler(path, "GET", op, &mut handlers, &mut registrations)?;
535 }
536 if let Some(op) = &path_item.post {
537 self.append_handler(path, "POST", op, &mut handlers, &mut registrations)?;
538 }
539 if let Some(op) = &path_item.put {
540 self.append_handler(path, "PUT", op, &mut handlers, &mut registrations)?;
541 }
542 if let Some(op) = &path_item.delete {
543 self.append_handler(path, "DELETE", op, &mut handlers, &mut registrations)?;
544 }
545 if let Some(op) = &path_item.patch {
546 self.append_handler(path, "PATCH", op, &mut handlers, &mut registrations)?;
547 }
548 }
549
550 Ok((handlers, registrations))
551 }
552
553 fn append_handler(
554 &self,
555 path: &str,
556 method: &str,
557 operation: &Operation,
558 handlers: &mut String,
559 registrations: &mut String,
560 ) -> Result<()> {
561 let builder_fn = match method {
562 "GET" => "get",
563 "POST" => "post",
564 "PUT" => "put",
565 "PATCH" => "patch",
566 "DELETE" => "delete",
567 _ => return Ok(()),
568 };
569
570 let handler_name = operation.operation_id.as_ref().map_or_else(
571 || format!("{}_{}", method.to_lowercase(), sanitize_identifier(path)),
572 |id| id.to_snake_case(),
573 );
574
575 let request_type = self.extract_request_body_type(operation);
576 let response_type = self.extract_response_type(operation);
577 let escaped_path = path.replace('"', "\\\"");
578
579 let mut builder = format!("{builder_fn}(\"{escaped_path}\")");
580 builder.push_str(&format!(".handler_name(\"{handler_name}\")"));
581 if let Some(ref req_ty) = request_type {
582 builder.push_str(&format!(".request_body::<{req_ty}>()"));
583 }
584 if let Some(ref resp_ty) = response_type {
585 builder.push_str(&format!(".response_body::<{resp_ty}>()"));
586 }
587 registrations.push_str(&format!(" app.route({builder}, {handler_name})?;\n"));
588
589 if let Some(summary) = &operation.summary {
590 handlers.push_str(&render_doc_comment(summary, 0));
591 }
592 if let Some(description) = &operation.description {
593 handlers.push_str(&render_doc_comment(description, 0));
594 }
595 handlers.push_str(&format!(
596 "pub async fn {handler_name}(_ctx: RequestContext) -> HandlerResult {{\n"
597 ));
598 if let Some(req_ty) = request_type {
599 handlers.push_str(&format!(
600 " // let body: {req_ty} = _ctx.json().map_err(|err| (StatusCode::BAD_REQUEST, err.to_string()))?;\n"
601 ));
602 }
603 handlers.push_str(
604 " HttpResponse::builder()\n .status(StatusCode::NOT_IMPLEMENTED)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"error\":\"Not implemented\"}\"#))\n .map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))\n",
605 );
606 handlers.push_str("}\n\n");
607
608 Ok(())
609 }
610
611 fn generate_builder(&self, registrations: &str) -> String {
612 format!(
613 "pub fn build_app() -> Result<App, AppError> {{
614 let mut app = App::new();
615{registrations} Ok(app)
616}}
617
618"
619 )
620 }
621}
622
623fn sanitize_identifier(path: &str) -> String {
624 path.chars()
625 .map(|c| {
626 if c.is_ascii_alphanumeric() {
627 c.to_ascii_lowercase()
628 } else {
629 '_'
630 }
631 })
632 .collect::<String>()
633 .trim_matches('_')
634 .to_string()
635}
636
637fn sanitize_rust_identifier(name: &str) -> String {
638 sanitize_identifier_snake_case(name, TargetLanguage::Rust)
639}
640
641fn render_doc_comment(text: &str, indent: usize) -> String {
642 let prefix = " ".repeat(indent);
643 let mut output = String::new();
644 let mut previous_was_list_item = false;
645
646 for raw_line in text.lines() {
647 let line = raw_line.trim();
648 if line.is_empty() {
649 output.push_str(&format!("{prefix}///\n"));
650 previous_was_list_item = false;
651 continue;
652 }
653
654 let is_list_item = line.starts_with("- ")
655 || line.starts_with("* ")
656 || line
657 .chars()
658 .next()
659 .is_some_and(|first| first.is_ascii_digit() && line.contains(". "));
660
661 if previous_was_list_item && !is_list_item {
662 output.push_str(&format!("{prefix}///\n"));
663 }
664
665 output.push_str(&format!("{prefix}/// {line}\n"));
666 previous_was_list_item = is_list_item;
667 }
668
669 output
670}
671
672#[cfg(test)]
673mod tests {
674 use super::*;
675
676 fn sample_spec() -> OpenAPI {
677 serde_json::from_value(serde_json::json!({
678 "openapi": "3.1.0",
679 "info": { "title": "Todo API", "version": "1.0.0" },
680 "components": {
681 "schemas": {
682 "CreateTodoRequest": {
683 "type": "object",
684 "required": ["title"],
685 "properties": {
686 "title": { "type": "string" }
687 }
688 },
689 "TodoResponse": {
690 "type": "object",
691 "required": ["id"],
692 "properties": {
693 "id": { "type": "string" }
694 }
695 }
696 }
697 },
698 "paths": {
699 "/todos": {
700 "post": {
701 "operationId": "createTodo",
702 "summary": "Create a todo",
703 "description": "Creates a new todo item.",
704 "requestBody": {
705 "required": true,
706 "content": {
707 "application/json": {
708 "schema": { "$ref": "#/components/schemas/CreateTodoRequest" }
709 }
710 }
711 },
712 "responses": {
713 "201": {
714 "description": "Created",
715 "content": {
716 "application/json": {
717 "schema": { "$ref": "#/components/schemas/TodoResponse" }
718 }
719 }
720 }
721 }
722 }
723 }
724 }
725 }))
726 .expect("sample OpenAPI spec should deserialize")
727 }
728
729 #[test]
730 fn rust_openapi_generator_emits_module_style_scaffold() {
731 let generator = RustGenerator::new(sample_spec(), RustDtoStyle::SerdeStruct);
732 let output = generator.generate().unwrap();
733
734 assert!(output.contains("use spikard::{App, AppError, HandlerResult, RequestContext"));
735 assert!(output.contains("pub async fn create_todo(_ctx: RequestContext) -> HandlerResult"));
736 assert!(output.contains("pub fn build_app() -> Result<App, AppError>"));
737 assert!(!output.contains("#![allow(dead_code)]"));
738 assert!(!output.contains("async fn main()"));
739 }
740
741 #[test]
742 fn rust_openapi_generator_merges_all_of_object_fields() {
743 let spec: OpenAPI = serde_json::from_value(serde_json::json!({
744 "openapi": "3.1.0",
745 "info": { "title": "Errors", "version": "1.0.0" },
746 "components": {
747 "schemas": {
748 "BaseError": {
749 "type": "object",
750 "required": ["title", "status"],
751 "properties": {
752 "title": { "type": "string" },
753 "status": { "type": "integer" }
754 }
755 },
756 "AuthError": {
757 "allOf": [
758 { "$ref": "#/components/schemas/BaseError" },
759 {
760 "type": "object",
761 "properties": {
762 "detail": { "default": "Missing auth" }
763 }
764 }
765 ]
766 }
767 }
768 },
769 "paths": {}
770 }))
771 .expect("OpenAPI spec should deserialize");
772
773 let generator = RustGenerator::new(spec, RustDtoStyle::SerdeStruct);
774 let output = generator.generate().unwrap();
775
776 assert!(output.contains("pub struct AuthError"));
777 assert!(output.contains("pub title: String"));
778 assert!(output.contains("pub status: i64"));
779 }
780}