allframe_core/router/
graphiql.rs

1//! GraphiQL Playground Integration
2//!
3//! This module provides comprehensive GraphiQL playground integration for
4//! GraphQL API documentation, similar to our Scalar integration for REST APIs.
5//!
6//! # Features
7//!
8//! - **GraphiQL Configuration**: Customizable playground settings
9//! - **Theme Support**: Light and dark themes
10//! - **Query History**: Persistent query history
11//! - **Variables Editor**: JSON variable editing with validation
12//! - **Headers Configuration**: Custom HTTP headers
13//! - **Subscription Support**: WebSocket subscriptions
14//! - **Schema Explorer**: Interactive schema documentation
15//!
16//! # Example
17//!
18//! ```rust
19//! use allframe_core::router::{GraphiQLConfig, GraphiQLTheme, graphiql_html};
20//!
21//! let config = GraphiQLConfig::new()
22//!     .endpoint_url("/graphql")
23//!     .subscription_url("ws://localhost:3000/graphql")
24//!     .theme(GraphiQLTheme::Dark)
25//!     .enable_explorer(true)
26//!     .enable_history(true);
27//!
28//! let html = graphiql_html(&config, "My GraphQL API");
29//! // Serve this HTML at /graphql/playground
30//! ```
31
32use std::collections::HashMap;
33
34use serde::{Deserialize, Serialize};
35
36/// GraphiQL theme options
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
38#[serde(rename_all = "lowercase")]
39pub enum GraphiQLTheme {
40    /// Light theme
41    Light,
42    /// Dark theme (default)
43    #[default]
44    Dark,
45}
46
47/// GraphiQL playground configuration
48///
49/// Provides comprehensive configuration for the GraphiQL playground including
50/// themes, subscriptions, headers, and interactive features.
51#[derive(Debug, Clone)]
52pub struct GraphiQLConfig {
53    /// GraphQL endpoint URL (required)
54    pub endpoint_url: String,
55
56    /// WebSocket URL for subscriptions (optional)
57    pub subscription_url: Option<String>,
58
59    /// UI theme (Light or Dark)
60    pub theme: GraphiQLTheme,
61
62    /// Enable schema explorer sidebar
63    pub enable_explorer: bool,
64
65    /// Enable query history persistence
66    pub enable_history: bool,
67
68    /// Custom HTTP headers
69    pub headers: HashMap<String, String>,
70
71    /// CDN URL for GraphiQL (for version pinning)
72    pub cdn_url: String,
73
74    /// Custom CSS styling
75    pub custom_css: Option<String>,
76}
77
78impl Default for GraphiQLConfig {
79    fn default() -> Self {
80        Self {
81            endpoint_url: "/graphql".to_string(),
82            subscription_url: None,
83            theme: GraphiQLTheme::Dark,
84            enable_explorer: true,
85            enable_history: true,
86            headers: HashMap::new(),
87            cdn_url: "https://unpkg.com/graphiql@3.0.0/graphiql.min.css".to_string(),
88            custom_css: None,
89        }
90    }
91}
92
93impl GraphiQLConfig {
94    /// Create a new GraphiQL configuration with defaults
95    pub fn new() -> Self {
96        Self::default()
97    }
98
99    /// Set the GraphQL endpoint URL
100    pub fn endpoint_url(mut self, url: impl Into<String>) -> Self {
101        self.endpoint_url = url.into();
102        self
103    }
104
105    /// Set the WebSocket URL for subscriptions
106    pub fn subscription_url(mut self, url: impl Into<String>) -> Self {
107        self.subscription_url = Some(url.into());
108        self
109    }
110
111    /// Set the UI theme
112    pub fn theme(mut self, theme: GraphiQLTheme) -> Self {
113        self.theme = theme;
114        self
115    }
116
117    /// Enable or disable the schema explorer
118    pub fn enable_explorer(mut self, enable: bool) -> Self {
119        self.enable_explorer = enable;
120        self
121    }
122
123    /// Enable or disable query history
124    pub fn enable_history(mut self, enable: bool) -> Self {
125        self.enable_history = enable;
126        self
127    }
128
129    /// Add a custom HTTP header
130    pub fn add_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
131        self.headers.insert(key.into(), value.into());
132        self
133    }
134
135    /// Set the CDN URL for version pinning
136    pub fn cdn_url(mut self, url: impl Into<String>) -> Self {
137        self.cdn_url = url.into();
138        self
139    }
140
141    /// Set custom CSS styling
142    pub fn custom_css(mut self, css: impl Into<String>) -> Self {
143        self.custom_css = Some(css.into());
144        self
145    }
146
147    /// Convert configuration to JSON for embedding in HTML
148    pub fn to_json(&self) -> serde_json::Value {
149        let mut config = serde_json::json!({
150            "endpoint": self.endpoint_url,
151            "theme": self.theme,
152            "explorer": self.enable_explorer,
153            "history": self.enable_history,
154        });
155
156        if let Some(ref sub_url) = self.subscription_url {
157            config["subscriptionUrl"] = serde_json::Value::String(sub_url.clone());
158        }
159
160        if !self.headers.is_empty() {
161            config["headers"] = serde_json::to_value(&self.headers).unwrap();
162        }
163
164        config
165    }
166}
167
168/// Generate GraphiQL playground HTML
169///
170/// Creates a complete HTML page with the GraphiQL playground embedded,
171/// configured according to the provided settings.
172///
173/// # Arguments
174///
175/// * `config` - GraphiQL configuration
176/// * `title` - Page title
177///
178/// # Returns
179///
180/// Complete HTML string ready to serve
181///
182/// # Example
183///
184/// ```rust
185/// use allframe_core::router::{GraphiQLConfig, graphiql_html};
186///
187/// let config = GraphiQLConfig::new()
188///     .endpoint_url("/graphql")
189///     .theme(allframe_core::router::GraphiQLTheme::Dark);
190///
191/// let html = graphiql_html(&config, "My API");
192/// // Serve at /graphql/playground
193/// ```
194pub fn graphiql_html(config: &GraphiQLConfig, title: &str) -> String {
195    let config_json = serde_json::to_string(&config.to_json()).unwrap();
196    let theme_class = match config.theme {
197        GraphiQLTheme::Light => "graphiql-light",
198        GraphiQLTheme::Dark => "graphiql-dark",
199    };
200
201    let custom_css = config.custom_css.as_deref().unwrap_or("");
202
203    format!(
204        r#"<!DOCTYPE html>
205<html>
206<head>
207    <meta charset="utf-8">
208    <meta name="viewport" content="width=device-width, initial-scale=1">
209    <title>{title}</title>
210    <link rel="stylesheet" href="{cdn_url}" />
211    <style>
212        body {{
213            height: 100vh;
214            margin: 0;
215            overflow: hidden;
216        }}
217        #graphiql {{
218            height: 100vh;
219        }}
220        {custom_css}
221    </style>
222</head>
223<body class="{theme_class}">
224    <div id="graphiql">Loading GraphiQL...</div>
225    <script
226        crossorigin
227        src="https://unpkg.com/react@18/umd/react.production.min.js"
228    ></script>
229    <script
230        crossorigin
231        src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"
232    ></script>
233    <script
234        crossorigin
235        src="https://unpkg.com/graphiql@3.0.0/graphiql.min.js"
236    ></script>
237    <script>
238        const config = {config_json};
239        const root = ReactDOM.createRoot(document.getElementById('graphiql'));
240        const fetcher = GraphiQL.createFetcher({{
241            url: config.endpoint,
242            subscriptionUrl: config.subscriptionUrl,
243            headers: config.headers || {{}}
244        }});
245        root.render(
246            React.createElement(GraphiQL, {{
247                fetcher: fetcher,
248                defaultEditorToolsVisibility: config.explorer,
249                storage: config.history ? window.localStorage : null
250            }})
251        );
252    </script>
253</body>
254</html>"#,
255        title = title,
256        cdn_url = config.cdn_url,
257        theme_class = theme_class,
258        custom_css = custom_css,
259        config_json = config_json,
260    )
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn test_graphiql_config_defaults() {
269        let config = GraphiQLConfig::new();
270        assert_eq!(config.endpoint_url, "/graphql");
271        assert_eq!(config.theme, GraphiQLTheme::Dark);
272        assert!(config.enable_explorer);
273        assert!(config.enable_history);
274        assert!(config.subscription_url.is_none());
275    }
276
277    #[test]
278    fn test_graphiql_config_builder() {
279        let config = GraphiQLConfig::new()
280            .endpoint_url("/api/graphql")
281            .subscription_url("ws://localhost:4000/graphql")
282            .theme(GraphiQLTheme::Light)
283            .enable_explorer(false)
284            .enable_history(false)
285            .add_header("Authorization", "Bearer token123");
286
287        assert_eq!(config.endpoint_url, "/api/graphql");
288        assert_eq!(
289            config.subscription_url,
290            Some("ws://localhost:4000/graphql".to_string())
291        );
292        assert_eq!(config.theme, GraphiQLTheme::Light);
293        assert!(!config.enable_explorer);
294        assert!(!config.enable_history);
295        assert_eq!(
296            config.headers.get("Authorization"),
297            Some(&"Bearer token123".to_string())
298        );
299    }
300
301    #[test]
302    fn test_graphiql_html_generation() {
303        let config = GraphiQLConfig::new()
304            .endpoint_url("/graphql")
305            .theme(GraphiQLTheme::Dark);
306
307        let html = graphiql_html(&config, "Test API");
308
309        assert!(html.contains("<!DOCTYPE html>"));
310        assert!(html.contains("Test API"));
311        assert!(html.contains("/graphql"));
312        assert!(html.contains("graphiql-dark"));
313        assert!(html.contains("GraphiQL"));
314    }
315
316    #[test]
317    fn test_graphiql_html_with_subscription() {
318        let config = GraphiQLConfig::new()
319            .endpoint_url("/graphql")
320            .subscription_url("ws://localhost:3000/graphql");
321
322        let html = graphiql_html(&config, "Test API");
323
324        assert!(html.contains("ws://localhost:3000/graphql"));
325        assert!(html.contains("subscriptionUrl"));
326    }
327
328    #[test]
329    fn test_graphiql_theme_serialization() {
330        let light = GraphiQLTheme::Light;
331        let dark = GraphiQLTheme::Dark;
332
333        assert_eq!(serde_json::to_string(&light).unwrap(), "\"light\"");
334        assert_eq!(serde_json::to_string(&dark).unwrap(), "\"dark\"");
335    }
336
337    #[test]
338    fn test_graphiql_config_json_generation() {
339        let config = GraphiQLConfig::new()
340            .endpoint_url("/api/graphql")
341            .theme(GraphiQLTheme::Light)
342            .enable_explorer(true)
343            .add_header("X-API-Key", "secret");
344
345        let json = config.to_json();
346
347        assert_eq!(json["endpoint"], "/api/graphql");
348        assert_eq!(json["theme"], "light");
349        assert_eq!(json["explorer"], true);
350        assert_eq!(json["headers"]["X-API-Key"], "secret");
351    }
352
353    #[test]
354    fn test_graphiql_custom_css() {
355        let config = GraphiQLConfig::new().custom_css("body { background: #1a1a1a; }");
356
357        let html = graphiql_html(&config, "Test API");
358
359        assert!(html.contains("body { background: #1a1a1a; }"));
360    }
361}