1use heck::ToPascalCase;
2use openapiv3::{
3 MediaType, OpenAPI, Operation, Parameter, PathItem, ReferenceOr, RequestBody, Response,
4 Responses, Schema, StatusCode,
5};
6use proc_macro2::TokenStream;
7use quote::{format_ident, quote};
8
9use super::idents;
10use super::schemas::doc_attr;
11use super::security::{resolve_op_security, OpSecurity, SchemeInfo};
12use super::types::{is_string_enum, schema_to_rust_type, string_enum_values};
13
14#[derive(Debug)]
16pub struct OperationInfo {
17 pub operation_id: String,
18 pub method_ident: syn::Ident,
21 pub method: String,
22 pub path: String,
23 pub summary: Option<String>,
24 pub description: Option<String>,
25 pub path_params: Vec<ParamInfo>,
26 pub query_params: Vec<ParamInfo>,
27 pub header_params: Vec<ParamInfo>,
28 pub body: Option<BodyInfo>,
29 pub responses: Vec<ResponseInfo>,
30 pub auth: OpSecurity,
31}
32
33#[derive(Debug)]
34pub struct ParamInfo {
35 pub name: String,
36 pub field_ident: syn::Ident,
39 pub description: Option<String>,
40 pub required: bool,
41 pub rust_type: TokenStream,
43 pub is_enum: bool,
45 pub enum_ident: Option<syn::Ident>,
47 pub enum_values: Vec<String>,
49}
50
51#[derive(Debug)]
52pub struct BodyInfo {
53 pub description: Option<String>,
54 pub required: bool,
55 pub rust_type: TokenStream,
56}
57
58#[derive(Debug)]
59pub struct ResponseInfo {
60 pub status: ResponseStatus,
61 pub description: String,
62 pub rust_type: Option<TokenStream>,
63}
64
65#[derive(Debug)]
66pub enum ResponseStatus {
67 Code(u16),
68 Default,
69}
70
71#[derive(Debug, Default)]
77pub struct Diagnostics {
78 pub errors: Vec<String>,
80 pub warnings: Vec<String>,
82}
83
84impl Diagnostics {
85 fn error(&mut self, msg: String) {
87 self.errors.push(msg);
88 }
89
90 fn warn(&mut self, msg: String) {
92 self.warnings.push(msg);
93 }
94
95 pub fn emit_warnings(&self) {
97 for warning in &self.warnings {
98 eprintln!("openapi-trait: warning: {warning}");
99 }
100 }
101}
102
103#[must_use]
109pub fn collect_operations(
110 openapi: &OpenAPI,
111 schemes: &[SchemeInfo],
112) -> (Vec<OperationInfo>, Diagnostics) {
113 let mut ops = Vec::new();
114 let mut diag = Diagnostics::default();
115 for (path, ref_or_item) in &openapi.paths.paths {
116 let item = match ref_or_item {
117 ReferenceOr::Item(i) => i,
118 ReferenceOr::Reference { .. } => {
119 diag.warn(format!(
120 "path `{path}` is a $ref to a path item, which is not supported; all its operations were skipped"
121 ));
122 continue;
123 }
124 };
125 for (method, operation) in path_item_operations(item) {
126 if let Some(info) =
127 build_operation_info(path, &method, operation, item, openapi, schemes, &mut diag)
128 {
129 ops.push(info);
130 }
131 }
132 }
133 (ops, diag)
134}
135
136fn path_item_operations(item: &PathItem) -> Vec<(String, &Operation)> {
138 let mut out = Vec::new();
139 if let Some(op) = &item.get {
140 out.push(("get".into(), op));
141 }
142 if let Some(op) = &item.post {
143 out.push(("post".into(), op));
144 }
145 if let Some(op) = &item.put {
146 out.push(("put".into(), op));
147 }
148 if let Some(op) = &item.delete {
149 out.push(("delete".into(), op));
150 }
151 if let Some(op) = &item.patch {
152 out.push(("patch".into(), op));
153 }
154 if let Some(op) = &item.head {
155 out.push(("head".into(), op));
156 }
157 if let Some(op) = &item.options {
158 out.push(("options".into(), op));
159 }
160 if let Some(op) = &item.trace {
161 out.push(("trace".into(), op));
162 }
163 out
164}
165
166fn build_operation_info(
168 path: &str,
169 method: &str,
170 operation: &Operation,
171 path_item: &PathItem,
172 openapi: &OpenAPI,
173 schemes: &[SchemeInfo],
174 diag: &mut Diagnostics,
175) -> Option<OperationInfo> {
176 let Some(operation_id) = operation.operation_id.clone() else {
177 diag.error(format!(
178 "operation `{method} {path}` is missing an `operationId`; one is required to name the generated Rust method"
179 ));
180 return None;
181 };
182
183 let method_ident = match idents::method_ident(&operation_id) {
188 Ok(id) => id,
189 Err(msg) => {
190 diag.error(format!("operation `{method} {path}`: {msg}"));
191 return None;
192 }
193 };
194 if let Err(msg) = idents::validate_type_base(&operation_id) {
195 diag.error(format!("operation `{method} {path}`: {msg}"));
196 return None;
197 }
198
199 let mut all_params: Vec<&ReferenceOr<Parameter>> = Vec::new();
201 all_params.extend(path_item.parameters.iter());
202 all_params.extend(operation.parameters.iter());
203 let (path_params, query_params, header_params) =
204 collect_params(&all_params, &operation_id, method, path, openapi, diag)?;
205
206 let body = operation
207 .request_body
208 .as_ref()
209 .and_then(|rb| build_body_info(rb, openapi));
210
211 let responses = build_responses(&operation.responses, openapi, &operation_id, diag);
212 let auth = resolve_op_security(operation, openapi, schemes);
213
214 Some(OperationInfo {
215 operation_id,
216 method_ident,
217 method: method.to_owned(),
218 path: path.to_owned(),
219 summary: operation.summary.clone(),
220 description: operation.description.clone(),
221 path_params,
222 query_params,
223 header_params,
224 body,
225 responses,
226 auth,
227 })
228}
229
230fn collect_params(
237 all_params: &[&ReferenceOr<Parameter>],
238 operation_id: &str,
239 method: &str,
240 path: &str,
241 openapi: &OpenAPI,
242 diag: &mut Diagnostics,
243) -> Option<(Vec<ParamInfo>, Vec<ParamInfo>, Vec<ParamInfo>)> {
244 let mut path_params = Vec::new();
245 let mut query_params = Vec::new();
246 let mut header_params = Vec::new();
247
248 for ref_or_param in all_params {
249 let param = match ref_or_param {
250 ReferenceOr::Item(p) => p,
251 ReferenceOr::Reference { reference } => {
252 if let Some(resolved) = resolve_param_ref(reference, openapi) {
254 resolved
255 } else {
256 diag.warn(format!(
257 "operation `{operation_id}`: could not resolve parameter $ref `{reference}`; parameter skipped"
258 ));
259 continue;
260 }
261 }
262 };
263
264 let data = param.parameter_data_ref();
265 let param_schema = param_schema(param, openapi);
266
267 let field_ident = match idents::field_ident(&data.name) {
270 Ok(id) => id,
271 Err(msg) => {
272 diag.error(format!("operation `{method} {path}`: {msg}"));
273 return None;
274 }
275 };
276
277 let (is_enum, enum_ident, enum_values) =
278 if param_schema.as_ref().is_some_and(is_string_enum) {
279 let schema = param_schema.as_ref().expect("checked is_some_and above");
280 let name = format!(
281 "{}{}Query",
282 operation_id.to_pascal_case(),
283 data.name.to_pascal_case()
284 );
285 let ident = match idents::type_ident(&name, operation_id) {
286 Ok(id) => id,
287 Err(msg) => {
288 diag.error(format!("operation `{method} {path}`: {msg}"));
289 return None;
290 }
291 };
292 let vals = string_enum_values(schema);
293 (true, Some(ident), vals)
294 } else {
295 (false, None, vec![])
296 };
297
298 let rust_type = if is_enum {
299 let ei = enum_ident.as_ref().unwrap();
300 quote! { #ei }
301 } else if let Some(schema) = ¶m_schema {
302 let ref_or = ReferenceOr::Item(schema.clone());
303 schema_to_rust_type(&ref_or, true)
304 } else {
305 quote! { ::std::string::String }
306 };
307
308 let info = ParamInfo {
309 name: data.name.clone(),
310 field_ident,
311 description: data.description.clone(),
312 required: data.required,
313 rust_type,
314 is_enum,
315 enum_ident,
316 enum_values,
317 };
318
319 match param {
320 Parameter::Path { .. } => path_params.push(info),
321 Parameter::Query { .. } => query_params.push(info),
322 Parameter::Header { .. } => header_params.push(info),
323 Parameter::Cookie { .. } => diag.warn(format!(
324 "operation `{operation_id}`: cookie parameter `{}` is not supported and was skipped",
325 data.name
326 )),
327 }
328 }
329
330 Some((path_params, query_params, header_params))
331}
332
333fn resolve_param_ref<'a>(reference: &str, openapi: &'a OpenAPI) -> Option<&'a Parameter> {
335 let name = reference.strip_prefix("#/components/parameters/")?;
336 openapi.components.as_ref()?.parameters.get(name)?.as_item()
337}
338
339fn param_schema(param: &Parameter, openapi: &OpenAPI) -> Option<Schema> {
341 use openapiv3::ParameterSchemaOrContent;
342 let data = param.parameter_data_ref();
343 match &data.format {
344 ParameterSchemaOrContent::Schema(ref_or) => match ref_or {
345 ReferenceOr::Item(s) => Some(s.clone()),
346 ReferenceOr::Reference { reference } => {
347 let name = reference.strip_prefix("#/components/schemas/")?;
348 openapi
349 .components
350 .as_ref()?
351 .schemas
352 .get(name)?
353 .as_item()
354 .cloned()
355 }
356 },
357 ParameterSchemaOrContent::Content(_) => None,
358 }
359}
360
361fn build_body_info(ref_or_rb: &ReferenceOr<RequestBody>, openapi: &OpenAPI) -> Option<BodyInfo> {
363 let rb = match ref_or_rb {
364 ReferenceOr::Item(r) => r,
365 ReferenceOr::Reference { reference } => {
366 let name = reference.strip_prefix("#/components/requestBodies/")?;
367 openapi
368 .components
369 .as_ref()?
370 .request_bodies
371 .get(name)?
372 .as_item()?
373 }
374 };
375
376 let rust_type = json_media_type_to_rust(&rb.content, openapi)?;
377
378 Some(BodyInfo {
379 description: rb.description.clone(),
380 required: rb.required,
381 rust_type,
382 })
383}
384
385fn json_media_type_to_rust(
387 content: &indexmap::IndexMap<String, MediaType>,
388 _openapi: &OpenAPI,
389) -> Option<TokenStream> {
390 let media = content
391 .get("application/json")
392 .or_else(|| content.values().next())?;
393 let ref_or_schema = media.schema.as_ref()?;
394 Some(schema_to_rust_type(ref_or_schema, true))
395}
396
397fn build_responses(
399 responses: &Responses,
400 openapi: &OpenAPI,
401 op_id: &str,
402 diag: &mut Diagnostics,
403) -> Vec<ResponseInfo> {
404 let mut out = Vec::new();
405
406 for (status_code, ref_or_resp) in &responses.responses {
407 let resp = match ref_or_resp {
408 ReferenceOr::Item(r) => r,
409 ReferenceOr::Reference { reference } => {
410 if let Some(r) = resolve_response_ref(reference, openapi) {
411 r
412 } else {
413 diag.warn(format!(
414 "operation `{op_id}`: could not resolve response $ref `{reference}`; response skipped"
415 ));
416 continue;
417 }
418 }
419 };
420
421 let rust_type = json_media_type_to_rust(&resp.content, openapi);
422
423 let status = match status_code {
424 StatusCode::Code(n) => ResponseStatus::Code(*n),
425 StatusCode::Range(n) => {
426 diag.warn(format!(
427 "operation `{op_id}`: response status range `{n}XX` is not supported and was skipped"
428 ));
429 continue;
430 }
431 };
432
433 out.push(ResponseInfo {
434 status,
435 description: resp.description.clone(),
436 rust_type,
437 });
438 }
439
440 if let Some(ref_or_default) = &responses.default {
442 let resp = match ref_or_default {
443 ReferenceOr::Item(r) => r,
444 ReferenceOr::Reference { reference } => {
445 if let Some(r) = resolve_response_ref(reference, openapi) {
446 r
447 } else {
448 diag.warn(format!(
449 "operation `{op_id}`: could not resolve default response $ref `{reference}`; default response skipped"
450 ));
451 return out;
452 }
453 }
454 };
455 out.push(ResponseInfo {
456 status: ResponseStatus::Default,
457 description: resp.description.clone(),
458 rust_type: None, });
460 }
461
462 out
463}
464
465fn resolve_response_ref<'a>(reference: &str, openapi: &'a OpenAPI) -> Option<&'a Response> {
467 let name = reference.strip_prefix("#/components/responses/")?;
468 openapi.components.as_ref()?.responses.get(name)?.as_item()
469}
470
471#[must_use]
473pub fn generate_operation_errors(errors: &[String]) -> TokenStream {
474 if errors.is_empty() {
475 return TokenStream::new();
476 }
477 let msgs: Vec<TokenStream> = errors
478 .iter()
479 .map(|err| {
480 let msg = format!("openapi-trait: {err}");
481 quote! { ::core::compile_error!(#msg); }
482 })
483 .collect();
484 quote! { #(#msgs)* }
485}
486
487#[must_use]
489pub fn generate_operation_types(ops: &[OperationInfo]) -> TokenStream {
490 let items: Vec<TokenStream> = ops.iter().map(generate_single_operation_types).collect();
491 quote! { #(#items)* }
492}
493
494fn generate_single_operation_types(op: &OperationInfo) -> TokenStream {
496 let query_enums = generate_query_enums(op);
497 let request_struct = generate_request_struct(op);
498 let response_enum = generate_response_enum(op);
499 quote! {
500 #query_enums
501 #request_struct
502 #response_enum
503 }
504}
505
506fn generate_query_enums(op: &OperationInfo) -> TokenStream {
508 let enums: Vec<TokenStream> = op
509 .query_params
510 .iter()
511 .filter(|p| p.is_enum)
512 .map(|p| {
513 let ident = p.enum_ident.as_ref().unwrap();
514 let doc = doc_attr(&p.description);
515 let variants: Vec<TokenStream> = p
516 .enum_values
517 .iter()
518 .map(|v| {
519 let variant_ident = format_ident!("{}", v.to_pascal_case());
520 if variant_ident == v.as_str() {
521 quote! { #variant_ident }
522 } else {
523 quote! {
524 #[serde(rename = #v)]
525 #variant_ident
526 }
527 }
528 })
529 .collect();
530
531 quote! {
532 #doc
533 #[derive(
534 ::core::fmt::Debug,
535 ::core::clone::Clone,
536 ::serde::Serialize,
537 ::serde::Deserialize,
538 )]
539
540 pub enum #ident {
541 #(#variants,)*
542 }
543 }
544 })
545 .collect();
546
547 quote! { #(#enums)* }
548}
549
550fn generate_request_struct(op: &OperationInfo) -> TokenStream {
552 let ident = format_ident!("{}Request", op.operation_id.to_pascal_case());
553 let doc = combined_doc(op.summary.as_ref(), op.description.as_ref());
554
555 let mut fields: Vec<TokenStream> = Vec::new();
556
557 for p in &op.path_params {
558 let field_ident = &p.field_ident;
559 let ftype = &p.rust_type;
560 let fdoc = doc_attr(&p.description);
561 fields.push(quote! {
562 #fdoc
563 pub #field_ident: #ftype,
564 });
565 }
566
567 for p in &op.query_params {
568 let field_ident = &p.field_ident;
569 let inner = &p.rust_type;
570 let ftype = if p.required {
571 quote! { #inner }
572 } else {
573 quote! { ::core::option::Option<#inner> }
574 };
575 let fdoc = doc_attr(&p.description);
576 fields.push(quote! {
577 #fdoc
578 pub #field_ident: #ftype,
579 });
580 }
581
582 for p in &op.header_params {
583 let field_ident = &p.field_ident;
584 let fdoc = doc_attr(&p.description);
585 let ftype = if p.required {
589 quote! { ::std::string::String }
590 } else {
591 quote! { ::core::option::Option<::std::string::String> }
592 };
593 fields.push(quote! {
594 #fdoc
595 pub #field_ident: #ftype,
596 });
597 }
598
599 if let Some(body) = &op.body {
600 let inner = &body.rust_type;
601 let ftype = if body.required {
602 quote! { #inner }
603 } else {
604 quote! { ::core::option::Option<#inner> }
605 };
606 let bdoc = doc_attr(&body.description);
607 fields.push(quote! {
608 #bdoc
609 pub body: #ftype,
610 });
611 }
612
613 quote! {
614 #doc
615 #[derive(::core::fmt::Debug, ::core::clone::Clone)]
616 pub struct #ident {
617 #(#fields)*
618 }
619 }
620}
621
622fn generate_response_enum(op: &OperationInfo) -> TokenStream {
624 let ident = format_ident!("{}Response", op.operation_id.to_pascal_case());
625 let doc = combined_doc(op.summary.as_ref(), op.description.as_ref());
626
627 let variants: Vec<TokenStream> = op
628 .responses
629 .iter()
630 .map(|r| {
631 let vdoc = doc_attr(&Some(r.description.clone()));
632 match &r.status {
633 ResponseStatus::Code(n) => {
634 let variant_ident = format_ident!("Status{}", n);
635 r.rust_type.as_ref().map_or_else(
636 || {
637 quote! {
638 #vdoc
639 #variant_ident
640 }
641 },
642 |ty| {
643 quote! {
644 #vdoc
645 #variant_ident(#ty)
646 }
647 },
648 )
649 }
650 ResponseStatus::Default => {
651 quote! {
652 #vdoc
653 Default(::std::string::String)
654 }
655 }
656 }
657 })
658 .collect();
659
660 quote! {
661 #doc
662 #[derive(::core::fmt::Debug, ::core::clone::Clone)]
663 pub enum #ident {
664 #(#variants,)*
665 }
666 }
667}
668
669fn combined_doc(summary: Option<&String>, description: Option<&String>) -> TokenStream {
671 match (summary, description) {
672 (Some(s), Some(d)) if s != d => quote! { #[doc = #s] #[doc = ""] #[doc = #d] },
673 (Some(s), _) => quote! { #[doc = #s] },
674 (None, Some(d)) => quote! { #[doc = #d] },
675 (None, None) => quote! {},
676 }
677}
678
679#[cfg(test)]
680mod tests {
681 use super::*;
682
683 fn collect(spec: &str) -> (Vec<OperationInfo>, Diagnostics) {
685 let openapi: OpenAPI = serde_yaml::from_str(spec).expect("spec parses");
686 collect_operations(&openapi, &[])
687 }
688
689 #[test]
690 fn missing_operation_id_is_a_fatal_error_and_drops_the_operation() {
691 let (ops, diag) = collect(
692 r#"
693openapi: 3.0.0
694info: { title: t, version: "1.0" }
695paths:
696 /pets:
697 get:
698 responses:
699 '200': { description: ok }
700"#,
701 );
702 assert!(
703 ops.is_empty(),
704 "operation without operationId must be dropped"
705 );
706 assert!(diag.warnings.is_empty());
707 assert_eq!(diag.errors.len(), 1);
708 assert!(diag.errors[0].contains("missing an `operationId`"));
709 assert!(diag.errors[0].contains("get /pets"));
710 }
711
712 #[test]
713 fn cookie_param_warns_but_keeps_the_operation() {
714 let (ops, diag) = collect(
715 r#"
716openapi: 3.0.0
717info: { title: t, version: "1.0" }
718paths:
719 /pets:
720 get:
721 operationId: listPets
722 parameters:
723 - { name: session, in: cookie, schema: { type: string } }
724 responses:
725 '200': { description: ok }
726"#,
727 );
728 assert_eq!(ops.len(), 1, "operation must still be generated");
729 assert!(diag.errors.is_empty());
730 assert_eq!(diag.warnings.len(), 1);
731 assert!(diag.warnings[0].contains("cookie parameter `session`"));
732 }
733
734 #[test]
735 fn status_range_warns_but_keeps_the_operation() {
736 let (ops, diag) = collect(
737 r#"
738openapi: 3.0.0
739info: { title: t, version: "1.0" }
740paths:
741 /pets:
742 get:
743 operationId: listPets
744 responses:
745 '2XX': { description: ok }
746"#,
747 );
748 assert_eq!(ops.len(), 1);
749 assert!(diag.errors.is_empty());
750 assert_eq!(diag.warnings.len(), 1);
751 assert!(diag.warnings[0].contains("status range `2XX`"));
752 }
753
754 #[test]
755 fn keyword_operation_id_becomes_a_raw_method_ident() {
756 let (ops, diag) = collect(
757 r#"
758openapi: 3.0.0
759info: { title: t, version: "1.0" }
760paths:
761 /things:
762 get:
763 operationId: type
764 responses:
765 '200': { description: ok }
766"#,
767 );
768 assert_eq!(ops.len(), 1, "keyword operationId must still generate");
769 assert!(diag.errors.is_empty(), "{:?}", diag.errors);
770 assert_eq!(ops[0].method_ident.to_string(), "r#type");
771 }
772
773 #[test]
774 fn hyphenated_operation_id_is_snake_cased() {
775 let (ops, diag) = collect(
776 r#"
777openapi: 3.0.0
778info: { title: t, version: "1.0" }
779paths:
780 /pets:
781 get:
782 operationId: list-pets
783 responses:
784 '200': { description: ok }
785"#,
786 );
787 assert_eq!(ops.len(), 1);
788 assert!(diag.errors.is_empty());
789 assert_eq!(ops[0].method_ident.to_string(), "list_pets");
790 }
791
792 #[test]
793 fn operation_id_with_leading_digit_is_a_fatal_error() {
794 let (ops, diag) = collect(
795 r#"
796openapi: 3.0.0
797info: { title: t, version: "1.0" }
798paths:
799 /pets:
800 get:
801 operationId: 1pet
802 responses:
803 '200': { description: ok }
804"#,
805 );
806 assert!(ops.is_empty(), "invalid operationId must be dropped");
807 assert_eq!(diag.errors.len(), 1);
808 assert!(diag.errors[0].contains("1pet"), "{:?}", diag.errors);
809 }
810
811 #[test]
812 fn non_raw_keyword_operation_id_is_a_fatal_error() {
813 let (ops, diag) = collect(
814 r#"
815openapi: 3.0.0
816info: { title: t, version: "1.0" }
817paths:
818 /me:
819 get:
820 operationId: self
821 responses:
822 '200': { description: ok }
823"#,
824 );
825 assert!(ops.is_empty());
826 assert_eq!(diag.errors.len(), 1);
827 assert!(
828 diag.errors[0].contains("reserved Rust keyword"),
829 "{:?}",
830 diag.errors
831 );
832 }
833
834 #[test]
835 fn keyword_parameter_becomes_a_raw_field_ident() {
836 let (ops, diag) = collect(
837 r#"
838openapi: 3.0.0
839info: { title: t, version: "1.0" }
840paths:
841 /pets:
842 get:
843 operationId: listPets
844 parameters:
845 - { name: type, in: query, schema: { type: string } }
846 responses:
847 '200': { description: ok }
848"#,
849 );
850 assert_eq!(ops.len(), 1);
851 assert!(diag.errors.is_empty(), "{:?}", diag.errors);
852 assert_eq!(ops[0].query_params.len(), 1);
853 assert_eq!(ops[0].query_params[0].field_ident.to_string(), "r#type");
854 }
855
856 #[test]
857 fn parameter_with_leading_digit_is_a_fatal_error() {
858 let (ops, diag) = collect(
859 r#"
860openapi: 3.0.0
861info: { title: t, version: "1.0" }
862paths:
863 /pets:
864 get:
865 operationId: listPets
866 parameters:
867 - { name: 1abc, in: query, schema: { type: string } }
868 responses:
869 '200': { description: ok }
870"#,
871 );
872 assert!(ops.is_empty(), "invalid parameter name must drop the op");
873 assert_eq!(diag.errors.len(), 1);
874 assert!(diag.errors[0].contains("1abc"), "{:?}", diag.errors);
875 }
876
877 #[test]
878 fn required_header_is_non_optional_string_optional_one_is_option() {
879 let (ops, diag) = collect(
880 r#"
881openapi: 3.0.0
882info: { title: t, version: "1.0" }
883paths:
884 /pets:
885 get:
886 operationId: listPets
887 parameters:
888 - { name: X-Required, in: header, required: true, schema: { type: string } }
889 - { name: X-Optional, in: header, schema: { type: string } }
890 responses:
891 '200': { description: ok }
892"#,
893 );
894 assert_eq!(ops.len(), 1);
895 assert!(diag.errors.is_empty(), "{:?}", diag.errors);
896 assert_eq!(ops[0].header_params.len(), 2);
897
898 let struct_src = generate_request_struct(&ops[0]).to_string();
899 let normalized: String = struct_src.split_whitespace().collect();
902 assert!(
903 normalized.contains("x_required:::std::string::String,"),
904 "required header must be a non-optional String: {struct_src}"
905 );
906 assert!(
907 normalized.contains("x_optional:::core::option::Option<::std::string::String>"),
908 "optional header must stay an Option: {struct_src}"
909 );
910 }
911
912 #[test]
913 fn specific_status_codes_are_handled_without_diagnostics() {
914 let (ops, diag) = collect(
915 r#"
916openapi: 3.0.0
917info: { title: t, version: "1.0" }
918paths:
919 /pets:
920 post:
921 operationId: createPet
922 responses:
923 '201': { description: created }
924 '202': { description: accepted }
925"#,
926 );
927 assert_eq!(ops.len(), 1);
928 assert_eq!(ops[0].responses.len(), 2, "201 and 202 both generated");
929 assert!(diag.errors.is_empty(), "no errors for a clean operation");
930 assert!(
931 diag.warnings.is_empty(),
932 "no warnings for a clean operation"
933 );
934 }
935}