Skip to main content

openapi_trait_shared/codegen/
operations.rs

1use heck::{ToPascalCase, ToSnakeCase};
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::schemas::doc_attr;
10use super::security::{resolve_op_security, OpSecurity, SchemeInfo};
11use super::types::{is_string_enum, schema_to_rust_type, string_enum_values};
12
13/// All information about a single API operation needed by later codegen stages.
14#[derive(Debug)]
15pub struct OperationInfo {
16    pub operation_id: String,
17    pub method: String,
18    pub path: String,
19    pub summary: Option<String>,
20    pub description: Option<String>,
21    pub path_params: Vec<ParamInfo>,
22    pub query_params: Vec<ParamInfo>,
23    pub header_params: Vec<ParamInfo>,
24    pub body: Option<BodyInfo>,
25    pub responses: Vec<ResponseInfo>,
26    pub auth: OpSecurity,
27}
28
29#[derive(Debug)]
30pub struct ParamInfo {
31    pub name: String,
32    pub description: Option<String>,
33    pub required: bool,
34    /// The Rust type for this parameter (e.g. `i64`, `String`, or an enum ident).
35    pub rust_type: TokenStream,
36    /// True when this param's schema is a string enum (so we emit a dedicated enum type).
37    pub is_enum: bool,
38    /// The enum ident when `is_enum` is true, e.g. `FindPetsByStatusStatusQuery`.
39    pub enum_ident: Option<syn::Ident>,
40    /// Enum values when `is_enum` is true.
41    pub enum_values: Vec<String>,
42}
43
44#[derive(Debug)]
45pub struct BodyInfo {
46    pub description: Option<String>,
47    pub required: bool,
48    pub rust_type: TokenStream,
49}
50
51#[derive(Debug)]
52pub struct ResponseInfo {
53    pub status: ResponseStatus,
54    pub description: String,
55    pub rust_type: Option<TokenStream>,
56}
57
58#[derive(Debug)]
59pub enum ResponseStatus {
60    Code(u16),
61    Default,
62}
63
64/// Collect all operations from the `OpenAPI` document.
65#[must_use]
66pub fn collect_operations(openapi: &OpenAPI, schemes: &[SchemeInfo]) -> Vec<OperationInfo> {
67    let mut ops = Vec::new();
68    for (path, ref_or_item) in &openapi.paths.paths {
69        let item = match ref_or_item {
70            ReferenceOr::Item(i) => i,
71            ReferenceOr::Reference { .. } => continue,
72        };
73        for (method, operation) in path_item_operations(item) {
74            if let Some(info) =
75                build_operation_info(path, &method, operation, item, openapi, schemes)
76            {
77                ops.push(info);
78            }
79        }
80    }
81    ops
82}
83
84/// Returns all operations in a path item with their HTTP methods.
85fn path_item_operations(item: &PathItem) -> Vec<(String, &Operation)> {
86    let mut out = Vec::new();
87    if let Some(op) = &item.get {
88        out.push(("get".into(), op));
89    }
90    if let Some(op) = &item.post {
91        out.push(("post".into(), op));
92    }
93    if let Some(op) = &item.put {
94        out.push(("put".into(), op));
95    }
96    if let Some(op) = &item.delete {
97        out.push(("delete".into(), op));
98    }
99    if let Some(op) = &item.patch {
100        out.push(("patch".into(), op));
101    }
102    if let Some(op) = &item.head {
103        out.push(("head".into(), op));
104    }
105    if let Some(op) = &item.options {
106        out.push(("options".into(), op));
107    }
108    if let Some(op) = &item.trace {
109        out.push(("trace".into(), op));
110    }
111    out
112}
113
114/// Build an `OperationInfo` for a single operation in the path item.
115fn build_operation_info(
116    path: &str,
117    method: &str,
118    operation: &Operation,
119    path_item: &PathItem,
120    openapi: &OpenAPI,
121    schemes: &[SchemeInfo],
122) -> Option<OperationInfo> {
123    let operation_id = operation.operation_id.clone()?;
124
125    // Collect parameters: path-level then operation-level (operation wins on name clash)
126    let mut all_params: Vec<&ReferenceOr<Parameter>> = Vec::new();
127    all_params.extend(path_item.parameters.iter());
128    all_params.extend(operation.parameters.iter());
129
130    let mut path_params = Vec::new();
131    let mut query_params = Vec::new();
132    let mut header_params = Vec::new();
133
134    for ref_or_param in &all_params {
135        let param = match ref_or_param {
136            ReferenceOr::Item(p) => p,
137            ReferenceOr::Reference { reference } => {
138                // Try to resolve from components
139                if let Some(resolved) = resolve_param_ref(reference, openapi) {
140                    resolved
141                } else {
142                    continue;
143                }
144            }
145        };
146
147        let data = param.parameter_data_ref();
148        let param_schema = param_schema(param, openapi);
149
150        let (is_enum, enum_ident, enum_values) = param_schema.as_ref().map_or_else(
151            || (false, None, vec![]),
152            |schema| {
153                if is_string_enum(schema) {
154                    let ident = format_ident!(
155                        "{}{}Query",
156                        operation_id.to_pascal_case(),
157                        data.name.to_pascal_case()
158                    );
159                    let vals = string_enum_values(schema);
160                    (true, Some(ident), vals)
161                } else {
162                    (false, None, vec![])
163                }
164            },
165        );
166
167        let rust_type = if is_enum {
168            let ei = enum_ident.as_ref().unwrap();
169            quote! { #ei }
170        } else if let Some(schema) = &param_schema {
171            let ref_or = ReferenceOr::Item(schema.clone());
172            schema_to_rust_type(&ref_or, true)
173        } else {
174            quote! { ::std::string::String }
175        };
176
177        let info = ParamInfo {
178            name: data.name.clone(),
179            description: data.description.clone(),
180            required: data.required,
181            rust_type,
182            is_enum,
183            enum_ident,
184            enum_values,
185        };
186
187        match param {
188            Parameter::Path { .. } => path_params.push(info),
189            Parameter::Query { .. } => query_params.push(info),
190            Parameter::Header { .. } => header_params.push(info),
191            Parameter::Cookie { .. } => {}
192        }
193    }
194
195    let body = operation
196        .request_body
197        .as_ref()
198        .and_then(|rb| build_body_info(rb, openapi));
199
200    let responses = build_responses(&operation.responses, openapi);
201    let auth = resolve_op_security(operation, openapi, schemes);
202
203    Some(OperationInfo {
204        operation_id,
205        method: method.to_owned(),
206        path: path.to_owned(),
207        summary: operation.summary.clone(),
208        description: operation.description.clone(),
209        path_params,
210        query_params,
211        header_params,
212        body,
213        responses,
214        auth,
215    })
216}
217
218/// Resolve a `$ref` to a parameter from the components section.
219fn resolve_param_ref<'a>(reference: &str, openapi: &'a OpenAPI) -> Option<&'a Parameter> {
220    let name = reference.strip_prefix("#/components/parameters/")?;
221    openapi.components.as_ref()?.parameters.get(name)?.as_item()
222}
223
224/// Extract the schema for a parameter, resolving component refs if needed.
225fn param_schema(param: &Parameter, openapi: &OpenAPI) -> Option<Schema> {
226    use openapiv3::ParameterSchemaOrContent;
227    let data = param.parameter_data_ref();
228    match &data.format {
229        ParameterSchemaOrContent::Schema(ref_or) => match ref_or {
230            ReferenceOr::Item(s) => Some(s.clone()),
231            ReferenceOr::Reference { reference } => {
232                let name = reference.strip_prefix("#/components/schemas/")?;
233                openapi
234                    .components
235                    .as_ref()?
236                    .schemas
237                    .get(name)?
238                    .as_item()
239                    .cloned()
240            }
241        },
242        ParameterSchemaOrContent::Content(_) => None,
243    }
244}
245
246/// Build body info from a request body reference.
247fn build_body_info(ref_or_rb: &ReferenceOr<RequestBody>, openapi: &OpenAPI) -> Option<BodyInfo> {
248    let rb = match ref_or_rb {
249        ReferenceOr::Item(r) => r,
250        ReferenceOr::Reference { reference } => {
251            let name = reference.strip_prefix("#/components/requestBodies/")?;
252            openapi
253                .components
254                .as_ref()?
255                .request_bodies
256                .get(name)?
257                .as_item()?
258        }
259    };
260
261    let rust_type = json_media_type_to_rust(&rb.content, openapi)?;
262
263    Some(BodyInfo {
264        description: rb.description.clone(),
265        required: rb.required,
266        rust_type,
267    })
268}
269
270/// Extract the Rust type from a JSON media type content map.
271fn json_media_type_to_rust(
272    content: &indexmap::IndexMap<String, MediaType>,
273    _openapi: &OpenAPI,
274) -> Option<TokenStream> {
275    let media = content
276        .get("application/json")
277        .or_else(|| content.values().next())?;
278    let ref_or_schema = media.schema.as_ref()?;
279    Some(schema_to_rust_type(ref_or_schema, true))
280}
281
282/// Build response info list from an `OpenAPI` responses object.
283fn build_responses(responses: &Responses, openapi: &OpenAPI) -> Vec<ResponseInfo> {
284    let mut out = Vec::new();
285
286    for (status_code, ref_or_resp) in &responses.responses {
287        let resp = match ref_or_resp {
288            ReferenceOr::Item(r) => r,
289            ReferenceOr::Reference { reference } => {
290                if let Some(r) = resolve_response_ref(reference, openapi) {
291                    r
292                } else {
293                    continue;
294                }
295            }
296        };
297
298        let rust_type = json_media_type_to_rust(&resp.content, openapi);
299
300        let status = match status_code {
301            StatusCode::Code(n) => ResponseStatus::Code(*n),
302            StatusCode::Range(_) => continue, // skip range codes
303        };
304
305        out.push(ResponseInfo {
306            status,
307            description: resp.description.clone(),
308            rust_type,
309        });
310    }
311
312    // Handle default response
313    if let Some(ref_or_default) = &responses.default {
314        let resp = match ref_or_default {
315            ReferenceOr::Item(r) => r,
316            ReferenceOr::Reference { reference } => {
317                if let Some(r) = resolve_response_ref(reference, openapi) {
318                    r
319                } else {
320                    return out;
321                }
322            }
323        };
324        out.push(ResponseInfo {
325            status: ResponseStatus::Default,
326            description: resp.description.clone(),
327            rust_type: None, // Default carries a String message
328        });
329    }
330
331    out
332}
333
334/// Resolve a `$ref` to a response from the components section.
335fn resolve_response_ref<'a>(reference: &str, openapi: &'a OpenAPI) -> Option<&'a Response> {
336    let name = reference.strip_prefix("#/components/responses/")?;
337    openapi.components.as_ref()?.responses.get(name)?.as_item()
338}
339
340/// Generate query-param enum types + request structs + response enums for all operations.
341pub fn generate_operation_types(ops: &[OperationInfo]) -> TokenStream {
342    let items: Vec<TokenStream> = ops.iter().map(generate_single_operation_types).collect();
343    quote! { #(#items)* }
344}
345
346/// Generate all types for a single operation.
347fn generate_single_operation_types(op: &OperationInfo) -> TokenStream {
348    let query_enums = generate_query_enums(op);
349    let request_struct = generate_request_struct(op);
350    let response_enum = generate_response_enum(op);
351    quote! {
352        #query_enums
353        #request_struct
354        #response_enum
355    }
356}
357
358/// Generate enum types for query parameters with string enum schemas.
359fn generate_query_enums(op: &OperationInfo) -> TokenStream {
360    let enums: Vec<TokenStream> = op
361        .query_params
362        .iter()
363        .filter(|p| p.is_enum)
364        .map(|p| {
365            let ident = p.enum_ident.as_ref().unwrap();
366            let doc = doc_attr(&p.description);
367            let variants: Vec<TokenStream> = p
368                .enum_values
369                .iter()
370                .map(|v| {
371                    let variant_ident = format_ident!("{}", v.to_pascal_case());
372                    if variant_ident == v.as_str() {
373                        quote! { #variant_ident }
374                    } else {
375                        quote! {
376                            #[serde(rename = #v)]
377                            #variant_ident
378                        }
379                    }
380                })
381                .collect();
382
383            quote! {
384                #doc
385                #[derive(
386                    ::core::fmt::Debug,
387                    ::core::clone::Clone,
388                    ::serde::Serialize,
389                    ::serde::Deserialize,
390                )]
391
392                pub enum #ident {
393                    #(#variants,)*
394                }
395            }
396        })
397        .collect();
398
399    quote! { #(#enums)* }
400}
401
402/// Generate the request struct for an operation.
403fn generate_request_struct(op: &OperationInfo) -> TokenStream {
404    let ident = format_ident!("{}Request", op.operation_id.to_pascal_case());
405    let doc = combined_doc(op.summary.as_ref(), op.description.as_ref());
406
407    let mut fields: Vec<TokenStream> = Vec::new();
408
409    for p in &op.path_params {
410        let field_ident = format_ident!("{}", p.name.to_snake_case());
411        let ftype = &p.rust_type;
412        let fdoc = doc_attr(&p.description);
413        fields.push(quote! {
414            #fdoc
415            pub #field_ident: #ftype,
416        });
417    }
418
419    for p in &op.query_params {
420        let field_ident = format_ident!("{}", p.name.to_snake_case());
421        let inner = &p.rust_type;
422        let ftype = if p.required {
423            quote! { #inner }
424        } else {
425            quote! { ::core::option::Option<#inner> }
426        };
427        let fdoc = doc_attr(&p.description);
428        fields.push(quote! {
429            #fdoc
430            pub #field_ident: #ftype,
431        });
432    }
433
434    for p in &op.header_params {
435        let field_ident = format_ident!("{}", p.name.to_snake_case());
436        let fdoc = doc_attr(&p.description);
437        // Header params are always Option<String> since extraction from HeaderMap can fail
438        fields.push(quote! {
439            #fdoc
440            pub #field_ident: ::core::option::Option<::std::string::String>,
441        });
442    }
443
444    if let Some(body) = &op.body {
445        let inner = &body.rust_type;
446        let ftype = if body.required {
447            quote! { #inner }
448        } else {
449            quote! { ::core::option::Option<#inner> }
450        };
451        let bdoc = doc_attr(&body.description);
452        fields.push(quote! {
453            #bdoc
454            pub body: #ftype,
455        });
456    }
457
458    quote! {
459        #doc
460        #[derive(::core::fmt::Debug, ::core::clone::Clone)]
461        pub struct #ident {
462            #(#fields)*
463        }
464    }
465}
466
467/// Generate the response enum for an operation.
468fn generate_response_enum(op: &OperationInfo) -> TokenStream {
469    let ident = format_ident!("{}Response", op.operation_id.to_pascal_case());
470    let doc = combined_doc(op.summary.as_ref(), op.description.as_ref());
471
472    let variants: Vec<TokenStream> = op
473        .responses
474        .iter()
475        .map(|r| {
476            let vdoc = doc_attr(&Some(r.description.clone()));
477            match &r.status {
478                ResponseStatus::Code(n) => {
479                    let variant_ident = format_ident!("Status{}", n);
480                    r.rust_type.as_ref().map_or_else(
481                        || {
482                            quote! {
483                                #vdoc
484                                #variant_ident
485                            }
486                        },
487                        |ty| {
488                            quote! {
489                                #vdoc
490                                #variant_ident(#ty)
491                            }
492                        },
493                    )
494                }
495                ResponseStatus::Default => {
496                    quote! {
497                        #vdoc
498                        Default(::std::string::String)
499                    }
500                }
501            }
502        })
503        .collect();
504
505    quote! {
506        #doc
507        #[derive(::core::fmt::Debug, ::core::clone::Clone)]
508        pub enum #ident {
509            #(#variants,)*
510        }
511    }
512}
513
514/// Combine summary and description into doc attributes.
515fn combined_doc(summary: Option<&String>, description: Option<&String>) -> TokenStream {
516    match (summary, description) {
517        (Some(s), Some(d)) if s != d => quote! { #[doc = #s] #[doc = ""] #[doc = #d] },
518        (Some(s), _) => quote! { #[doc = #s] },
519        (None, Some(d)) => quote! { #[doc = #d] },
520        (None, None) => quote! {},
521    }
522}