Skip to main content

openapi_trait_shared/codegen/
operations.rs

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/// 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/// How a query parameter's array (or object) value is delimited on the wire,
34/// derived from the `OpenAPI` `style` keyword for `in: query` parameters.
35///
36/// Only meaningful for query params; other locations default to [`Self::Form`].
37/// `DeepObject` is recognized but not yet serialized per-spec — it falls back to
38/// default serialization with a build warning.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum QueryStyleKind {
41    /// `style: form` — repeated keys when exploded, comma-joined otherwise.
42    Form,
43    /// `style: spaceDelimited` — space-joined array values (when not exploded).
44    Space,
45    /// `style: pipeDelimited` — pipe-joined array values (when not exploded).
46    Pipe,
47    /// `style: deepObject` — nested object rendering (unsupported; warns).
48    DeepObject,
49}
50
51impl QueryStyleKind {
52    /// The single-character delimiter used to join array values when the
53    /// parameter is not exploded (`form` → `,`, space → ` `, pipe → `|`).
54    #[must_use]
55    pub const fn delimiter(self) -> &'static str {
56        match self {
57            Self::Space => " ",
58            Self::Pipe => "|",
59            // `form` uses comma; `deepObject` never reaches the join path but
60            // comma is a harmless fallback.
61            Self::Form | Self::DeepObject => ",",
62        }
63    }
64}
65
66/// How a query parameter is serialized on the wire, resolved from its `style`,
67/// `explode`, and schema shape. Populated with defaults for non-query params.
68#[derive(Debug)]
69pub struct QuerySerialization {
70    /// The `style` keyword; defaults to [`QueryStyleKind::Form`].
71    pub style: QueryStyleKind,
72    /// Resolved `explode` flag (spec default: `true` for `form`, `false` for the
73    /// other styles).
74    pub explode: bool,
75    /// True when the schema is an array (the Rust type is `Vec<T>`).
76    pub is_array: bool,
77    /// The element type `T` when [`Self::is_array`] is true, so the server can
78    /// parse array elements one at a time.
79    pub array_item_type: Option<TokenStream>,
80}
81
82#[derive(Debug)]
83pub struct ParamInfo {
84    pub name: String,
85    /// Keyword-safe Rust struct-field identifier derived from `name`
86    /// (snake-cased, raw-escaped for keywords, e.g. `r#type`).
87    pub field_ident: syn::Ident,
88    pub description: Option<String>,
89    pub required: bool,
90    /// The Rust type for this parameter (e.g. `i64`, `String`, or an enum ident).
91    pub rust_type: TokenStream,
92    /// True when this param's schema is a string enum (so we emit a dedicated enum type).
93    pub is_enum: bool,
94    /// The enum ident when `is_enum` is true, e.g. `FindPetsByStatusStatusQuery`.
95    pub enum_ident: Option<syn::Ident>,
96    /// Enum values when `is_enum` is true.
97    pub enum_values: Vec<String>,
98    /// Query serialization strategy (meaningful only for query params).
99    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/// Collected codegen diagnostics, separated by severity.
123///
124/// Errors are turned into `compile_error!` tokens (the build fails); warnings
125/// are printed to stderr during macro expansion (the build proceeds). This
126/// keeps unsupported `OpenAPI` constructs from being dropped silently.
127#[derive(Debug, Default)]
128pub struct Diagnostics {
129    /// Fatal problems, emitted as `compile_error!` tokens.
130    pub errors: Vec<String>,
131    /// Non-fatal skips, emitted as `eprintln!` warnings; the build proceeds.
132    pub warnings: Vec<String>,
133}
134
135impl Diagnostics {
136    /// Record a fatal diagnostic.
137    fn error(&mut self, msg: String) {
138        self.errors.push(msg);
139    }
140
141    /// Record a non-fatal diagnostic.
142    fn warn(&mut self, msg: String) {
143        self.warnings.push(msg);
144    }
145
146    /// Print all collected warnings to stderr during macro expansion.
147    pub fn emit_warnings(&self) {
148        for warning in &self.warnings {
149            eprintln!("openapi-trait: warning: {warning}");
150        }
151    }
152}
153
154/// Collect all operations from the `OpenAPI` document.
155///
156/// Returns the operations along with any [`Diagnostics`] gathered while walking
157/// the spec (unsupported constructs, unresolved `$ref`s, missing
158/// `operationId`s).
159#[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
187/// Returns all operations in a path item with their HTTP methods.
188fn 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
217/// Build an `OperationInfo` for a single operation in the path item.
218fn 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    // Validate the operationId yields usable Rust identifiers up front, so that
235    // keyword/invalid names surface a clear diagnostic instead of a downstream
236    // proc-macro panic. Keywords become raw identifiers (`type` -> `r#type`);
237    // truly invalid names drop the operation with an error.
238    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    // Collect parameters: path-level then operation-level (operation wins on name clash)
251    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
281/// Collect and classify an operation's parameters into `(path, query, header)`
282/// buckets.
283///
284/// Returns `None` (after recording a fatal diagnostic) when a parameter name
285/// cannot become a valid Rust identifier; cookie params and unresolved `$ref`s
286/// are non-fatal and merely warn.
287fn 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                // Try to resolve from components
304                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        // Keyword-safe field identifier for this parameter (shared by the request
319        // struct and every transport that constructs it).
320        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) = &param_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        // deepObject and object-valued query params are not yet serialized
362        // per-spec; warn and fall back to default form serialization.
363        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
402/// Resolve a parameter's [`QuerySerialization`] from its `style`/`explode` and
403/// schema shape. Only `in: query` params carry a `style`; other locations get
404/// defaults (`form`, not exploded, not an array).
405fn query_serialization(param: &Parameter, param_schema: Option<&Schema>) -> QuerySerialization {
406    // Per the spec `explode` defaults to true for `form` and false otherwise.
407    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    // Detect array schemas so array query params can be serialized per style and
425    // parsed element-by-element on the server.
426    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
445/// Resolve a `$ref` to a parameter from the components section.
446fn 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
451/// Extract the schema for a parameter, resolving component refs if needed.
452fn 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
473/// Build body info from a request body reference.
474fn 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
497/// Extract the Rust type from a JSON media type content map.
498fn 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
509/// Build response info list from an `OpenAPI` responses object.
510fn 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    // Handle default response
553    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, // Default carries a String message
571        });
572    }
573
574    out
575}
576
577/// Resolve a `$ref` to a response from the components section.
578fn 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/// Emit a `compile_error!` token for each fatal operation diagnostic.
584#[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/// Generate query-param enum types + request structs + response enums for all operations.
600#[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
606/// Generate all types for a single operation.
607fn 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
618/// Generate enum types for query parameters with string enum schemas.
619fn 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
662/// Generate the request struct for an operation.
663fn 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        // Header values arrive as strings over the wire. A required header is a
698        // plain `String` so the type system guarantees it is present; an optional
699        // one is `Option<String>` since extraction from a `HeaderMap` can fail.
700        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
734/// Generate the response enum for an operation.
735fn 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
781/// Combine summary and description into doc attributes.
782fn 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    /// Parse a spec and collect its operations + diagnostics.
796    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        // The required header is a plain `String`; the optional one is wrapped in
1012        // `Option`. Normalize whitespace so the token spacing doesn't matter.
1013        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        // spaceDelimited defaults explode to false.
1062        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}