1use serde::{Deserialize, Serialize};
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ApiExplorerConfig {
20 pub enabled: bool,
22 pub endpoint: String,
24 pub title: String,
26 pub show_schema: bool,
28 pub show_history: bool,
30 pub enable_sharing: bool,
32 pub custom_css: Option<String>,
34 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
55pub enum ExampleCategory {
56 Basic,
58 Rdf,
60 Sparql,
62 Federation,
64 Aggregation,
66 Subscription,
68 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#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct QueryExample {
89 pub id: String,
91 pub title: String,
93 pub category: ExampleCategory,
95 pub description: String,
97 pub query: String,
99 pub variables: Option<String>,
101 pub result_description: Option<String>,
103 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
143fn default_examples() -> Vec<QueryExample> {
145 vec![
146 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 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 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 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 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
283pub 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#[derive(Debug)]
661pub struct ApiExplorer {
662 config: ApiExplorerConfig,
663}
664
665impl ApiExplorer {
666 pub fn new(config: ApiExplorerConfig) -> Self {
668 Self { config }
669 }
670
671 pub fn html(&self, graphql_endpoint: &str) -> String {
673 generate_api_explorer_html(&self.config, graphql_endpoint)
674 }
675
676 pub fn config(&self) -> &ApiExplorerConfig {
678 &self.config
679 }
680
681 pub fn add_example(&mut self, example: QueryExample) {
683 self.config.examples.push(example);
684 }
685
686 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 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 assert!(html.contains("Hello World") || html.contains("examples"));
814 assert!(html.contains("graphqlEndpoint"));
815 }
816}