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.
- package/LICENSE +672 -0
- package/README.md +141 -0
- package/exporter.js +658 -0
- 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
|
+
}
|