Skip to main content

unistructgen_openapi_parser/
client.rs

1//! API client trait generation from OpenAPI paths
2
3use crate::error::Result;
4use crate::options::OpenApiParserOptions;
5use crate::types::{extract_type_name_from_ref, sanitize_field_name, to_pascal_case};
6use openapiv3::{OpenAPI, Operation, PathItem, ReferenceOr};
7use unistructgen_core::{IRField, IRStruct, IRType, IRTypeRef, PrimitiveKind};
8
9/// API client generator
10pub struct ClientGenerator<'a> {
11    spec: &'a OpenAPI,
12    options: &'a OpenApiParserOptions,
13}
14
15impl<'a> ClientGenerator<'a> {
16    /// Create a new client generator
17    pub fn new(spec: &'a OpenAPI, options: &'a OpenApiParserOptions) -> Self {
18        Self { spec, options }
19    }
20
21    /// Generate API client trait and method types
22    pub fn generate_client_types(&self) -> Result<Vec<IRType>> {
23        let mut types = Vec::new();
24
25        // Generate request/response types for each operation
26        for (path, path_item) in &self.spec.paths.paths {
27            let path_item = match path_item {
28                ReferenceOr::Item(item) => item,
29                ReferenceOr::Reference { .. } => continue,
30            };
31
32            self.generate_path_types(path, path_item, &mut types)?;
33        }
34
35        Ok(types)
36    }
37
38    /// Generate types for a single path
39    fn generate_path_types(
40        &self,
41        path: &str,
42        path_item: &PathItem,
43        types: &mut Vec<IRType>,
44    ) -> Result<()> {
45        // Process each HTTP method
46        if let Some(op) = &path_item.get {
47            self.generate_operation_types(path, "Get", op, types)?;
48        }
49        if let Some(op) = &path_item.post {
50            self.generate_operation_types(path, "Post", op, types)?;
51        }
52        if let Some(op) = &path_item.put {
53            self.generate_operation_types(path, "Put", op, types)?;
54        }
55        if let Some(op) = &path_item.delete {
56            self.generate_operation_types(path, "Delete", op, types)?;
57        }
58        if let Some(op) = &path_item.patch {
59            self.generate_operation_types(path, "Patch", op, types)?;
60        }
61
62        Ok(())
63    }
64
65    /// Generate types for a single operation
66    fn generate_operation_types(
67        &self,
68        path: &str,
69        method: &str,
70        operation: &Operation,
71        types: &mut Vec<IRType>,
72    ) -> Result<()> {
73        // Determine operation name
74        let operation_name = if let Some(operation_id) = &operation.operation_id {
75            to_pascal_case(operation_id)
76        } else {
77            // Generate from path and method
78            let path_parts: Vec<_> = path
79                .split('/')
80                .filter(|s| !s.is_empty() && !s.starts_with('{'))
81                .collect();
82            format!("{}{}", method, path_parts.join(""))
83        };
84
85        // Generate request type if there are parameters or request body
86        if !operation.parameters.is_empty() || operation.request_body.is_some() {
87            let request_type = self.generate_request_type(&operation_name, operation)?;
88            if let Some(ty) = request_type {
89                types.push(ty);
90            }
91        }
92
93        // Generate response types
94        for (status_code, response_ref) in &operation.responses.responses {
95            let response = match response_ref {
96                ReferenceOr::Item(resp) => resp,
97                ReferenceOr::Reference { .. } => continue,
98            };
99
100            let _response_name = format!("{}{}Response", operation_name, status_code);
101
102            // Check if response has content
103            if let Some(media_type) = response.content.get("application/json") {
104                if let Some(schema_ref) = &media_type.schema {
105                    // Extract type from schema reference
106                    match schema_ref {
107                        ReferenceOr::Reference { .. } => {
108                            // Type already defined in components
109                            continue;
110                        }
111                        ReferenceOr::Item(_schema) => {
112                            // Inline schema - would need to generate type
113                            // For now, skip inline response schemas
114                            continue;
115                        }
116                    }
117                }
118            }
119        }
120
121        Ok(())
122    }
123
124    /// Generate request type from operation parameters and body
125    fn generate_request_type(
126        &self,
127        operation_name: &str,
128        operation: &Operation,
129    ) -> Result<Option<IRType>> {
130        let mut ir_struct = IRStruct::new(format!("{}Request", operation_name));
131
132        // Add documentation
133        if self.options.generate_docs {
134            if let Some(summary) = &operation.summary {
135                ir_struct.doc = Some(format!("Request parameters for {}", summary));
136            }
137        }
138
139        // Add derives
140        if self.options.derive_serde {
141            ir_struct.add_derive("serde::Serialize".to_string());
142            ir_struct.add_derive("serde::Deserialize".to_string());
143        }
144        if self.options.derive_default {
145            ir_struct.add_derive("Default".to_string());
146        }
147
148        // Process parameters
149        for param_ref in &operation.parameters {
150            let param = match param_ref {
151                ReferenceOr::Item(p) => p,
152                ReferenceOr::Reference { .. } => continue,
153            };
154
155            match param {
156                openapiv3::Parameter::Query { parameter_data, .. }
157                | openapiv3::Parameter::Path { parameter_data, .. }
158                | openapiv3::Parameter::Header { parameter_data, .. } => {
159                    let field_name = sanitize_field_name(&parameter_data.name);
160
161                    // Determine type from schema
162                    let field_type = match &parameter_data.format {
163                        openapiv3::ParameterSchemaOrContent::Schema(schema_ref) => {
164                            match schema_ref {
165                                ReferenceOr::Reference { reference } => {
166                                    IRTypeRef::Named(extract_type_name_from_ref(reference))
167                                }
168                                ReferenceOr::Item(_schema) => {
169                                    // Default to string for inline schemas
170                                    IRTypeRef::Primitive(PrimitiveKind::String)
171                                }
172                            }
173                        }
174                        openapiv3::ParameterSchemaOrContent::Content(_) => {
175                            IRTypeRef::Primitive(PrimitiveKind::String)
176                        }
177                    };
178
179                    let mut field = IRField::new(field_name.clone(), field_type);
180
181                    // Make optional if not required
182                    if !parameter_data.required {
183                        field.ty = field.ty.make_optional();
184                        field.optional = true;
185                    }
186
187                    // Add documentation
188                    if self.options.generate_docs {
189                        if let Some(desc) = &parameter_data.description {
190                            field.doc = Some(desc.clone());
191                        }
192                    }
193
194                    // Add serde rename if needed
195                    if field_name != parameter_data.name {
196                        field.source_name = Some(parameter_data.name.clone());
197                        field.attributes.push(format!(
198                            "#[serde(rename = \"{}\")]",
199                            parameter_data.name
200                        ));
201                    }
202
203                    ir_struct.add_field(field);
204                }
205                _ => {}
206            }
207        }
208
209        // If no fields, don't generate the type
210        if ir_struct.fields.is_empty() {
211            return Ok(None);
212        }
213
214        Ok(Some(IRType::Struct(ir_struct)))
215    }
216
217    /// Generate API client trait definition (as documentation)
218    pub fn generate_client_trait_doc(&self) -> String {
219        let mut output = String::new();
220
221        output.push_str("// API Client Trait\n");
222        output.push_str("// This trait can be implemented to create an API client\n\n");
223        output.push_str("#[async_trait::async_trait]\n");
224        output.push_str("pub trait ApiClient {\n");
225
226        for (path, path_item) in &self.spec.paths.paths {
227            let path_item = match path_item {
228                ReferenceOr::Item(item) => item,
229                ReferenceOr::Reference { .. } => continue,
230            };
231
232            self.generate_client_methods(path, path_item, &mut output);
233        }
234
235        output.push_str("}\n");
236        output
237    }
238
239    fn generate_client_methods(&self, path: &str, path_item: &PathItem, output: &mut String) {
240        if let Some(op) = &path_item.get {
241            self.generate_client_method(path, "get", op, output);
242        }
243        if let Some(op) = &path_item.post {
244            self.generate_client_method(path, "post", op, output);
245        }
246        if let Some(op) = &path_item.put {
247            self.generate_client_method(path, "put", op, output);
248        }
249        if let Some(op) = &path_item.delete {
250            self.generate_client_method(path, "delete", op, output);
251        }
252    }
253
254    fn generate_client_method(
255        &self,
256        path: &str,
257        method: &str,
258        operation: &Operation,
259        output: &mut String,
260    ) {
261        let operation_name = if let Some(operation_id) = &operation.operation_id {
262            sanitize_field_name(operation_id)
263        } else {
264            format!("{}_{}", method, path.replace(['/', '{', '}'], "_"))
265        };
266
267        output.push_str(&format!("    async fn {}(", operation_name));
268        output.push_str("&self");
269
270        // Add parameters
271        for param_ref in &operation.parameters {
272            if let ReferenceOr::Item(param) = param_ref {
273                match param {
274                    openapiv3::Parameter::Path { parameter_data, .. } => {
275                        let param_name = sanitize_field_name(&parameter_data.name);
276                        output.push_str(&format!(", {}: &str", param_name));
277                    }
278                    _ => {}
279                }
280            }
281        }
282
283        output.push_str(") -> Result<serde_json::Value, Box<dyn std::error::Error>>;\n\n");
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn test_operation_name_generation() {
293        let name = to_pascal_case("get_users");
294        assert_eq!(name, "GetUsers");
295    }
296}