reinhardt_views/browsable_api/
templates.rs1use http_body_util::Full;
4use hyper::{Response, StatusCode, body::Bytes};
5use reinhardt_core::security::xss::{escape_html, escape_javascript};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ApiEndpoint {
12 pub method: String,
14 pub path: String,
16 pub description: String,
18 pub parameters: Vec<Parameter>,
20 pub response_schema: Option<String>,
22 pub example_request: Option<String>,
24 pub example_response: Option<String>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct Parameter {
31 pub name: String,
33 pub param_type: String,
35 pub required: bool,
37 pub description: Option<String>,
39}
40
41#[derive(Debug, Clone)]
63pub struct InteractiveDocsRenderer {
64 title: String,
65 description: Option<String>,
66 endpoints: Vec<ApiEndpoint>,
67 base_url: String,
68}
69
70impl InteractiveDocsRenderer {
71 pub fn new(title: impl Into<String>) -> Self {
81 Self {
82 title: title.into(),
83 description: None,
84 endpoints: Vec::new(),
85 base_url: String::new(),
86 }
87 }
88
89 pub fn set_description(&mut self, description: impl Into<String>) -> &mut Self {
100 self.description = Some(description.into());
101 self
102 }
103
104 pub fn set_base_url(&mut self, base_url: impl Into<String>) -> &mut Self {
115 self.base_url = base_url.into();
116 self
117 }
118
119 pub fn add_endpoint(&mut self, endpoint: ApiEndpoint) -> &mut Self {
139 self.endpoints.push(endpoint);
140 self
141 }
142
143 fn group_endpoints(&self) -> HashMap<String, Vec<&ApiEndpoint>> {
145 let mut groups: HashMap<String, Vec<&ApiEndpoint>> = HashMap::new();
146
147 for endpoint in &self.endpoints {
148 let group = endpoint
149 .path
150 .split('/')
151 .nth(2)
152 .unwrap_or("default")
153 .to_string();
154 groups.entry(group).or_default().push(endpoint);
155 }
156
157 groups
158 }
159
160 pub fn render(&self) -> Result<String, String> {
182 let mut html = self.generate_header();
183
184 if let Some(desc) = &self.description {
186 html.push_str(&format!(
187 r#" <div class="description">{}</div>"#,
188 escape_html(desc)
189 ));
190 html.push('\n');
191 }
192
193 let groups = self.group_endpoints();
195
196 for (group_name, endpoints) in groups {
197 html.push_str(&format!(
198 r#" <div class="endpoint-group">
199 <h2>{}</h2>
200"#,
201 escape_html(&group_name)
202 ));
203
204 for endpoint in endpoints {
205 html.push_str(&self.render_endpoint(endpoint));
206 }
207
208 html.push_str(" </div>\n");
209 }
210
211 html.push_str(&self.generate_footer());
212
213 Ok(html)
214 }
215
216 fn render_endpoint(&self, endpoint: &ApiEndpoint) -> String {
218 let method_class = endpoint.method.to_lowercase();
219 let mut html = format!(
221 r#" <div class="endpoint">
222 <div class="endpoint-header">
223 <span class="method method-{}">{}</span>
224 <span class="path">{}</span>
225 </div>
226 <div class="endpoint-body">
227 <p class="description">{}</p>
228"#,
229 escape_html(&method_class),
230 escape_html(&endpoint.method),
231 escape_html(&endpoint.path),
232 escape_html(&endpoint.description)
233 );
234
235 if !endpoint.parameters.is_empty() {
237 html.push_str(" <h4>Parameters:</h4>\n");
238 html.push_str(" <table class=\"params-table\">\n");
239 html.push_str(" <thead><tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr></thead>\n");
240 html.push_str(" <tbody>\n");
241
242 for param in &endpoint.parameters {
243 html.push_str(&format!(
244 " <tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>\n",
245 escape_html(¶m.name),
246 escape_html(¶m.param_type),
247 if param.required { "Yes" } else { "No" },
248 escape_html(param.description.as_deref().unwrap_or("-"))
249 ));
250 }
251
252 html.push_str(" </tbody>\n");
253 html.push_str(" </table>\n");
254 }
255
256 if let Some(example_req) = &endpoint.example_request {
258 html.push_str(&format!(
259 r#" <h4>Example Request:</h4>
260 <pre class="example">{}</pre>
261"#,
262 escape_html(example_req)
263 ));
264 }
265
266 if let Some(example_resp) = &endpoint.example_response {
268 html.push_str(&format!(
269 r#" <h4>Example Response:</h4>
270 <pre class="example">{}</pre>
271"#,
272 escape_html(example_resp)
273 ));
274 }
275
276 html.push_str(&format!(
278 r#" <button class="try-it-btn" onclick="tryEndpoint('{}', '{}')">Try it out</button>
279"#,
280 escape_javascript(&endpoint.method),
281 escape_javascript(&endpoint.path)
282 ));
283
284 html.push_str(" </div>\n");
285 html.push_str(" </div>\n");
286
287 html
288 }
289
290 fn generate_header(&self) -> String {
292 let escaped_title = escape_html(&self.title);
293 format!(
294 r#"<!doctype html>
295<html lang="en">
296 <head>
297 <meta charset="UTF-8" />
298 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
299 <title>{}</title>
300 <style>
301 body {{
302 font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
303 margin: 0;
304 padding: 20px;
305 background-color: #f5f5f5;
306 }}
307 .container {{
308 max-width: 1400px;
309 margin: 0 auto;
310 background-color: white;
311 padding: 30px;
312 border-radius: 8px;
313 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
314 }}
315 h1 {{
316 color: #333;
317 border-bottom: 3px solid #007bff;
318 padding-bottom: 15px;
319 margin-bottom: 20px;
320 }}
321 h2 {{
322 color: #555;
323 margin-top: 30px;
324 border-bottom: 1px solid #ddd;
325 padding-bottom: 10px;
326 }}
327 .description {{
328 color: #666;
329 margin-bottom: 30px;
330 line-height: 1.6;
331 }}
332 .endpoint-group {{
333 margin-bottom: 40px;
334 }}
335 .endpoint {{
336 border: 1px solid #dee2e6;
337 border-radius: 6px;
338 margin-bottom: 20px;
339 overflow: hidden;
340 }}
341 .endpoint-header {{
342 background-color: #f8f9fa;
343 padding: 15px 20px;
344 display: flex;
345 align-items: center;
346 gap: 15px;
347 }}
348 .method {{
349 padding: 5px 12px;
350 border-radius: 4px;
351 font-weight: bold;
352 font-size: 13px;
353 text-transform: uppercase;
354 }}
355 .method-get {{ background-color: #61affe; color: white; }}
356 .method-post {{ background-color: #49cc90; color: white; }}
357 .method-put {{ background-color: #fca130; color: white; }}
358 .method-patch {{ background-color: #50e3c2; color: white; }}
359 .method-delete {{ background-color: #f93e3e; color: white; }}
360 .path {{
361 font-family: 'Courier New', monospace;
362 font-size: 16px;
363 color: #333;
364 }}
365 .endpoint-body {{
366 padding: 20px;
367 }}
368 .endpoint-body .description {{
369 color: #555;
370 margin-bottom: 15px;
371 }}
372 .params-table {{
373 width: 100%;
374 border-collapse: collapse;
375 margin: 15px 0;
376 }}
377 .params-table th {{
378 background-color: #f8f9fa;
379 padding: 10px;
380 text-align: left;
381 border-bottom: 2px solid #dee2e6;
382 }}
383 .params-table td {{
384 padding: 10px;
385 border-bottom: 1px solid #dee2e6;
386 }}
387 .example {{
388 background-color: #282c34;
389 color: #abb2bf;
390 padding: 15px;
391 border-radius: 4px;
392 overflow-x: auto;
393 font-family: 'Courier New', monospace;
394 line-height: 1.5;
395 margin: 10px 0;
396 }}
397 .try-it-btn {{
398 background-color: #007bff;
399 color: white;
400 border: none;
401 padding: 10px 20px;
402 border-radius: 4px;
403 cursor: pointer;
404 font-size: 14px;
405 margin-top: 15px;
406 }}
407 .try-it-btn:hover {{
408 background-color: #0056b3;
409 }}
410 h4 {{
411 color: #333;
412 margin-top: 20px;
413 margin-bottom: 10px;
414 }}
415 </style>
416 <script>
417 function tryEndpoint(method, path) {{
418 alert('Try it out: ' + method + ' ' + path + '\n\nThis feature is coming soon!');
419 }}
420 </script>
421 </head>
422 <body>
423 <div class="container">
424 <h1>{}</h1>
425"#,
426 escaped_title, escaped_title
427 )
428 }
429
430 fn generate_footer(&self) -> String {
432 r#" </div>
433 </body>
434</html>
435"#
436 .to_string()
437 }
438
439 pub fn create_response(&self) -> Result<Response<Full<Bytes>>, String> {
441 let html = self.render()?;
442
443 Response::builder()
444 .status(StatusCode::OK)
445 .header("Content-Type", "text/html; charset=utf-8")
446 .body(Full::new(Bytes::from(html)))
447 .map_err(|e| e.to_string())
448 }
449}
450
451impl Default for InteractiveDocsRenderer {
452 fn default() -> Self {
453 Self::new("API Documentation")
454 }
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460
461 #[test]
462 fn test_renderer_creation() {
463 let renderer = InteractiveDocsRenderer::new("Test API");
464 assert_eq!(renderer.title, "Test API");
465 assert!(renderer.endpoints.is_empty());
466 }
467
468 #[test]
469 fn test_set_description() {
470 let mut renderer = InteractiveDocsRenderer::new("API");
471 renderer.set_description("Test description");
472 assert_eq!(renderer.description, Some("Test description".to_string()));
473 }
474
475 #[test]
476 fn test_set_base_url() {
477 let mut renderer = InteractiveDocsRenderer::new("API");
478 renderer.set_base_url("https://example.com");
479 assert_eq!(renderer.base_url, "https://example.com");
480 }
481
482 #[test]
483 fn test_add_endpoint() {
484 let mut renderer = InteractiveDocsRenderer::new("API");
485 let endpoint = ApiEndpoint {
486 method: "GET".to_string(),
487 path: "/api/test/".to_string(),
488 description: "Test".to_string(),
489 parameters: vec![],
490 response_schema: None,
491 example_request: None,
492 example_response: None,
493 };
494 renderer.add_endpoint(endpoint);
495 assert_eq!(renderer.endpoints.len(), 1);
496 }
497
498 #[test]
499 fn test_render_basic() {
500 let mut renderer = InteractiveDocsRenderer::new("API Docs");
501 let endpoint = ApiEndpoint {
502 method: "GET".to_string(),
503 path: "/api/users/".to_string(),
504 description: "List users".to_string(),
505 parameters: vec![],
506 response_schema: None,
507 example_request: None,
508 example_response: None,
509 };
510 renderer.add_endpoint(endpoint);
511 let result = renderer.render();
512 let html = result.unwrap();
513 assert!(html.contains("API Docs"));
514 assert!(html.contains("/api/users/"));
515 }
516
517 #[test]
518 fn test_render_with_parameters() {
519 let mut renderer = InteractiveDocsRenderer::new("API");
520 let endpoint = ApiEndpoint {
521 method: "POST".to_string(),
522 path: "/api/items/".to_string(),
523 description: "Create item".to_string(),
524 parameters: vec![Parameter {
525 name: "name".to_string(),
526 param_type: "string".to_string(),
527 required: true,
528 description: Some("Item name".to_string()),
529 }],
530 response_schema: None,
531 example_request: None,
532 example_response: None,
533 };
534 renderer.add_endpoint(endpoint);
535 let html = renderer.render().unwrap();
536 assert!(html.contains("Parameters"));
537 assert!(html.contains("name"));
538 }
539
540 #[test]
541 fn test_default_renderer() {
542 let renderer = InteractiveDocsRenderer::default();
543 assert_eq!(renderer.title, "API Documentation");
544 }
545}