Skip to main content

rust_webx_openapi/
apiui.rs

1//! API discovery UI — interactive Swagger-like explorer page.
2//!
3//! A single-page HTML/CSS/JS application served at `/api/openapi.html`.
4//! Fetches `/api/openapi.json` and renders endpoints grouped by tag
5//! with expandable detail cards and a "Try it out" request builder.
6
7/// The embedded HTML for the API docs UI.
8pub const APIUI_HTML: &str = r##"<!DOCTYPE html>
9<html lang="en">
10<head>
11<meta charset="UTF-8">
12<meta name="viewport" content="width=device-width, initial-scale=1.0">
13<title>Rust WebX API Docs</title>
14<style>
15*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
16body{font-family:system-ui,-apple-system,sans-serif;background:#0d1117;color:#c9d1d9;line-height:1.6}
17.container{max-width:960px;margin:0 auto;padding:2rem 1rem}
18.header{margin-bottom:2rem;padding-bottom:1.5rem;border-bottom:1px solid #21262d}
19.header h1{font-size:1.75rem;font-weight:600;color:#f0f6fc;margin-bottom:.25rem}
20.header .sub{font-size:.875rem;color:#8b949e}
21.tag-group{margin-bottom:2rem}
22.tag-header{display:flex;align-items:center;gap:.5rem;cursor:pointer;padding:.5rem 0;margin-bottom:.75rem;user-select:none}
23.tag-header:hover .tag-title{color:#79c0ff}
24.tag-arrow{color:#8b949e;font-size:.75rem;transition:transform .2s;width:16px;text-align:center}
25.tag-arrow.open{transform:rotate(90deg)}
26.tag-title{font-size:1.05rem;font-weight:600;color:#58a6ff;text-transform:capitalize}
27.tag-count{font-size:.75rem;color:#8b949e;background:#21262d;padding:1px 8px;border-radius:10px}
28.tag-body{display:none}
29.tag-body.open{display:block}
30.endpoint{margin-bottom:.5rem;border:1px solid #21262d;border-radius:8px;background:#161b22;overflow:hidden;transition:border-color .15s}
31.endpoint:hover{border-color:#30363d}
32.ep-summary{display:flex;align-items:center;padding:.625rem 1rem;cursor:pointer;gap:.75rem;user-select:none}
33.ep-summary:hover{background:#1c2129}
34.method{display:inline-block;min-width:60px;padding:2px 8px;border-radius:4px;font-size:.75rem;font-weight:700;text-align:center;text-transform:uppercase;flex-shrink:0}
35.method.get{background:#1b3a2d;color:#3fb950}
36.method.post{background:#1b3a4a;color:#58a6ff}
37.method.put{background:#3a2d1b;color:#d29922}
38.method.delete{background:#3a1b1b;color:#f85149}
39.method.patch{background:#2d1b3a;color:#bc8cff}
40.ep-path{font-family:'SFMono-Regular',Consolas,monospace;font-size:.9rem;color:#e6edf3;flex:1}
41.ep-summary-text{font-size:.8rem;color:#8b949e;margin-left:auto;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:200px}
42.ep-detail{display:none;border-top:1px solid #21262d;padding:1rem}
43.ep-detail.open{display:block}
44.ep-section{margin-bottom:1.25rem}
45.ep-section:last-child{margin-bottom:0}
46.ep-section-title{font-size:.75rem;font-weight:600;color:#8b949e;text-transform:uppercase;margin-bottom:.5rem;letter-spacing:.5px}
47.param-table{width:100%;border-collapse:collapse;font-size:.85rem}
48.param-table th{text-align:left;padding:.35rem .5rem;color:#8b949e;font-weight:600;font-size:.75rem;text-transform:uppercase;border-bottom:1px solid #21262d}
49.param-table td{padding:.35rem .5rem;border-bottom:1px solid #1a1f27}
50.param-table tr:last-child td{border-bottom:none}
51.param-name{font-family:'SFMono-Regular',Consolas,monospace;color:#e6edf3}
52.param-in{color:#d2a8ff;font-size:.8rem}
53.param-type{font-family:'SFMono-Regular',Consolas,monospace;color:#7ee787;font-size:.82rem}
54.param-required{color:#f85149;font-weight:700;font-size:.75rem}
55.body-example{background:#0d1117;border:1px solid #21262d;border-radius:6px;padding:.75rem;overflow-x:auto}
56.body-example pre{font-family:'SFMono-Regular',Consolas,monospace;font-size:.8rem;color:#e6edf3;white-space:pre-wrap;word-break:break-all}
57.ep-description{font-size:.85rem;color:#8b949e;margin-bottom:.75rem;line-height:1.6;padding:.5rem .75rem;background:#0d1117;border-radius:6px;border:1px solid #21262d}
58.resp-list{list-style:none}
59.resp-item{display:flex;align-items:center;gap:.5rem;padding:.35rem 0;font-size:.85rem}
60.resp-status{display:inline-block;min-width:36px;padding:1px 6px;border-radius:4px;font-size:.75rem;font-weight:600;text-align:center}
61.resp-status.s2xx{background:#1b3a2d;color:#3fb950}
62.resp-status.s4xx{background:#3a2d1b;color:#d29922}
63.resp-status.s5xx{background:#3a1b1b;color:#f85149}
64.resp-desc{color:#c9d1d9}
65.copy-btn{display:inline-block;padding:2px 10px;font-size:.75rem;color:#58a6ff;background:#1b3a4a;border:1px solid #30363d;border-radius:4px;cursor:pointer;margin-top:.5rem;transition:background .15s}
66.copy-btn:hover{background:#1f4050}
67.copy-btn.copied{background:#1b3a2d;color:#3fb950;border-color:#3fb950}
68.try-btn{display:inline-block;padding:2px 10px;font-size:.75rem;color:#d29922;background:#3a2d1b;border:1px solid #5a4622;border-radius:4px;cursor:pointer;margin-top:.5rem;margin-left:.5rem;transition:background .15s}
69.try-btn:hover{background:#4a3d2b}
70.try-btn.active{background:#d29922;color:#0d1117;border-color:#d29922}
71.try-panel{display:none;margin-top:.75rem;padding:.75rem;background:#0a0e14;border:1px solid #21262d;border-radius:6px}
72.try-panel.open{display:block}
73.try-panel .path-builder{font-family:'SFMono-Regular',Consolas,monospace;font-size:.85rem;color:#e6edf3;margin-bottom:.75rem;padding:.5rem .75rem;background:#12171f;border-radius:4px;display:flex;flex-wrap:wrap;align-items:center;gap:2px}
74.try-panel .path-builder span{color:#8b949e}
75.try-panel .path-builder input{font-family:'SFMono-Regular',Consolas,monospace;font-size:.8rem;background:#0d1117;color:#e6edf3;border:1px solid #30363d;border-radius:3px;padding:2px 6px;min-width:80px;outline:none;transition:border-color .15s}
76.try-panel .path-builder input:focus{border-color:#58a6ff}
77.try-panel .body-input{margin-bottom:.75rem}
78.try-panel .body-input textarea{width:100%;min-width:100%;max-width:100%;min-height:80px;height:120px;resize:vertical;font-family:'SFMono-Regular',Consolas,monospace;font-size:.8rem;background:#0d1117;color:#e6edf3;border:1px solid #30363d;border-radius:4px;padding:.5rem .75rem;outline:none;transition:border-color .15s;line-height:1.5;tab-size:2}
79.try-panel .body-input textarea:focus{border-color:#58a6ff}
80.try-panel .body-input label{display:block;font-size:.7rem;font-weight:600;color:#8b949e;text-transform:uppercase;margin-bottom:.35rem;letter-spacing:.5px}
81.try-panel .send-btn{display:inline-block;padding:4px 16px;font-size:.75rem;font-weight:600;color:#fff;background:#238636;border:1px solid #2ea043;border-radius:4px;cursor:pointer;transition:background .15s}
82.try-panel .send-btn:hover{background:#2ea043}
83.try-panel .send-btn:disabled{opacity:.5;cursor:not-allowed}
84.resp-area{display:none;margin-top:.75rem;padding:.75rem;background:#0a0e14;border:1px solid #21262d;border-radius:6px}
85.resp-area.open{display:block}
86.resp-area .status{font-size:1.5rem;font-weight:700;margin-bottom:.5rem;padding-bottom:.5rem;border-bottom:1px solid #21262d}
87.resp-area .status.s2xx{color:#3fb950}
88.resp-area .status.s4xx{color:#d29922}
89.resp-area .status.s5xx{color:#f85149}
90.resp-area .body{font-family:'SFMono-Regular',Consolas,monospace;font-size:.8rem;color:#e6edf3;white-space:pre-wrap;word-break:break-all;background:#0d1117;padding:.5rem .75rem;border-radius:4px;max-height:400px;overflow-y:auto;line-height:1.5}
91.resp-area .loading-text{color:#8b949e;font-size:.85rem}
92.resp-area .error-text{color:#f85149;font-size:.85rem}
93.loading{text-align:center;padding:3rem 0;color:#8b949e}
94.error{text-align:center;padding:2rem;background:#3a1b1b;border-radius:6px;color:#f85149}
95.empty{text-align:center;padding:3rem 0;color:#8b949e}
96.badge{display:inline-block;padding:1px 6px;border-radius:4px;font-size:.7rem;font-weight:600;margin-left:.35rem}
97.badge.req{background:#3a2d1b;color:#d29922}
98</style>
99</head>
100<body>
101<div class="container">
102  <div class="header">
103    <h1 id="title">API Documentation</h1>
104    <div class="sub" id="version"></div>
105  </div>
106  <div id="content"><div class="loading">Loading API specification...</div></div>
107</div>
108<script>
109(function(){
110var content=document.getElementById('content');
111
112function h(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
113
114function buildBodyExample(params){
115  var obj={};
116  for(var i=0;i<params.length;i++){
117    var p=params[i];
118    if(p.source==='body'){obj.id='string';obj.name='string';obj.email='string';break;}
119  }
120  return JSON.stringify(obj,null,2);
121}
122
123function buildCurl(method,path,params){
124  var curl='curl -X '+method;
125  if(params){
126    var hasBody=false;
127    for(var i=0;i<params.length;i++){
128      if(params[i].source==='body'){hasBody=true;break;}
129    }
130    if(hasBody){
131      curl+=' -H "Content-Type: application/json" -d '+JSON.stringify(JSON.stringify({id:'string',name:'string',email:'string'}));
132    }
133  }
134  curl+=' "http://localhost:5000'+path+'"';
135  return curl;
136}
137
138function ucFirst(s){return s.charAt(0).toUpperCase()+s.slice(1);}
139
140function safeParams(params){return params||[];}
141
142function statusClass(code){
143  if(code>=200&&code<300)return's2xx';
144  if(code>=400&&code<500)return's4xx';
145  return's5xx';
146}
147
148function hasBodyMethod(method){
149  return method==='POST'||method==='PUT'||method==='PATCH';
150}
151
152function extractPathParams(path){
153  var re=/\{(\w+)\}/g;
154  var params=[];
155  var match;
156  while((match=re.exec(path))!==null){
157    params.push(match[1]);
158  }
159  return params;
160}
161
162function buildPathBuilderHTML(path){
163  var parts=[];
164  var last=0;
165  var re=/\{(\w+)\}/g;
166  var match;
167  while((match=re.exec(path))!==null){
168    if(match.index>last){
169      parts.push('<span>'+h(path.slice(last,match.index))+'</span>');
170    }
171    parts.push('<input type="text" class="path-param" data-param="'+h(match[1])+'" placeholder="'+h(match[1])+'" value="">');
172    last=re.lastIndex;
173  }
174  if(last<path.length){
175    parts.push('<span>'+h(path.slice(last))+'</span>');
176  }
177  if(parts.length===0){
178    parts.push('<span>'+h(path)+'</span>');
179  }
180  return parts.join('');
181}
182
183function buildTryPanelHTML(epId,method,path,params,requestBody){
184  var pathParams=extractPathParams(path);
185  var hasBody=hasBodyMethod(method);
186  var html='';
187  html+='<div class="try-panel" id="try-'+epId+'">';
188
189  if(pathParams.length>0){
190    html+='<div class="path-builder">'+buildPathBuilderHTML(path)+'</div>';
191  }
192
193  if(hasBody){
194    var bodyExample=requestBody||buildBodyExample(params);
195    html+='<div class="body-input">';
196    html+='<label>Request Body (JSON)</label>';
197    html+='<textarea class="try-body" id="req-body-'+epId+'">'+h(bodyExample)+'</textarea>';
198    html+='</div>';
199  }
200
201  html+='<button class="send-btn" onclick="executeRequest(\''+epId+'\',\''+h(method)+'\',\''+h(path)+'\')">Send</button>';
202  html+='<div class="resp-area" id="resp-'+epId+'">';
203  html+='<div class="status" id="status-'+epId+'"></div>';
204  html+='<div class="body" id="resp-body-'+epId+'"></div>';
205  html+='</div>';
206  html+='</div>';
207  return html;
208}
209
210function buildDescriptionHTML(description){
211  if(!description)return'';
212  return'<div class="ep-description">'+h(description).replace(/\n/g,'<br>')+'</div>';
213}
214
215fetch('/api/openapi.json')
216.then(function(r){if(!r.ok)throw new Error('HTTP '+r.status);return r.json();})
217.then(function(spec){
218  document.getElementById('title').textContent=spec.info.title||'API Documentation';
219  document.getElementById('version').textContent='v'+(spec.info.version||'0.0.0');
220
221  var paths=spec.paths||{};
222  var entries=Object.entries(paths);
223  if(entries.length===0){content.innerHTML='<div class="empty">No endpoints registered.</div>';return;}
224
225  var groups=new Map();
226  for(var i=0;i<entries.length;i++){
227    var path=entries[i][0];
228    var methods=entries[i][1];
229    var methodKeys=Object.keys(methods);
230    for(var j=0;j<methodKeys.length;j++){
231      var method=methodKeys[j];
232      var op=methods[method];
233      var tags=op.tags||['default'];
234      for(var k=0;k<tags.length;k++){
235        var tag=tags[k];
236        if(!groups.has(tag))groups.set(tag,[]);
237        groups.get(tag).push({
238          method:method.toUpperCase(),
239          path:path,
240          summary:op.summary||'',
241          description:op.description||'',
242          operationId:op.operationId||'',
243          parameters:safeParams(op.parameters),
244          requestBody:op.requestBody||null,
245          responses:op.responses||{},
246        });
247      }
248    }
249  }
250
251  var html='';
252  var tagKeys=Array.from(groups.keys());
253  for(var ti=0;ti<tagKeys.length;ti++){
254    var tag=tagKeys[ti];
255    var eps=groups.get(tag);
256    html+='<div class="tag-group">';
257    html+='<div class="tag-header" onclick="var b=this.nextElementSibling;var a=this.querySelector(\'.tag-arrow\');b.classList.toggle(\'open\');a.classList.toggle(\'open\')">';
258    html+='<span class="tag-arrow open">&#9654;</span>';
259    html+='<span class="tag-title">'+h(tag)+'</span>';
260    html+='<span class="tag-count">'+eps.length+'</span>';
261    html+='</div>';
262    html+='<div class="tag-body open">';
263
264    for(var ei=0;ei<eps.length;ei++){
265      var ep=eps[ei];
266      var epId='ep-'+ti+'-'+ei;
267      html+='<div class="endpoint">';
268      html+='<div class="ep-summary" onclick="var d=document.getElementById(\''+epId+'\');d.classList.toggle(\'open\')">';
269      html+='<span class="method '+ep.method.toLowerCase()+'">'+ep.method+'</span>';
270      html+='<span class="ep-path">'+h(ep.path)+'</span>';
271      html+='<span class="ep-summary-text">'+h(ep.summary)+'</span>';
272      html+='</div>';
273      html+='<div class="ep-detail" id="'+epId+'">';
274
275      html+=buildDescriptionHTML(ep.description);
276
277      if(ep.parameters.length>0){
278        html+='<div class="ep-section"><div class="ep-section-title">Parameters</div>';
279        html+='<table class="param-table"><tr><th>Name</th><th>In</th><th>Type</th><th>Required</th></tr>';
280        for(var pi=0;pi<ep.parameters.length;pi++){
281          var p=ep.parameters[pi];
282          var ptype=p.schema?p.schema.type:'string';
283          html+='<tr><td class="param-name">'+h(p.name)+'</td>';
284          html+='<td class="param-in">'+h(p.in)+'</td>';
285          html+='<td class="param-type">'+h(ptype)+'</td>';
286          html+='<td class="param-required">'+(p.required?'required':'')+'</td></tr>';
287        }
288        html+='</table></div>';
289      }
290
291      if(ep.requestBody){
292        html+='<div class="ep-section"><div class="ep-section-title">Request Body <span class="badge req">application/json</span></div>';
293        html+='<div class="body-example"><pre>'+h(buildBodyExample(ep.parameters))+'</pre></div></div>';
294      }
295
296      var respKeys=Object.keys(ep.responses).sort();
297      if(respKeys.length>0){
298        html+='<div class="ep-section"><div class="ep-section-title">Responses</div><ul class="resp-list">';
299        for(var ri=0;ri<respKeys.length;ri++){
300          var code=respKeys[ri];
301          var resp=ep.responses[code];
302          html+='<li class="resp-item"><span class="resp-status '+statusClass(parseInt(code))+'">'+h(code)+'</span><span class="resp-desc">'+h(resp.description||'')+'</span></li>';
303        }
304        html+='</ul></div>';
305      }
306
307      html+='<button class="copy-btn" data-curl="'+h(buildCurl(ep.method,ep.path,ep.parameters))+'">Copy curl</button>';
308      html+='<button class="try-btn" data-ep="'+epId+'">Try it</button>';
309      html+=buildTryPanelHTML(epId,ep.method,ep.path,ep.parameters,ep.requestBody);
310      html+='</div></div>';
311    }
312
313    html+='</div></div>';
314  }
315
316  content.innerHTML=html;
317
318  // --- Event Delegation ---
319  content.addEventListener('click',function(e){
320    // Copy-curl button
321    var btn=e.target.closest('.copy-btn');
322    if(btn){
323      var curl=btn.getAttribute('data-curl');
324      if(!curl)return;
325      navigator.clipboard.writeText(curl).then(function(){
326        btn.textContent='Copied!';
327        btn.classList.add('copied');
328        setTimeout(function(){
329          btn.textContent='Copy curl';
330          btn.classList.remove('copied');
331        },2000);
332      }).catch(function(){
333        btn.textContent='Error';
334      });
335      return;
336    }
337
338    // Try-it button
339    var tryBtn=e.target.closest('.try-btn');
340    if(tryBtn){
341      var epId=tryBtn.getAttribute('data-ep');
342      var panel=document.getElementById('try-'+epId);
343      if(!panel)return;
344      var isOpen=panel.classList.contains('open');
345      if(isOpen){
346        panel.classList.remove('open');
347        tryBtn.classList.remove('active');
348        tryBtn.textContent='Try it';
349      }else{
350        panel.classList.add('open');
351        tryBtn.classList.add('active');
352        tryBtn.textContent='Hide';
353      }
354      return;
355    }
356  });
357})
358.catch(function(e){
359  content.innerHTML='<div class="error">Failed to load API spec: '+h(e.message)+'</div>';
360});
361})();
362
363// Global request execution function
364function executeRequest(epId,method,path){
365  var panel=document.getElementById('try-'+epId);
366  var respArea=document.getElementById('resp-'+epId);
367  var statusEl=document.getElementById('status-'+epId);
368  var bodyEl=document.getElementById('resp-body-'+epId);
369  var sendBtn=panel?panel.querySelector('.send-btn'):null;
370
371  respArea.classList.add('open');
372  statusEl.textContent='';
373  statusEl.className='status';
374  bodyEl.textContent='Sending...';
375  bodyEl.className='body';
376
377  if(sendBtn)sendBtn.disabled=true;
378
379  // Build URL by replacing path parameters
380  var url=path;
381  var pathInputs=panel?panel.querySelectorAll('.path-param'):[];
382  for(var i=0;i<pathInputs.length;i++){
383    var input=pathInputs[i];
384    var paramName=input.getAttribute('data-param');
385    var value=input.value.trim();
386    if(!value)value=paramName;
387    url=url.replace('{'+paramName+'}',encodeURIComponent(value));
388  }
389
390  var fetchOpts={method:method,headers:{}};
391
392  // Check for body
393  if(method==='POST'||method==='PUT'||method==='PATCH'){
394    var textarea=document.getElementById('req-body-'+epId);
395    if(textarea&&textarea.value.trim()){
396      fetchOpts.headers['Content-Type']='application/json';
397      fetchOpts.body=textarea.value;
398    }
399  }
400
401  fetch(url,fetchOpts)
402  .then(function(resp){
403    var sc=resp.status>=200&&resp.status<300?'s2xx':resp.status>=400&&resp.status<500?'s4xx':'s5xx';
404    statusEl.textContent=resp.status+' '+resp.statusText;
405    statusEl.className='status '+sc;
406    return resp.text().then(function(txt){return{status:resp.status,body:txt};});
407  })
408  .then(function(result){
409    var display=result.body;
410    try{
411      var parsed=JSON.parse(result.body);
412      display=JSON.stringify(parsed,null,2);
413    }catch(e){}
414    bodyEl.textContent=display;
415    bodyEl.className='body';
416    if(sendBtn)sendBtn.disabled=false;
417  })
418  .catch(function(err){
419    statusEl.textContent='Error';
420    statusEl.className='status s5xx';
421    bodyEl.textContent=err.message||'Request failed';
422    bodyEl.className='body error-text';
423    if(sendBtn)sendBtn.disabled=false;
424  });
425}
426</script>
427</body>
428</html>
429"##;