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