Skip to main content

openapi_to_rust/
registry_generator.rs

1//! Operation registry generation for OpenAPI specifications.
2//!
3//! Generates a static registry of operation metadata from analyzed OpenAPI operations.
4//! The registry contains everything needed to:
5//! - Build CLI subcommands dynamically (names, params, help text)
6//! - Route and validate operations in a proxy (URL templates, param types, methods)
7//!
8//! The generated code is pure data — no HTTP client, no clap derives, no runtime dependencies
9//! beyond serde. Consumers (CLI shims, proxy routers) interpret the registry generically.
10
11use crate::analysis::SchemaAnalysis;
12use crate::generator::CodeGenerator;
13use proc_macro2::TokenStream;
14use quote::{format_ident, quote};
15use std::collections::BTreeMap;
16
17impl CodeGenerator {
18    /// Generate the registry.rs file content
19    pub fn generate_registry(&self, analysis: &SchemaAnalysis) -> crate::Result<String> {
20        let provenance_attribute = self.provenance_attribute();
21        let custom_methods = registry_custom_methods(analysis);
22        let registry_types = Self::generate_registry_types(&custom_methods);
23        let operation_defs = self.generate_operation_defs(analysis, &custom_methods);
24
25        let tokens = quote! {
26            //! Auto-generated operation registry. Do not edit.
27
28            #provenance_attribute
29
30            #registry_types
31            #operation_defs
32        };
33
34        let file = syn::parse2(tokens).map_err(|e| {
35            crate::GeneratorError::CodeGenError(format!("Failed to parse registry tokens: {}", e))
36        })?;
37        Ok(prettyplease::unparse(&file))
38    }
39
40    /// Generate the registry data types
41    fn generate_registry_types(custom_methods: &BTreeMap<String, syn::Ident>) -> TokenStream {
42        let custom_variants = custom_methods.values();
43        let custom_as_str = custom_methods.iter().map(|(method, variant)| {
44            quote! { Self::#variant => #method }
45        });
46        quote! {
47            /// HTTP method for an operation
48            #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
49            pub enum HttpMethod {
50                Get,
51                Post,
52                Put,
53                Patch,
54                Delete,
55                Head,
56                Options,
57                Trace,
58                Connect,
59                Query,
60                #(#custom_variants),*
61            }
62
63            impl HttpMethod {
64                pub fn as_str(&self) -> &'static str {
65                    match self {
66                        Self::Get => "GET",
67                        Self::Post => "POST",
68                        Self::Put => "PUT",
69                        Self::Patch => "PATCH",
70                        Self::Delete => "DELETE",
71                        Self::Head => "HEAD",
72                        Self::Options => "OPTIONS",
73                        Self::Trace => "TRACE",
74                        Self::Connect => "CONNECT",
75                        Self::Query => "QUERY",
76                        #(#custom_as_str),*
77                    }
78                }
79            }
80
81            impl std::fmt::Display for HttpMethod {
82                fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83                    f.write_str(self.as_str())
84                }
85            }
86
87            /// Where a parameter appears in the request
88            #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
89            pub enum ParamLocation {
90                Path,
91                Query,
92                Header,
93            }
94
95            /// Primitive type of a parameter (for validation and CLI parsing)
96            #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
97            pub enum ParamType {
98                String,
99                Integer,
100                Number,
101                Boolean,
102            }
103
104            /// Content type for request bodies
105            #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
106            pub enum BodyContentType {
107                Json,
108                FormUrlEncoded,
109                Multipart,
110                OctetStream,
111                TextPlain,
112            }
113
114            /// Definition of an operation parameter.
115            ///
116            /// Only `Serialize` is derived: this struct holds `&'static`
117            /// references to data baked into the binary, which cannot be
118            /// reconstructed by `Deserialize`.
119            #[derive(Debug, Clone, serde::Serialize)]
120            pub struct ParamDef {
121                pub name: &'static str,
122                pub location: ParamLocation,
123                pub required: bool,
124                pub param_type: ParamType,
125                pub description: Option<&'static str>,
126            }
127
128            /// Definition of a request body.
129            ///
130            /// `Serialize`-only for the same reason as [`ParamDef`].
131            #[derive(Debug, Clone, serde::Serialize)]
132            pub struct BodyDef {
133                pub content_type: BodyContentType,
134                /// Name of the schema type (for JSON/form bodies)
135                pub schema_name: Option<&'static str>,
136            }
137
138            /// A single operation in the registry.
139            ///
140            /// `Serialize`-only because of the `&'static` fields.
141            #[derive(Debug, Clone, serde::Serialize)]
142            pub struct OperationDef {
143                /// Unique operation identifier (e.g. "repos/get", "issues/create-comment")
144                pub id: &'static str,
145                /// HTTP method
146                pub method: HttpMethod,
147                /// URL path template with {param} placeholders
148                pub path: &'static str,
149                /// Short summary for CLI help
150                pub summary: Option<&'static str>,
151                /// Longer description
152                pub description: Option<&'static str>,
153                /// Parameters (path, query, header)
154                pub params: &'static [ParamDef],
155                /// Request body definition
156                pub body: Option<BodyDef>,
157                /// Response schema name for the success (2xx) case
158                pub response_schema: Option<&'static str>,
159            }
160
161            /// Look up an operation by ID
162            pub fn find_operation(id: &str) -> Option<&'static OperationDef> {
163                OPERATIONS.iter().find(|op| op.id == id)
164            }
165
166            /// List all operation IDs
167            pub fn operation_ids() -> impl Iterator<Item = &'static str> {
168                OPERATIONS.iter().map(|op| op.id)
169            }
170        }
171    }
172
173    /// Generate the static OPERATIONS slice from analyzed operations
174    fn generate_operation_defs(
175        &self,
176        analysis: &SchemaAnalysis,
177        custom_methods: &BTreeMap<String, syn::Ident>,
178    ) -> TokenStream {
179        let mut param_statics: Vec<TokenStream> = Vec::new();
180        let mut op_entries: Vec<TokenStream> = Vec::new();
181
182        // Sort for deterministic output
183        let mut sorted_ops: Vec<_> = analysis.operations.values().collect();
184        sorted_ops.sort_by_key(|op| &op.operation_id);
185
186        for op in sorted_ops {
187            let id = &op.operation_id;
188            let method = match op.method.to_uppercase().as_str() {
189                "GET" => quote! { HttpMethod::Get },
190                "POST" => quote! { HttpMethod::Post },
191                "PUT" => quote! { HttpMethod::Put },
192                "PATCH" => quote! { HttpMethod::Patch },
193                "DELETE" => quote! { HttpMethod::Delete },
194                "HEAD" => quote! { HttpMethod::Head },
195                "OPTIONS" => quote! { HttpMethod::Options },
196                "TRACE" => quote! { HttpMethod::Trace },
197                "CONNECT" => quote! { HttpMethod::Connect },
198                "QUERY" => quote! { HttpMethod::Query },
199                other => {
200                    let variant = &custom_methods[other];
201                    quote! { HttpMethod::#variant }
202                }
203            };
204            let path = &op.path;
205
206            let summary = match &op.summary {
207                Some(s) => quote! { Some(#s) },
208                None => quote! { None },
209            };
210            let description = match &op.description {
211                Some(d) => quote! { Some(#d) },
212                None => quote! { None },
213            };
214
215            // Generate params
216            let param_defs: Vec<TokenStream> = op
217                .parameters
218                .iter()
219                .map(|p| {
220                    let name = &p.name;
221                    let location = match p.location.as_str() {
222                        "path" => quote! { ParamLocation::Path },
223                        "query" => quote! { ParamLocation::Query },
224                        "header" => quote! { ParamLocation::Header },
225                        _ => quote! { ParamLocation::Query },
226                    };
227                    let required = p.required;
228                    let param_type = match p.rust_type.as_str() {
229                        "i64" | "i32" => quote! { ParamType::Integer },
230                        "f64" => quote! { ParamType::Number },
231                        "bool" => quote! { ParamType::Boolean },
232                        _ => quote! { ParamType::String },
233                    };
234                    let desc = match &p.description {
235                        Some(d) => quote! { Some(#d) },
236                        None => quote! { None },
237                    };
238                    quote! {
239                        ParamDef {
240                            name: #name,
241                            location: #location,
242                            required: #required,
243                            param_type: #param_type,
244                            description: #desc,
245                        }
246                    }
247                })
248                .collect();
249
250            // Sanitize operation ID to a valid Rust identifier for the static name
251            let sanitized_id: String = op
252                .operation_id
253                .chars()
254                .map(|c| {
255                    if c.is_ascii_alphanumeric() {
256                        c.to_ascii_uppercase()
257                    } else {
258                        '_'
259                    }
260                })
261                .collect();
262            let params_static_name = syn::Ident::new(
263                &format!("PARAMS_{sanitized_id}"),
264                proc_macro2::Span::call_site(),
265            );
266            let param_count = param_defs.len();
267
268            // Emit the param array as a separate static
269            param_statics.push(quote! {
270                static #params_static_name: [ParamDef; #param_count] = [#(#param_defs),*];
271            });
272
273            // Generate body def
274            let body = match &op.request_body {
275                Some(rb) => {
276                    use crate::analysis::RequestBodyContent;
277                    let (content_type, schema_name) = match rb {
278                        RequestBodyContent::Json { schema_name } => (
279                            quote! { BodyContentType::Json },
280                            quote! { Some(#schema_name) },
281                        ),
282                        RequestBodyContent::FormUrlEncoded { schema_name } => (
283                            quote! { BodyContentType::FormUrlEncoded },
284                            quote! { Some(#schema_name) },
285                        ),
286                        RequestBodyContent::Multipart => {
287                            (quote! { BodyContentType::Multipart }, quote! { None })
288                        }
289                        RequestBodyContent::OctetStream => {
290                            (quote! { BodyContentType::OctetStream }, quote! { None })
291                        }
292                        RequestBodyContent::TextPlain => {
293                            (quote! { BodyContentType::TextPlain }, quote! { None })
294                        }
295                    };
296                    quote! {
297                        Some(BodyDef {
298                            content_type: #content_type,
299                            schema_name: #schema_name,
300                        })
301                    }
302                }
303                None => quote! { None },
304            };
305
306            // Response schema (2xx)
307            let response_schema = op
308                .response_schemas
309                .get("200")
310                .or_else(|| op.response_schemas.get("201"))
311                .or_else(|| {
312                    op.response_schemas
313                        .iter()
314                        .find(|(code, _)| code.starts_with('2'))
315                        .map(|(_, v)| v)
316                });
317            let response_schema_token = match response_schema {
318                Some(s) => quote! { Some(#s) },
319                None => quote! { None },
320            };
321
322            op_entries.push(quote! {
323                OperationDef {
324                    id: #id,
325                    method: #method,
326                    path: #path,
327                    summary: #summary,
328                    description: #description,
329                    params: &#params_static_name,
330                    body: #body,
331                    response_schema: #response_schema_token,
332                }
333            });
334        }
335
336        let op_count = op_entries.len();
337        quote! {
338            #(#param_statics)*
339
340            pub static OPERATIONS: [OperationDef; #op_count] = [#(#op_entries),*];
341        }
342    }
343}
344
345/// Generate deterministic per-spec enum variants for extension methods. The
346/// registry remains static data and keeps `Deserialize` support without
347/// reducing arbitrary methods to a lossy catch-all variant.
348fn registry_custom_methods(analysis: &SchemaAnalysis) -> BTreeMap<String, syn::Ident> {
349    const STANDARD: &[&str] = &[
350        "CONNECT", "DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT", "QUERY", "TRACE",
351    ];
352    analysis
353        .operations
354        .values()
355        .map(|operation| operation.method.to_ascii_uppercase())
356        .filter(|method| !STANDARD.contains(&method.as_str()))
357        .collect::<std::collections::BTreeSet<_>>()
358        .into_iter()
359        .enumerate()
360        .map(|(index, method)| (method, format_ident!("Custom{index}")))
361        .collect()
362}