]> git.proxmox.com Git - proxmox-widget-toolkit.git/blob - src/api-viewer/APIViewer.js
2b04b8caf822ecb21e3c75ff2744410a16e57eea
[proxmox-widget-toolkit.git] / src / api-viewer / APIViewer.js
1 /*global apiSchema*/
2
3 Ext.onReady(function() {
4 Ext.define('pmx-param-schema', {
5 extend: 'Ext.data.Model',
6 fields: [
7 'name', 'type', 'typetext', 'description', 'verbose_description',
8 'enum', 'minimum', 'maximum', 'minLength', 'maxLength',
9 'pattern', 'title', 'requires', 'format', 'default',
10 'disallow', 'extends', 'links',
11 {
12 name: 'optional',
13 type: 'boolean',
14 },
15 ],
16 });
17
18 let store = Ext.define('pmx-updated-treestore', {
19 extend: 'Ext.data.TreeStore',
20 model: Ext.define('pmx-api-doc', {
21 extend: 'Ext.data.Model',
22 fields: [
23 'path', 'info', 'text',
24 ],
25 }),
26 proxy: {
27 type: 'memory',
28 data: apiSchema,
29 },
30 sorters: [{
31 property: 'leaf',
32 direction: 'ASC',
33 }, {
34 property: 'text',
35 direction: 'ASC',
36 }],
37 filterer: 'bottomup',
38 doFilter: function(node) {
39 this.filterNodes(node, this.getFilters().getFilterFn(), true);
40 },
41
42 filterNodes: function(node, filterFn, parentVisible) {
43 let me = this;
44
45 let match = filterFn(node) && (parentVisible || (node.isRoot() && !me.getRootVisible()));
46
47 if (node.childNodes && node.childNodes.length) {
48 let bottomUpFiltering = me.filterer === 'bottomup';
49 let childMatch;
50 for (const child of node.childNodes) {
51 childMatch = me.filterNodes(child, filterFn, match || bottomUpFiltering) || childMatch;
52 }
53 if (bottomUpFiltering) {
54 match = childMatch || match;
55 }
56 }
57
58 node.set("visible", match, me._silentOptions);
59 return match;
60 },
61
62 }).create();
63
64 let render_description = function(value, metaData, record) {
65 let pdef = record.data;
66
67 value = pdef.verbose_description || value;
68
69 // TODO: try to render asciidoc correctly
70
71 metaData.style = 'white-space:pre-wrap;';
72
73 return Ext.htmlEncode(value);
74 };
75
76 let render_type = function(value, metaData, record) {
77 let pdef = record.data;
78
79 return pdef.enum ? 'enum' : pdef.type || 'string';
80 };
81
82 const renderFormatString = function(obj) {
83 if (!Ext.isObject(obj)) {
84 return obj;
85 }
86 const mandatory = [];
87 const optional = [];
88 Object.entries(obj).forEach(function([name, param]) {
89 let list = param.optional ? optional : mandatory;
90 let str = param.default_key ? `[${name}=]` : `${name}=`;
91 if (param.alias) {
92 return;
93 } else if (param.enum) {
94 str += `(${param.enum?.join(' | ')})`;
95 } else {
96 str += `<${param.format_description || param.pattern || param.type}>`;
97 }
98 list.push(str);
99 });
100 return mandatory.join(", ") + ' ' + optional.map(each => `[,${each}]`).join(' ');
101 };
102
103 let render_simple_format = function(pdef, type_fallback) {
104 if (pdef.typetext) {
105 return pdef.typetext;
106 }
107 if (pdef.enum) {
108 return pdef.enum.join(' | ');
109 }
110 if (pdef.format) {
111 return renderFormatString(pdef.format);
112 }
113 if (pdef.pattern) {
114 return pdef.pattern;
115 }
116 if (pdef.type === 'boolean') {
117 return `<true|false>`;
118 }
119 if (type_fallback && pdef.type) {
120 return `<${pdef.type}>`;
121 }
122 return '';
123 };
124
125 let render_format = function(value, metaData, record) {
126 let pdef = record.data;
127
128 metaData.style = 'white-space:normal;';
129
130 if (pdef.type === 'array' && pdef.items) {
131 let format = render_simple_format(pdef.items, true);
132 return `[${Ext.htmlEncode(format)}, ...]`;
133 }
134
135 return Ext.htmlEncode(render_simple_format(pdef));
136 };
137
138 let real_path = function(path) {
139 if (!path.match(/^[/]/)) {
140 path = `/${path}`;
141 }
142 return path.replace(/^.*\/_upgrade_(\/)?/, "/");
143 };
144
145 let permission_text = function(permission) {
146 let permhtml = "";
147
148 if (permission.user) {
149 if (!permission.description) {
150 if (permission.user === 'world') {
151 permhtml += "Accessible without any authentication.";
152 } else if (permission.user === 'all') {
153 permhtml += "Accessible by all authenticated users.";
154 } else {
155 permhtml += `Only accessible by user "${permission.user}"`;
156 }
157 }
158 } else if (permission.check) {
159 permhtml += `<pre>Check: ${Ext.htmlEncode(JSON.stringify(permission.check))}</pre>`;
160 } else if (permission.userParam) {
161 permhtml += `<div>Check if user matches parameter '${permission.userParam}'`;
162 } else if (permission.or) {
163 permhtml += "<div>Or<div style='padding-left: 10px;'>";
164 permhtml += permission.or.map(v => permission_text(v)).join('');
165 permhtml += "</div></div>";
166 } else if (permission.and) {
167 permhtml += "<div>And<div style='padding-left: 10px;'>";
168 permhtml += permission.and.map(v => permission_text(v)).join('');
169 permhtml += "</div></div>";
170 } else {
171 permhtml += "Unknown syntax!";
172 }
173
174 return permhtml;
175 };
176
177 let render_docu = function(data) {
178 let md = data.info;
179
180 let items = [];
181
182 Ext.Array.each(['GET', 'POST', 'PUT', 'DELETE'], function(method) {
183 let info = md[method];
184 if (info) {
185 let endpoint = real_path(data.path);
186 let usage = `<table><tr><td>HTTP:&nbsp;&nbsp;&nbsp;</td><td>`;
187 usage += `${method} /api2/json${endpoint}</td></tr>`;
188
189 if (typeof cliUsageRenderer === 'function') {
190 usage += cliUsageRenderer(method, endpoint); // eslint-disable-line no-undef
191 }
192
193 let sections = [
194 {
195 title: 'Description',
196 html: Ext.htmlEncode(info.description),
197 bodyPadding: 10,
198 },
199 {
200 title: 'Usage',
201 html: usage,
202 bodyPadding: 10,
203 },
204 ];
205
206 if (info.parameters && info.parameters.properties) {
207 let pstore = Ext.create('Ext.data.Store', {
208 model: 'pmx-param-schema',
209 proxy: {
210 type: 'memory',
211 },
212 groupField: 'optional',
213 sorters: [
214 {
215 property: 'name',
216 direction: 'ASC',
217 },
218 ],
219 });
220
221 Ext.Object.each(info.parameters.properties, function(name, pdef) {
222 pdef.name = name;
223 pstore.add(pdef);
224 });
225
226 pstore.sort();
227
228 let groupingFeature = Ext.create('Ext.grid.feature.Grouping', {
229 enableGroupingMenu: false,
230 groupHeaderTpl: '<tpl if="groupValue">Optional</tpl><tpl if="!groupValue">Required</tpl>',
231 });
232
233 sections.push({
234 xtype: 'gridpanel',
235 title: 'Parameters',
236 features: [groupingFeature],
237 store: pstore,
238 viewConfig: {
239 trackOver: false,
240 stripeRows: true,
241 },
242 columns: [
243 {
244 header: 'Name',
245 dataIndex: 'name',
246 flex: 1,
247 },
248 {
249 header: 'Type',
250 dataIndex: 'type',
251 renderer: render_type,
252 flex: 1,
253 },
254 {
255 header: 'Default',
256 dataIndex: 'default',
257 flex: 1,
258 },
259 {
260 header: 'Format',
261 dataIndex: 'type',
262 renderer: render_format,
263 flex: 2,
264 },
265 {
266 header: 'Description',
267 dataIndex: 'description',
268 renderer: render_description,
269 flex: 6,
270 },
271 ],
272 });
273 }
274
275 if (info.returns) {
276 let retinf = info.returns;
277 let rtype = retinf.type;
278 if (!rtype && retinf.items) {rtype = 'array';}
279 if (!rtype) {rtype = 'object';}
280
281 let rpstore = Ext.create('Ext.data.Store', {
282 model: 'pmx-param-schema',
283 proxy: {
284 type: 'memory',
285 },
286 groupField: 'optional',
287 sorters: [
288 {
289 property: 'name',
290 direction: 'ASC',
291 },
292 ],
293 });
294
295 let properties;
296 if (rtype === 'array' && retinf.items.properties) {
297 properties = retinf.items.properties;
298 }
299
300 if (rtype === 'object' && retinf.properties) {
301 properties = retinf.properties;
302 }
303
304 Ext.Object.each(properties, function(name, pdef) {
305 pdef.name = name;
306 rpstore.add(pdef);
307 });
308
309 rpstore.sort();
310
311 let groupingFeature = Ext.create('Ext.grid.feature.Grouping', {
312 enableGroupingMenu: false,
313 groupHeaderTpl: '<tpl if="groupValue">Optional</tpl><tpl if="!groupValue">Obligatory</tpl>',
314 });
315 let returnhtml;
316 if (retinf.items) {
317 returnhtml = '<pre>items: ' + Ext.htmlEncode(JSON.stringify(retinf.items, null, 4)) + '</pre>';
318 }
319
320 if (retinf.properties) {
321 returnhtml = returnhtml || '';
322 returnhtml += '<pre>properties:' + Ext.htmlEncode(JSON.stringify(retinf.properties, null, 4)) + '</pre>';
323 }
324
325 let rawSection = Ext.create('Ext.panel.Panel', {
326 bodyPadding: '0px 10px 10px 10px',
327 html: returnhtml,
328 hidden: true,
329 });
330
331 sections.push({
332 xtype: 'gridpanel',
333 title: 'Returns: ' + rtype,
334 features: [groupingFeature],
335 store: rpstore,
336 viewConfig: {
337 trackOver: false,
338 stripeRows: true,
339 },
340 columns: [
341 {
342 header: 'Name',
343 dataIndex: 'name',
344 flex: 1,
345 },
346 {
347 header: 'Type',
348 dataIndex: 'type',
349 renderer: render_type,
350 flex: 1,
351 },
352 {
353 header: 'Default',
354 dataIndex: 'default',
355 flex: 1,
356 },
357 {
358 header: 'Format',
359 dataIndex: 'type',
360 renderer: render_format,
361 flex: 2,
362 },
363 {
364 header: 'Description',
365 dataIndex: 'description',
366 renderer: render_description,
367 flex: 6,
368 },
369 ],
370 bbar: [
371 {
372 xtype: 'button',
373 text: 'Show RAW',
374 handler: function(btn) {
375 rawSection.setVisible(!rawSection.isVisible());
376 btn.setText(rawSection.isVisible() ? 'Hide RAW' : 'Show RAW');
377 },
378 },
379 ],
380 });
381
382 sections.push(rawSection);
383 }
384
385 if (!data.path.match(/\/_upgrade_/)) {
386 let permhtml = '';
387
388 if (!info.permissions) {
389 permhtml = "Root only.";
390 } else {
391 if (info.permissions.description) {
392 permhtml += "<div style='white-space:pre-wrap;padding-bottom:10px;'>" +
393 Ext.htmlEncode(info.permissions.description) + "</div>";
394 }
395 permhtml += permission_text(info.permissions);
396 }
397
398 if (info.allowtoken !== undefined && !info.allowtoken) {
399 permhtml += "<br />This API endpoint is not available for API tokens.";
400 }
401
402 sections.push({
403 title: 'Required permissions',
404 bodyPadding: 10,
405 html: permhtml,
406 });
407 }
408
409 items.push({
410 title: method,
411 autoScroll: true,
412 defaults: {
413 border: false,
414 },
415 items: sections,
416 });
417 }
418 });
419
420 let ct = Ext.getCmp('docview');
421 ct.setTitle("Path: " + real_path(data.path));
422 ct.removeAll(true);
423 ct.add(items);
424 ct.setActiveTab(0);
425 };
426
427 Ext.define('Ext.form.SearchField', {
428 extend: 'Ext.form.field.Text',
429 alias: 'widget.searchfield',
430
431 emptyText: 'Search...',
432
433 flex: 1,
434
435 inputType: 'search',
436 listeners: {
437 'change': function() {
438 let value = this.getValue();
439 if (!Ext.isEmpty(value)) {
440 store.filter({
441 property: 'path',
442 value: value,
443 anyMatch: true,
444 });
445 } else {
446 store.clearFilter();
447 }
448 },
449 },
450 });
451
452 let treePanel = Ext.create('Ext.tree.Panel', {
453 title: 'Resource Tree',
454 tbar: [
455 {
456 xtype: 'searchfield',
457 },
458 ],
459 tools: [
460 {
461 type: 'expand',
462 tooltip: 'Expand all',
463 tooltipType: 'title',
464 callback: tree => tree.expandAll(),
465 },
466 {
467 type: 'collapse',
468 tooltip: 'Collapse all',
469 tooltipType: 'title',
470 callback: tree => tree.collapseAll(),
471 },
472 ],
473 store: store,
474 width: 200,
475 region: 'west',
476 split: true,
477 margins: '5 0 5 5',
478 rootVisible: false,
479 listeners: {
480 selectionchange: function(v, selections) {
481 if (!selections[0]) {return;}
482 let rec = selections[0];
483 render_docu(rec.data);
484 location.hash = '#' + rec.data.path;
485 },
486 },
487 });
488
489 Ext.create('Ext.container.Viewport', {
490 layout: 'border',
491 renderTo: Ext.getBody(),
492 items: [
493 treePanel,
494 {
495 xtype: 'tabpanel',
496 title: 'Documentation',
497 id: 'docview',
498 region: 'center',
499 margins: '5 5 5 0',
500 layout: 'fit',
501 items: [],
502 },
503 ],
504 });
505
506 let deepLink = function() {
507 let path = window.location.hash.substring(1).replace(/\/\s*$/, '');
508 let endpoint = store.findNode('path', path);
509
510 if (endpoint) {
511 treePanel.getSelectionModel().select(endpoint);
512 treePanel.expandPath(endpoint.getPath());
513 render_docu(endpoint.data);
514 }
515 };
516 window.onhashchange = deepLink;
517
518 deepLink();
519 });