omni_orchestrator/schemas/v1/api/
index.rs1use 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#[derive(Serialize, Clone)]
11pub struct RouteInfo {
12 path: String,
14 methods: Vec<String>,
16}
17
18#[derive(Serialize)]
20pub struct RoutesResponse {
21 routes: Vec<RouteInfo>,
23}
24
25#[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 if let Some(route) = self.routes.iter_mut().find(|r| r.path == path) {
39 if !route.methods.contains(&method) {
41 route.methods.push(method);
42 }
43 } else {
44 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
57lazy_static! {
60 static ref ROUTES_COLLECTION: Arc<Mutex<RoutesCollection>> = Arc::new(Mutex::new(RoutesCollection::new()));
61}
62
63fn decode_route_path(path: &str) -> String {
65 let mut result = path.to_string();
66
67 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("<", "<").replace(">", ">")
85}
86
87pub fn collect_routes(rocket: &Rocket<Build>) {
89 let mut routes_collection = ROUTES_COLLECTION.lock().unwrap();
90
91 for route in rocket.routes() {
92 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#[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 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 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 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 let mut route_rows = String::new();
149
150 let mut sorted_routes = routes.clone();
152 sorted_routes.sort_by(|a, b| a.path.cmp(&b.path));
153
154 for route in sorted_routes {
156 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 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}