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)]
35#[serde(rename_all = "lowercase")]
36pub enum GrpcExplorerTheme {
37    /// Light theme
38    Light,
39    /// Dark theme (default)
40    Dark,
41}
42
43impl Default for GrpcExplorerTheme {
44    fn default() -> Self {
45        Self::Dark
46    }
47}
48
49/// gRPC Explorer configuration
50///
51/// Provides comprehensive configuration for the gRPC service explorer including
52/// server connection, reflection, and UI customization.
53#[derive(Debug, Clone)]
54pub struct GrpcExplorerConfig {
55    /// gRPC server URL (required)
56    pub server_url: String,
57
58    /// Enable gRPC reflection for service discovery
59    pub enable_reflection: bool,
60
61    /// Enable TLS/SSL connection
62    pub enable_tls: bool,
63
64    /// UI theme (Light or Dark)
65    pub theme: GrpcExplorerTheme,
66
67    /// Custom HTTP headers for metadata
68    pub headers: HashMap<String, String>,
69
70    /// Custom CSS styling
71    pub custom_css: Option<String>,
72
73    /// Request timeout in seconds
74    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    /// Create a new gRPC Explorer configuration with defaults
93    pub fn new() -> Self {
94        Self::default()
95    }
96
97    /// Set the gRPC server URL
98    pub fn server_url(mut self, url: impl Into<String>) -> Self {
99        self.server_url = url.into();
100        self
101    }
102
103    /// Enable or disable gRPC reflection
104    pub fn enable_reflection(mut self, enable: bool) -> Self {
105        self.enable_reflection = enable;
106        self
107    }
108
109    /// Enable or disable TLS
110    pub fn enable_tls(mut self, enable: bool) -> Self {
111        self.enable_tls = enable;
112        self
113    }
114
115    /// Set the UI theme
116    pub fn theme(mut self, theme: GrpcExplorerTheme) -> Self {
117        self.theme = theme;
118        self
119    }
120
121    /// Add a custom metadata header
122    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    /// Set custom CSS styling
128    pub fn custom_css(mut self, css: impl Into<String>) -> Self {
129        self.custom_css = Some(css.into());
130        self
131    }
132
133    /// Set request timeout in seconds
134    pub fn timeout_seconds(mut self, seconds: u32) -> Self {
135        self.timeout_seconds = seconds;
136        self
137    }
138
139    /// Convert configuration to JSON for embedding in HTML
140    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
157/// Generate gRPC Explorer HTML
158///
159/// Creates a complete HTML page with the gRPC service explorer embedded,
160/// configured according to the provided settings.
161///
162/// # Arguments
163///
164/// * `config` - gRPC Explorer configuration
165/// * `title` - Page title
166///
167/// # Returns
168///
169/// Complete HTML string ready to serve
170///
171/// # Example
172///
173/// ```rust
174/// use allframe_core::router::{GrpcExplorerConfig, grpc_explorer_html};
175///
176/// let config = GrpcExplorerConfig::new()
177///     .server_url("http://localhost:50051")
178///     .enable_reflection(true);
179///
180/// let html = grpc_explorer_html(&config, "My gRPC API");
181/// // Serve at /grpc/explorer
182/// ```
183pub 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}