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