allframe_core/router/
grpc_explorer.rs

1//! gRPC Service Explorer Integration
2//!
3//! This module provides a web-based gRPC service explorer for interactive
4//! gRPC API documentation and testing.
5//!
6//! # Features
7//!
8//! - **Service Explorer**: Interactive UI for browsing gRPC services
9//! - **Method Testing**: Test gRPC methods directly from the browser
10//! - **Reflection Support**: Automatic service discovery via gRPC reflection
11//! - **Stream Testing**: Test server/client/bidirectional streams
12//! - **Request Builder**: Build and send gRPC requests with JSON
13//! - **Proto Viewer**: View service definitions
14//!
15//! # Example
16//!
17//! ```rust
18//! use allframe_core::router::{GrpcExplorerConfig, grpc_explorer_html};
19//!
20//! let config = GrpcExplorerConfig::new()
21//!     .server_url("http://localhost:50051")
22//!     .enable_reflection(true)
23//!     .enable_tls(false);
24//!
25//! let html = grpc_explorer_html(&config, "My gRPC API");
26//! // Serve this HTML at /grpc/explorer
27//! ```
28
29use std::collections::HashMap;
30
31use serde::{Deserialize, Serialize};
32
33/// gRPC Explorer theme options
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
35#[serde(rename_all = "lowercase")]
36pub enum GrpcExplorerTheme {
37    /// Light theme
38    Light,
39    /// Dark theme (default)
40    #[default]
41    Dark,
42}
43
44/// gRPC Explorer configuration
45///
46/// Provides comprehensive configuration for the gRPC service explorer including
47/// server connection, reflection, and UI customization.
48#[derive(Debug, Clone)]
49pub struct GrpcExplorerConfig {
50    /// gRPC server URL (required)
51    pub server_url: String,
52
53    /// Enable gRPC reflection for service discovery
54    pub enable_reflection: bool,
55
56    /// Enable TLS/SSL connection
57    pub enable_tls: bool,
58
59    /// UI theme (Light or Dark)
60    pub theme: GrpcExplorerTheme,
61
62    /// Custom HTTP headers for metadata
63    pub headers: HashMap<String, String>,
64
65    /// Custom CSS styling
66    pub custom_css: Option<String>,
67
68    /// Request timeout in seconds
69    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    /// Create a new gRPC Explorer configuration with defaults
88    pub fn new() -> Self {
89        Self::default()
90    }
91
92    /// Set the gRPC server URL
93    pub fn server_url(mut self, url: impl Into<String>) -> Self {
94        self.server_url = url.into();
95        self
96    }
97
98    /// Enable or disable gRPC reflection
99    pub fn enable_reflection(mut self, enable: bool) -> Self {
100        self.enable_reflection = enable;
101        self
102    }
103
104    /// Enable or disable TLS
105    pub fn enable_tls(mut self, enable: bool) -> Self {
106        self.enable_tls = enable;
107        self
108    }
109
110    /// Set the UI theme
111    pub fn theme(mut self, theme: GrpcExplorerTheme) -> Self {
112        self.theme = theme;
113        self
114    }
115
116    /// Add a custom metadata header
117    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    /// Set custom CSS styling
123    pub fn custom_css(mut self, css: impl Into<String>) -> Self {
124        self.custom_css = Some(css.into());
125        self
126    }
127
128    /// Set request timeout in seconds
129    pub fn timeout_seconds(mut self, seconds: u32) -> Self {
130        self.timeout_seconds = seconds;
131        self
132    }
133
134    /// Convert configuration to JSON for embedding in HTML
135    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
152/// Generate gRPC Explorer HTML
153///
154/// Creates a complete HTML page with the gRPC service explorer embedded,
155/// configured according to the provided settings.
156///
157/// # Arguments
158///
159/// * `config` - gRPC Explorer configuration
160/// * `title` - Page title
161///
162/// # Returns
163///
164/// Complete HTML string ready to serve
165///
166/// # Example
167///
168/// ```rust
169/// use allframe_core::router::{GrpcExplorerConfig, grpc_explorer_html};
170///
171/// let config = GrpcExplorerConfig::new()
172///     .server_url("http://localhost:50051")
173///     .enable_reflection(true);
174///
175/// let html = grpc_explorer_html(&config, "My gRPC API");
176/// // Serve at /grpc/explorer
177/// ```
178pub 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}