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}