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