omni_orchestrator/schemas/v1/api/
index.rs

1use rocket::response::content;
2use serde::Serialize;
3use lazy_static::lazy_static;
4use rocket::Build;
5use rocket::Rocket;
6use std::sync::Arc;
7use std::sync::Mutex;
8
9/// Route information structure for API documentation
10#[derive(Serialize, Clone)]
11pub struct RouteInfo {
12    /// Path of the route
13    path: String,
14    /// HTTP methods supported by the route
15    methods: Vec<String>,
16}
17
18/// Response structure for routes listing endpoint
19#[derive(Serialize)]
20pub struct RoutesResponse {
21    /// List of all available routes and their methods
22    routes: Vec<RouteInfo>,
23}
24
25/// Routes collection that will be populated during startup
26#[derive(Clone)]
27pub struct RoutesCollection {
28    routes: Vec<RouteInfo>,
29}
30
31impl RoutesCollection {
32    pub fn new() -> Self {
33        Self { routes: Vec::new() }
34    }
35
36    pub fn add_route(&mut self, path: String, method: String) {
37        // Check if the route already exists
38        if let Some(route) = self.routes.iter_mut().find(|r| r.path == path) {
39            // Add method if it doesn't exist
40            if !route.methods.contains(&method) {
41                route.methods.push(method);
42            }
43        } else {
44            // Add new route info
45            self.routes.push(RouteInfo {
46                path,
47                methods: vec![method],
48            });
49        }
50    }
51
52    pub fn get_routes(&self) -> Vec<RouteInfo> {
53        self.routes.clone()
54    }
55}
56
57/// Global singleton instance of the routes collection
58/// Stores information about all registered API routes
59lazy_static! {
60    static ref ROUTES_COLLECTION: Arc<Mutex<RoutesCollection>> = Arc::new(Mutex::new(RoutesCollection::new()));
61}
62
63/// Decodes any encoded characters in route paths and preserves parameter notation
64fn decode_route_path(path: &str) -> String {
65    let mut result = path.to_string();
66    
67    // Handle common Unicode escape sequences
68    let replacements = [
69        ("\\u003C", "<"), ("\\u003E", ">"),
70        ("\\u003c", "<"), ("\\u003e", ">"),
71        ("\\u0026", "&"), ("\\u0027", "'"),
72        ("\\u0022", "\""), ("\\u003D", "="),
73        ("\\u003F", "?"), ("\\u002F", "/"),
74    ];
75    
76    for (encoded, decoded) in replacements.iter() {
77        result = result.replace(encoded, decoded);
78    }
79    
80    result
81}
82
83fn escape_html_in_path(path: &str) -> String {
84    path.replace("<", "&lt;").replace(">", "&gt;")
85}
86
87// Keep your existing collect_routes function
88pub fn collect_routes(rocket: &Rocket<Build>) {
89    let mut routes_collection = ROUTES_COLLECTION.lock().unwrap();
90    
91    for route in rocket.routes() {
92        // Get the path and decode any escaped characters
93        let path = decode_route_path(&route.uri.to_string());
94        
95        routes_collection.add_route(
96            path,
97            route.method.to_string(),
98        );
99    }
100}
101
102/// Routes listing endpoint providing HTML representation of routes
103#[get("/")]
104pub fn routes_ui() -> content::RawHtml<String> {
105    let routes_collection = ROUTES_COLLECTION.lock().unwrap();
106    let routes = routes_collection.get_routes();
107
108    // Collect unique versions dynamically
109    let mut versions: Vec<String> = routes
110        .iter()
111        .filter_map(|route| {
112            let path = &route.path;
113            if let Some(start) = path.find("/api/v") {
114                let rest = &path[start + 6..];
115                let end = rest.find('/').unwrap_or(rest.len());
116                let version = &rest[..end];
117                if version.chars().all(|c| c.is_numeric()) {
118                    Some(format!("v{}", version))
119                } else {
120                    None
121                }
122            } else {
123                None
124            }
125        })
126        .collect();
127
128    versions.sort();
129    versions.dedup();
130
131    // Add "All Versions" and "Unversioned" options
132    let mut version_options = String::from(
133        r#"
134        <option value="all">All Versions</option>
135        <option value="unversioned">Unversioned</option>
136    "#,
137    );
138
139    // Add detected versions dynamically
140    version_options.push_str(
141        &versions
142            .iter()
143            .map(|v| format!(r#"<option value="{}">{}</option>"#, v.to_lowercase(), v))
144            .collect::<String>(),
145    );
146
147    // Start building the HTML for the table
148    let mut route_rows = String::new();
149
150    // Sort routes for better presentation
151    let mut sorted_routes = routes.clone();
152    sorted_routes.sort_by(|a, b| a.path.cmp(&b.path));
153
154    // Create table rows
155    for route in sorted_routes {
156        // Sort methods for consistency
157        let mut methods = route.methods.clone();
158        methods.sort();
159
160        for method in methods {
161            let method_class = method.to_lowercase();
162            let escaped_path = escape_html_in_path(&route.path);
163            let version = if let Some(start) = route.path.find("/api/v") {
164                let rest = &route.path[start + 6..];
165                let end = rest.find('/').unwrap_or(rest.len());
166                format!("v{}", &rest[..end])
167            } else {
168                "unversioned".to_string()
169            };
170
171            route_rows.push_str(&format!(
172                r#"<tr class="route-row border-b border-gray-800" data-method="{}" data-path="{}" data-version="{}">
173                    <td class="py-3 px-4">
174                      <span class="method {} text-sm font-medium px-3 py-1 rounded">{}</span>
175                    </td>
176                    <td class="py-3 px-4">
177                      <a href="{}" class="text-gray-300 hover:text-white hover:underline transition duration-150">{}</a>
178                    </td>
179                </tr>"#,
180                method.to_lowercase(),
181                escaped_path.to_lowercase(),
182                version.to_lowercase(),
183                method_class,
184                method,
185                escaped_path,
186                escaped_path
187            ));
188        }
189    }
190
191    // Complete HTML with search, method, and version filters
192    let html = format!(
193        r#"<!DOCTYPE html>
194<html lang="en">
195<head>
196    <meta charset="UTF-8">
197    <meta name="viewport" content="width=device-width, initial-scale=1.0">
198    <title>OmniOrchestrator API</title>
199    <style>
200        :root {{
201            --color-gray-900: #111827;
202            --color-gray-800: #1F2937;
203            --color-gray-700: #374151;
204            --color-gray-600: #4B5563;
205            --color-gray-500: #6B7280;
206            --color-gray-400: #9CA3AF;
207            --color-gray-300: #D1D5DB;
208            --color-gray-200: #E5E7EB;
209            --color-gray-100: #F3F4F6;
210            --color-gray-50: #F9FAFB;
211            --color-blue-500: #3B82F6;
212            --color-blue-600: #2563EB;
213            --color-green-500: #10B981;
214            --color-yellow-500: #F59E0B;
215            --color-red-500: #EF4444;
216            --color-purple-500: #8B5CF6;
217        }}
218        
219        * {{
220            margin: 0;
221            padding: 0;
222            box-sizing: border-box;
223        }}
224        
225        body {{
226            font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
227            background-color: var(--color-gray-900);
228            color: var(--color-gray-300);
229            line-height: 1.5;
230        }}
231
232        a {{
233            color: var(--color-blue-200);
234            text-decoration: none;
235        }}
236        
237        .container {{
238            max-width: 1200px;
239            margin: 0 auto;
240            padding: 2rem 1rem;
241        }}
242        
243        .header {{
244            margin-bottom: 2rem;
245            border-bottom: 1px solid var(--color-gray-800);
246            padding-bottom: 1.5rem;
247        }}
248        
249        h1 {{
250            font-size: 2rem;
251            font-weight: 700;
252            margin-bottom: 0.5rem;
253            color: white;
254        }}
255        
256        h2 {{
257            font-size: 1.5rem;
258            font-weight: 600;
259            margin-bottom: 1.5rem;
260            color: white;
261        }}
262        
263        p {{
264            margin-bottom: 1rem;
265            color: var(--color-gray-400);
266        }}
267        
268        .filters {{
269            display: flex;
270            gap: 1rem;
271            margin-bottom: 1.5rem;
272            flex-wrap: wrap;
273        }}
274        
275        .filter-input {{
276            flex: 1;
277            min-width: 200px;
278            padding: 0.75rem 1rem;
279            background-color: var(--color-gray-800);
280            border: 1px solid var(--color-gray-700);
281            border-radius: 0.5rem;
282            color: var(--color-gray-300);
283            font-size: 0.875rem;
284            transition: all 0.2s;
285        }}
286        
287        .filter-input:focus {{
288            outline: none;
289            border-color: var(--color-blue-500);
290            box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
291        }}
292        
293        .table-container {{
294            background-color: var(--color-gray-800);
295            border-radius: 0.75rem;
296            overflow: hidden;
297            box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
298        }}
299        
300        table {{
301            width: 100%;
302            border-collapse: collapse;
303        }}
304        
305        thead {{
306            background-color: var(--color-gray-800);
307            border-bottom: 2px solid var(--color-gray-700);
308        }}
309        
310        th {{
311            text-align: left;
312            padding: 1rem;
313            font-weight: 600;
314            color: var(--color-gray-200);
315            text-transform: uppercase;
316            font-size: 0.75rem;
317            letter-spacing: 0.05em;
318        }}
319        
320        td {{
321            padding: 0.75rem 1rem;
322        }}
323        
324        .method {{
325            display: inline-block;
326            min-width: 60px;
327            text-align: center;
328            font-weight: 500;
329        }}
330        
331        .get {{
332            background-color: var(--color-blue-500);
333            color: white;
334        }}
335        
336        .post {{
337            background-color: var(--color-green-500);
338            color: white;
339        }}
340        
341        .put {{
342            background-color: var(--color-yellow-500);
343            color: white;
344        }}
345        
346        .delete {{
347            background-color: var(--color-red-500);
348            color: white;
349        }}
350        
351        .patch {{
352            background-color: var(--color-purple-500);
353            color: white;
354        }}
355        
356        .empty-message {{
357            padding: 2rem;
358            text-align: center;
359            color: var(--color-gray-500);
360        }}
361
362        .border-b {{
363            border-bottom-width: 1px;
364        }}
365
366        .border-gray-800 {{
367            border-color: var(--color-gray-700);
368        }}
369
370        /* Responsive adjustments */
371        @media (max-width: 640px) {{
372            .filters {{
373                flex-direction: column;
374            }}
375            
376            .filter-input {{
377                width: 100%;
378            }}
379        }}
380    </style>
381</head>
382<body>
383    <div class="container">
384        <div class="header">
385            <h1>OmniOrchestrator API</h1>
386            <p>Browse the available API endpoints and explore the platform capabilities.</p>
387        </div>
388
389        <h2>API Routes</h2>
390        
391        <div class="filters">
392            <input type="text" id="searchInput" class="filter-input" placeholder="Search routes..." onkeyup="filterRoutes()">
393            
394            <select id="methodFilter" class="filter-input" onchange="filterRoutes()">
395                <option value="all">All Methods</option>
396                <option value="get">GET</option>
397                <option value="post">POST</option>
398                <option value="put">PUT</option>
399                <option value="delete">DELETE</option>
400                <option value="patch">PATCH</option>
401            </select>
402            
403            <select id="versionFilter" class="filter-input" onchange="filterRoutes()">
404                {version_options}
405            </select>
406        </div>
407        
408        <div class="table-container">
409            <table>
410                <thead>
411                    <tr>
412                        <th width="120">Method</th>
413                        <th>Endpoint</th>
414                    </tr>
415                </thead>
416                <tbody id="routesTable">
417                    {route_rows}
418                </tbody>
419            </table>
420            <div id="emptyMessage" class="empty-message" style="display: none;">
421                No routes match your filter criteria
422            </div>
423        </div>
424    </div>
425
426    <script>
427        function filterRoutes() {{
428            const input = document.getElementById('searchInput').value.toLowerCase();
429            const methodFilter = document.getElementById('methodFilter').value.toLowerCase();
430            const versionFilter = document.getElementById('versionFilter').value.toLowerCase();
431            const rows = document.querySelectorAll('.route-row');
432            const emptyMessage = document.getElementById('emptyMessage');
433            
434            let visibleCount = 0;
435            
436            rows.forEach(row => {{
437                const method = row.getAttribute('data-method').toLowerCase();
438                const path = row.getAttribute('data-path').toLowerCase();
439                const version = row.getAttribute('data-version').toLowerCase();
440                
441                // Match criteria
442                const methodMatch = methodFilter === 'all' || method === methodFilter;
443                const versionMatch = 
444                    versionFilter === 'all' || 
445                    (versionFilter === 'unversioned' && version === 'unversioned') || 
446                    version === versionFilter;
447                const pathMatch = path.includes(input);
448                
449                // Show/hide row based on matches
450                if (methodMatch && versionMatch && pathMatch) {{
451                    row.style.display = '';
452                    visibleCount++;
453                }} else {{
454                    row.style.display = 'none';
455                }}
456            }});
457            
458            // Show empty message if no results
459            emptyMessage.style.display = visibleCount > 0 ? 'none' : 'block';
460        }}
461    </script>
462</body>
463</html>"#
464    );
465
466    content::RawHtml(html)
467}