Skip to main content

oxirs_gql/
api_explorer.rs

1//! API Explorer with Curated Examples
2//!
3//! Provides an interactive API documentation and exploration UI with curated
4//! example queries for common RDF/SPARQL use cases.
5//!
6//! ## Features
7//!
8//! - **Curated Examples**: Pre-built queries organized by category
9//! - **Live Execution**: Execute examples directly in the browser
10//! - **Schema Documentation**: Integrated schema browser
11//! - **Response Visualization**: JSON and table views
12//! - **Query Modification**: Edit and customize examples
13//! - **Code Snippets**: Export queries in multiple languages
14
15use serde::{Deserialize, Serialize};
16
17/// API Explorer configuration
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ApiExplorerConfig {
20    /// Enable API Explorer
21    pub enabled: bool,
22    /// Endpoint path (default: /api-explorer)
23    pub endpoint: String,
24    /// Page title
25    pub title: String,
26    /// Show schema documentation
27    pub show_schema: bool,
28    /// Show query history
29    pub show_history: bool,
30    /// Enable query sharing
31    pub enable_sharing: bool,
32    /// Custom CSS URL
33    pub custom_css: Option<String>,
34    /// Custom examples
35    pub examples: Vec<QueryExample>,
36}
37
38impl Default for ApiExplorerConfig {
39    fn default() -> Self {
40        Self {
41            enabled: true,
42            endpoint: "/api-explorer".to_string(),
43            title: "OxiRS GraphQL API Explorer".to_string(),
44            show_schema: true,
45            show_history: true,
46            enable_sharing: false,
47            custom_css: None,
48            examples: default_examples(),
49        }
50    }
51}
52
53/// Query example category
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
55pub enum ExampleCategory {
56    /// Basic queries
57    Basic,
58    /// RDF-specific queries
59    Rdf,
60    /// SPARQL translation examples
61    Sparql,
62    /// Federation examples
63    Federation,
64    /// Aggregation examples
65    Aggregation,
66    /// Subscription examples
67    Subscription,
68    /// Advanced/complex queries
69    Advanced,
70}
71
72impl std::fmt::Display for ExampleCategory {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        match self {
75            ExampleCategory::Basic => write!(f, "Basic Queries"),
76            ExampleCategory::Rdf => write!(f, "RDF Queries"),
77            ExampleCategory::Sparql => write!(f, "SPARQL Examples"),
78            ExampleCategory::Federation => write!(f, "Federation"),
79            ExampleCategory::Aggregation => write!(f, "Aggregations"),
80            ExampleCategory::Subscription => write!(f, "Subscriptions"),
81            ExampleCategory::Advanced => write!(f, "Advanced"),
82        }
83    }
84}
85
86/// Query example
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct QueryExample {
89    /// Example ID
90    pub id: String,
91    /// Example title
92    pub title: String,
93    /// Category
94    pub category: ExampleCategory,
95    /// Description
96    pub description: String,
97    /// GraphQL query
98    pub query: String,
99    /// Variables (JSON)
100    pub variables: Option<String>,
101    /// Expected result schema
102    pub result_description: Option<String>,
103    /// Tags for search
104    pub tags: Vec<String>,
105}
106
107impl QueryExample {
108    pub fn new(
109        id: impl Into<String>,
110        title: impl Into<String>,
111        category: ExampleCategory,
112        description: impl Into<String>,
113        query: impl Into<String>,
114    ) -> Self {
115        Self {
116            id: id.into(),
117            title: title.into(),
118            category,
119            description: description.into(),
120            query: query.into(),
121            variables: None,
122            result_description: None,
123            tags: Vec::new(),
124        }
125    }
126
127    pub fn with_variables(mut self, variables: impl Into<String>) -> Self {
128        self.variables = Some(variables.into());
129        self
130    }
131
132    pub fn with_result_description(mut self, desc: impl Into<String>) -> Self {
133        self.result_description = Some(desc.into());
134        self
135    }
136
137    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
138        self.tags = tags;
139        self
140    }
141}
142
143/// Default curated examples
144fn default_examples() -> Vec<QueryExample> {
145    vec![
146        // Basic queries
147        QueryExample::new(
148            "basic-hello",
149            "Hello World",
150            ExampleCategory::Basic,
151            "A simple query to test connectivity",
152            r#"query {
153  hello
154  version
155}"#,
156        )
157        .with_tags(vec!["basic".to_string(), "test".to_string()]),
158        QueryExample::new(
159            "basic-triple-count",
160            "Count Triples",
161            ExampleCategory::Basic,
162            "Get the total number of triples in the store",
163            r#"query {
164  triples
165}"#,
166        )
167        .with_result_description("Returns the total count of RDF triples")
168        .with_tags(vec!["basic".to_string(), "count".to_string()]),
169        // RDF queries
170        QueryExample::new(
171            "rdf-subjects",
172            "List Subjects",
173            ExampleCategory::Rdf,
174            "Retrieve all subject IRIs with optional limit",
175            r#"query GetSubjects($limit: Int = 10) {
176  subjects(limit: $limit)
177}"#,
178        )
179        .with_variables(r#"{"limit": 10}"#)
180        .with_tags(vec!["rdf".to_string(), "subjects".to_string()]),
181        QueryExample::new(
182            "rdf-predicates",
183            "List Predicates",
184            ExampleCategory::Rdf,
185            "Retrieve all predicate IRIs (property types)",
186            r#"query GetPredicates($limit: Int = 20) {
187  predicates(limit: $limit)
188}"#,
189        )
190        .with_variables(r#"{"limit": 20}"#)
191        .with_tags(vec!["rdf".to_string(), "predicates".to_string()]),
192        QueryExample::new(
193            "rdf-objects",
194            "List Objects",
195            ExampleCategory::Rdf,
196            "Retrieve object values with their types",
197            r#"query GetObjects($limit: Int = 15) {
198  objects(limit: $limit)
199}"#,
200        )
201        .with_variables(r#"{"limit": 15}"#)
202        .with_tags(vec!["rdf".to_string(), "objects".to_string()]),
203        // SPARQL queries
204        QueryExample::new(
205            "sparql-select",
206            "Execute SPARQL SELECT",
207            ExampleCategory::Sparql,
208            "Execute a raw SPARQL SELECT query",
209            r#"query RunSparql($sparqlQuery: String!) {
210  sparql(query: $sparqlQuery)
211}"#,
212        )
213        .with_variables(r#"{"sparqlQuery": "SELECT * WHERE { ?s ?p ?o } LIMIT 10"}"#)
214        .with_result_description("Returns SPARQL query results as JSON")
215        .with_tags(vec!["sparql".to_string(), "select".to_string()]),
216        QueryExample::new(
217            "sparql-filter",
218            "SPARQL with Filter",
219            ExampleCategory::Sparql,
220            "Query with FILTER clause for advanced filtering",
221            r#"query FilteredSparql($sparqlQuery: String!) {
222  sparql(query: $sparqlQuery)
223}"#,
224        )
225        .with_variables(
226            r#"{"sparqlQuery": "SELECT ?s ?name WHERE { ?s <http://xmlns.com/foaf/0.1/name> ?name . FILTER(LANG(?name) = 'en') } LIMIT 10"}"#,
227        )
228        .with_tags(vec!["sparql".to_string(), "filter".to_string()]),
229        // Aggregation queries
230        QueryExample::new(
231            "aggregation-count",
232            "Count with GROUP BY",
233            ExampleCategory::Aggregation,
234            "Aggregate query with grouping and counting",
235            r#"query CountByType($sparqlQuery: String!) {
236  sparql(query: $sparqlQuery)
237}"#,
238        )
239        .with_variables(
240            r#"{"sparqlQuery": "SELECT ?type (COUNT(?s) as ?count) WHERE { ?s a ?type } GROUP BY ?type ORDER BY DESC(?count)"}"#,
241        )
242        .with_result_description("Returns entity counts grouped by type")
243        .with_tags(vec!["aggregation".to_string(), "count".to_string(), "group-by".to_string()]),
244        // Advanced queries
245        QueryExample::new(
246            "advanced-introspection",
247            "Schema Introspection",
248            ExampleCategory::Advanced,
249            "Introspect the GraphQL schema structure",
250            r#"query IntrospectSchema {
251  __schema {
252    queryType {
253      name
254    }
255    mutationType {
256      name
257    }
258    subscriptionType {
259      name
260    }
261  }
262}"#,
263        )
264        .with_tags(vec!["introspection".to_string(), "schema".to_string()]),
265        QueryExample::new(
266            "advanced-type-query",
267            "Type Information",
268            ExampleCategory::Advanced,
269            "Get detailed information about a specific type",
270            r#"query TypeInfo($typeName: String!) {
271  __type(name: $typeName) {
272    name
273    kind
274    description
275  }
276}"#,
277        )
278        .with_variables(r#"{"typeName": "Query"}"#)
279        .with_tags(vec!["introspection".to_string(), "type".to_string()]),
280    ]
281}
282
283/// Generate HTML for API Explorer
284pub fn generate_api_explorer_html(config: &ApiExplorerConfig, graphql_endpoint: &str) -> String {
285    let examples_json =
286        serde_json::to_string(&config.examples).unwrap_or_else(|_| "[]".to_string());
287
288    let custom_css = config
289        .custom_css
290        .as_ref()
291        .map(|url| format!(r#"<link rel="stylesheet" href="{}">"#, url))
292        .unwrap_or_default();
293
294    format!(
295        r#"<!DOCTYPE html>
296<html lang="en">
297<head>
298    <meta charset="UTF-8">
299    <meta name="viewport" content="width=device-width, initial-scale=1.0">
300    <title>{title}</title>
301    <style>
302        * {{ margin: 0; padding: 0; box-sizing: border-box; }}
303        body {{
304            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
305            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
306            min-height: 100vh;
307            padding: 20px;
308        }}
309        .container {{
310            max-width: 1400px;
311            margin: 0 auto;
312            background: white;
313            border-radius: 12px;
314            box-shadow: 0 20px 60px rgba(0,0,0,0.3);
315            overflow: hidden;
316            display: grid;
317            grid-template-columns: 350px 1fr;
318            min-height: calc(100vh - 40px);
319        }}
320        .sidebar {{
321            background: #f7fafc;
322            border-right: 1px solid #e2e8f0;
323            overflow-y: auto;
324        }}
325        .header {{
326            padding: 24px;
327            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
328            color: white;
329        }}
330        .header h1 {{
331            font-size: 22px;
332            font-weight: 600;
333            margin-bottom: 8px;
334        }}
335        .header p {{
336            font-size: 13px;
337            opacity: 0.9;
338        }}
339        .search {{
340            padding: 16px;
341            border-bottom: 1px solid #e2e8f0;
342        }}
343        .search input {{
344            width: 100%;
345            padding: 10px 16px;
346            border: 1px solid #cbd5e0;
347            border-radius: 6px;
348            font-size: 14px;
349        }}
350        .search input:focus {{
351            outline: none;
352            border-color: #667eea;
353            box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
354        }}
355        .category {{
356            padding: 12px 16px;
357            font-size: 11px;
358            font-weight: 600;
359            text-transform: uppercase;
360            color: #718096;
361            letter-spacing: 0.5px;
362        }}
363        .example-item {{
364            padding: 12px 16px;
365            cursor: pointer;
366            border-left: 3px solid transparent;
367            transition: all 0.2s;
368        }}
369        .example-item:hover {{
370            background: #edf2f7;
371            border-left-color: #667eea;
372        }}
373        .example-item.active {{
374            background: #e6f2ff;
375            border-left-color: #667eea;
376        }}
377        .example-title {{
378            font-size: 14px;
379            font-weight: 500;
380            color: #2d3748;
381            margin-bottom: 4px;
382        }}
383        .example-desc {{
384            font-size: 12px;
385            color: #718096;
386        }}
387        .main-content {{
388            display: flex;
389            flex-direction: column;
390        }}
391        .toolbar {{
392            padding: 16px 24px;
393            background: #f7fafc;
394            border-bottom: 1px solid #e2e8f0;
395            display: flex;
396            gap: 12px;
397            align-items: center;
398        }}
399        .btn {{
400            padding: 8px 16px;
401            border: none;
402            border-radius: 6px;
403            font-size: 14px;
404            font-weight: 500;
405            cursor: pointer;
406            transition: all 0.2s;
407        }}
408        .btn-primary {{
409            background: #667eea;
410            color: white;
411        }}
412        .btn-primary:hover {{
413            background: #5568d3;
414            transform: translateY(-1px);
415            box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
416        }}
417        .btn-secondary {{
418            background: #e2e8f0;
419            color: #2d3748;
420        }}
421        .btn-secondary:hover {{
422            background: #cbd5e0;
423        }}
424        .content-area {{
425            flex: 1;
426            display: grid;
427            grid-template-rows: auto 1fr auto 1fr;
428            overflow: hidden;
429        }}
430        .section-title {{
431            padding: 12px 24px;
432            background: #f7fafc;
433            border-bottom: 1px solid #e2e8f0;
434            font-size: 13px;
435            font-weight: 600;
436            color: #4a5568;
437        }}
438        .editor {{
439            padding: 16px 24px;
440            overflow-y: auto;
441        }}
442        .code-block {{
443            background: #1a202c;
444            color: #e2e8f0;
445            padding: 16px;
446            border-radius: 6px;
447            font-family: 'Monaco', 'Menlo', monospace;
448            font-size: 13px;
449            line-height: 1.6;
450            overflow-x: auto;
451        }}
452        textarea {{
453            width: 100%;
454            min-height: 200px;
455            padding: 16px;
456            border: 1px solid #cbd5e0;
457            border-radius: 6px;
458            font-family: 'Monaco', 'Menlo', monospace;
459            font-size: 13px;
460            resize: vertical;
461        }}
462        .response {{
463            padding: 16px 24px;
464            overflow-y: auto;
465            background: #fafafa;
466        }}
467        .loading {{
468            text-align: center;
469            padding: 40px;
470            color: #718096;
471        }}
472        .error {{
473            background: #fed7d7;
474            color: #c53030;
475            padding: 16px;
476            border-radius: 6px;
477            margin: 16px 24px;
478        }}
479        .tags {{
480            display: flex;
481            gap: 6px;
482            flex-wrap: wrap;
483            margin-top: 8px;
484        }}
485        .tag {{
486            background: #e6f2ff;
487            color: #2c5282;
488            padding: 2px 8px;
489            border-radius: 3px;
490            font-size: 11px;
491        }}
492    </style>
493    {custom_css}
494</head>
495<body>
496    <div class="container">
497        <div class="sidebar">
498            <div class="header">
499                <h1>📚 API Explorer</h1>
500                <p>Curated query examples</p>
501            </div>
502            <div class="search">
503                <input type="text" id="searchInput" placeholder="Search examples...">
504            </div>
505            <div id="exampleList"></div>
506        </div>
507        <div class="main-content">
508            <div class="toolbar">
509                <button class="btn btn-primary" id="runBtn">▶ Run Query</button>
510                <button class="btn btn-secondary" id="prettifyBtn">✨ Prettify</button>
511                <button class="btn btn-secondary" id="copyBtn">📋 Copy</button>
512                <button class="btn btn-secondary" id="shareBtn">🔗 Share</button>
513            </div>
514            <div class="content-area">
515                <div class="section-title">Query</div>
516                <div class="editor">
517                    <textarea id="queryEditor" placeholder="Select an example or write your query..."></textarea>
518                </div>
519                <div class="section-title">Variables (JSON)</div>
520                <div class="editor">
521                    <textarea id="variablesEditor" placeholder='{{"key": "value"}}'></textarea>
522                </div>
523                <div class="section-title">Response</div>
524                <div class="response">
525                    <div id="responseArea" class="loading">
526                        Select an example and click "Run Query" to see results
527                    </div>
528                </div>
529            </div>
530        </div>
531    </div>
532
533    <script>
534        const examples = {examples_json};
535        const graphqlEndpoint = '{graphql_endpoint}';
536        let currentExample = null;
537
538        function renderExamples(filtered = examples) {{
539            const categories = {{}};
540            filtered.forEach(ex => {{
541                if (!categories[ex.category]) categories[ex.category] = [];
542                categories[ex.category].push(ex);
543            }});
544
545            const html = Object.entries(categories).map(([cat, items]) => `
546                <div class="category">${{getCategoryName(cat)}}</div>
547                ${{items.map(ex => `
548                    <div class="example-item" data-id="${{ex.id}}" onclick="selectExample('${{ex.id}}')">
549                        <div class="example-title">${{ex.title}}</div>
550                        <div class="example-desc">${{ex.description}}</div>
551                        ${{ex.tags.length ? `<div class="tags">${{ex.tags.map(t => `<span class="tag">${{t}}</span>`).join('')}}</div>` : ''}}
552                    </div>
553                `).join('')}}
554            `).join('');
555
556            document.getElementById('exampleList').innerHTML = html;
557        }}
558
559        function getCategoryName(cat) {{
560            const names = {{
561                'Basic': 'Basic Queries',
562                'Rdf': 'RDF Queries',
563                'Sparql': 'SPARQL Examples',
564                'Federation': 'Federation',
565                'Aggregation': 'Aggregations',
566                'Subscription': 'Subscriptions',
567                'Advanced': 'Advanced'
568            }};
569            return names[cat] || cat;
570        }}
571
572        function selectExample(id) {{
573            currentExample = examples.find(ex => ex.id === id);
574            if (!currentExample) return;
575
576            document.querySelectorAll('.example-item').forEach(el => el.classList.remove('active'));
577            document.querySelector(`[data-id="${{id}}"]`).classList.add('active');
578
579            document.getElementById('queryEditor').value = currentExample.query;
580            document.getElementById('variablesEditor').value = currentExample.variables || '';
581            document.getElementById('responseArea').innerHTML = '<div class="loading">Ready to execute</div>';
582        }}
583
584        async function runQuery() {{
585            const query = document.getElementById('queryEditor').value;
586            const variablesText = document.getElementById('variablesEditor').value.trim();
587            let variables = null;
588
589            if (variablesText) {{
590                try {{
591                    variables = JSON.parse(variablesText);
592                }} catch (e) {{
593                    document.getElementById('responseArea').innerHTML =
594                        `<div class="error">Invalid JSON in variables: ${{e.message}}</div>`;
595                    return;
596                }}
597            }}
598
599            document.getElementById('responseArea').innerHTML = '<div class="loading">Executing query...</div>';
600
601            try {{
602                const response = await fetch(graphqlEndpoint, {{
603                    method: 'POST',
604                    headers: {{ 'Content-Type': 'application/json' }},
605                    body: JSON.stringify({{ query, variables }})
606                }});
607
608                const result = await response.json();
609                document.getElementById('responseArea').innerHTML =
610                    `<pre class="code-block">${{JSON.stringify(result, null, 2)}}</pre>`;
611            }} catch (error) {{
612                document.getElementById('responseArea').innerHTML =
613                    `<div class="error">Error: ${{error.message}}</div>`;
614            }}
615        }}
616
617        function prettifyQuery() {{
618            const editor = document.getElementById('queryEditor');
619            // Simple prettification (proper implementation would use a GraphQL parser)
620            editor.value = editor.value.replace(/\\s+/g, ' ').trim();
621        }}
622
623        function copyQuery() {{
624            const editor = document.getElementById('queryEditor');
625            editor.select();
626            document.execCommand('copy');
627        }}
628
629        function shareQuery() {{
630            alert('Share functionality would generate a shareable link here');
631        }}
632
633        document.getElementById('searchInput').addEventListener('input', (e) => {{
634            const term = e.target.value.toLowerCase();
635            const filtered = examples.filter(ex =>
636                ex.title.toLowerCase().includes(term) ||
637                ex.description.toLowerCase().includes(term) ||
638                ex.tags.some(tag => tag.toLowerCase().includes(term))
639            );
640            renderExamples(filtered);
641        }});
642
643        document.getElementById('runBtn').addEventListener('click', runQuery);
644        document.getElementById('prettifyBtn').addEventListener('click', prettifyQuery);
645        document.getElementById('copyBtn').addEventListener('click', copyQuery);
646        document.getElementById('shareBtn').addEventListener('click', shareQuery);
647
648        renderExamples();
649    </script>
650</body>
651</html>"#,
652        title = config.title,
653        examples_json = examples_json,
654        graphql_endpoint = graphql_endpoint,
655        custom_css = custom_css
656    )
657}
658
659/// API Explorer manager
660#[derive(Debug)]
661pub struct ApiExplorer {
662    config: ApiExplorerConfig,
663}
664
665impl ApiExplorer {
666    /// Create new API Explorer
667    pub fn new(config: ApiExplorerConfig) -> Self {
668        Self { config }
669    }
670
671    /// Get HTML page for the explorer
672    pub fn html(&self, graphql_endpoint: &str) -> String {
673        generate_api_explorer_html(&self.config, graphql_endpoint)
674    }
675
676    /// Get configuration
677    pub fn config(&self) -> &ApiExplorerConfig {
678        &self.config
679    }
680
681    /// Add custom example
682    pub fn add_example(&mut self, example: QueryExample) {
683        self.config.examples.push(example);
684    }
685
686    /// Get examples by category
687    pub fn get_examples_by_category(&self, category: ExampleCategory) -> Vec<&QueryExample> {
688        self.config
689            .examples
690            .iter()
691            .filter(|e| e.category == category)
692            .collect()
693    }
694}
695
696impl Default for ApiExplorer {
697    fn default() -> Self {
698        Self::new(ApiExplorerConfig::default())
699    }
700}
701
702#[cfg(test)]
703mod tests {
704    use super::*;
705
706    #[test]
707    fn test_api_explorer_config_default() {
708        let config = ApiExplorerConfig::default();
709        assert!(config.enabled);
710        assert_eq!(config.endpoint, "/api-explorer");
711        assert!(config.show_schema);
712        assert!(config.show_history);
713        assert!(!config.examples.is_empty());
714    }
715
716    #[test]
717    fn test_query_example_creation() {
718        let example = QueryExample::new(
719            "test-1",
720            "Test Query",
721            ExampleCategory::Basic,
722            "A test query",
723            "query { hello }",
724        )
725        .with_variables(r#"{"key": "value"}"#)
726        .with_result_description("Returns hello message")
727        .with_tags(vec!["test".to_string(), "basic".to_string()]);
728
729        assert_eq!(example.id, "test-1");
730        assert_eq!(example.title, "Test Query");
731        assert_eq!(example.category, ExampleCategory::Basic);
732        assert!(example.variables.is_some());
733        assert!(example.result_description.is_some());
734        assert_eq!(example.tags.len(), 2);
735    }
736
737    #[test]
738    fn test_example_category_display() {
739        assert_eq!(ExampleCategory::Basic.to_string(), "Basic Queries");
740        assert_eq!(ExampleCategory::Rdf.to_string(), "RDF Queries");
741        assert_eq!(ExampleCategory::Sparql.to_string(), "SPARQL Examples");
742        assert_eq!(ExampleCategory::Federation.to_string(), "Federation");
743        assert_eq!(ExampleCategory::Aggregation.to_string(), "Aggregations");
744        assert_eq!(ExampleCategory::Subscription.to_string(), "Subscriptions");
745        assert_eq!(ExampleCategory::Advanced.to_string(), "Advanced");
746    }
747
748    #[test]
749    fn test_default_examples() {
750        let examples = default_examples();
751        assert!(!examples.is_empty());
752
753        // Verify we have examples from different categories
754        let categories: std::collections::HashSet<_> =
755            examples.iter().map(|e| e.category).collect();
756        assert!(categories.contains(&ExampleCategory::Basic));
757        assert!(categories.contains(&ExampleCategory::Rdf));
758        assert!(categories.contains(&ExampleCategory::Sparql));
759    }
760
761    #[test]
762    fn test_api_explorer_creation() {
763        let explorer = ApiExplorer::default();
764        assert!(explorer.config().enabled);
765        assert!(!explorer.config().examples.is_empty());
766    }
767
768    #[test]
769    fn test_api_explorer_add_example() {
770        let mut explorer = ApiExplorer::default();
771        let initial_count = explorer.config().examples.len();
772
773        let example = QueryExample::new(
774            "custom-1",
775            "Custom Query",
776            ExampleCategory::Advanced,
777            "Custom example",
778            "query { custom }",
779        );
780
781        explorer.add_example(example);
782        assert_eq!(explorer.config().examples.len(), initial_count + 1);
783    }
784
785    #[test]
786    fn test_get_examples_by_category() {
787        let explorer = ApiExplorer::default();
788        let basic_examples = explorer.get_examples_by_category(ExampleCategory::Basic);
789        assert!(!basic_examples.is_empty());
790
791        for example in basic_examples {
792            assert_eq!(example.category, ExampleCategory::Basic);
793        }
794    }
795
796    #[test]
797    fn test_generate_html() {
798        let config = ApiExplorerConfig::default();
799        let html = generate_api_explorer_html(&config, "/graphql");
800
801        assert!(html.contains("<!DOCTYPE html>"));
802        assert!(html.contains("API Explorer"));
803        assert!(html.contains("/graphql"));
804        assert!(html.contains("const examples ="));
805    }
806
807    #[test]
808    fn test_html_includes_examples() {
809        let explorer = ApiExplorer::default();
810        let html = explorer.html("/graphql");
811
812        // HTML should include serialized examples
813        assert!(html.contains("Hello World") || html.contains("examples"));
814        assert!(html.contains("graphqlEndpoint"));
815    }
816}