Skip to main content

openapi_trait_shared/codegen/
operations.rs

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/// All information about a single API operation needed by later codegen stages.
15#[derive(Debug)]
16pub struct OperationInfo {
17    pub operation_id: String,
18    /// Keyword-safe Rust method identifier derived from `operation_id`
19    /// (snake-cased, raw-escaped for keywords, e.g. `r#type`).
20    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    /// Keyword-safe Rust struct-field identifier derived from `name`
37    /// (snake-cased, raw-escaped for keywords, e.g. `r#type`).
38    pub field_ident: syn::Ident,
39    pub description: Option<String>,
40    pub required: bool,
41    /// The Rust type for this parameter (e.g. `i64`, `String`, or an enum ident).
42    pub rust_type: TokenStream,
43    /// True when this param's schema is a string enum (so we emit a dedicated enum type).
44    pub is_enum: bool,
45    /// The enum ident when `is_enum` is true, e.g. `FindPetsByStatusStatusQuery`.
46    pub enum_ident: Option<syn::Ident>,
47    /// Enum values when `is_enum` is true.
48    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/// Collected codegen diagnostics, separated by severity.
72///
73/// Errors are turned into `compile_error!` tokens (the build fails); warnings
74/// are printed to stderr during macro expansion (the build proceeds). This
75/// keeps unsupported `OpenAPI` constructs from being dropped silently.
76#[derive(Debug, Default)]
77pub struct Diagnostics {
78    /// Fatal problems, emitted as `compile_error!` tokens.
79    pub errors: Vec<String>,
80    /// Non-fatal skips, emitted as `eprintln!` warnings; the build proceeds.
81    pub warnings: Vec<String>,
82}
83
84impl Diagnostics {
85    /// Record a fatal diagnostic.
86    fn error(&mut self, msg: String) {
87        self.errors.push(msg);
88    }
89
90    /// Record a non-fatal diagnostic.
91    fn warn(&mut self, msg: String) {
92        self.warnings.push(msg);
93    }
94
95    /// Print all collected warnings to stderr during macro expansion.
96    pub fn emit_warnings(&self) {
97        for warning in &self.warnings {
98            eprintln!("openapi-trait: warning: {warning}");
99        }
100    }
101}
102
103/// Collect all operations from the `OpenAPI` document.
104///
105/// Returns the operations along with any [`Diagnostics`] gathered while walking
106/// the spec (unsupported constructs, unresolved `$ref`s, missing
107/// `operationId`s).
108#[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
136/// Returns all operations in a path item with their HTTP methods.
137fn 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
166/// Build an `OperationInfo` for a single operation in the path item.
167fn 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    // Validate the operationId yields usable Rust identifiers up front, so that
184    // keyword/invalid names surface a clear diagnostic instead of a downstream
185    // proc-macro panic. Keywords become raw identifiers (`type` -> `r#type`);
186    // truly invalid names drop the operation with an error.
187    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    // Collect parameters: path-level then operation-level (operation wins on name clash)
200    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
230/// Collect and classify an operation's parameters into `(path, query, header)`
231/// buckets.
232///
233/// Returns `None` (after recording a fatal diagnostic) when a parameter name
234/// cannot become a valid Rust identifier; cookie params and unresolved `$ref`s
235/// are non-fatal and merely warn.
236fn 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                // Try to resolve from components
253                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        // Keyword-safe field identifier for this parameter (shared by the request
268        // struct and every transport that constructs it).
269        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) = &param_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
333/// Resolve a `$ref` to a parameter from the components section.
334fn 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
339/// Extract the schema for a parameter, resolving component refs if needed.
340fn 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
361/// Build body info from a request body reference.
362fn 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
385/// Extract the Rust type from a JSON media type content map.
386fn 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
397/// Build response info list from an `OpenAPI` responses object.
398fn 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    // Handle default response
441    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, // Default carries a String message
459        });
460    }
461
462    out
463}
464
465/// Resolve a `$ref` to a response from the components section.
466fn 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/// Emit a `compile_error!` token for each fatal operation diagnostic.
472#[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/// Generate query-param enum types + request structs + response enums for all operations.
488#[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
494/// Generate all types for a single operation.
495fn 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
506/// Generate enum types for query parameters with string enum schemas.
507fn 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
550/// Generate the request struct for an operation.
551fn 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        // Header values arrive as strings over the wire. A required header is a
586        // plain `String` so the type system guarantees it is present; an optional
587        // one is `Option<String>` since extraction from a `HeaderMap` can fail.
588        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
622/// Generate the response enum for an operation.
623fn 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
669/// Combine summary and description into doc attributes.
670fn 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    /// Parse a spec and collect its operations + diagnostics.
684    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        // The required header is a plain `String`; the optional one is wrapped in
900        // `Option`. Normalize whitespace so the token spacing doesn't matter.
901        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}