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