openapi-to-xlsx 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +672 -0
  2. package/README.md +141 -0
  3. package/exporter.js +658 -0
  4. package/package.json +36 -0
package/exporter.js ADDED
@@ -0,0 +1,658 @@
1
+ #!/usr/bin/env node
2
+ /* eslint-disable no-console */
3
+ /**
4
+ * openapi-to-xlsx
5
+ * Generate an Excel workbook from an OpenAPI 3.x spec.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { URL } = require('url');
11
+ const https = require('https');
12
+ const { program } = require('commander');
13
+ const ExcelJS = require('exceljs');
14
+ const SwaggerParser = require('@apidevtools/swagger-parser');
15
+ const yaml = require('js-yaml');
16
+
17
+ // --------------------------- CLI ---------------------------
18
+ program
19
+ .name('openapi-to-xlsx')
20
+ .description('Generate an Excel workbook from an OpenAPI 3.x spec / Swagger UI URL.')
21
+ .requiredOption('--url <url>', 'Swagger UI URL, OpenAPI JSON URL, or local file path')
22
+ .option('--token <bearer>', 'Bearer token used when fetching from a URL')
23
+ .option('--out <file>', 'Output xlsx file path (default: <api-title>-<version>.xlsx)')
24
+ .option('--insecure', 'Ignore TLS certificate errors (for self-signed https)', false)
25
+ .parse(process.argv);
26
+
27
+ const opts = program.opts();
28
+
29
+ if (opts.insecure) {
30
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
31
+ }
32
+
33
+ // --------------------------- helpers ---------------------------
34
+ function isHttpUrl(s) {
35
+ return /^https?:\/\//i.test(s);
36
+ }
37
+
38
+ async function httpGet(url, token) {
39
+ const headers = { Accept: 'application/json, text/html, */*' };
40
+ if (token) headers.Authorization = `Bearer ${token}`;
41
+
42
+ const fetchOpts = { headers };
43
+
44
+ // For HTTPS with --insecure, disable certificate validation
45
+ if (opts.insecure && url.startsWith('https:')) {
46
+ fetchOpts.agent = new https.Agent({ rejectUnauthorized: false });
47
+ }
48
+
49
+ const res = await fetch(url, fetchOpts);
50
+ if (!res.ok) {
51
+ throw new Error(`GET ${url} -> HTTP ${res.status} ${res.statusText}`);
52
+ }
53
+ return res;
54
+ }
55
+
56
+ /**
57
+ * Try hard to obtain the OpenAPI JSON object from whatever URL/path the user gave us.
58
+ */
59
+ async function loadSpec(input, token) {
60
+ // 1) local file
61
+ if (!isHttpUrl(input)) {
62
+ const abs = path.resolve(input);
63
+ console.log(`[load] local file: ${abs}`);
64
+ let raw = fs.readFileSync(abs, 'utf8');
65
+ if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1); // strip UTF-8 BOM
66
+ try {
67
+ return yaml.load(raw);
68
+ } catch (e) {
69
+ throw new Error(`Failed to parse local file as JSON or YAML: ${e.message}`);
70
+ }
71
+ }
72
+
73
+ // 2) http(s) - first attempt
74
+ console.log(`[load] GET ${input}`);
75
+ const res = await httpGet(input, token);
76
+ const contentType = (res.headers.get('content-type') || '').toLowerCase();
77
+ let text = await res.text();
78
+ if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); // strip BOM if any
79
+
80
+ // try parse as JSON/YAML directly
81
+ const isPossibleSpec =
82
+ contentType.includes('json') ||
83
+ contentType.includes('yaml') ||
84
+ contentType.includes('yml') ||
85
+ text.trim().startsWith('{') ||
86
+ text.trim().startsWith('openapi:') ||
87
+ text.trim().startsWith('swagger:');
88
+
89
+ if (isPossibleSpec) {
90
+ try {
91
+ const obj = yaml.load(text);
92
+ if (obj && (obj.openapi || obj.swagger)) return obj;
93
+ } catch (e) {
94
+ // fall through to HTML parsing
95
+ }
96
+ }
97
+
98
+ // 3) probably the Swagger UI HTML - extract the real spec URL from it
99
+ let specUrl = extractSpecUrlFromHtml(text, input);
100
+
101
+ // 3a) If HTML references swagger-initializer.js, try to fetch that
102
+ if (!specUrl && /swagger-initializer\.js/.test(text)) {
103
+ const initUrl = new URL('swagger-initializer.js', input).toString();
104
+ console.log(`[load] fetching initializer: ${initUrl}`);
105
+ try {
106
+ const initRes = await httpGet(initUrl, token);
107
+ const initJs = await initRes.text();
108
+ specUrl = extractSpecUrlFromHtml(initJs, input);
109
+ } catch (e) {
110
+ console.warn(`[warn] failed to fetch initializer: ${e.message}`);
111
+ }
112
+ }
113
+
114
+ // 3b) If we found a configUrl, fetch that to get the real spec URL
115
+ if (specUrl && /swagger-config/.test(specUrl)) {
116
+ console.log(`[load] fetching config: ${specUrl}`);
117
+ try {
118
+ const cfgRes = await httpGet(specUrl, token);
119
+ const cfg = await cfgRes.json();
120
+ if (cfg && cfg.url) {
121
+ specUrl = new URL(cfg.url, input).toString();
122
+ console.log(`[load] resolved from config: ${specUrl}`);
123
+ } else if (cfg && Array.isArray(cfg.urls) && cfg.urls[0] && cfg.urls[0].url) {
124
+ specUrl = new URL(cfg.urls[0].url, input).toString();
125
+ console.log(`[load] resolved from config: ${specUrl}`);
126
+ }
127
+ } catch (e) {
128
+ console.warn(`[warn] failed to fetch config: ${e.message}`);
129
+ }
130
+ }
131
+
132
+ if (!specUrl) {
133
+ throw new Error(
134
+ `Could not detect spec URL from HTML at ${input}. ` +
135
+ `Please pass the OpenAPI JSON/YAML URL directly.`
136
+ );
137
+ }
138
+
139
+ console.log(`[load] detected spec URL in HTML: ${specUrl}`);
140
+ const res2 = await httpGet(specUrl, token);
141
+ const text2 = await res2.text();
142
+ let obj2;
143
+ try {
144
+ obj2 = yaml.load(text2);
145
+ } catch (e) {
146
+ throw new Error(`Failed to parse fetched spec from ${specUrl}: ${e.message}`);
147
+ }
148
+ if (!obj2 || (!obj2.openapi && !obj2.swagger)) {
149
+ throw new Error(`Fetched ${specUrl} but it doesn't look like an OpenAPI doc.`);
150
+ }
151
+ return obj2;
152
+ }
153
+
154
+ /**
155
+ * Look inside a Swagger UI page HTML and find the spec URL.
156
+ */
157
+ function extractSpecUrlFromHtml(html, baseUrl) {
158
+ const patterns = [
159
+ // Explicit url property (e.g. url: "http://...") using word boundary
160
+ { re: /\burl\s*:\s*['"]([^'"]+)['"]/i, priority: 1 },
161
+ // Inside a urls array (e.g. urls: [{url: "http://..."}])
162
+ { re: /\burls\s*:\s*\[\s*\{\s*url\s*:\s*['"]([^'"]+)['"]/i, priority: 2 },
163
+ // Common variable names used for spec URL definition
164
+ { re: /\b(?:defaultDefinitionUrl|definitionUrl|specUrl)\s*=\s*['"]([^'"]+)['"]/i, priority: 3 },
165
+ // fetch("http://...")
166
+ { re: /\bfetch\s*\(\s*['"]([^'"]+)['"]\s*\)/i, priority: 4 },
167
+ // Any string ending with /swagger.json, /openapi.json, etc.
168
+ { re: /['"]([^'"]+\/(?:swagger|openapi)\.(?:json|yaml|yml))['"]/i, priority: 5 },
169
+ // Any string containing v2/swagger.json or v3/openapi.json etc.
170
+ { re: /['"]([^'"]+\/(?:v2|v3|v31)\/[^'"]+\.(?:json|yaml|yml))['"]/i, priority: 6 }
171
+ ];
172
+
173
+ let bestMatch = null;
174
+ let bestPriority = Infinity;
175
+
176
+ for (const { re, priority } of patterns) {
177
+ // Match globally in the html string
178
+ const matches = html.matchAll(new RegExp(re.source, re.flags + 'g'));
179
+ for (const m of matches) {
180
+ if (m && m[1] && priority < bestPriority) {
181
+ try {
182
+ const candidate = m[1].trim();
183
+ // Skip validatorUrl if it doesn't end with JSON/YAML
184
+ if (candidate.includes('validator.swagger.io/validator') && !candidate.endsWith('.json') && !candidate.endsWith('.yaml')) {
185
+ continue;
186
+ }
187
+ bestMatch = new URL(candidate, baseUrl).toString();
188
+ bestPriority = priority;
189
+ } catch (e) {
190
+ /* ignore invalid URLs */
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ // Fallback: search for any string ending in .json, .yaml, or .yml
197
+ if (!bestMatch) {
198
+ const anyJsonYamlRe = /['"]([^'"]+\.(?:json|yaml|yml)(?:\?[^'"]*)?)['"]/gi;
199
+ let match;
200
+ while ((match = anyJsonYamlRe.exec(html)) !== null) {
201
+ const candidate = match[1].trim();
202
+ if (!candidate.includes('package.json') && !candidate.includes('tsconfig.json')) {
203
+ try {
204
+ bestMatch = new URL(candidate, baseUrl).toString();
205
+ break;
206
+ } catch (e) {
207
+ /* ignore */
208
+ }
209
+ }
210
+ }
211
+ }
212
+
213
+ return bestMatch;
214
+ }
215
+
216
+ /**
217
+ * Sheet name sanitizer
218
+ */
219
+ function makeSheetNamer() {
220
+ const used = new Set();
221
+ return function nameFor(method, urlPath) {
222
+ let raw = `${method.toUpperCase()} ${urlPath}`;
223
+ let clean = raw.replace(/[:\\/\?\*\[\]]/g, '_').replace(/^'+|'+$/g, '_');
224
+ if (clean.length > 31) clean = clean.slice(0, 31);
225
+ let candidate = clean;
226
+ let i = 1;
227
+ while (used.has(candidate)) {
228
+ const suffix = `~${i++}`;
229
+ candidate = clean.slice(0, 31 - suffix.length) + suffix;
230
+ }
231
+ used.add(candidate);
232
+ return candidate;
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Render any JS value into multi-line readable text.
238
+ */
239
+ function formatValue(val, indent = 0) {
240
+ const pad = ' '.repeat(indent);
241
+ if (val === null || val === undefined) return `${pad}(none)`;
242
+ const t = typeof val;
243
+ if (t === 'string' || t === 'number' || t === 'boolean') return `${pad}${val}`;
244
+ if (Array.isArray(val)) {
245
+ if (val.length === 0) return `${pad}[]`;
246
+ return val
247
+ .map((x, i) => {
248
+ if (x && typeof x === 'object') {
249
+ return `${pad}- [${i}]\n${formatValue(x, indent + 1)}`;
250
+ }
251
+ return `${pad}- ${x}`;
252
+ })
253
+ .join('\n');
254
+ }
255
+ if (t === 'object') {
256
+ const keys = Object.keys(val);
257
+ if (keys.length === 0) return `${pad}{}`;
258
+ return keys
259
+ .map((k) => {
260
+ const v = val[k];
261
+ if (v && typeof v === 'object') {
262
+ return `${pad}${k}:\n${formatValue(v, indent + 1)}`;
263
+ }
264
+ return `${pad}${k}: ${v}`;
265
+ })
266
+ .join('\n');
267
+ }
268
+ return `${pad}${String(val)}`;
269
+ }
270
+
271
+ /**
272
+ * Walk a (dereferenced) JSON Schema and produce a representative example value.
273
+ */
274
+ function generateExample(schema, depth = 0, seen = new WeakSet()) {
275
+ if (!schema || typeof schema !== 'object') return null;
276
+ if (depth > 8) return '...';
277
+ if (seen.has(schema)) return null;
278
+ seen.add(schema);
279
+
280
+ if (schema.example !== undefined) return schema.example;
281
+ if (schema.examples) {
282
+ const first = Array.isArray(schema.examples)
283
+ ? schema.examples[0]
284
+ : Object.values(schema.examples)[0];
285
+ if (first && typeof first === 'object' && 'value' in first) return first.value;
286
+ if (first !== undefined) return first;
287
+ }
288
+ if (schema.default !== undefined) return schema.default;
289
+ if (Array.isArray(schema.enum) && schema.enum.length) return schema.enum[0];
290
+
291
+ if (Array.isArray(schema.allOf) && schema.allOf.length) {
292
+ const merged = {};
293
+ for (const s of schema.allOf) {
294
+ const v = generateExample(s, depth + 1, seen);
295
+ if (v && typeof v === 'object' && !Array.isArray(v)) Object.assign(merged, v);
296
+ }
297
+ if (Object.keys(merged).length) return merged;
298
+ }
299
+ if (Array.isArray(schema.oneOf) && schema.oneOf.length) {
300
+ return generateExample(schema.oneOf[0], depth + 1, seen);
301
+ }
302
+ if (Array.isArray(schema.anyOf) && schema.anyOf.length) {
303
+ return generateExample(schema.anyOf[0], depth + 1, seen);
304
+ }
305
+
306
+ const type = schema.type || (schema.properties ? 'object' : null);
307
+
308
+ switch (type) {
309
+ case 'object': {
310
+ const obj = {};
311
+ const props = schema.properties || {};
312
+ for (const k of Object.keys(props)) {
313
+ obj[k] = generateExample(props[k], depth + 1, seen);
314
+ }
315
+ if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
316
+ obj['<key>'] = generateExample(schema.additionalProperties, depth + 1, seen);
317
+ }
318
+ return obj;
319
+ }
320
+ case 'array': {
321
+ return [generateExample(schema.items || {}, depth + 1, seen)];
322
+ }
323
+ case 'string': {
324
+ switch (schema.format) {
325
+ case 'date': return '2025-01-01';
326
+ case 'date-time': return '2025-01-01T00:00:00Z';
327
+ case 'uuid': return '00000000-0000-0000-0000-000000000000';
328
+ case 'email': return 'user@example.com';
329
+ case 'uri':
330
+ case 'url': return 'https://example.com';
331
+ case 'byte': return 'base64string';
332
+ case 'binary': return '<binary>';
333
+ case 'password': return '********';
334
+ default: return 'string';
335
+ }
336
+ }
337
+ case 'integer': return 0;
338
+ case 'number': return 0;
339
+ case 'boolean': return false;
340
+ default: return null;
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Pick the JSON-ish content schema from an OpenAPI content map.
346
+ */
347
+ function pickJsonSchema(content) {
348
+ if (!content || typeof content !== 'object') return null;
349
+ const keys = Object.keys(content);
350
+ const jsonKey =
351
+ keys.find((k) => /^application\/json\b/i.test(k)) ||
352
+ keys.find((k) => /\+json\b/i.test(k)) ||
353
+ keys[0];
354
+ if (!jsonKey) return null;
355
+ return content[jsonKey] && content[jsonKey].schema ? content[jsonKey].schema : null;
356
+ }
357
+
358
+ function safeJsonStringify(v) {
359
+ try {
360
+ return JSON.stringify(v, null, 2);
361
+ } catch (e) {
362
+ return String(v);
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Build a filesystem-safe filename fragment from any string.
368
+ */
369
+ function slugify(s, fallback = 'openapi-spec') {
370
+ if (!s) return fallback;
371
+ return String(s)
372
+ .trim()
373
+ .replace(/[\\\/:\*\?"<>\|]+/g, '_')
374
+ .replace(/\s+/g, '_')
375
+ .slice(0, 80) || fallback;
376
+ }
377
+
378
+ // --------------------------- build ---------------------------
379
+ async function build() {
380
+ // 1. Load + dereference
381
+ const rawSpec = await loadSpec(opts.url, opts.token);
382
+
383
+ const cloneForDeref = JSON.parse(JSON.stringify(rawSpec));
384
+ let spec;
385
+ try {
386
+ spec = await SwaggerParser.dereference(cloneForDeref, {
387
+ dereference: { circular: 'ignore' },
388
+ });
389
+ } catch (e) {
390
+ console.warn(`[warn] dereference failed (${e.message}). Falling back to raw spec.`);
391
+ spec = rawSpec;
392
+ }
393
+
394
+ // 2. Collect operations
395
+ const METHODS = ['get', 'post', 'put', 'delete', 'patch', 'options', 'head'];
396
+ const nameFor = makeSheetNamer();
397
+ const ops = [];
398
+ const paths = spec.paths || {};
399
+ for (const p of Object.keys(paths)) {
400
+ const pathItem = paths[p] || {};
401
+ for (const m of Object.keys(pathItem)) {
402
+ if (!METHODS.includes(m)) continue;
403
+ const op = pathItem[m] || {};
404
+ const sheetName = nameFor(m, p);
405
+ ops.push({
406
+ method: m.toUpperCase(),
407
+ path: p,
408
+ tag: (op.tags && op.tags[0]) || '',
409
+ summary: op.summary || '',
410
+ description: op.description || '',
411
+ operationId: op.operationId || '',
412
+ deprecated: !!op.deprecated,
413
+ security: op.security || spec.security || null,
414
+ parameters: op.parameters || [],
415
+ requestBody: op.requestBody || null,
416
+ responses: op.responses || {},
417
+ sheetName,
418
+ });
419
+ }
420
+ }
421
+ console.log(
422
+ `[spec] openapi=${spec.openapi || spec.swagger} title=${spec.info && spec.info.title} ` +
423
+ `paths=${Object.keys(paths).length} ops=${ops.length}`
424
+ );
425
+
426
+ // 3. Workbook
427
+ const wb = new ExcelJS.Workbook();
428
+ wb.creator = 'openapi-to-xlsx';
429
+ wb.created = new Date();
430
+
431
+ // 3a. Index sheet
432
+ const indexWs = wb.addWorksheet('API List', {
433
+ views: [{ state: 'frozen', ySplit: 1 }],
434
+ });
435
+ indexWs.columns = [
436
+ { header: 'No.', key: 'idx', width: 6 },
437
+ { header: 'Method', key: 'method', width: 10 },
438
+ { header: 'API Endpoint', key: 'path', width: 55 },
439
+ { header: 'Category', key: 'tag', width: 18 },
440
+ { header: 'Summary', key: 'summary', width: 60 },
441
+ { header: 'Operation ID', key: 'operationId', width: 35 },
442
+ { header: 'Detail', key: 'detail', width: 12 },
443
+ ];
444
+
445
+ const headerRow = indexWs.getRow(1);
446
+ headerRow.font = { bold: true, size: 11, name: 'Calibri', color: { argb: 'FFFFFF' } };
447
+ headerRow.alignment = { vertical: 'middle', horizontal: 'center' };
448
+ headerRow.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1976D2' } };
449
+ headerRow.height = 22;
450
+ indexWs.autoFilter = { from: 'A1', to: 'G1' };
451
+
452
+ ops.forEach((o, i) => {
453
+ const row = indexWs.addRow({
454
+ idx: i + 1,
455
+ method: o.method,
456
+ path: o.path,
457
+ tag: o.tag,
458
+ summary: o.summary,
459
+ operationId: o.operationId,
460
+ detail: '',
461
+ });
462
+
463
+ row.font = { name: 'Calibri', size: 10 };
464
+ row.alignment = { vertical: 'middle', wrapText: false };
465
+ if (i % 2 === 0) {
466
+ row.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF5F5F5' } };
467
+ }
468
+
469
+ const methodCell = row.getCell('method');
470
+ methodCell.alignment = { horizontal: 'center', vertical: 'middle' };
471
+ const methodColors = {
472
+ GET: { bg: 'FFE8F5E9', fg: 'FF2E7D32' },
473
+ POST: { bg: 'FFE3F2FD', fg: 'FF1565C0' },
474
+ PUT: { bg: 'FFFFF3E0', fg: 'FFEF6C00' },
475
+ PATCH: { bg: 'FFF3E5F5', fg: 'FF6A1B9A' },
476
+ DELETE: { bg: 'FFFFEBEE', fg: 'FFC62828' },
477
+ };
478
+ const mc = methodColors[o.method];
479
+ if (mc) {
480
+ methodCell.font = { bold: true, color: { argb: mc.fg }, size: 10, name: 'Calibri' };
481
+ methodCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: mc.bg } };
482
+ }
483
+
484
+ row.getCell('path').font = { name: 'Consolas', size: 9, color: { argb: 'FF37474F' } };
485
+
486
+ if (o.deprecated) {
487
+ row.getCell('summary').font = {
488
+ italic: true,
489
+ strikethrough: true,
490
+ color: { argb: 'FF9E9E9E' },
491
+ size: 10,
492
+ name: 'Calibri'
493
+ };
494
+ }
495
+
496
+ const cell = row.getCell('detail');
497
+ cell.value = {
498
+ text: 'Open',
499
+ hyperlink: `#'${o.sheetName.replace(/'/g, "''")}'!A1`,
500
+ tooltip: `${o.method} ${o.path}`,
501
+ };
502
+ cell.font = { color: { argb: 'FF0563C1' }, underline: 'single', bold: true, size: 10, name: 'Calibri' };
503
+ cell.alignment = { horizontal: 'center', vertical: 'middle' };
504
+ });
505
+
506
+ // 3b. Detail sheets
507
+ for (const o of ops) {
508
+ const ws = wb.addWorksheet(o.sheetName);
509
+ ws.columns = [
510
+ { header: 'Field', key: 'field', width: 24 },
511
+ { header: 'Value', key: 'value', width: 110 },
512
+ ];
513
+
514
+ const dHeaderRow = ws.getRow(1);
515
+ dHeaderRow.font = { bold: true, size: 11, name: 'Calibri', color: { argb: 'FFFFFF' } };
516
+ dHeaderRow.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF455A64' } };
517
+ dHeaderRow.alignment = { vertical: 'middle', horizontal: 'center' };
518
+ dHeaderRow.height = 20;
519
+
520
+ const addRow = (k, v, opts = {}) => {
521
+ const r = ws.addRow({ field: k, value: v });
522
+ r.getCell('field').font = {
523
+ bold: true,
524
+ size: 10,
525
+ name: 'Calibri',
526
+ color: opts.fieldColor || { argb: 'FF263238' }
527
+ };
528
+ r.getCell('field').fill = {
529
+ type: 'pattern',
530
+ pattern: 'solid',
531
+ fgColor: { argb: opts.fieldBg || 'FFECEFF1' }
532
+ };
533
+ r.getCell('field').alignment = { vertical: 'top', horizontal: 'left', indent: 1 };
534
+
535
+ r.getCell('value').font = {
536
+ size: opts.mono ? 9 : 10,
537
+ name: opts.mono ? 'Consolas' : 'Calibri',
538
+ color: opts.valueColor || { argb: 'FF37474F' }
539
+ };
540
+ r.getCell('value').fill = opts.valueBg ? {
541
+ type: 'pattern',
542
+ pattern: 'solid',
543
+ fgColor: { argb: opts.valueBg }
544
+ } : undefined;
545
+ r.getCell('value').alignment = { vertical: 'top', wrapText: true };
546
+ return r;
547
+ };
548
+
549
+ addRow('Method', o.method, { valueBg: 'FFE3F2FD' });
550
+ addRow('API Endpoint', o.path, { mono: true, valueBg: 'FFF5F5F5' });
551
+ addRow('Category', o.tag);
552
+ addRow('Operation ID', o.operationId, { mono: true });
553
+ addRow('Summary', o.summary);
554
+ addRow('Description', o.description);
555
+ addRow('Deprecated', String(o.deprecated), {
556
+ valueBg: o.deprecated ? 'FFFFEBEE' : undefined
557
+ });
558
+ if (o.security) addRow('Security', formatValue(o.security), { mono: true });
559
+ if (o.parameters && o.parameters.length) {
560
+ addRow('Parameters', formatValue(o.parameters), { mono: true, valueBg: 'FFFFF8E7' });
561
+ }
562
+
563
+ if (o.requestBody) {
564
+ addRow('Request document', formatValue(o.requestBody), {
565
+ fieldBg: 'FFE8F5E9',
566
+ fieldColor: { argb: 'FF2E7D32' },
567
+ mono: true
568
+ });
569
+
570
+ const reqSchema = pickJsonSchema(o.requestBody.content);
571
+ if (reqSchema) {
572
+ const example = generateExample(reqSchema);
573
+ addRow('Request example', safeJsonStringify(example), {
574
+ mono: true,
575
+ valueBg: 'FFE8F5E9'
576
+ });
577
+ }
578
+ }
579
+
580
+ if (o.responses) {
581
+ for (const code of Object.keys(o.responses)) {
582
+ const resp = o.responses[code];
583
+ const isSuccess = /^2\d\d$/.test(code);
584
+ const isClientErr = /^4\d\d$/.test(code);
585
+ const isServerErr = /^5\d\d$/.test(code);
586
+
587
+ let respBg = 'FFE3F2FD';
588
+ let respFieldBg = 'FFBBDEFB';
589
+ let respFieldColor = { argb: 'FF1565C0' };
590
+ if (isSuccess) {
591
+ respBg = 'FFE8F5E9';
592
+ respFieldBg = 'FFC8E6C9';
593
+ respFieldColor = { argb: 'FF2E7D32' };
594
+ } else if (isClientErr) {
595
+ respBg = 'FFFFF3E0';
596
+ respFieldBg = 'FFFFE0B2';
597
+ respFieldColor = { argb: 'FFEF6C00' };
598
+ } else if (isServerErr) {
599
+ respBg = 'FFFFEBEE';
600
+ respFieldBg = 'FFFFCDD2';
601
+ respFieldColor = { argb: 'FFC62828' };
602
+ }
603
+
604
+ addRow(`Response document (${code})`, formatValue(resp), {
605
+ fieldBg: respFieldBg,
606
+ fieldColor: respFieldColor,
607
+ mono: true,
608
+ valueBg: respBg
609
+ });
610
+
611
+ const respSchema = pickJsonSchema(resp && resp.content);
612
+ if (respSchema) {
613
+ const example = generateExample(respSchema);
614
+ addRow(`Response example (${code})`, safeJsonStringify(example), {
615
+ mono: true,
616
+ valueBg: respBg
617
+ });
618
+ }
619
+ }
620
+ }
621
+
622
+ const backRow = ws.addRow({ field: '', value: '' });
623
+ const backCell = backRow.getCell('field');
624
+ backCell.value = {
625
+ text: '<< Back to API List',
626
+ hyperlink: "#'API List'!A1",
627
+ };
628
+ backCell.font = {
629
+ color: { argb: 'FF0563C1' },
630
+ underline: 'single',
631
+ bold: true,
632
+ size: 10,
633
+ name: 'Calibri'
634
+ };
635
+ }
636
+
637
+ // 4. Save to ./export/ directory
638
+ const exportDir = path.join(process.cwd(), 'archived');
639
+ if (!fs.existsSync(exportDir)) {
640
+ fs.mkdirSync(exportDir, { recursive: true });
641
+ }
642
+
643
+ const defaultName = `${slugify(spec.info && spec.info.title, 'openapi-spec')}` +
644
+ (spec.info && spec.info.version ? `-${slugify(spec.info.version, 'v')}` : '') +
645
+ '.xlsx';
646
+
647
+ const outPath = opts.out
648
+ ? path.resolve(opts.out)
649
+ : path.join(exportDir, defaultName);
650
+
651
+ await wb.xlsx.writeFile(outPath);
652
+ console.log(`[done] wrote ${outPath}`);
653
+ }
654
+
655
+ build().catch((e) => {
656
+ console.error(`[error] ${e.stack || e.message || e}`);
657
+ process.exit(1);
658
+ });
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "openapi-to-xlsx",
3
+ "version": "1.0.0",
4
+ "description": "Generate an Excel workbook (index + per-operation sheets with cross-sheet hyperlinks) from OpenAPI/Swagger specs",
5
+ "main": "exporter.js",
6
+ "type": "commonjs",
7
+ "bin": {
8
+ "openapi-to-xlsx": "exporter.js"
9
+ },
10
+ "files": [
11
+ "exporter.js",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "start": "node exporter.js",
16
+ "gen": "node exporter.js"
17
+ },
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "dependencies": {
22
+ "@apidevtools/swagger-parser": "^10.1.0",
23
+ "commander": "^12.1.0",
24
+ "exceljs": "^4.4.0",
25
+ "js-yaml": "^4.1.0"
26
+ },
27
+ "keywords": [
28
+ "openapi",
29
+ "swagger",
30
+ "excel",
31
+ "xlsx",
32
+ "exporter"
33
+ ],
34
+ "author": "Eddie Hsu <aoe102198@gmail.com>",
35
+ "license": "GPL-3.0-only"
36
+ }