1use std::collections::HashMap;
30
31use serde::{Deserialize, Serialize};
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "lowercase")]
36pub enum GrpcExplorerTheme {
37 Light,
39 Dark,
41}
42
43impl Default for GrpcExplorerTheme {
44 fn default() -> Self {
45 Self::Dark
46 }
47}
48
49#[derive(Debug, Clone)]
54pub struct GrpcExplorerConfig {
55 pub server_url: String,
57
58 pub enable_reflection: bool,
60
61 pub enable_tls: bool,
63
64 pub theme: GrpcExplorerTheme,
66
67 pub headers: HashMap<String, String>,
69
70 pub custom_css: Option<String>,
72
73 pub timeout_seconds: u32,
75}
76
77impl Default for GrpcExplorerConfig {
78 fn default() -> Self {
79 Self {
80 server_url: "http://localhost:50051".to_string(),
81 enable_reflection: true,
82 enable_tls: false,
83 theme: GrpcExplorerTheme::Dark,
84 headers: HashMap::new(),
85 custom_css: None,
86 timeout_seconds: 30,
87 }
88 }
89}
90
91impl GrpcExplorerConfig {
92 pub fn new() -> Self {
94 Self::default()
95 }
96
97 pub fn server_url(mut self, url: impl Into<String>) -> Self {
99 self.server_url = url.into();
100 self
101 }
102
103 pub fn enable_reflection(mut self, enable: bool) -> Self {
105 self.enable_reflection = enable;
106 self
107 }
108
109 pub fn enable_tls(mut self, enable: bool) -> Self {
111 self.enable_tls = enable;
112 self
113 }
114
115 pub fn theme(mut self, theme: GrpcExplorerTheme) -> Self {
117 self.theme = theme;
118 self
119 }
120
121 pub fn add_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
123 self.headers.insert(key.into(), value.into());
124 self
125 }
126
127 pub fn custom_css(mut self, css: impl Into<String>) -> Self {
129 self.custom_css = Some(css.into());
130 self
131 }
132
133 pub fn timeout_seconds(mut self, seconds: u32) -> Self {
135 self.timeout_seconds = seconds;
136 self
137 }
138
139 pub fn to_json(&self) -> serde_json::Value {
141 let mut config = serde_json::json!({
142 "serverUrl": self.server_url,
143 "reflection": self.enable_reflection,
144 "tls": self.enable_tls,
145 "theme": self.theme,
146 "timeout": self.timeout_seconds,
147 });
148
149 if !self.headers.is_empty() {
150 config["headers"] = serde_json::to_value(&self.headers).unwrap();
151 }
152
153 config
154 }
155}
156
157pub fn grpc_explorer_html(config: &GrpcExplorerConfig, title: &str) -> String {
184 let config_json = serde_json::to_string(&config.to_json()).unwrap();
185 let theme_class = match config.theme {
186 GrpcExplorerTheme::Light => "grpc-light",
187 GrpcExplorerTheme::Dark => "grpc-dark",
188 };
189
190 let custom_css = config.custom_css.as_deref().unwrap_or("");
191
192 format!(
193 r#"<!DOCTYPE html>
194<html>
195<head>
196 <meta charset="utf-8">
197 <meta name="viewport" content="width=device-width, initial-scale=1">
198 <title>{title}</title>
199 <style>
200 * {{
201 margin: 0;
202 padding: 0;
203 box-sizing: border-box;
204 }}
205
206 body {{
207 height: 100vh;
208 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
209 background: {bg_color};
210 color: {text_color};
211 }}
212
213 .grpc-explorer {{
214 height: 100vh;
215 display: flex;
216 flex-direction: column;
217 }}
218
219 .header {{
220 padding: 1rem 2rem;
221 background: {header_bg};
222 border-bottom: 1px solid {border_color};
223 }}
224
225 .header h1 {{
226 font-size: 1.5rem;
227 font-weight: 600;
228 }}
229
230 .server-info {{
231 margin-top: 0.5rem;
232 font-size: 0.875rem;
233 opacity: 0.8;
234 }}
235
236 .main {{
237 display: flex;
238 flex: 1;
239 overflow: hidden;
240 }}
241
242 .sidebar {{
243 width: 300px;
244 background: {sidebar_bg};
245 border-right: 1px solid {border_color};
246 overflow-y: auto;
247 padding: 1rem;
248 }}
249
250 .content {{
251 flex: 1;
252 padding: 2rem;
253 overflow-y: auto;
254 }}
255
256 .service-list {{
257 list-style: none;
258 }}
259
260 .service-item {{
261 padding: 0.75rem;
262 margin-bottom: 0.5rem;
263 background: {item_bg};
264 border-radius: 0.5rem;
265 cursor: pointer;
266 transition: background 0.2s;
267 }}
268
269 .service-item:hover {{
270 background: {item_hover_bg};
271 }}
272
273 .service-name {{
274 font-weight: 600;
275 margin-bottom: 0.25rem;
276 }}
277
278 .method-count {{
279 font-size: 0.875rem;
280 opacity: 0.7;
281 }}
282
283 .loading {{
284 text-align: center;
285 padding: 3rem;
286 }}
287
288 .spinner {{
289 border: 3px solid rgba(255, 255, 255, 0.1);
290 border-top: 3px solid {accent_color};
291 border-radius: 50%;
292 width: 40px;
293 height: 40px;
294 animation: spin 1s linear infinite;
295 margin: 0 auto 1rem;
296 }}
297
298 @keyframes spin {{
299 0% {{ transform: rotate(0deg); }}
300 100% {{ transform: rotate(360deg); }}
301 }}
302
303 .error {{
304 background: #dc2626;
305 color: white;
306 padding: 1rem;
307 border-radius: 0.5rem;
308 margin: 1rem 0;
309 }}
310
311 .info-box {{
312 background: {info_bg};
313 padding: 1.5rem;
314 border-radius: 0.5rem;
315 border-left: 4px solid {accent_color};
316 }}
317
318 .info-box h3 {{
319 margin-bottom: 0.5rem;
320 color: {accent_color};
321 }}
322
323 .features-list {{
324 list-style: none;
325 margin-top: 1rem;
326 }}
327
328 .features-list li {{
329 padding: 0.5rem 0;
330 display: flex;
331 align-items: center;
332 }}
333
334 .features-list li:before {{
335 content: "✓";
336 color: {accent_color};
337 font-weight: bold;
338 margin-right: 0.75rem;
339 }}
340
341 {custom_css}
342 </style>
343</head>
344<body class="{theme_class}">
345 <div class="grpc-explorer">
346 <div class="header">
347 <h1>{title}</h1>
348 <div class="server-info" id="server-info">
349 Connecting to {server_url}...
350 </div>
351 </div>
352 <div class="main">
353 <div class="sidebar">
354 <h2 style="margin-bottom: 1rem;">Services</h2>
355 <div id="service-list">
356 <div class="loading">
357 <div class="spinner"></div>
358 <div>Loading services...</div>
359 </div>
360 </div>
361 </div>
362 <div class="content">
363 <div class="info-box">
364 <h3>gRPC Service Explorer</h3>
365 <p>Interactive gRPC API documentation and testing.</p>
366
367 <ul class="features-list">
368 <li>Browse gRPC services and methods</li>
369 <li>Test unary, server stream, client stream, and bidirectional calls</li>
370 <li>View service definitions and proto files</li>
371 <li>Automatic service discovery via gRPC reflection</li>
372 <li>Real-time request/response testing</li>
373 </ul>
374
375 <div style="margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid {border_color};">
376 <strong>Configuration:</strong>
377 <ul style="list-style: none; margin-top: 0.5rem;">
378 <li>Server: <code>{server_url}</code></li>
379 <li>Reflection: {reflection_status}</li>
380 <li>TLS: {tls_status}</li>
381 <li>Timeout: {timeout}s</li>
382 </ul>
383 </div>
384 </div>
385
386 <div id="service-detail" style="margin-top: 2rem;"></div>
387 </div>
388 </div>
389 </div>
390
391 <script>
392 const config = {config_json};
393
394 // Placeholder for future gRPC Web client integration
395 // This will be enhanced with actual gRPC-Web support in future iterations
396
397 console.log('gRPC Explorer Config:', config);
398
399 // Simulate service loading
400 setTimeout(() => {{
401 const serviceList = document.getElementById('service-list');
402 const serverInfo = document.getElementById('server-info');
403
404 if (config.reflection) {{
405 serviceList.innerHTML = `
406 <div class="info-box">
407 <h3>Reflection Enabled</h3>
408 <p>gRPC reflection API support will automatically discover services when connected to a gRPC server with reflection enabled.</p>
409 <p style="margin-top: 1rem;"><strong>To enable reflection in your gRPC server:</strong></p>
410 <pre style="background: rgba(0,0,0,0.2); padding: 1rem; border-radius: 0.25rem; margin-top: 0.5rem; overflow-x: auto;">
411use tonic::transport::Server;
412use tonic_reflection::server::Builder;
413
414Server::builder()
415 .add_service(Builder::configure()
416 .register_encoded_file_descriptor_set(DESCRIPTOR_SET)
417 .build()
418 .unwrap())
419 .serve(addr)
420 .await?;</pre>
421 </div>
422 `;
423 serverInfo.textContent = `Connected to ${{config.serverUrl}} (Reflection enabled)`;
424 }} else {{
425 serviceList.innerHTML = `
426 <div class="error">
427 <strong>Reflection Disabled</strong>
428 <p style="margin-top: 0.5rem;">Enable gRPC reflection to automatically discover services.</p>
429 </div>
430 `;
431 serverInfo.textContent = `Server: ${{config.serverUrl}} (Reflection disabled)`;
432 }}
433 }}, 1000);
434 </script>
435</body>
436</html>"#,
437 title = title,
438 server_url = config.server_url,
439 theme_class = theme_class,
440 config_json = config_json,
441 custom_css = custom_css,
442 bg_color = if matches!(config.theme, GrpcExplorerTheme::Dark) {
443 "#1a1a1a"
444 } else {
445 "#ffffff"
446 },
447 text_color = if matches!(config.theme, GrpcExplorerTheme::Dark) {
448 "#e5e5e5"
449 } else {
450 "#1a1a1a"
451 },
452 header_bg = if matches!(config.theme, GrpcExplorerTheme::Dark) {
453 "#252525"
454 } else {
455 "#f5f5f5"
456 },
457 sidebar_bg = if matches!(config.theme, GrpcExplorerTheme::Dark) {
458 "#1f1f1f"
459 } else {
460 "#fafafa"
461 },
462 border_color = if matches!(config.theme, GrpcExplorerTheme::Dark) {
463 "#333"
464 } else {
465 "#e5e5e5"
466 },
467 item_bg = if matches!(config.theme, GrpcExplorerTheme::Dark) {
468 "#2a2a2a"
469 } else {
470 "#ffffff"
471 },
472 item_hover_bg = if matches!(config.theme, GrpcExplorerTheme::Dark) {
473 "#333"
474 } else {
475 "#f0f0f0"
476 },
477 info_bg = if matches!(config.theme, GrpcExplorerTheme::Dark) {
478 "#1f2937"
479 } else {
480 "#f0f9ff"
481 },
482 accent_color = if matches!(config.theme, GrpcExplorerTheme::Dark) {
483 "#60a5fa"
484 } else {
485 "#3b82f6"
486 },
487 reflection_status = if config.enable_reflection {
488 "✓ Enabled"
489 } else {
490 "✗ Disabled"
491 },
492 tls_status = if config.enable_tls {
493 "✓ Enabled"
494 } else {
495 "✗ Disabled"
496 },
497 timeout = config.timeout_seconds,
498 )
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504
505 #[test]
506 fn test_grpc_explorer_config_defaults() {
507 let config = GrpcExplorerConfig::new();
508 assert_eq!(config.server_url, "http://localhost:50051");
509 assert_eq!(config.theme, GrpcExplorerTheme::Dark);
510 assert!(config.enable_reflection);
511 assert!(!config.enable_tls);
512 assert_eq!(config.timeout_seconds, 30);
513 }
514
515 #[test]
516 fn test_grpc_explorer_config_builder() {
517 let config = GrpcExplorerConfig::new()
518 .server_url("http://localhost:9090")
519 .enable_reflection(false)
520 .enable_tls(true)
521 .theme(GrpcExplorerTheme::Light)
522 .timeout_seconds(60)
523 .add_header("Authorization", "Bearer token123");
524
525 assert_eq!(config.server_url, "http://localhost:9090");
526 assert!(!config.enable_reflection);
527 assert!(config.enable_tls);
528 assert_eq!(config.theme, GrpcExplorerTheme::Light);
529 assert_eq!(config.timeout_seconds, 60);
530 assert_eq!(
531 config.headers.get("Authorization"),
532 Some(&"Bearer token123".to_string())
533 );
534 }
535
536 #[test]
537 fn test_grpc_explorer_html_generation() {
538 let config = GrpcExplorerConfig::new()
539 .server_url("http://localhost:50051")
540 .theme(GrpcExplorerTheme::Dark);
541
542 let html = grpc_explorer_html(&config, "Test gRPC API");
543
544 assert!(html.contains("<!DOCTYPE html>"));
545 assert!(html.contains("Test gRPC API"));
546 assert!(html.contains("http://localhost:50051"));
547 assert!(html.contains("grpc-dark"));
548 }
549
550 #[test]
551 fn test_grpc_explorer_with_tls() {
552 let config = GrpcExplorerConfig::new()
553 .server_url("https://api.example.com:443")
554 .enable_tls(true);
555
556 let html = grpc_explorer_html(&config, "Secure API");
557
558 assert!(html.contains("https://api.example.com:443"));
559 assert!(html.contains("✓ Enabled"));
560 }
561
562 #[test]
563 fn test_grpc_explorer_theme_serialization() {
564 let light = GrpcExplorerTheme::Light;
565 let dark = GrpcExplorerTheme::Dark;
566
567 assert_eq!(serde_json::to_string(&light).unwrap(), "\"light\"");
568 assert_eq!(serde_json::to_string(&dark).unwrap(), "\"dark\"");
569 }
570
571 #[test]
572 fn test_grpc_explorer_config_json_generation() {
573 let config = GrpcExplorerConfig::new()
574 .server_url("http://localhost:9090")
575 .enable_reflection(true)
576 .add_header("X-API-Key", "secret");
577
578 let json = config.to_json();
579
580 assert_eq!(json["serverUrl"], "http://localhost:9090");
581 assert_eq!(json["reflection"], true);
582 assert_eq!(json["headers"]["X-API-Key"], "secret");
583 }
584
585 #[test]
586 fn test_grpc_explorer_custom_css() {
587 let config = GrpcExplorerConfig::new().custom_css("body { background: #000; }");
588
589 let html = grpc_explorer_html(&config, "Custom API");
590
591 assert!(html.contains("body { background: #000; }"));
592 }
593}