1use serde::Serialize;
2use serde_json::Value;
3use std::sync::Arc;
4use tera::Tera;
5
6#[derive(Debug, thiserror::Error)]
8pub enum BrowsableApiError {
9 #[error("Template render error: {0}")]
11 Render(String),
12 #[error("Template error: {0}")]
14 Template(String),
15 #[error("Serialization error: {0}")]
17 Serialization(#[from] serde_json::Error),
18 #[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
29pub type BrowsableApiResult<T> = Result<T, BrowsableApiError>;
31
32#[derive(Debug, Clone, Serialize)]
34pub struct ApiContext {
35 pub title: String,
37 pub description: Option<String>,
39 pub endpoint: String,
41 pub method: String,
43 pub response_data: Value,
45 pub response_status: u16,
47 pub allowed_methods: Vec<String>,
49 pub request_form: Option<FormContext>,
51 pub headers: Vec<(String, String)>,
53 pub csrf_token: Option<String>,
55}
56
57#[derive(Debug, Clone, Serialize)]
59pub struct FormContext {
60 pub fields: Vec<FormField>,
62 pub submit_url: String,
64 pub submit_method: String,
66}
67
68#[derive(Debug, Clone, Serialize)]
70pub struct FormField {
71 pub name: String,
73 pub label: String,
75 pub field_type: String,
77 pub required: bool,
79 pub help_text: Option<String>,
81 pub initial_value: Option<Value>,
83 pub options: Option<Vec<SelectOption>>,
85 pub initial_label: Option<String>,
87}
88
89#[derive(Debug, Clone, Serialize)]
91pub struct SelectOption {
92 pub value: String,
94 pub label: String,
96}
97
98pub struct BrowsableApiRenderer {
100 tera: Arc<Tera>,
101}
102
103impl BrowsableApiRenderer {
104 pub fn new() -> Self {
113 let mut tera = Tera::default();
114
115 tera.autoescape_on(vec![".html", ".tpl"]);
117
118 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 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 pub fn render(&self, context: &ApiContext) -> BrowsableApiResult<String> {
137 let mut tera_context = tera::Context::from_serialize(context)?;
139
140 let formatted_json = serde_json::to_string_pretty(&context.response_data)?;
142 tera_context.insert("response_data_formatted", &formatted_json);
143
144 if let Some(form) = &context.request_form {
149 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 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 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 assert!(html.contains("/api/users/"));
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 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 assert!(html.contains("<select"));
517 assert!(html.contains("name=\"category\""));
518
519 assert!(html.contains("-- Select a category --"));
521 assert!(html.contains(r#"<option value="" selected>-- Select a category --</option>"#));
522
523 assert!(html.contains("Technology"));
525 assert!(html.contains("Science"));
526
527 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}