1use heck::ToPascalCase;
2use openapiv3::{
3 MediaType, OpenAPI, Operation, Parameter, PathItem, QueryStyle, ReferenceOr, RequestBody,
4 Response, Responses, Schema, SchemaKind, StatusCode, Type,
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, Clone, Copy, PartialEq, Eq)]
40pub enum QueryStyleKind {
41 Form,
43 Space,
45 Pipe,
47 DeepObject,
49}
50
51impl QueryStyleKind {
52 #[must_use]
55 pub const fn delimiter(self) -> &'static str {
56 match self {
57 Self::Space => " ",
58 Self::Pipe => "|",
59 Self::Form | Self::DeepObject => ",",
62 }
63 }
64}
65
66#[derive(Debug)]
69pub struct QuerySerialization {
70 pub style: QueryStyleKind,
72 pub explode: bool,
75 pub is_array: bool,
77 pub array_item_type: Option<TokenStream>,
80}
81
82#[derive(Debug)]
83pub struct ParamInfo {
84 pub name: String,
85 pub field_ident: syn::Ident,
88 pub description: Option<String>,
89 pub required: bool,
90 pub rust_type: TokenStream,
92 pub is_enum: bool,
94 pub enum_ident: Option<syn::Ident>,
96 pub enum_values: Vec<String>,
98 pub query: QuerySerialization,
100}
101
102#[derive(Debug)]
103pub struct BodyInfo {
104 pub description: Option<String>,
105 pub required: bool,
106 pub rust_type: TokenStream,
107}
108
109#[derive(Debug)]
110pub struct ResponseInfo {
111 pub status: ResponseStatus,
112 pub description: String,
113 pub rust_type: Option<TokenStream>,
114}
115
116#[derive(Debug)]
117pub enum ResponseStatus {
118 Code(u16),
119 Default,
120}
121
122#[derive(Debug, Default)]
128pub struct Diagnostics {
129 pub errors: Vec<String>,
131 pub warnings: Vec<String>,
133}
134
135impl Diagnostics {
136 fn error(&mut self, msg: String) {
138 self.errors.push(msg);
139 }
140
141 fn warn(&mut self, msg: String) {
143 self.warnings.push(msg);
144 }
145
146 pub fn emit_warnings(&self) {
148 for warning in &self.warnings {
149 eprintln!("openapi-trait: warning: {warning}");
150 }
151 }
152}
153
154#[must_use]
160pub fn collect_operations(
161 openapi: &OpenAPI,
162 schemes: &[SchemeInfo],
163) -> (Vec<OperationInfo>, Diagnostics) {
164 let mut ops = Vec::new();
165 let mut diag = Diagnostics::default();
166 for (path, ref_or_item) in &openapi.paths.paths {
167 let item = match ref_or_item {
168 ReferenceOr::Item(i) => i,
169 ReferenceOr::Reference { .. } => {
170 diag.warn(format!(
171 "path `{path}` is a $ref to a path item, which is not supported; all its operations were skipped"
172 ));
173 continue;
174 }
175 };
176 for (method, operation) in path_item_operations(item) {
177 if let Some(info) =
178 build_operation_info(path, &method, operation, item, openapi, schemes, &mut diag)
179 {
180 ops.push(info);
181 }
182 }
183 }
184 (ops, diag)
185}
186
187fn path_item_operations(item: &PathItem) -> Vec<(String, &Operation)> {
189 let mut out = Vec::new();
190 if let Some(op) = &item.get {
191 out.push(("get".into(), op));
192 }
193 if let Some(op) = &item.post {
194 out.push(("post".into(), op));
195 }
196 if let Some(op) = &item.put {
197 out.push(("put".into(), op));
198 }
199 if let Some(op) = &item.delete {
200 out.push(("delete".into(), op));
201 }
202 if let Some(op) = &item.patch {
203 out.push(("patch".into(), op));
204 }
205 if let Some(op) = &item.head {
206 out.push(("head".into(), op));
207 }
208 if let Some(op) = &item.options {
209 out.push(("options".into(), op));
210 }
211 if let Some(op) = &item.trace {
212 out.push(("trace".into(), op));
213 }
214 out
215}
216
217fn build_operation_info(
219 path: &str,
220 method: &str,
221 operation: &Operation,
222 path_item: &PathItem,
223 openapi: &OpenAPI,
224 schemes: &[SchemeInfo],
225 diag: &mut Diagnostics,
226) -> Option<OperationInfo> {
227 let Some(operation_id) = operation.operation_id.clone() else {
228 diag.error(format!(
229 "operation `{method} {path}` is missing an `operationId`; one is required to name the generated Rust method"
230 ));
231 return None;
232 };
233
234 let method_ident = match idents::method_ident(&operation_id) {
239 Ok(id) => id,
240 Err(msg) => {
241 diag.error(format!("operation `{method} {path}`: {msg}"));
242 return None;
243 }
244 };
245 if let Err(msg) = idents::validate_type_base(&operation_id) {
246 diag.error(format!("operation `{method} {path}`: {msg}"));
247 return None;
248 }
249
250 let mut all_params: Vec<&ReferenceOr<Parameter>> = Vec::new();
252 all_params.extend(path_item.parameters.iter());
253 all_params.extend(operation.parameters.iter());
254 let (path_params, query_params, header_params) =
255 collect_params(&all_params, &operation_id, method, path, openapi, diag)?;
256
257 let body = operation
258 .request_body
259 .as_ref()
260 .and_then(|rb| build_body_info(rb, openapi));
261
262 let responses = build_responses(&operation.responses, openapi, &operation_id, diag);
263 let auth = resolve_op_security(operation, openapi, schemes);
264
265 Some(OperationInfo {
266 operation_id,
267 method_ident,
268 method: method.to_owned(),
269 path: path.to_owned(),
270 summary: operation.summary.clone(),
271 description: operation.description.clone(),
272 path_params,
273 query_params,
274 header_params,
275 body,
276 responses,
277 auth,
278 })
279}
280
281fn collect_params(
288 all_params: &[&ReferenceOr<Parameter>],
289 operation_id: &str,
290 method: &str,
291 path: &str,
292 openapi: &OpenAPI,
293 diag: &mut Diagnostics,
294) -> Option<(Vec<ParamInfo>, Vec<ParamInfo>, Vec<ParamInfo>)> {
295 let mut path_params = Vec::new();
296 let mut query_params = Vec::new();
297 let mut header_params = Vec::new();
298
299 for ref_or_param in all_params {
300 let param = match ref_or_param {
301 ReferenceOr::Item(p) => p,
302 ReferenceOr::Reference { reference } => {
303 if let Some(resolved) = resolve_param_ref(reference, openapi) {
305 resolved
306 } else {
307 diag.warn(format!(
308 "operation `{operation_id}`: could not resolve parameter $ref `{reference}`; parameter skipped"
309 ));
310 continue;
311 }
312 }
313 };
314
315 let data = param.parameter_data_ref();
316 let param_schema = param_schema(param, openapi);
317
318 let field_ident = match idents::field_ident(&data.name) {
321 Ok(id) => id,
322 Err(msg) => {
323 diag.error(format!("operation `{method} {path}`: {msg}"));
324 return None;
325 }
326 };
327
328 let (is_enum, enum_ident, enum_values) =
329 if param_schema.as_ref().is_some_and(is_string_enum) {
330 let schema = param_schema.as_ref().expect("checked is_some_and above");
331 let name = format!(
332 "{}{}Query",
333 operation_id.to_pascal_case(),
334 data.name.to_pascal_case()
335 );
336 let ident = match idents::type_ident(&name, operation_id) {
337 Ok(id) => id,
338 Err(msg) => {
339 diag.error(format!("operation `{method} {path}`: {msg}"));
340 return None;
341 }
342 };
343 let vals = string_enum_values(schema);
344 (true, Some(ident), vals)
345 } else {
346 (false, None, vec![])
347 };
348
349 let rust_type = if is_enum {
350 let ei = enum_ident.as_ref().unwrap();
351 quote! { #ei }
352 } else if let Some(schema) = ¶m_schema {
353 let ref_or = ReferenceOr::Item(schema.clone());
354 schema_to_rust_type(&ref_or, true)
355 } else {
356 quote! { ::std::string::String }
357 };
358
359 let query = query_serialization(param, param_schema.as_ref());
360
361 if matches!(param, Parameter::Query { .. }) {
364 let is_object = matches!(
365 param_schema.as_ref().map(|s| &s.schema_kind),
366 Some(SchemaKind::Type(Type::Object(_)))
367 );
368 if matches!(query.style, QueryStyleKind::DeepObject) || is_object {
369 diag.warn(format!(
370 "operation `{operation_id}`: query parameter `{}` uses deepObject/object serialization, which is not yet supported; falling back to default form serialization",
371 data.name
372 ));
373 }
374 }
375
376 let info = ParamInfo {
377 name: data.name.clone(),
378 field_ident,
379 description: data.description.clone(),
380 required: data.required,
381 rust_type,
382 is_enum,
383 enum_ident,
384 enum_values,
385 query,
386 };
387
388 match param {
389 Parameter::Path { .. } => path_params.push(info),
390 Parameter::Query { .. } => query_params.push(info),
391 Parameter::Header { .. } => header_params.push(info),
392 Parameter::Cookie { .. } => diag.warn(format!(
393 "operation `{operation_id}`: cookie parameter `{}` is not supported and was skipped",
394 data.name
395 )),
396 }
397 }
398
399 Some((path_params, query_params, header_params))
400}
401
402fn query_serialization(param: &Parameter, param_schema: Option<&Schema>) -> QuerySerialization {
406 let (style, explode) = match param {
408 Parameter::Query { style, .. } => {
409 let kind = match style {
410 QueryStyle::Form => QueryStyleKind::Form,
411 QueryStyle::SpaceDelimited => QueryStyleKind::Space,
412 QueryStyle::PipeDelimited => QueryStyleKind::Pipe,
413 QueryStyle::DeepObject => QueryStyleKind::DeepObject,
414 };
415 let explode = param
416 .parameter_data_ref()
417 .explode
418 .unwrap_or(matches!(kind, QueryStyleKind::Form));
419 (kind, explode)
420 }
421 _ => (QueryStyleKind::Form, false),
422 };
423
424 let (is_array, array_item_type) = match param_schema.map(|s| &s.schema_kind) {
427 Some(SchemaKind::Type(Type::Array(arr))) => {
428 let item_ty = arr.items.as_ref().map_or_else(
429 || quote! { ::serde_json::Value },
430 |items| schema_to_rust_type(&items.clone().unbox(), true),
431 );
432 (true, Some(item_ty))
433 }
434 _ => (false, None),
435 };
436
437 QuerySerialization {
438 style,
439 explode,
440 is_array,
441 array_item_type,
442 }
443}
444
445fn resolve_param_ref<'a>(reference: &str, openapi: &'a OpenAPI) -> Option<&'a Parameter> {
447 let name = reference.strip_prefix("#/components/parameters/")?;
448 openapi.components.as_ref()?.parameters.get(name)?.as_item()
449}
450
451fn param_schema(param: &Parameter, openapi: &OpenAPI) -> Option<Schema> {
453 use openapiv3::ParameterSchemaOrContent;
454 let data = param.parameter_data_ref();
455 match &data.format {
456 ParameterSchemaOrContent::Schema(ref_or) => match ref_or {
457 ReferenceOr::Item(s) => Some(s.clone()),
458 ReferenceOr::Reference { reference } => {
459 let name = reference.strip_prefix("#/components/schemas/")?;
460 openapi
461 .components
462 .as_ref()?
463 .schemas
464 .get(name)?
465 .as_item()
466 .cloned()
467 }
468 },
469 ParameterSchemaOrContent::Content(_) => None,
470 }
471}
472
473fn build_body_info(ref_or_rb: &ReferenceOr<RequestBody>, openapi: &OpenAPI) -> Option<BodyInfo> {
475 let rb = match ref_or_rb {
476 ReferenceOr::Item(r) => r,
477 ReferenceOr::Reference { reference } => {
478 let name = reference.strip_prefix("#/components/requestBodies/")?;
479 openapi
480 .components
481 .as_ref()?
482 .request_bodies
483 .get(name)?
484 .as_item()?
485 }
486 };
487
488 let rust_type = json_media_type_to_rust(&rb.content, openapi)?;
489
490 Some(BodyInfo {
491 description: rb.description.clone(),
492 required: rb.required,
493 rust_type,
494 })
495}
496
497fn json_media_type_to_rust(
499 content: &indexmap::IndexMap<String, MediaType>,
500 _openapi: &OpenAPI,
501) -> Option<TokenStream> {
502 let media = content
503 .get("application/json")
504 .or_else(|| content.values().next())?;
505 let ref_or_schema = media.schema.as_ref()?;
506 Some(schema_to_rust_type(ref_or_schema, true))
507}
508
509fn build_responses(
511 responses: &Responses,
512 openapi: &OpenAPI,
513 op_id: &str,
514 diag: &mut Diagnostics,
515) -> Vec<ResponseInfo> {
516 let mut out = Vec::new();
517
518 for (status_code, ref_or_resp) in &responses.responses {
519 let resp = match ref_or_resp {
520 ReferenceOr::Item(r) => r,
521 ReferenceOr::Reference { reference } => {
522 if let Some(r) = resolve_response_ref(reference, openapi) {
523 r
524 } else {
525 diag.warn(format!(
526 "operation `{op_id}`: could not resolve response $ref `{reference}`; response skipped"
527 ));
528 continue;
529 }
530 }
531 };
532
533 let rust_type = json_media_type_to_rust(&resp.content, openapi);
534
535 let status = match status_code {
536 StatusCode::Code(n) => ResponseStatus::Code(*n),
537 StatusCode::Range(n) => {
538 diag.warn(format!(
539 "operation `{op_id}`: response status range `{n}XX` is not supported and was skipped"
540 ));
541 continue;
542 }
543 };
544
545 out.push(ResponseInfo {
546 status,
547 description: resp.description.clone(),
548 rust_type,
549 });
550 }
551
552 if let Some(ref_or_default) = &responses.default {
554 let resp = match ref_or_default {
555 ReferenceOr::Item(r) => r,
556 ReferenceOr::Reference { reference } => {
557 if let Some(r) = resolve_response_ref(reference, openapi) {
558 r
559 } else {
560 diag.warn(format!(
561 "operation `{op_id}`: could not resolve default response $ref `{reference}`; default response skipped"
562 ));
563 return out;
564 }
565 }
566 };
567 out.push(ResponseInfo {
568 status: ResponseStatus::Default,
569 description: resp.description.clone(),
570 rust_type: None, });
572 }
573
574 out
575}
576
577fn resolve_response_ref<'a>(reference: &str, openapi: &'a OpenAPI) -> Option<&'a Response> {
579 let name = reference.strip_prefix("#/components/responses/")?;
580 openapi.components.as_ref()?.responses.get(name)?.as_item()
581}
582
583#[must_use]
585pub fn generate_operation_errors(errors: &[String]) -> TokenStream {
586 if errors.is_empty() {
587 return TokenStream::new();
588 }
589 let msgs: Vec<TokenStream> = errors
590 .iter()
591 .map(|err| {
592 let msg = format!("openapi-trait: {err}");
593 quote! { ::core::compile_error!(#msg); }
594 })
595 .collect();
596 quote! { #(#msgs)* }
597}
598
599#[must_use]
601pub fn generate_operation_types(ops: &[OperationInfo]) -> TokenStream {
602 let items: Vec<TokenStream> = ops.iter().map(generate_single_operation_types).collect();
603 quote! { #(#items)* }
604}
605
606fn generate_single_operation_types(op: &OperationInfo) -> TokenStream {
608 let query_enums = generate_query_enums(op);
609 let request_struct = generate_request_struct(op);
610 let response_enum = generate_response_enum(op);
611 quote! {
612 #query_enums
613 #request_struct
614 #response_enum
615 }
616}
617
618fn generate_query_enums(op: &OperationInfo) -> TokenStream {
620 let enums: Vec<TokenStream> = op
621 .query_params
622 .iter()
623 .filter(|p| p.is_enum)
624 .map(|p| {
625 let ident = p.enum_ident.as_ref().unwrap();
626 let doc = doc_attr(&p.description);
627 let variants: Vec<TokenStream> = p
628 .enum_values
629 .iter()
630 .map(|v| {
631 let variant_ident = format_ident!("{}", v.to_pascal_case());
632 if variant_ident == v.as_str() {
633 quote! { #variant_ident }
634 } else {
635 quote! {
636 #[serde(rename = #v)]
637 #variant_ident
638 }
639 }
640 })
641 .collect();
642
643 quote! {
644 #doc
645 #[derive(
646 ::core::fmt::Debug,
647 ::core::clone::Clone,
648 ::serde::Serialize,
649 ::serde::Deserialize,
650 )]
651
652 pub enum #ident {
653 #(#variants,)*
654 }
655 }
656 })
657 .collect();
658
659 quote! { #(#enums)* }
660}
661
662fn generate_request_struct(op: &OperationInfo) -> TokenStream {
664 let ident = format_ident!("{}Request", op.operation_id.to_pascal_case());
665 let doc = combined_doc(op.summary.as_ref(), op.description.as_ref());
666
667 let mut fields: Vec<TokenStream> = Vec::new();
668
669 for p in &op.path_params {
670 let field_ident = &p.field_ident;
671 let ftype = &p.rust_type;
672 let fdoc = doc_attr(&p.description);
673 fields.push(quote! {
674 #fdoc
675 pub #field_ident: #ftype,
676 });
677 }
678
679 for p in &op.query_params {
680 let field_ident = &p.field_ident;
681 let inner = &p.rust_type;
682 let ftype = if p.required {
683 quote! { #inner }
684 } else {
685 quote! { ::core::option::Option<#inner> }
686 };
687 let fdoc = doc_attr(&p.description);
688 fields.push(quote! {
689 #fdoc
690 pub #field_ident: #ftype,
691 });
692 }
693
694 for p in &op.header_params {
695 let field_ident = &p.field_ident;
696 let fdoc = doc_attr(&p.description);
697 let ftype = if p.required {
701 quote! { ::std::string::String }
702 } else {
703 quote! { ::core::option::Option<::std::string::String> }
704 };
705 fields.push(quote! {
706 #fdoc
707 pub #field_ident: #ftype,
708 });
709 }
710
711 if let Some(body) = &op.body {
712 let inner = &body.rust_type;
713 let ftype = if body.required {
714 quote! { #inner }
715 } else {
716 quote! { ::core::option::Option<#inner> }
717 };
718 let bdoc = doc_attr(&body.description);
719 fields.push(quote! {
720 #bdoc
721 pub body: #ftype,
722 });
723 }
724
725 quote! {
726 #doc
727 #[derive(::core::fmt::Debug, ::core::clone::Clone)]
728 pub struct #ident {
729 #(#fields)*
730 }
731 }
732}
733
734fn generate_response_enum(op: &OperationInfo) -> TokenStream {
736 let ident = format_ident!("{}Response", op.operation_id.to_pascal_case());
737 let doc = combined_doc(op.summary.as_ref(), op.description.as_ref());
738
739 let variants: Vec<TokenStream> = op
740 .responses
741 .iter()
742 .map(|r| {
743 let vdoc = doc_attr(&Some(r.description.clone()));
744 match &r.status {
745 ResponseStatus::Code(n) => {
746 let variant_ident = format_ident!("Status{}", n);
747 r.rust_type.as_ref().map_or_else(
748 || {
749 quote! {
750 #vdoc
751 #variant_ident
752 }
753 },
754 |ty| {
755 quote! {
756 #vdoc
757 #variant_ident(#ty)
758 }
759 },
760 )
761 }
762 ResponseStatus::Default => {
763 quote! {
764 #vdoc
765 Default(::std::string::String)
766 }
767 }
768 }
769 })
770 .collect();
771
772 quote! {
773 #doc
774 #[derive(::core::fmt::Debug, ::core::clone::Clone)]
775 pub enum #ident {
776 #(#variants,)*
777 }
778 }
779}
780
781fn combined_doc(summary: Option<&String>, description: Option<&String>) -> TokenStream {
783 match (summary, description) {
784 (Some(s), Some(d)) if s != d => quote! { #[doc = #s] #[doc = ""] #[doc = #d] },
785 (Some(s), _) => quote! { #[doc = #s] },
786 (None, Some(d)) => quote! { #[doc = #d] },
787 (None, None) => quote! {},
788 }
789}
790
791#[cfg(test)]
792mod tests {
793 use super::*;
794
795 fn collect(spec: &str) -> (Vec<OperationInfo>, Diagnostics) {
797 let openapi: OpenAPI = serde_yaml::from_str(spec).expect("spec parses");
798 collect_operations(&openapi, &[])
799 }
800
801 #[test]
802 fn missing_operation_id_is_a_fatal_error_and_drops_the_operation() {
803 let (ops, diag) = collect(
804 r#"
805openapi: 3.0.0
806info: { title: t, version: "1.0" }
807paths:
808 /pets:
809 get:
810 responses:
811 '200': { description: ok }
812"#,
813 );
814 assert!(
815 ops.is_empty(),
816 "operation without operationId must be dropped"
817 );
818 assert!(diag.warnings.is_empty());
819 assert_eq!(diag.errors.len(), 1);
820 assert!(diag.errors[0].contains("missing an `operationId`"));
821 assert!(diag.errors[0].contains("get /pets"));
822 }
823
824 #[test]
825 fn cookie_param_warns_but_keeps_the_operation() {
826 let (ops, diag) = collect(
827 r#"
828openapi: 3.0.0
829info: { title: t, version: "1.0" }
830paths:
831 /pets:
832 get:
833 operationId: listPets
834 parameters:
835 - { name: session, in: cookie, schema: { type: string } }
836 responses:
837 '200': { description: ok }
838"#,
839 );
840 assert_eq!(ops.len(), 1, "operation must still be generated");
841 assert!(diag.errors.is_empty());
842 assert_eq!(diag.warnings.len(), 1);
843 assert!(diag.warnings[0].contains("cookie parameter `session`"));
844 }
845
846 #[test]
847 fn status_range_warns_but_keeps_the_operation() {
848 let (ops, diag) = collect(
849 r#"
850openapi: 3.0.0
851info: { title: t, version: "1.0" }
852paths:
853 /pets:
854 get:
855 operationId: listPets
856 responses:
857 '2XX': { description: ok }
858"#,
859 );
860 assert_eq!(ops.len(), 1);
861 assert!(diag.errors.is_empty());
862 assert_eq!(diag.warnings.len(), 1);
863 assert!(diag.warnings[0].contains("status range `2XX`"));
864 }
865
866 #[test]
867 fn keyword_operation_id_becomes_a_raw_method_ident() {
868 let (ops, diag) = collect(
869 r#"
870openapi: 3.0.0
871info: { title: t, version: "1.0" }
872paths:
873 /things:
874 get:
875 operationId: type
876 responses:
877 '200': { description: ok }
878"#,
879 );
880 assert_eq!(ops.len(), 1, "keyword operationId must still generate");
881 assert!(diag.errors.is_empty(), "{:?}", diag.errors);
882 assert_eq!(ops[0].method_ident.to_string(), "r#type");
883 }
884
885 #[test]
886 fn hyphenated_operation_id_is_snake_cased() {
887 let (ops, diag) = collect(
888 r#"
889openapi: 3.0.0
890info: { title: t, version: "1.0" }
891paths:
892 /pets:
893 get:
894 operationId: list-pets
895 responses:
896 '200': { description: ok }
897"#,
898 );
899 assert_eq!(ops.len(), 1);
900 assert!(diag.errors.is_empty());
901 assert_eq!(ops[0].method_ident.to_string(), "list_pets");
902 }
903
904 #[test]
905 fn operation_id_with_leading_digit_is_a_fatal_error() {
906 let (ops, diag) = collect(
907 r#"
908openapi: 3.0.0
909info: { title: t, version: "1.0" }
910paths:
911 /pets:
912 get:
913 operationId: 1pet
914 responses:
915 '200': { description: ok }
916"#,
917 );
918 assert!(ops.is_empty(), "invalid operationId must be dropped");
919 assert_eq!(diag.errors.len(), 1);
920 assert!(diag.errors[0].contains("1pet"), "{:?}", diag.errors);
921 }
922
923 #[test]
924 fn non_raw_keyword_operation_id_is_a_fatal_error() {
925 let (ops, diag) = collect(
926 r#"
927openapi: 3.0.0
928info: { title: t, version: "1.0" }
929paths:
930 /me:
931 get:
932 operationId: self
933 responses:
934 '200': { description: ok }
935"#,
936 );
937 assert!(ops.is_empty());
938 assert_eq!(diag.errors.len(), 1);
939 assert!(
940 diag.errors[0].contains("reserved Rust keyword"),
941 "{:?}",
942 diag.errors
943 );
944 }
945
946 #[test]
947 fn keyword_parameter_becomes_a_raw_field_ident() {
948 let (ops, diag) = collect(
949 r#"
950openapi: 3.0.0
951info: { title: t, version: "1.0" }
952paths:
953 /pets:
954 get:
955 operationId: listPets
956 parameters:
957 - { name: type, in: query, schema: { type: string } }
958 responses:
959 '200': { description: ok }
960"#,
961 );
962 assert_eq!(ops.len(), 1);
963 assert!(diag.errors.is_empty(), "{:?}", diag.errors);
964 assert_eq!(ops[0].query_params.len(), 1);
965 assert_eq!(ops[0].query_params[0].field_ident.to_string(), "r#type");
966 }
967
968 #[test]
969 fn parameter_with_leading_digit_is_a_fatal_error() {
970 let (ops, diag) = collect(
971 r#"
972openapi: 3.0.0
973info: { title: t, version: "1.0" }
974paths:
975 /pets:
976 get:
977 operationId: listPets
978 parameters:
979 - { name: 1abc, in: query, schema: { type: string } }
980 responses:
981 '200': { description: ok }
982"#,
983 );
984 assert!(ops.is_empty(), "invalid parameter name must drop the op");
985 assert_eq!(diag.errors.len(), 1);
986 assert!(diag.errors[0].contains("1abc"), "{:?}", diag.errors);
987 }
988
989 #[test]
990 fn required_header_is_non_optional_string_optional_one_is_option() {
991 let (ops, diag) = collect(
992 r#"
993openapi: 3.0.0
994info: { title: t, version: "1.0" }
995paths:
996 /pets:
997 get:
998 operationId: listPets
999 parameters:
1000 - { name: X-Required, in: header, required: true, schema: { type: string } }
1001 - { name: X-Optional, in: header, schema: { type: string } }
1002 responses:
1003 '200': { description: ok }
1004"#,
1005 );
1006 assert_eq!(ops.len(), 1);
1007 assert!(diag.errors.is_empty(), "{:?}", diag.errors);
1008 assert_eq!(ops[0].header_params.len(), 2);
1009
1010 let struct_src = generate_request_struct(&ops[0]).to_string();
1011 let normalized: String = struct_src.split_whitespace().collect();
1014 assert!(
1015 normalized.contains("x_required:::std::string::String,"),
1016 "required header must be a non-optional String: {struct_src}"
1017 );
1018 assert!(
1019 normalized.contains("x_optional:::core::option::Option<::std::string::String>"),
1020 "optional header must stay an Option: {struct_src}"
1021 );
1022 }
1023
1024 #[test]
1025 fn query_style_and_explode_are_parsed_from_the_spec() {
1026 let (ops, diag) = collect(
1027 r#"
1028openapi: 3.0.0
1029info: { title: t, version: "1.0" }
1030paths:
1031 /items:
1032 get:
1033 operationId: searchItems
1034 parameters:
1035 - name: tag
1036 in: query
1037 style: form
1038 explode: true
1039 schema: { type: array, items: { type: string } }
1040 - name: csv
1041 in: query
1042 style: spaceDelimited
1043 schema: { type: array, items: { type: integer } }
1044 responses:
1045 '200': { description: ok }
1046"#,
1047 );
1048 assert_eq!(ops.len(), 1);
1049 assert!(diag.errors.is_empty(), "{:?}", diag.errors);
1050 let q = &ops[0].query_params;
1051 assert_eq!(q.len(), 2);
1052
1053 let tag = &q[0];
1054 assert_eq!(tag.query.style, QueryStyleKind::Form);
1055 assert!(tag.query.explode, "explode: true is honored");
1056 assert!(tag.query.is_array);
1057 assert!(tag.query.array_item_type.is_some());
1058
1059 let csv = &q[1];
1060 assert_eq!(csv.query.style, QueryStyleKind::Space);
1061 assert!(!csv.query.explode);
1063 assert!(csv.query.is_array);
1064 }
1065
1066 #[test]
1067 fn explode_defaults_true_for_form_and_false_for_other_styles() {
1068 let (ops, _) = collect(
1069 r#"
1070openapi: 3.0.0
1071info: { title: t, version: "1.0" }
1072paths:
1073 /items:
1074 get:
1075 operationId: searchItems
1076 parameters:
1077 - name: form_default
1078 in: query
1079 schema: { type: array, items: { type: string } }
1080 - name: pipe_default
1081 in: query
1082 style: pipeDelimited
1083 schema: { type: array, items: { type: string } }
1084 responses:
1085 '200': { description: ok }
1086"#,
1087 );
1088 let q = &ops[0].query_params;
1089 assert_eq!(q[0].query.style, QueryStyleKind::Form);
1090 assert!(q[0].query.explode, "form defaults explode to true");
1091 assert_eq!(q[1].query.style, QueryStyleKind::Pipe);
1092 assert!(
1093 !q[1].query.explode,
1094 "non-form styles default explode to false"
1095 );
1096 }
1097
1098 #[test]
1099 fn deep_object_and_object_query_params_warn_but_keep_the_operation() {
1100 let (ops, diag) = collect(
1101 r#"
1102openapi: 3.0.0
1103info: { title: t, version: "1.0" }
1104paths:
1105 /items:
1106 get:
1107 operationId: searchItems
1108 parameters:
1109 - name: filter
1110 in: query
1111 style: deepObject
1112 explode: true
1113 schema:
1114 type: object
1115 additionalProperties: { type: string }
1116 responses:
1117 '200': { description: ok }
1118"#,
1119 );
1120 assert_eq!(ops.len(), 1);
1121 assert!(diag.errors.is_empty(), "{:?}", diag.errors);
1122 assert_eq!(diag.warnings.len(), 1);
1123 assert!(
1124 diag.warnings[0].contains("deepObject/object"),
1125 "{:?}",
1126 diag.warnings
1127 );
1128 }
1129
1130 #[test]
1131 fn specific_status_codes_are_handled_without_diagnostics() {
1132 let (ops, diag) = collect(
1133 r#"
1134openapi: 3.0.0
1135info: { title: t, version: "1.0" }
1136paths:
1137 /pets:
1138 post:
1139 operationId: createPet
1140 responses:
1141 '201': { description: created }
1142 '202': { description: accepted }
1143"#,
1144 );
1145 assert_eq!(ops.len(), 1);
1146 assert_eq!(ops[0].responses.len(), 2, "201 and 202 both generated");
1147 assert!(diag.errors.is_empty(), "no errors for a clean operation");
1148 assert!(
1149 diag.warnings.is_empty(),
1150 "no warnings for a clean operation"
1151 );
1152 }
1153}