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