Skip to main content

reinhardt_rest/browsable_api/
renderer.rs

1use serde::Serialize;
2use serde_json::Value;
3use std::sync::Arc;
4use tera::Tera;
5
6/// Errors that can occur during browsable API rendering.
7#[derive(Debug, thiserror::Error)]
8pub enum BrowsableApiError {
9	/// A template rendering error.
10	#[error("Template render error: {0}")]
11	Render(String),
12	/// A template compilation or loading error.
13	#[error("Template error: {0}")]
14	Template(String),
15	/// A JSON serialization error.
16	#[error("Serialization error: {0}")]
17	Serialization(#[from] serde_json::Error),
18	/// Any other error.
19	#[error("{0}")]
20	Other(String),
21}
22
23impl From<tera::Error> for BrowsableApiError {
24	fn from(err: tera::Error) -> Self {
25		BrowsableApiError::Render(err.to_string())
26	}
27}
28
29/// A convenience type alias for browsable API rendering results.
30pub type BrowsableApiResult<T> = Result<T, BrowsableApiError>;
31
32/// Context for rendering browsable API HTML
33#[derive(Debug, Clone, Serialize)]
34pub struct ApiContext {
35	/// The page title displayed in the header.
36	pub title: String,
37	/// An optional description of the endpoint.
38	pub description: Option<String>,
39	/// The API endpoint URL.
40	pub endpoint: String,
41	/// The HTTP method used for the current request.
42	pub method: String,
43	/// The JSON response data to display.
44	pub response_data: Value,
45	/// The HTTP status code of the response.
46	pub response_status: u16,
47	/// The HTTP methods allowed on this endpoint.
48	pub allowed_methods: Vec<String>,
49	/// An optional form context for making requests via the browsable interface.
50	pub request_form: Option<FormContext>,
51	/// Response headers to display.
52	pub headers: Vec<(String, String)>,
53	/// CSRF token for form protection
54	pub csrf_token: Option<String>,
55}
56
57/// Context for rendering request forms
58#[derive(Debug, Clone, Serialize)]
59pub struct FormContext {
60	/// The form fields to render.
61	pub fields: Vec<FormField>,
62	/// The URL to submit the form to.
63	pub submit_url: String,
64	/// The HTTP method for form submission.
65	pub submit_method: String,
66}
67
68/// A single form field in the browsable API request form.
69#[derive(Debug, Clone, Serialize)]
70pub struct FormField {
71	/// The field's HTML name attribute.
72	pub name: String,
73	/// The human-readable label for the field.
74	pub label: String,
75	/// The HTML input type (e.g., `"text"`, `"select"`, `"textarea"`).
76	pub field_type: String,
77	/// Whether the field is required for submission.
78	pub required: bool,
79	/// Optional help text displayed below the field.
80	pub help_text: Option<String>,
81	/// An optional pre-filled initial value for the field.
82	pub initial_value: Option<Value>,
83	/// Available options for select-type fields.
84	pub options: Option<Vec<SelectOption>>,
85	/// An optional placeholder label for the initial empty select option.
86	pub initial_label: Option<String>,
87}
88
89/// A single option within a select dropdown field.
90#[derive(Debug, Clone, Serialize)]
91pub struct SelectOption {
92	/// The value submitted when this option is selected.
93	pub value: String,
94	/// The display text shown to the user.
95	pub label: String,
96}
97
98/// Renderer for browsable API HTML responses
99pub struct BrowsableApiRenderer {
100	tera: Arc<Tera>,
101}
102
103impl BrowsableApiRenderer {
104	/// Create a new BrowsableApiRenderer with default templates
105	///
106	/// # Examples
107	///
108	/// ```
109	/// use reinhardt_rest::browsable_api::renderer::BrowsableApiRenderer;
110	/// let renderer = BrowsableApiRenderer::new();
111	/// ```
112	pub fn new() -> Self {
113		let mut tera = Tera::default();
114
115		// Enable autoescape for HTML (this is the default but we make it explicit)
116		tera.autoescape_on(vec![".html", ".tpl"]);
117
118		// Register template from external file
119		let template_path = concat!(env!("CARGO_MANIFEST_DIR"), "/templates/api.tpl");
120		if let Err(e) = tera.add_template_file(template_path, Some("api.html")) {
121			// Fallback to default template if file cannot be read
122			eprintln!(
123				"Warning: Failed to load template file: {}. Using default template.",
124				e
125			);
126			tera.add_raw_template("api.html", Self::default_template())
127				.expect("Failed to register default template");
128		}
129
130		Self {
131			tera: Arc::new(tera),
132		}
133	}
134	/// Render API context as HTML
135	///
136	pub fn render(&self, context: &ApiContext) -> BrowsableApiResult<String> {
137		// Convert the context to a Tera Context
138		let mut tera_context = tera::Context::from_serialize(context)?;
139
140		// Add formatted JSON
141		let formatted_json = serde_json::to_string_pretty(&context.response_data)?;
142		tera_context.insert("response_data_formatted", &formatted_json);
143
144		// Process form fields to convert initial_value (serde_json::Value) to string
145		// This ensures proper HTML escaping by Tera's automatic escaping
146		// IMPORTANT: Use Tera-compatible struct instead of serde_json::json!()
147		// to enable automatic HTML escaping
148		if let Some(form) = &context.request_form {
149			// Create a new FormContext with string initial values
150			use serde::Serialize;
151
152			#[derive(Serialize)]
153			struct FieldWithText<'a> {
154				name: &'a str,
155				label: &'a str,
156				field_type: &'a str,
157				required: bool,
158				help_text: Option<&'a str>,
159				initial_value_text: Option<String>,
160				options: Option<&'a Vec<SelectOption>>,
161				initial_label: Option<&'a str>,
162			}
163
164			#[derive(Serialize)]
165			struct FormWithText<'a> {
166				fields: Vec<FieldWithText<'a>>,
167				submit_url: &'a str,
168				submit_method: &'a str,
169			}
170
171			let fields_with_text: Vec<FieldWithText> = form
172				.fields
173				.iter()
174				.map(|field| {
175					let initial_value_text = field.initial_value.as_ref().and_then(|v| match v {
176						serde_json::Value::String(s) => Some(s.clone()),
177						serde_json::Value::Number(n) => Some(n.to_string()),
178						serde_json::Value::Bool(b) => Some(b.to_string()),
179						serde_json::Value::Null => None,
180						other => Some(other.to_string()),
181					});
182
183					FieldWithText {
184						name: &field.name,
185						label: &field.label,
186						field_type: &field.field_type,
187						required: field.required,
188						help_text: field.help_text.as_deref(),
189						initial_value_text,
190						options: field.options.as_ref(),
191						initial_label: field.initial_label.as_deref(),
192					}
193				})
194				.collect();
195
196			let form_with_text = FormWithText {
197				fields: fields_with_text,
198				submit_url: &form.submit_url,
199				submit_method: &form.submit_method,
200			};
201
202			tera_context.insert("request_form_text", &form_with_text);
203		}
204
205		Ok(self.tera.render("api.html", &tera_context)?)
206	}
207	/// Register a custom template
208	///
209	pub fn register_template(&mut self, name: &str, template: &str) -> BrowsableApiResult<()> {
210		let tera_mut = Arc::get_mut(&mut self.tera).ok_or_else(|| {
211			BrowsableApiError::Other("Cannot modify shared template registry".to_string())
212		})?;
213		tera_mut
214			.add_raw_template(name, template)
215			.map_err(|e| BrowsableApiError::Template(e.to_string()))?;
216		Ok(())
217	}
218
219	/// Default HTML template
220	fn default_template() -> &'static str {
221		r#"
222<!DOCTYPE html>
223<html>
224<head>
225    <meta charset="UTF-8">
226    <meta name="viewport" content="width=device-width, initial-scale=1.0">
227    <title>{{ title }} - Reinhardt API</title>
228    <style>
229        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }
230        .container { max-width: 1200px; margin: 0 auto; background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
231        .header { padding: 20px; border-bottom: 1px solid #e0e0e0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border-radius: 8px 8px 0 0; }
232        .header h1 { margin: 0 0 10px 0; font-size: 24px; }
233        .header p { margin: 0; opacity: 0.9; }
234        .content { padding: 20px; }
235        .method-badge { display: inline-block; padding: 4px 12px; border-radius: 4px; font-weight: bold; font-size: 12px; margin-right: 10px; }
236        .method-get { background: #4caf50; color: white; }
237        .method-post { background: #2196f3; color: white; }
238        .method-put { background: #ff9800; color: white; }
239        .method-patch { background: #9c27b0; color: white; }
240        .method-delete { background: #f44336; color: white; }
241        .endpoint { font-family: monospace; background: #f5f5f5; padding: 8px 12px; border-radius: 4px; display: inline-block; margin: 10px 0; }
242        .response { background: #263238; color: #aed581; padding: 20px; border-radius: 4px; overflow-x: auto; margin: 20px 0; }
243        .response pre { margin: 0; white-space: pre-wrap; word-wrap: break-word; }
244        .form-section { margin: 20px 0; padding: 20px; background: #f9f9f9; border-radius: 4px; }
245        .form-field { margin-bottom: 15px; }
246        .form-field label { display: block; margin-bottom: 5px; font-weight: 500; }
247        .form-field input, .form-field textarea, .form-field select { width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
248        .form-field textarea { min-height: 100px; font-family: monospace; }
249        .help-text { font-size: 12px; color: #666; margin-top: 4px; }
250        .submit-btn { background: #667eea; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: 500; }
251        .submit-btn:hover { background: #5568d3; }
252        .allowed-methods { margin: 15px 0; }
253        .allowed-methods span { margin-right: 10px; }
254        .headers { margin: 20px 0; }
255        .headers table { width: 100%; border-collapse: collapse; }
256        .headers th, .headers td { text-align: left; padding: 8px; border-bottom: 1px solid #e0e0e0; }
257        .headers th { font-weight: 500; background: #f5f5f5; }
258    </style>
259</head>
260<body>
261    <div class="container">
262        <div class="header">
263            <h1>{{ title }}</h1>
264            {% if description %}<p>{{ description }}</p>{% endif %}
265        </div>
266
267        <div class="content">
268            <div class="allowed-methods">
269                <strong>Allowed methods:</strong>
270                {% for method_name in allowed_methods %}
271                <span class="method-badge method-{{ method_name | lower }}">{{ method_name }}</span>
272                {% endfor %}
273            </div>
274
275            <div class="endpoint">
276                <span class="method-badge method-{{ method | lower }}">{{ method }}</span>
277                {{ endpoint }}
278            </div>
279
280            <h2>Response ({{ response_status }})</h2>
281            <div class="response">
282                <pre>{{ response_data_formatted }}</pre>
283            </div>
284
285            {% if request_form_text %}
286            <div class="form-section">
287                <h2>Make a Request</h2>
288                <form method="{{ request_form_text.submit_method }}" action="{{ request_form_text.submit_url }}">
289                    {% if csrf_token %}
290                    <input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
291                    {% endif %}
292                    {% for field in request_form_text.fields %}
293                    <div class="form-field">
294                        <label for="{{ field.name }}">
295                            {{ field.label }}
296                            {% if field.required %}<span style="color: red;">*</span>{% endif %}
297                        </label>
298                        {% if field.field_type == "select" %}
299                        <select id="{{ field.name }}" name="{{ field.name }}" {% if field.required %}required{% endif %}>
300                            {% if field.initial_label %}
301                            <option value="" selected>{{ field.initial_label }}</option>
302                            {% endif %}
303                            {% for option in field.options %}
304                            <option value="{{ option.value }}" {% if option.value == field.initial_value_text %}selected{% endif %}>{{ option.label }}</option>
305                            {% endfor %}
306                        </select>
307                        {% elif field.field_type == "textarea" %}
308                        <textarea id="{{ field.name }}" name="{{ field.name }}" {% if field.required %}required{% endif %}>{% if field.initial_value_text %}{{ field.initial_value_text }}{% endif %}</textarea>
309                        {% else %}
310                        <input type="{{ field.field_type }}" id="{{ field.name }}" name="{{ field.name }}" {% if field.required %}required{% endif %} {% if field.initial_value_text %}value="{{ field.initial_value_text }}"{% endif %}>
311                        {% endif %}
312                        {% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
313                    </div>
314                    {% endfor %}
315                    <button type="submit" class="submit-btn">Submit</button>
316                </form>
317            </div>
318            {% endif %}
319
320            {% if headers %}
321            <div class="headers">
322                <h2>Response Headers</h2>
323                <table>
324                    <thead>
325                        <tr>
326                            <th>Header</th>
327                            <th>Value</th>
328                        </tr>
329                    </thead>
330                    <tbody>
331                        {% for header in headers %}
332                        <tr>
333                            <td><strong>{{ header.0 }}</strong></td>
334                            <td>{{ header.1 }}</td>
335                        </tr>
336                        {% endfor %}
337                    </tbody>
338                </table>
339            </div>
340            {% endif %}
341        </div>
342    </div>
343</body>
344</html>
345"#
346	}
347}
348
349impl Default for BrowsableApiRenderer {
350	fn default() -> Self {
351		Self::new()
352	}
353}
354
355#[cfg(test)]
356mod tests {
357	use super::*;
358
359	#[test]
360	fn test_render_basic_context() {
361		let renderer = BrowsableApiRenderer::new();
362		let context = ApiContext {
363			title: "User List".to_string(),
364			description: Some("List all users".to_string()),
365			endpoint: "/api/users/".to_string(),
366			method: "GET".to_string(),
367			response_data: serde_json::json!([
368				{"id": 1, "name": "Alice"},
369				{"id": 2, "name": "Bob"}
370			]),
371			response_status: 200,
372			allowed_methods: vec!["GET".to_string(), "POST".to_string()],
373			request_form: None,
374			headers: vec![("Content-Type".to_string(), "application/json".to_string())],
375			csrf_token: None,
376		};
377
378		let html = renderer.render(&context).unwrap();
379		assert!(html.contains("User List"));
380		// Tera autoescapes `/` as `&#x2F;` for XSS protection
381		assert!(html.contains("&#x2F;api&#x2F;users&#x2F;"));
382		assert!(html.contains("Alice"));
383		assert!(html.contains("Bob"));
384	}
385
386	#[test]
387	fn test_render_with_form() {
388		let renderer = BrowsableApiRenderer::new();
389		let context = ApiContext {
390			title: "Create User".to_string(),
391			description: None,
392			endpoint: "/api/users/".to_string(),
393			method: "POST".to_string(),
394			response_data: serde_json::json!({"message": "Success"}),
395			response_status: 201,
396			allowed_methods: vec!["GET".to_string(), "POST".to_string()],
397			request_form: Some(FormContext {
398				fields: vec![FormField {
399					name: "name".to_string(),
400					label: "Name".to_string(),
401					field_type: "text".to_string(),
402					required: true,
403					help_text: Some("Enter user name".to_string()),
404					initial_value: None,
405					options: None,
406					initial_label: None,
407				}],
408				submit_url: "/api/users/".to_string(),
409				submit_method: "POST".to_string(),
410			}),
411			headers: vec![],
412			csrf_token: None,
413		};
414
415		let html = renderer.render(&context).unwrap();
416		assert!(html.contains("Make a Request"));
417		assert!(html.contains("name=\"name\""));
418		assert!(html.contains("Enter user name"));
419	}
420
421	#[test]
422	fn test_render_select_field() {
423		let renderer = BrowsableApiRenderer::new();
424		let context = ApiContext {
425			title: "Create Post".to_string(),
426			description: None,
427			endpoint: "/api/posts/".to_string(),
428			method: "POST".to_string(),
429			response_data: serde_json::json!({}),
430			response_status: 200,
431			allowed_methods: vec!["POST".to_string()],
432			request_form: Some(FormContext {
433				fields: vec![FormField {
434					name: "category".to_string(),
435					label: "Category".to_string(),
436					field_type: "select".to_string(),
437					required: true,
438					help_text: Some("Select a category".to_string()),
439					initial_value: Some(serde_json::json!("tech")),
440					options: Some(vec![
441						SelectOption {
442							value: "tech".to_string(),
443							label: "Technology".to_string(),
444						},
445						SelectOption {
446							value: "science".to_string(),
447							label: "Science".to_string(),
448						},
449						SelectOption {
450							value: "art".to_string(),
451							label: "Art".to_string(),
452						},
453					]),
454					initial_label: None,
455				}],
456				submit_url: "/api/posts/".to_string(),
457				submit_method: "POST".to_string(),
458			}),
459			headers: vec![],
460			csrf_token: None,
461		};
462
463		let html = renderer.render(&context).unwrap();
464		assert!(html.contains("<select"));
465		assert!(html.contains("name=\"category\""));
466		assert!(html.contains("Technology"));
467		assert!(html.contains("Science"));
468		assert!(html.contains("Art"));
469		assert!(html.contains("value=\"tech\""));
470		assert!(html.contains("value=\"science\""));
471		assert!(html.contains("value=\"art\""));
472	}
473
474	#[test]
475	fn test_render_select_with_initial_label() {
476		// Test: Select field with initial_label displays placeholder option
477		let renderer = BrowsableApiRenderer::new();
478		let context = ApiContext {
479			title: "Create Item".to_string(),
480			description: None,
481			endpoint: "/api/items/".to_string(),
482			method: "POST".to_string(),
483			response_data: serde_json::json!({}),
484			response_status: 200,
485			allowed_methods: vec!["POST".to_string()],
486			request_form: Some(FormContext {
487				fields: vec![FormField {
488					name: "category".to_string(),
489					label: "Category".to_string(),
490					field_type: "select".to_string(),
491					required: false,
492					help_text: Some("Choose a category".to_string()),
493					initial_value: None,
494					options: Some(vec![
495						SelectOption {
496							value: "tech".to_string(),
497							label: "Technology".to_string(),
498						},
499						SelectOption {
500							value: "science".to_string(),
501							label: "Science".to_string(),
502						},
503					]),
504					initial_label: Some("-- Select a category --".to_string()),
505				}],
506				submit_url: "/api/items/".to_string(),
507				submit_method: "POST".to_string(),
508			}),
509			headers: vec![],
510			csrf_token: None,
511		};
512
513		let html = renderer.render(&context).unwrap();
514
515		// Verify select element exists
516		assert!(html.contains("<select"));
517		assert!(html.contains("name=\"category\""));
518
519		// Verify initial option is rendered with empty value and selected attribute
520		assert!(html.contains("-- Select a category --"));
521		assert!(html.contains(r#"<option value="" selected>-- Select a category --</option>"#));
522
523		// Verify regular options are present
524		assert!(html.contains("Technology"));
525		assert!(html.contains("Science"));
526
527		// Verify initial option appears before regular options
528		let initial_pos = html.find("-- Select a category --").unwrap();
529		let tech_pos = html.find("Technology").unwrap();
530		assert!(
531			initial_pos < tech_pos,
532			"Initial option should appear before regular options"
533		);
534	}
535}