Skip to main content

nfw_core/openapi/
generator.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use crate::openapi::spec::{
5    MediaTypeObject, OpenApiSpec, OperationObject, ParameterLocation, ParameterObject,
6    RequestBodyObject, ResponseObject, SchemaObject, ServerObject, TagObject,
7};
8use crate::routing::Route;
9
10pub struct OpenApiGenerator {
11    spec: OpenApiSpec,
12}
13
14impl OpenApiGenerator {
15    pub fn new(title: &str, version: &str) -> Self {
16        Self {
17            spec: OpenApiSpec {
18                info: crate::openapi::spec::InfoObject {
19                    title: title.to_string(),
20                    description: None,
21                    version: version.to_string(),
22                    contact: None,
23                    license: None,
24                },
25                ..Default::default()
26            },
27        }
28    }
29
30    pub fn with_server(mut self, url: &str, description: Option<&str>) -> Self {
31        self.spec.servers.push(ServerObject {
32            url: url.to_string(),
33            description: description.map(|s| s.to_string()),
34        });
35        self
36    }
37
38    pub fn with_description(mut self, description: &str) -> Self {
39        self.spec.info.description = Some(description.to_string());
40        self
41    }
42
43    pub fn with_contact(mut self, name: &str, email: &str, url: Option<&str>) -> Self {
44        self.spec.info.contact = Some(crate::openapi::spec::ContactObject {
45            name: Some(name.to_string()),
46            email: Some(email.to_string()),
47            url: url.map(|s| s.to_string()),
48        });
49        self
50    }
51
52    pub fn add_tag(&mut self, name: &str, description: Option<&str>) {
53        self.spec.tags.push(TagObject {
54            name: name.to_string(),
55            description: description.map(|s| s.to_string()),
56        });
57    }
58
59    pub fn add_schema(&mut self, name: &str, schema: SchemaObject) {
60        self.spec
61            .components
62            .schemas
63            .insert(name.to_string(), schema);
64    }
65
66    pub fn add_route(&mut self, route: &Route) {
67        let path = route.path.clone();
68        let operation = self.create_operation(route);
69
70        let path_item = self.spec.paths.entry(path).or_default();
71
72        match route.method.as_str() {
73            "GET" => path_item.get = Some(operation),
74            "POST" => path_item.post = Some(operation),
75            "PUT" => path_item.put = Some(operation),
76            "PATCH" => path_item.patch = Some(operation),
77            "DELETE" => path_item.delete = Some(operation),
78            "OPTIONS" => path_item.options = Some(operation),
79            "HEAD" => path_item.head = Some(operation),
80            _ => {}
81        }
82    }
83
84    pub fn add_routes(&mut self, routes: &[Route]) {
85        for route in routes {
86            self.add_route(route);
87        }
88    }
89
90    fn create_operation(&self, route: &Route) -> OperationObject {
91        let mut params = Vec::new();
92
93        for segment in &route.segments {
94            match segment {
95                crate::routing::RouteSegment::Dynamic(name) => {
96                    params.push(ParameterObject {
97                        name: name.clone(),
98                        location: ParameterLocation::Path,
99                        required: Some(true),
100                        description: Some(format!("Dynamic parameter: {}", name)),
101                        schema: SchemaObject::string(),
102                    });
103                }
104                crate::routing::RouteSegment::CatchAll(name) => {
105                    params.push(ParameterObject {
106                        name: name.clone(),
107                        location: ParameterLocation::Path,
108                        required: Some(true),
109                        description: Some(format!("Catch-all parameter: {}", name)),
110                        schema: SchemaObject::string(),
111                    });
112                }
113                crate::routing::RouteSegment::OptionalCatchAll(name) => {
114                    params.push(ParameterObject {
115                        name: name.clone(),
116                        location: ParameterLocation::Path,
117                        required: Some(false),
118                        description: Some(format!("Optional catch-all: {}", name)),
119                        schema: SchemaObject::string(),
120                    });
121                }
122                crate::routing::RouteSegment::Static(_) => {}
123            }
124        }
125
126        OperationObject {
127            operation_id: Some(route.handler_name.clone()),
128            summary: Some(format!("{} {}", route.method.as_str(), route.path)),
129            description: None,
130            tags: vec![self.extract_tag(&route.path)],
131            parameters: params,
132            request_body: None,
133            responses: self.default_responses(),
134            deprecated: false,
135        }
136    }
137
138    fn extract_tag(&self, path: &str) -> String {
139        let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
140        segments
141            .first()
142            .map(|s| s.to_string())
143            .unwrap_or_else(|| "default".to_string())
144    }
145
146    fn default_responses(&self) -> HashMap<String, ResponseObject> {
147        let mut responses = HashMap::new();
148
149        responses.insert(
150            "200".to_string(),
151            ResponseObject {
152                description: "Successful response".to_string(),
153                content: {
154                    let mut content = HashMap::new();
155                    content.insert(
156                        "application/json".to_string(),
157                        MediaTypeObject {
158                            schema: Some(SchemaObject::object(HashMap::new())),
159                            example: None,
160                        },
161                    );
162                    content
163                },
164                headers: HashMap::new(),
165            },
166        );
167
168        responses.insert(
169            "400".to_string(),
170            ResponseObject {
171                description: "Bad request".to_string(),
172                content: HashMap::new(),
173                headers: HashMap::new(),
174            },
175        );
176
177        responses.insert(
178            "404".to_string(),
179            ResponseObject {
180                description: "Not found".to_string(),
181                content: HashMap::new(),
182                headers: HashMap::new(),
183            },
184        );
185
186        responses
187    }
188
189    pub fn add_request_body(&mut self, path: &str, method: &str, schema_name: &str) {
190        if let Some(path_item) = self.spec.paths.get_mut(path) {
191            let operation = match method.to_uppercase().as_str() {
192                "GET" => &mut path_item.get,
193                "POST" => &mut path_item.post,
194                "PUT" => &mut path_item.put,
195                "PATCH" => &mut path_item.patch,
196                "DELETE" => &mut path_item.delete,
197                _ => return,
198            };
199
200            if let Some(op) = operation {
201                let mut content = HashMap::new();
202                content.insert(
203                    "application/json".to_string(),
204                    MediaTypeObject {
205                        schema: Some(SchemaObject::object(
206                            self.spec
207                                .components
208                                .schemas
209                                .get(schema_name)
210                                .cloned()
211                                .unwrap_or_default()
212                                .properties
213                                .unwrap_or_default(),
214                        )),
215                        example: None,
216                    },
217                );
218
219                op.request_body = Some(RequestBodyObject {
220                    description: Some(format!("Request body for {}", schema_name)),
221                    required: true,
222                    content,
223                });
224            }
225        }
226    }
227
228    pub fn add_response(
229        &mut self,
230        path: &str,
231        method: &str,
232        status: &str,
233        schema_name: &str,
234        description: &str,
235    ) {
236        if let Some(path_item) = self.spec.paths.get_mut(path) {
237            let operation = match method.to_uppercase().as_str() {
238                "GET" => &mut path_item.get,
239                "POST" => &mut path_item.post,
240                "PUT" => &mut path_item.put,
241                "PATCH" => &mut path_item.patch,
242                "DELETE" => &mut path_item.delete,
243                _ => return,
244            };
245
246            if let Some(op) = operation {
247                let schema = self.spec.components.schemas.get(schema_name).cloned();
248
249                let mut content = HashMap::new();
250                if let Some(ref s) = schema {
251                    content.insert(
252                        "application/json".to_string(),
253                        MediaTypeObject {
254                            schema: Some(s.clone()),
255                            example: None,
256                        },
257                    );
258                }
259
260                op.responses.insert(
261                    status.to_string(),
262                    ResponseObject {
263                        description: description.to_string(),
264                        content,
265                        headers: HashMap::new(),
266                    },
267                );
268            }
269        }
270    }
271
272    pub fn build(self) -> OpenApiSpec {
273        self.spec
274    }
275
276    pub fn to_json(&self) -> anyhow::Result<String> {
277        serde_json::to_string_pretty(&self.spec)
278            .map_err(|e| anyhow::anyhow!("Failed to serialize OpenAPI spec: {}", e))
279    }
280
281    pub fn to_yaml(&self) -> anyhow::Result<String> {
282        serde_yaml::to_string(&self.spec)
283            .map_err(|e| anyhow::anyhow!("Failed to serialize OpenAPI spec to YAML: {}", e))
284    }
285
286    pub fn write_json(&self, output_path: &Path) -> anyhow::Result<()> {
287        let json = self.to_json()?;
288        std::fs::write(output_path, json)?;
289        tracing::info!("Written OpenAPI spec to {}", output_path.display());
290        Ok(())
291    }
292
293    pub fn write_yaml(&self, output_path: &Path) -> anyhow::Result<()> {
294        let yaml = self.to_yaml()?;
295        std::fs::write(output_path, yaml)?;
296        tracing::info!("Written OpenAPI spec to {}", output_path.display());
297        Ok(())
298    }
299}
300
301pub struct RouteToOpenApiConverter {
302    generator: OpenApiGenerator,
303}
304
305impl RouteToOpenApiConverter {
306    pub fn from_routes(title: &str, version: &str, routes: &[Route]) -> Self {
307        let mut generator = OpenApiGenerator::new(title, version);
308
309        for route in routes {
310            generator.add_route(route);
311        }
312
313        Self { generator }
314    }
315
316    pub fn convert(mut self) -> OpenApiSpec {
317        for (name, schema) in self.infer_schemas() {
318            self.generator.add_schema(&name, schema);
319        }
320
321        self.generator.build()
322    }
323
324    fn infer_schemas(&self) -> Vec<(String, SchemaObject)> {
325        vec![(
326            "Error".to_string(),
327            SchemaObject::object(
328                vec![
329                    ("code".to_string(), SchemaObject::string()),
330                    ("message".to_string(), SchemaObject::string()),
331                ]
332                .into_iter()
333                .collect(),
334            ),
335        )]
336    }
337}