Skip to main content

reinhardt_views/browsable_api/
templates.rs

1//! Interactive API documentation templates
2
3use 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/// API endpoint information
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ApiEndpoint {
12	/// HTTP method (GET, POST, etc.)
13	pub method: String,
14	/// Endpoint path
15	pub path: String,
16	/// Description of the endpoint
17	pub description: String,
18	/// Request parameters
19	pub parameters: Vec<Parameter>,
20	/// Response schema
21	pub response_schema: Option<String>,
22	/// Example request
23	pub example_request: Option<String>,
24	/// Example response
25	pub example_response: Option<String>,
26}
27
28/// API parameter information
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct Parameter {
31	/// Parameter name
32	pub name: String,
33	/// Parameter type
34	pub param_type: String,
35	/// Whether the parameter is required
36	pub required: bool,
37	/// Parameter description
38	pub description: Option<String>,
39}
40
41/// Interactive API documentation renderer
42///
43/// # Examples
44///
45/// ```
46/// use reinhardt_views::browsable_api::templates::{InteractiveDocsRenderer, ApiEndpoint};
47///
48/// let mut renderer = InteractiveDocsRenderer::new("My API Documentation");
49/// let endpoint = ApiEndpoint {
50///     method: "GET".to_string(),
51///     path: "/api/users/".to_string(),
52///     description: "List all users".to_string(),
53///     parameters: vec![],
54///     response_schema: Some("User[]".to_string()),
55///     example_request: None,
56///     example_response: Some(r#"[{"id": 1, "name": "Alice"}]"#.to_string()),
57/// };
58/// renderer.add_endpoint(endpoint);
59/// let html = renderer.render().unwrap();
60/// assert!(html.contains("My API Documentation"));
61/// ```
62#[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	/// Create a new interactive docs renderer
72	///
73	/// # Examples
74	///
75	/// ```
76	/// use reinhardt_views::browsable_api::InteractiveDocsRenderer;
77	///
78	/// let renderer = InteractiveDocsRenderer::new("API Docs");
79	/// ```
80	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	/// Set API description
90	///
91	/// # Examples
92	///
93	/// ```
94	/// use reinhardt_views::browsable_api::InteractiveDocsRenderer;
95	///
96	/// let mut renderer = InteractiveDocsRenderer::new("API");
97	/// renderer.set_description("RESTful API for managing users");
98	/// ```
99	pub fn set_description(&mut self, description: impl Into<String>) -> &mut Self {
100		self.description = Some(description.into());
101		self
102	}
103
104	/// Set base URL for the API
105	///
106	/// # Examples
107	///
108	/// ```
109	/// use reinhardt_views::browsable_api::InteractiveDocsRenderer;
110	///
111	/// let mut renderer = InteractiveDocsRenderer::new("API");
112	/// renderer.set_base_url("https://api.example.com");
113	/// ```
114	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	/// Add an endpoint
120	///
121	/// # Examples
122	///
123	/// ```
124	/// use reinhardt_views::browsable_api::templates::{InteractiveDocsRenderer, ApiEndpoint};
125	///
126	/// let mut renderer = InteractiveDocsRenderer::new("API");
127	/// let endpoint = ApiEndpoint {
128	///     method: "POST".to_string(),
129	///     path: "/api/items/".to_string(),
130	///     description: "Create an item".to_string(),
131	///     parameters: vec![],
132	///     response_schema: None,
133	///     example_request: None,
134	///     example_response: None,
135	/// };
136	/// renderer.add_endpoint(endpoint);
137	/// ```
138	pub fn add_endpoint(&mut self, endpoint: ApiEndpoint) -> &mut Self {
139		self.endpoints.push(endpoint);
140		self
141	}
142
143	/// Group endpoints by path prefix
144	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	/// Render the interactive documentation as HTML
161	///
162	/// # Examples
163	///
164	/// ```
165	/// use reinhardt_views::browsable_api::templates::{InteractiveDocsRenderer, ApiEndpoint};
166	///
167	/// let mut renderer = InteractiveDocsRenderer::new("API Docs");
168	/// let endpoint = ApiEndpoint {
169	///     method: "GET".to_string(),
170	///     path: "/api/test/".to_string(),
171	///     description: "Test endpoint".to_string(),
172	///     parameters: vec![],
173	///     response_schema: None,
174	///     example_request: None,
175	///     example_response: None,
176	/// };
177	/// renderer.add_endpoint(endpoint);
178	/// let html = renderer.render().unwrap();
179	/// assert!(html.contains("API Docs"));
180	/// ```
181	pub fn render(&self) -> Result<String, String> {
182		let mut html = self.generate_header();
183
184		// API description - escape for HTML content
185		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		// Group endpoints
194		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	/// Render a single endpoint
217	fn render_endpoint(&self, endpoint: &ApiEndpoint) -> String {
218		let method_class = endpoint.method.to_lowercase();
219		// Escape all user-controlled values for HTML content
220		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		// Parameters
236		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(&param.name),
246					escape_html(&param.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		// Example request - escape for HTML content
257		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		// Example response - escape for HTML content
267		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		// Try it out button - CRITICAL: use escape_javascript for onclick handler
277		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	/// Generate HTML header
291	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	/// Generate HTML footer
431	fn generate_footer(&self) -> String {
432		r#"    </div>
433  </body>
434</html>
435"#
436		.to_string()
437	}
438
439	/// Create an HTTP response with rendered HTML
440	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}