oapiex 0.1.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 +15 -0
- package/README.md +221 -0
- package/bin/cli.mjs +2 -0
- package/dist/index.cjs +1424 -0
- package/dist/index.d.ts +310 -0
- package/dist/index.mjs +1302 -0
- package/package.json +97 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1424 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
2
|
+
//#region \0rolldown/runtime.js
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
12
|
+
key = keys[i];
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except) {
|
|
14
|
+
__defProp(to, key, {
|
|
15
|
+
get: ((k) => from[k]).bind(null, key),
|
|
16
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return to;
|
|
22
|
+
};
|
|
23
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
24
|
+
value: mod,
|
|
25
|
+
enumerable: true
|
|
26
|
+
}) : target, mod));
|
|
27
|
+
|
|
28
|
+
//#endregion
|
|
29
|
+
let jsdom = require("jsdom");
|
|
30
|
+
let happy_dom = require("happy-dom");
|
|
31
|
+
let axios = require("axios");
|
|
32
|
+
axios = __toESM(axios);
|
|
33
|
+
let _h3ravel_shared = require("@h3ravel/shared");
|
|
34
|
+
let node_path = require("node:path");
|
|
35
|
+
node_path = __toESM(node_path);
|
|
36
|
+
let node_fs_promises = require("node:fs/promises");
|
|
37
|
+
node_fs_promises = __toESM(node_fs_promises);
|
|
38
|
+
let _h3ravel_musket = require("@h3ravel/musket");
|
|
39
|
+
let url = require("url");
|
|
40
|
+
let prettier = require("prettier");
|
|
41
|
+
prettier = __toESM(prettier);
|
|
42
|
+
let node_url = require("node:url");
|
|
43
|
+
|
|
44
|
+
//#region src/Manager.ts
|
|
45
|
+
const supportedBrowsers = [
|
|
46
|
+
"axios",
|
|
47
|
+
"happy-dom",
|
|
48
|
+
"jsdom",
|
|
49
|
+
"puppeteer"
|
|
50
|
+
];
|
|
51
|
+
const defaultConfig = {
|
|
52
|
+
outputFormat: "pretty",
|
|
53
|
+
outputShape: "raw",
|
|
54
|
+
requestTimeout: 5e4,
|
|
55
|
+
maxRedirects: 5,
|
|
56
|
+
userAgent: "Mozilla/5.0 (X11; Linux x64) AppleWebKit/537.36 (KHTML, like Gecko) OpenApiExtractor/1.0.0",
|
|
57
|
+
retryCount: 3,
|
|
58
|
+
retryDelay: 1e3,
|
|
59
|
+
browser: "puppeteer",
|
|
60
|
+
happyDom: {
|
|
61
|
+
enableJavaScriptEvaluation: true,
|
|
62
|
+
suppressInsecureJavaScriptEnvironmentWarning: true
|
|
63
|
+
},
|
|
64
|
+
puppeteer: {
|
|
65
|
+
headless: true,
|
|
66
|
+
args: ["--no-sandbox", "--disable-setuid-sandbox"]
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
let globalConfig = defaultConfig;
|
|
70
|
+
const getBrowserSession = () => globalThis.__oapieBrowserSession;
|
|
71
|
+
const startBrowserSession = async (config = globalConfig) => {
|
|
72
|
+
const activeSession = getBrowserSession();
|
|
73
|
+
if (activeSession?.browser === config.browser) return activeSession;
|
|
74
|
+
if (activeSession) await endBrowserSession();
|
|
75
|
+
const nextSession = {
|
|
76
|
+
browser: config.browser,
|
|
77
|
+
closers: []
|
|
78
|
+
};
|
|
79
|
+
if (config.browser === "puppeteer") nextSession.puppeteerBrowser = await (await import("puppeteer")).launch({
|
|
80
|
+
headless: config.puppeteer?.headless ?? true,
|
|
81
|
+
args: config.puppeteer?.args ?? ["--no-sandbox", "--disable-setuid-sandbox"]
|
|
82
|
+
});
|
|
83
|
+
globalThis.__oapieBrowserSession = nextSession;
|
|
84
|
+
return nextSession;
|
|
85
|
+
};
|
|
86
|
+
const endBrowserSession = async () => {
|
|
87
|
+
const activeSession = getBrowserSession();
|
|
88
|
+
if (!activeSession) return;
|
|
89
|
+
globalThis.__oapieBrowserSession = void 0;
|
|
90
|
+
for (const closer of activeSession.closers.reverse()) await closer();
|
|
91
|
+
if (activeSession.puppeteerBrowser) await activeSession.puppeteerBrowser.close();
|
|
92
|
+
};
|
|
93
|
+
const registerDeferredCloser = (browserName, closer) => {
|
|
94
|
+
const activeSession = getBrowserSession();
|
|
95
|
+
if (!activeSession || activeSession.browser !== browserName) return false;
|
|
96
|
+
activeSession.closers.push(closer);
|
|
97
|
+
return true;
|
|
98
|
+
};
|
|
99
|
+
const defineConfig = (config) => {
|
|
100
|
+
const userConfig = {
|
|
101
|
+
...defaultConfig,
|
|
102
|
+
...config,
|
|
103
|
+
happyDom: {
|
|
104
|
+
...defaultConfig.happyDom,
|
|
105
|
+
...config.happyDom
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
globalConfig = userConfig;
|
|
109
|
+
return userConfig;
|
|
110
|
+
};
|
|
111
|
+
const isSupportedBrowser = (value) => {
|
|
112
|
+
return supportedBrowsers.includes(value);
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* Loads HTML content from a given source URL using the configured browser.
|
|
116
|
+
*
|
|
117
|
+
* @param source The URL of the source to load HTML from.
|
|
118
|
+
* @param config Optional user configuration to override global settings for this load operation.
|
|
119
|
+
* @returns A promise that resolves to the HTML content as a string.
|
|
120
|
+
*/
|
|
121
|
+
const browser = async (source, config = globalConfig, initial = false) => {
|
|
122
|
+
const { data } = config.browser !== "puppeteer" ? await axios.default.get(source, {
|
|
123
|
+
timeout: config.requestTimeout,
|
|
124
|
+
maxRedirects: config.maxRedirects,
|
|
125
|
+
headers: {
|
|
126
|
+
"User-Agent": config.userAgent,
|
|
127
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"
|
|
128
|
+
}
|
|
129
|
+
}) : { data: "" };
|
|
130
|
+
if (config.browser === "axios") return data;
|
|
131
|
+
else if (config.browser === "happy-dom") {
|
|
132
|
+
const window = new happy_dom.Window({
|
|
133
|
+
url: source,
|
|
134
|
+
innerWidth: 1024,
|
|
135
|
+
innerHeight: 768,
|
|
136
|
+
settings: config.happyDom
|
|
137
|
+
});
|
|
138
|
+
window.document.write(data);
|
|
139
|
+
await window.happyDOM.waitUntilComplete();
|
|
140
|
+
const html = window.document.documentElement.outerHTML;
|
|
141
|
+
if (!html) {
|
|
142
|
+
await window.happyDOM.close();
|
|
143
|
+
throw new Error(`Unable to extract HTML from remote source: ${source}`);
|
|
144
|
+
}
|
|
145
|
+
if (!registerDeferredCloser("happy-dom", () => window.happyDOM.close())) await window.happyDOM.close();
|
|
146
|
+
return html;
|
|
147
|
+
} else if (config.browser === "jsdom") {
|
|
148
|
+
let window;
|
|
149
|
+
try {
|
|
150
|
+
({window} = new jsdom.JSDOM(data, {
|
|
151
|
+
url: source,
|
|
152
|
+
contentType: "text/html",
|
|
153
|
+
runScripts: "dangerously",
|
|
154
|
+
includeNodeLocations: true
|
|
155
|
+
}));
|
|
156
|
+
const html = window.document.documentElement.outerHTML;
|
|
157
|
+
if (!html) throw new Error(`Unable to extract HTML from remote source: ${source}`);
|
|
158
|
+
const currentWindow = window;
|
|
159
|
+
if (!registerDeferredCloser("jsdom", () => currentWindow.close())) {
|
|
160
|
+
window.close();
|
|
161
|
+
window = void 0;
|
|
162
|
+
} else window = void 0;
|
|
163
|
+
return html;
|
|
164
|
+
} finally {
|
|
165
|
+
if (window) window.close();
|
|
166
|
+
}
|
|
167
|
+
} else if (config.browser === "puppeteer") {
|
|
168
|
+
const activeSession = getBrowserSession();
|
|
169
|
+
let browserInstance = activeSession?.browser === "puppeteer" ? activeSession.puppeteerBrowser : void 0;
|
|
170
|
+
let shouldClose = false;
|
|
171
|
+
let page;
|
|
172
|
+
try {
|
|
173
|
+
if (!browserInstance) {
|
|
174
|
+
browserInstance = await (await import("puppeteer")).launch({
|
|
175
|
+
headless: config.puppeteer?.headless ?? true,
|
|
176
|
+
args: config.puppeteer?.args ?? ["--no-sandbox", "--disable-setuid-sandbox"]
|
|
177
|
+
});
|
|
178
|
+
shouldClose = true;
|
|
179
|
+
}
|
|
180
|
+
page = await browserInstance.newPage();
|
|
181
|
+
await page.setUserAgent({ userAgent: config.userAgent });
|
|
182
|
+
await page.setRequestInterception(true);
|
|
183
|
+
page.on("request", (e) => {
|
|
184
|
+
const type = e.resourceType();
|
|
185
|
+
if ([
|
|
186
|
+
"image",
|
|
187
|
+
"font",
|
|
188
|
+
"media",
|
|
189
|
+
"stylesheet"
|
|
190
|
+
].includes(type)) return void e.abort();
|
|
191
|
+
e.continue();
|
|
192
|
+
});
|
|
193
|
+
try {
|
|
194
|
+
await page.goto(source, {
|
|
195
|
+
waitUntil: "domcontentloaded",
|
|
196
|
+
timeout: config.requestTimeout
|
|
197
|
+
});
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (!page || !await hasExtractableReadmeContent(page)) throw error;
|
|
200
|
+
}
|
|
201
|
+
await waitForExtractableReadmeContent(page, config.requestTimeout, initial);
|
|
202
|
+
await waitForOperationHydration(page, config.requestTimeout);
|
|
203
|
+
let html = await page.content();
|
|
204
|
+
if (!html) throw new Error(`Unable to extract HTML from remote source: ${source}`);
|
|
205
|
+
if (!html.includes("id=\"ssr-props\"")) {
|
|
206
|
+
const { data: rawHtml } = await axios.default.get(source, {
|
|
207
|
+
timeout: config.requestTimeout,
|
|
208
|
+
maxRedirects: config.maxRedirects,
|
|
209
|
+
headers: {
|
|
210
|
+
"User-Agent": config.userAgent,
|
|
211
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
html = mergeSsrPropsIntoRenderedHtml(html, rawHtml);
|
|
215
|
+
}
|
|
216
|
+
return html;
|
|
217
|
+
} finally {
|
|
218
|
+
if (page && !page.isClosed()) await page.close();
|
|
219
|
+
if (shouldClose && browserInstance) await browserInstance.close();
|
|
220
|
+
}
|
|
221
|
+
} else throw new Error(`Unsupported browser specified in configuration: ${globalConfig.browser}`);
|
|
222
|
+
};
|
|
223
|
+
const waitForExtractableReadmeContent = async (page, timeout, initial = false) => {
|
|
224
|
+
try {
|
|
225
|
+
if (initial) await page.waitForSelector(".hub-sidebar-content .rm-Sidebar-link");
|
|
226
|
+
await page.waitForSelector("[data-testid=\"http-method\"], article#content, script#ssr-props", { timeout });
|
|
227
|
+
} catch {}
|
|
228
|
+
};
|
|
229
|
+
const waitForOperationHydration = async (page, timeout) => {
|
|
230
|
+
try {
|
|
231
|
+
await page.waitForFunction(() => {
|
|
232
|
+
const hasMethod = !!document.querySelector("[data-testid=\"http-method\"]");
|
|
233
|
+
const hasRequestForm = !!document.querySelector("form[name=\"Parameters\"]");
|
|
234
|
+
const hasReqPlayground = !!document.querySelector(".rm-PlaygroundRequest");
|
|
235
|
+
const hasResPlayground = !!document.querySelector(".rm-PlaygroundResponse");
|
|
236
|
+
const hasSsrProps = !!document.querySelector("script#ssr-props");
|
|
237
|
+
if (!hasMethod) return false;
|
|
238
|
+
return hasRequestForm || hasReqPlayground || hasResPlayground || hasSsrProps;
|
|
239
|
+
}, { timeout });
|
|
240
|
+
} catch {}
|
|
241
|
+
try {
|
|
242
|
+
await page.waitForSelector(".rm-PlaygroundRequest, .rm-PlaygroundResponse, form[name=\"Parameters\"], script#ssr-props", { timeout: Math.min(timeout, 5e3) });
|
|
243
|
+
} catch {}
|
|
244
|
+
try {
|
|
245
|
+
await page.waitForNetworkIdle?.({
|
|
246
|
+
idleTime: 500,
|
|
247
|
+
timeout: Math.min(timeout, 5e3)
|
|
248
|
+
});
|
|
249
|
+
} catch {}
|
|
250
|
+
};
|
|
251
|
+
const hasExtractableReadmeContent = async (page) => {
|
|
252
|
+
return Boolean(await page.$("[data-testid=\"http-method\"], article#content, script#ssr-props"));
|
|
253
|
+
};
|
|
254
|
+
const mergeSsrPropsIntoRenderedHtml = (renderedHtml, rawHtml) => {
|
|
255
|
+
if (renderedHtml.includes("id=\"ssr-props\"")) return renderedHtml;
|
|
256
|
+
const ssrPropsScript = extractSsrPropsScript(rawHtml);
|
|
257
|
+
if (!ssrPropsScript) return renderedHtml;
|
|
258
|
+
if (renderedHtml.includes("</body>")) return renderedHtml.replace("</body>", `${ssrPropsScript}</body>`);
|
|
259
|
+
if (renderedHtml.includes("</html>")) return renderedHtml.replace("</html>", `${ssrPropsScript}</html>`);
|
|
260
|
+
return `${renderedHtml}${ssrPropsScript}`;
|
|
261
|
+
};
|
|
262
|
+
const extractSsrPropsScript = (html) => {
|
|
263
|
+
return html.match(/<script id="ssr-props"[^>]*>[\s\S]*?<\/script>/i)?.[0] ?? null;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
//#endregion
|
|
267
|
+
//#region src/Core.ts
|
|
268
|
+
const isRecord = (value) => {
|
|
269
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
//#endregion
|
|
273
|
+
//#region src/JsonRepair.ts
|
|
274
|
+
const parsePossiblyTruncatedJson = (value) => {
|
|
275
|
+
const trimmed = value.trim();
|
|
276
|
+
if (!/^(?:\{|\[)/.test(trimmed)) return null;
|
|
277
|
+
try {
|
|
278
|
+
return JSON.parse(trimmed);
|
|
279
|
+
} catch {
|
|
280
|
+
const repaired = repairCommonJsonIssues(trimmed);
|
|
281
|
+
try {
|
|
282
|
+
return JSON.parse(repaired);
|
|
283
|
+
} catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
const repairCommonJsonIssues = (value) => {
|
|
289
|
+
const withMissingCommasInserted = insertMissingCommas(value);
|
|
290
|
+
return `${withMissingCommasInserted}${buildMissingJsonClosers(withMissingCommasInserted)}`;
|
|
291
|
+
};
|
|
292
|
+
const insertMissingCommas = (value) => {
|
|
293
|
+
let result = "";
|
|
294
|
+
let inString = false;
|
|
295
|
+
let isEscaped = false;
|
|
296
|
+
let previousSignificantCharacter = "";
|
|
297
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
298
|
+
const character = value[index];
|
|
299
|
+
if (inString) {
|
|
300
|
+
result += character;
|
|
301
|
+
if (isEscaped) {
|
|
302
|
+
isEscaped = false;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (character === "\\") {
|
|
306
|
+
isEscaped = true;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (character === "\"") {
|
|
310
|
+
inString = false;
|
|
311
|
+
previousSignificantCharacter = "\"";
|
|
312
|
+
}
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (character === "\"") {
|
|
316
|
+
const remainder = value.slice(index);
|
|
317
|
+
if (/^"(?:\\.|[^"\\])*"\s*:/.test(remainder) && shouldInsertCommaBeforeKey(previousSignificantCharacter, result)) result += ",";
|
|
318
|
+
result += character;
|
|
319
|
+
inString = true;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
result += character;
|
|
323
|
+
if (!/\s/.test(character)) previousSignificantCharacter = character;
|
|
324
|
+
}
|
|
325
|
+
return result;
|
|
326
|
+
};
|
|
327
|
+
const shouldInsertCommaBeforeKey = (previousSignificantCharacter, currentOutput) => {
|
|
328
|
+
if (!previousSignificantCharacter) return false;
|
|
329
|
+
if (![
|
|
330
|
+
"\"",
|
|
331
|
+
"}",
|
|
332
|
+
"]",
|
|
333
|
+
"e",
|
|
334
|
+
"l",
|
|
335
|
+
"0",
|
|
336
|
+
"1",
|
|
337
|
+
"2",
|
|
338
|
+
"3",
|
|
339
|
+
"4",
|
|
340
|
+
"5",
|
|
341
|
+
"6",
|
|
342
|
+
"7",
|
|
343
|
+
"8",
|
|
344
|
+
"9"
|
|
345
|
+
].includes(previousSignificantCharacter)) return false;
|
|
346
|
+
const trimmedOutput = currentOutput.trimEnd();
|
|
347
|
+
return !trimmedOutput.endsWith(",") && !trimmedOutput.endsWith("{");
|
|
348
|
+
};
|
|
349
|
+
const buildMissingJsonClosers = (value) => {
|
|
350
|
+
const stack = [];
|
|
351
|
+
let inString = false;
|
|
352
|
+
let isEscaped = false;
|
|
353
|
+
for (const character of value) {
|
|
354
|
+
if (isEscaped) {
|
|
355
|
+
isEscaped = false;
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (character === "\\") {
|
|
359
|
+
isEscaped = true;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (character === "\"") {
|
|
363
|
+
inString = !inString;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
if (inString) continue;
|
|
367
|
+
if (character === "{") {
|
|
368
|
+
stack.push("}");
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (character === "[") {
|
|
372
|
+
stack.push("]");
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if ((character === "}" || character === "]") && stack[stack.length - 1] === character) stack.pop();
|
|
376
|
+
}
|
|
377
|
+
return stack.reverse().join("");
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
//#endregion
|
|
381
|
+
//#region src/ReadmeExtractor.ts
|
|
382
|
+
const extractReadmeOperationFromHtml = (html) => {
|
|
383
|
+
const window = new happy_dom.Window();
|
|
384
|
+
window.document.write(html);
|
|
385
|
+
const { document } = window;
|
|
386
|
+
const contentRoot = document.querySelector("article#content") ?? document.body;
|
|
387
|
+
const requestParamsForm = contentRoot.querySelector("form[name=\"Parameters\"]");
|
|
388
|
+
const requestPlayground = document.querySelector(".rm-PlaygroundRequest");
|
|
389
|
+
const responsePlayground = document.querySelector(".rm-PlaygroundResponse");
|
|
390
|
+
const requestCodeSnippets = extractRequestCodeSnippets(requestPlayground);
|
|
391
|
+
const requestExampleNormalized = normalizeRequestCodeSnippet(requestCodeSnippets[0] ?? null);
|
|
392
|
+
const responseBodies = extractResponseBodies(responsePlayground);
|
|
393
|
+
return mergeReadmeOperations({
|
|
394
|
+
method: readText(contentRoot.querySelector("[data-testid=\"http-method\"]"))?.toUpperCase() ?? null,
|
|
395
|
+
url: readText(contentRoot.querySelector("[data-testid=\"serverurl\"]")),
|
|
396
|
+
description: extractOperationDescription(contentRoot),
|
|
397
|
+
sidebarLinks: extractSidebarLinks(document.querySelector(".rm-Sidebar.hub-sidebar-content")),
|
|
398
|
+
requestParams: extractRequestParams(requestParamsForm),
|
|
399
|
+
requestCodeSnippets,
|
|
400
|
+
requestExample: requestCodeSnippets[0]?.body ?? null,
|
|
401
|
+
requestExampleNormalized,
|
|
402
|
+
responseSchemas: extractResponseSchemas(contentRoot),
|
|
403
|
+
responseBodies,
|
|
404
|
+
responseExample: responseBodies[0]?.body ?? null,
|
|
405
|
+
responseExampleRaw: responseBodies[0]?.rawBody ?? null
|
|
406
|
+
}, extractReadmeOperationFromSsrProps(document));
|
|
407
|
+
};
|
|
408
|
+
const mergeReadmeOperations = (primary, fallback) => {
|
|
409
|
+
if (!fallback) return primary;
|
|
410
|
+
return {
|
|
411
|
+
method: primary.method ?? fallback.method,
|
|
412
|
+
url: primary.url ?? fallback.url,
|
|
413
|
+
description: primary.description ?? fallback.description,
|
|
414
|
+
sidebarLinks: primary.sidebarLinks.length > 0 ? primary.sidebarLinks : fallback.sidebarLinks,
|
|
415
|
+
requestParams: primary.requestParams.length > 0 ? primary.requestParams : fallback.requestParams,
|
|
416
|
+
requestCodeSnippets: primary.requestCodeSnippets.length > 0 ? primary.requestCodeSnippets : fallback.requestCodeSnippets,
|
|
417
|
+
requestExample: primary.requestExample ?? fallback.requestExample,
|
|
418
|
+
requestExampleNormalized: primary.requestExampleNormalized ?? fallback.requestExampleNormalized,
|
|
419
|
+
responseSchemas: primary.responseSchemas.length > 0 ? primary.responseSchemas : fallback.responseSchemas,
|
|
420
|
+
responseBodies: primary.responseBodies.length > 0 ? primary.responseBodies : fallback.responseBodies,
|
|
421
|
+
responseExample: primary.responseExample ?? fallback.responseExample,
|
|
422
|
+
responseExampleRaw: primary.responseExampleRaw ?? fallback.responseExampleRaw
|
|
423
|
+
};
|
|
424
|
+
};
|
|
425
|
+
const extractReadmeOperationFromSsrProps = (document) => {
|
|
426
|
+
const rawSsrProps = document.querySelector("script#ssr-props")?.textContent?.trim();
|
|
427
|
+
if (!rawSsrProps) return null;
|
|
428
|
+
let ssrProps;
|
|
429
|
+
try {
|
|
430
|
+
ssrProps = JSON.parse(rawSsrProps);
|
|
431
|
+
} catch {
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
const apiDocument = isRecord(ssrProps) && isRecord(ssrProps.document) ? ssrProps.document.api : null;
|
|
435
|
+
if (!isRecord(apiDocument)) return null;
|
|
436
|
+
const method = typeof apiDocument.method === "string" ? apiDocument.method.toUpperCase() : null;
|
|
437
|
+
const path = typeof apiDocument.path === "string" ? apiDocument.path : null;
|
|
438
|
+
const schema = isRecord(apiDocument.schema) ? apiDocument.schema : null;
|
|
439
|
+
const operation = resolveSsrOperation(schema, path, method?.toLowerCase() ?? null);
|
|
440
|
+
const serverUrl = Array.isArray(schema?.servers) && isRecord(schema.servers[0]) && typeof schema.servers[0].url === "string" ? schema.servers[0].url : null;
|
|
441
|
+
if (!operation && !method && !path) return null;
|
|
442
|
+
const responseBodies = extractResponseBodiesFromOpenApi(operation?.responses ?? {});
|
|
443
|
+
return {
|
|
444
|
+
method,
|
|
445
|
+
url: buildOperationUrl(serverUrl, path),
|
|
446
|
+
description: operation?.description ?? null,
|
|
447
|
+
sidebarLinks: [],
|
|
448
|
+
requestParams: [...extractOperationParametersFromOpenApi(operation?.parameters), ...extractRequestParamsFromOpenApi(operation?.requestBody)],
|
|
449
|
+
requestCodeSnippets: [],
|
|
450
|
+
requestExample: null,
|
|
451
|
+
requestExampleNormalized: null,
|
|
452
|
+
responseSchemas: extractResponseSchemasFromOpenApi(operation?.responses ?? {}),
|
|
453
|
+
responseBodies,
|
|
454
|
+
responseExample: responseBodies[0]?.body ?? null,
|
|
455
|
+
responseExampleRaw: responseBodies[0]?.rawBody ?? null
|
|
456
|
+
};
|
|
457
|
+
};
|
|
458
|
+
const extractOperationParametersFromOpenApi = (parameters) => {
|
|
459
|
+
if (!parameters) return [];
|
|
460
|
+
return parameters.map((parameter) => ({
|
|
461
|
+
name: parameter.name,
|
|
462
|
+
in: parameter.in,
|
|
463
|
+
path: [parameter.name],
|
|
464
|
+
type: parameter.schema?.type ?? null,
|
|
465
|
+
required: parameter.required ?? false,
|
|
466
|
+
defaultValue: parameter.schema?.default == null ? parameter.example == null ? null : String(parameter.example) : String(parameter.schema.default),
|
|
467
|
+
description: parameter.description ?? parameter.schema?.description ?? null
|
|
468
|
+
}));
|
|
469
|
+
};
|
|
470
|
+
const resolveSsrOperation = (schema, path, method) => {
|
|
471
|
+
if (!schema || !path || !method || !isRecord(schema.paths)) return null;
|
|
472
|
+
const pathItem = schema.paths[path];
|
|
473
|
+
if (!isRecord(pathItem) || !isRecord(pathItem[method])) return null;
|
|
474
|
+
return pathItem[method];
|
|
475
|
+
};
|
|
476
|
+
const buildOperationUrl = (serverUrl, path) => {
|
|
477
|
+
if (!serverUrl || !path) return null;
|
|
478
|
+
return `${serverUrl.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
|
|
479
|
+
};
|
|
480
|
+
const extractRequestParamsFromOpenApi = (requestBody) => {
|
|
481
|
+
const schema = requestBody?.content?.["application/json"]?.schema;
|
|
482
|
+
if (!schema) return [];
|
|
483
|
+
return flattenOpenApiSchemaProperties(schema);
|
|
484
|
+
};
|
|
485
|
+
const flattenOpenApiSchemaProperties = (schema, path = []) => {
|
|
486
|
+
if (!schema.properties) return [];
|
|
487
|
+
const params = [];
|
|
488
|
+
for (const [name, property] of Object.entries(schema.properties)) {
|
|
489
|
+
const propertyPath = [...path, name];
|
|
490
|
+
const required = schema.required?.includes(name) ?? false;
|
|
491
|
+
if (property.type === "object" && property.properties) {
|
|
492
|
+
params.push(...flattenOpenApiSchemaProperties(property, propertyPath).map((param) => ({
|
|
493
|
+
...param,
|
|
494
|
+
required: param.path.length === propertyPath.length + 1 ? required && param.required : param.required
|
|
495
|
+
})));
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
params.push({
|
|
499
|
+
name,
|
|
500
|
+
in: "body",
|
|
501
|
+
path: propertyPath,
|
|
502
|
+
type: property.type ?? null,
|
|
503
|
+
required,
|
|
504
|
+
defaultValue: property.default == null ? null : String(property.default),
|
|
505
|
+
description: property.description ?? null
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
return params;
|
|
509
|
+
};
|
|
510
|
+
const extractResponseSchemasFromOpenApi = (responses) => {
|
|
511
|
+
return Object.entries(responses).map(([statusCode, response]) => ({
|
|
512
|
+
statusCode,
|
|
513
|
+
description: response.description ?? statusCode
|
|
514
|
+
}));
|
|
515
|
+
};
|
|
516
|
+
const extractResponseBodiesFromOpenApi = (responses) => {
|
|
517
|
+
const bodies = [];
|
|
518
|
+
for (const [statusCode, response] of Object.entries(responses)) for (const [contentType, mediaType] of Object.entries(response.content ?? {})) {
|
|
519
|
+
const example = resolveOpenApiMediaExample(mediaType);
|
|
520
|
+
if (example == null) continue;
|
|
521
|
+
const rawBody = typeof example === "string" ? example : JSON.stringify(example, null, 2);
|
|
522
|
+
const normalized = normalizeResponseBody(rawBody, contentType);
|
|
523
|
+
bodies.push({
|
|
524
|
+
format: normalized.format,
|
|
525
|
+
contentType,
|
|
526
|
+
statusCode,
|
|
527
|
+
label: response.description ?? statusCode,
|
|
528
|
+
body: normalized.body,
|
|
529
|
+
rawBody
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
return bodies;
|
|
533
|
+
};
|
|
534
|
+
const resolveOpenApiMediaExample = (mediaType) => {
|
|
535
|
+
if (mediaType.example !== void 0) return mediaType.example;
|
|
536
|
+
const examples = mediaType.examples;
|
|
537
|
+
if (examples && isRecord(examples)) {
|
|
538
|
+
for (const example of Object.values(examples)) if (isRecord(example) && "value" in example) return example.value ?? null;
|
|
539
|
+
}
|
|
540
|
+
if (mediaType.schema?.example !== void 0) return mediaType.schema.example;
|
|
541
|
+
return null;
|
|
542
|
+
};
|
|
543
|
+
const extractOperationDescription = (root) => {
|
|
544
|
+
const header = root.querySelector("header");
|
|
545
|
+
if (!header) return null;
|
|
546
|
+
const markdownBlocks = Array.from(header.querySelectorAll("[data-testid=\"RDMD\"]"));
|
|
547
|
+
return readText(markdownBlocks[markdownBlocks.length - 1] ?? null);
|
|
548
|
+
};
|
|
549
|
+
const extractRequestCodeSnippets = (root) => {
|
|
550
|
+
if (!root) return [];
|
|
551
|
+
const label = extractRequestSnippetLabel(root);
|
|
552
|
+
return extractCodeSnippets(root).map((body) => ({
|
|
553
|
+
label,
|
|
554
|
+
body
|
|
555
|
+
}));
|
|
556
|
+
};
|
|
557
|
+
const extractResponseBodies = (root) => {
|
|
558
|
+
if (!root) return [];
|
|
559
|
+
const bodies = extractCodeSnippets(root);
|
|
560
|
+
const contentTypes = extractResponseContentTypes(root);
|
|
561
|
+
const labels = extractResponseLabels(root);
|
|
562
|
+
return bodies.map((body, index) => {
|
|
563
|
+
const label = labels[index] ?? labels[0] ?? null;
|
|
564
|
+
const contentType = contentTypes[index] ?? contentTypes[0] ?? null;
|
|
565
|
+
const normalized = normalizeResponseBody(body, contentType);
|
|
566
|
+
return {
|
|
567
|
+
format: normalized.format,
|
|
568
|
+
contentType,
|
|
569
|
+
statusCode: label?.match(/\b\d{3}\b/)?.[0] ?? null,
|
|
570
|
+
label,
|
|
571
|
+
body: normalized.body,
|
|
572
|
+
rawBody: body
|
|
573
|
+
};
|
|
574
|
+
});
|
|
575
|
+
};
|
|
576
|
+
const extractSidebarLinks = (root) => {
|
|
577
|
+
if (!root) return [];
|
|
578
|
+
return Array.from(root.querySelectorAll(".rm-Sidebar-link")).map((link) => {
|
|
579
|
+
const section = link.closest(".rm-Sidebar-section");
|
|
580
|
+
const method = readText(link.querySelector("[data-testid=\"http-method\"]"))?.toUpperCase() ?? null;
|
|
581
|
+
return {
|
|
582
|
+
section: readText(section?.querySelector(".rm-Sidebar-heading") ?? null),
|
|
583
|
+
label: extractSidebarLinkLabel(link, method),
|
|
584
|
+
href: link.getAttribute("href"),
|
|
585
|
+
method,
|
|
586
|
+
active: link.classList.contains("active") || link.getAttribute("aria-current") === "page",
|
|
587
|
+
subpage: link.classList.contains("subpage")
|
|
588
|
+
};
|
|
589
|
+
}).filter((link) => link.label.length > 0);
|
|
590
|
+
};
|
|
591
|
+
const extractRequestParams = (root) => {
|
|
592
|
+
if (!root) return [];
|
|
593
|
+
return Array.from(root.querySelectorAll("label")).map((label) => {
|
|
594
|
+
const name = readText(label);
|
|
595
|
+
const param = findParameterRoot(label, root);
|
|
596
|
+
const input = resolveParameterInput(param, label);
|
|
597
|
+
return {
|
|
598
|
+
name: name ?? "",
|
|
599
|
+
in: inferParameterLocation(param, label, root),
|
|
600
|
+
path: inferParameterPath(label, name),
|
|
601
|
+
type: inferParameterType(param, input),
|
|
602
|
+
required: isRequiredParameter(param, input),
|
|
603
|
+
defaultValue: readInputValue(input),
|
|
604
|
+
description: extractParameterDescription(param)
|
|
605
|
+
};
|
|
606
|
+
}).filter((param) => param.name.length > 0);
|
|
607
|
+
};
|
|
608
|
+
const extractResponseSchemas = (root) => {
|
|
609
|
+
const picker = root.querySelector("#response-schemas")?.nextElementSibling;
|
|
610
|
+
if (!picker || !picker.classList.contains("rm-APIResponseSchemaPicker")) return [];
|
|
611
|
+
return Array.from(picker.querySelectorAll("button")).map((option) => {
|
|
612
|
+
const optionText = readText(option);
|
|
613
|
+
const statusCodeMatch = optionText?.match(/\b\d{3}\b/);
|
|
614
|
+
const markdownBlocks = Array.from(option.querySelectorAll("[data-testid=\"RDMD\"]"));
|
|
615
|
+
return {
|
|
616
|
+
statusCode: statusCodeMatch?.[0] ?? null,
|
|
617
|
+
description: readText(markdownBlocks[0] ?? null) ?? optionText
|
|
618
|
+
};
|
|
619
|
+
}).filter((schema) => schema.statusCode !== null);
|
|
620
|
+
};
|
|
621
|
+
const extractCodeSnippets = (root) => {
|
|
622
|
+
return Array.from(root.querySelectorAll(".CodeSnippet")).map((snippet) => extractCodeMirrorText(snippet)).filter((snippet) => Boolean(snippet));
|
|
623
|
+
};
|
|
624
|
+
const extractCodeMirrorText = (root) => {
|
|
625
|
+
if (!root) return null;
|
|
626
|
+
const codeMirrorLines = Array.from(root.querySelectorAll(".CodeMirror-code pre.CodeMirror-line"));
|
|
627
|
+
if (codeMirrorLines.length === 0) return null;
|
|
628
|
+
return codeMirrorLines.map((line) => line.textContent?.replace(/\u00a0/g, " ") ?? "").join("\n").trimEnd() || null;
|
|
629
|
+
};
|
|
630
|
+
const extractRequestSnippetLabel = (root) => {
|
|
631
|
+
const header = root.querySelector("header");
|
|
632
|
+
if (!header) return null;
|
|
633
|
+
const buttonTexts = Array.from(header.querySelectorAll("button")).map((button) => extractButtonText(button)).filter((text) => Boolean(text));
|
|
634
|
+
return buttonTexts.find((text) => text.toLowerCase() !== "examples") ?? buttonTexts[0] ?? null;
|
|
635
|
+
};
|
|
636
|
+
const readInputValue = (element) => {
|
|
637
|
+
if (!element) return null;
|
|
638
|
+
return element.getAttribute("value")?.trim() || null;
|
|
639
|
+
};
|
|
640
|
+
const extractSidebarLinkLabel = (link, method) => {
|
|
641
|
+
return readTexts(link.querySelectorAll("span")).filter((text) => !method || text.toUpperCase() !== method).sort((left, right) => right.length - left.length)[0] ?? readText(link) ?? "";
|
|
642
|
+
};
|
|
643
|
+
const extractResponseContentTypes = (root) => {
|
|
644
|
+
return readTexts(root.querySelectorAll("div")).filter((text) => /^[\w.+-]+\/[\w.+-]+$/i.test(text));
|
|
645
|
+
};
|
|
646
|
+
const extractResponseLabels = (root) => {
|
|
647
|
+
return Array.from(root.querySelectorAll("button, [role=\"button\"]")).map((element) => extractButtonText(element)).filter((text) => Boolean(text)).filter((text) => /\b\d{3}\b/.test(text));
|
|
648
|
+
};
|
|
649
|
+
const findParameterRoot = (label, root) => {
|
|
650
|
+
const fieldId = label.getAttribute("for");
|
|
651
|
+
let current = label;
|
|
652
|
+
while (current) {
|
|
653
|
+
const fieldInput = fieldId ? current.querySelector(`#${escapeSelector(fieldId)}`) : null;
|
|
654
|
+
const fallbackInput = current.querySelector("input, textarea, select");
|
|
655
|
+
if ((fieldInput || fallbackInput) && current !== root) return current;
|
|
656
|
+
current = current.parentElement;
|
|
657
|
+
}
|
|
658
|
+
return label;
|
|
659
|
+
};
|
|
660
|
+
const resolveParameterInput = (param, label) => {
|
|
661
|
+
const fieldId = label.getAttribute("for");
|
|
662
|
+
const byId = fieldId ? param.querySelector(`#${escapeSelector(fieldId)}`) : null;
|
|
663
|
+
const fallback = param.querySelector("input, textarea, select");
|
|
664
|
+
return byId ?? fallback;
|
|
665
|
+
};
|
|
666
|
+
const inferParameterLocation = (param, label, root) => {
|
|
667
|
+
const locationFromId = inferParameterLocationFromText(`${label.getAttribute("for")?.toLowerCase() ?? ""} ${param.closest("[id]")?.getAttribute("id")?.toLowerCase() ?? ""}`);
|
|
668
|
+
if (locationFromId) return locationFromId;
|
|
669
|
+
let current = param;
|
|
670
|
+
while (current && current !== root) {
|
|
671
|
+
let sibling = current.previousElementSibling;
|
|
672
|
+
while (sibling) {
|
|
673
|
+
if (sibling.tagName === "HEADER") {
|
|
674
|
+
const locationFromHeader = inferParameterLocationFromText(readText(sibling) ?? "");
|
|
675
|
+
if (locationFromHeader) return locationFromHeader;
|
|
676
|
+
}
|
|
677
|
+
sibling = sibling.previousElementSibling;
|
|
678
|
+
}
|
|
679
|
+
current = current.parentElement;
|
|
680
|
+
}
|
|
681
|
+
return null;
|
|
682
|
+
};
|
|
683
|
+
const inferParameterLocationFromText = (value) => {
|
|
684
|
+
const normalized = value.trim().toLowerCase();
|
|
685
|
+
if (normalized.includes("query params") || normalized.includes("query-")) return "query";
|
|
686
|
+
if (normalized.includes("headers") || normalized.includes("header-")) return "header";
|
|
687
|
+
if (normalized.includes("body params") || normalized.includes("request body") || normalized.includes("body-")) return "body";
|
|
688
|
+
if (normalized.includes("path params") || normalized.includes("path-")) return "path";
|
|
689
|
+
if (normalized.includes("cookie") || normalized.includes("cookie-")) return "cookie";
|
|
690
|
+
return null;
|
|
691
|
+
};
|
|
692
|
+
const inferParameterPath = (label, name) => {
|
|
693
|
+
const fieldId = label.getAttribute("for") ?? "";
|
|
694
|
+
const suffix = fieldId.includes("_") ? fieldId.slice(fieldId.indexOf("_") + 1) : "";
|
|
695
|
+
if (suffix.includes(".")) return suffix.split(".").map((segment) => segment.trim()).filter((segment) => segment.length > 0);
|
|
696
|
+
return name ? [name] : [];
|
|
697
|
+
};
|
|
698
|
+
const inferParameterType = (param, input) => {
|
|
699
|
+
const inputType = input?.getAttribute("type")?.toLowerCase();
|
|
700
|
+
if (inputType === "text") return "string";
|
|
701
|
+
if (inputType) return inputType;
|
|
702
|
+
const fieldClass = Array.from(param.querySelectorAll("[class]")).find((element) => {
|
|
703
|
+
return (element.getAttribute("class") ?? "").split(/\s+/).some((token) => token.startsWith("field-"));
|
|
704
|
+
})?.getAttribute("class")?.split(/\s+/).find((token) => token.startsWith("field-"));
|
|
705
|
+
if (fieldClass) return fieldClass.replace(/^field-/, "");
|
|
706
|
+
return null;
|
|
707
|
+
};
|
|
708
|
+
const isRequiredParameter = (param, input) => {
|
|
709
|
+
if (input?.hasAttribute("required")) return true;
|
|
710
|
+
return readTexts(param.querySelectorAll("*")).some((text) => text.toLowerCase() === "required");
|
|
711
|
+
};
|
|
712
|
+
const extractParameterDescription = (param) => {
|
|
713
|
+
const description = param.querySelector("[id$=\"__description\"]");
|
|
714
|
+
if (description) return readText(description);
|
|
715
|
+
return readText(Array.from(param.querySelectorAll("[data-testid=\"RDMD\"]"))[0] ?? null);
|
|
716
|
+
};
|
|
717
|
+
const normalizeResponseBody = (body, contentType) => {
|
|
718
|
+
const trimmed = body.trim();
|
|
719
|
+
if (contentType?.toLowerCase().includes("json") || /^(?:\{|\[)/.test(trimmed)) {
|
|
720
|
+
const parsedBody = parsePossiblyTruncatedJson(trimmed);
|
|
721
|
+
if (parsedBody !== null) return {
|
|
722
|
+
format: "json",
|
|
723
|
+
body: parsedBody
|
|
724
|
+
};
|
|
725
|
+
return {
|
|
726
|
+
format: "text",
|
|
727
|
+
body
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
return {
|
|
731
|
+
format: "text",
|
|
732
|
+
body
|
|
733
|
+
};
|
|
734
|
+
};
|
|
735
|
+
const normalizeRequestCodeSnippet = (snippet) => {
|
|
736
|
+
if (!snippet) return null;
|
|
737
|
+
return normalizeCurlSnippet(snippet) ?? normalizeFetchSnippet(snippet) ?? {
|
|
738
|
+
sourceLabel: snippet.label,
|
|
739
|
+
method: null,
|
|
740
|
+
url: null,
|
|
741
|
+
headers: {},
|
|
742
|
+
bodyFormat: null,
|
|
743
|
+
body: null,
|
|
744
|
+
rawBody: null
|
|
745
|
+
};
|
|
746
|
+
};
|
|
747
|
+
const normalizeCurlSnippet = (snippet) => {
|
|
748
|
+
if (!snippet.body.startsWith("curl ")) return null;
|
|
749
|
+
const method = snippet.body.match(/--request\s+([A-Z]+)/)?.[1] ?? null;
|
|
750
|
+
const url = snippet.body.match(/--url\s+(\S+)/)?.[1] ?? null;
|
|
751
|
+
const headers = Object.fromEntries(Array.from(snippet.body.matchAll(/--header\s+'([^:]+):\s*([^']+)'/g)).map((match) => [match[1].trim(), match[2].trim()]));
|
|
752
|
+
const rawBody = snippet.body.match(/--data\s+'([\s\S]*)'$/)?.[1]?.trim() ?? null;
|
|
753
|
+
const normalizedBody = rawBody ? normalizeResponseBody(rawBody, headers["content-type"] ?? headers["Content-Type"] ?? null) : null;
|
|
754
|
+
return {
|
|
755
|
+
sourceLabel: snippet.label,
|
|
756
|
+
method,
|
|
757
|
+
url,
|
|
758
|
+
headers,
|
|
759
|
+
bodyFormat: normalizedBody?.format ?? null,
|
|
760
|
+
body: normalizedBody?.body ?? null,
|
|
761
|
+
rawBody
|
|
762
|
+
};
|
|
763
|
+
};
|
|
764
|
+
const normalizeFetchSnippet = (snippet) => {
|
|
765
|
+
const fetchMatch = snippet.body.match(/fetch\(\s*(["'])(.*?)\1\s*,\s*\{([\s\S]*)\}\s*\)/);
|
|
766
|
+
if (!fetchMatch) return null;
|
|
767
|
+
const [, , url, optionsBody] = fetchMatch;
|
|
768
|
+
const method = extractObjectPropertyValue(optionsBody, "method")?.toUpperCase() ?? null;
|
|
769
|
+
const headers = extractFetchHeaders(optionsBody);
|
|
770
|
+
const rawBody = extractFetchBody(optionsBody);
|
|
771
|
+
const contentType = headers["content-type"] ?? headers["Content-Type"] ?? null;
|
|
772
|
+
const normalizedBody = rawBody ? normalizeStructuredRequestBody(rawBody, contentType) : null;
|
|
773
|
+
return {
|
|
774
|
+
sourceLabel: snippet.label,
|
|
775
|
+
method,
|
|
776
|
+
url,
|
|
777
|
+
headers,
|
|
778
|
+
bodyFormat: normalizedBody?.format ?? null,
|
|
779
|
+
body: normalizedBody?.body ?? null,
|
|
780
|
+
rawBody
|
|
781
|
+
};
|
|
782
|
+
};
|
|
783
|
+
const extractFetchHeaders = (optionsBody) => {
|
|
784
|
+
const headersBlock = extractObjectPropertyValue(optionsBody, "headers");
|
|
785
|
+
if (!headersBlock) return {};
|
|
786
|
+
const parsedHeaders = parseLooseStructuredValue(headersBlock);
|
|
787
|
+
if (!isRecord(parsedHeaders)) return {};
|
|
788
|
+
return Object.fromEntries(Object.entries(parsedHeaders).map(([key, value]) => [key, String(value)]));
|
|
789
|
+
};
|
|
790
|
+
const extractFetchBody = (optionsBody) => {
|
|
791
|
+
return extractObjectPropertyValue(optionsBody, "body");
|
|
792
|
+
};
|
|
793
|
+
const normalizeStructuredRequestBody = (body, contentType) => {
|
|
794
|
+
const parsedBody = parseLooseStructuredValue(body);
|
|
795
|
+
if (parsedBody !== null && (contentType?.toLowerCase().includes("json") || /^[[{]/.test(body.trim()))) return {
|
|
796
|
+
format: "json",
|
|
797
|
+
body: parsedBody
|
|
798
|
+
};
|
|
799
|
+
return normalizeResponseBody(body, contentType);
|
|
800
|
+
};
|
|
801
|
+
const extractObjectPropertyValue = (source, propertyName) => {
|
|
802
|
+
const propertyMatch = source.match(new RegExp(`\\b${propertyName}\\s*:`, "m"));
|
|
803
|
+
if (!propertyMatch || propertyMatch.index === void 0) return null;
|
|
804
|
+
let cursor = propertyMatch.index + propertyMatch[0].length;
|
|
805
|
+
while (/\s/.test(source[cursor] ?? "")) cursor += 1;
|
|
806
|
+
if (source.startsWith("JSON.stringify", cursor)) {
|
|
807
|
+
const openParenIndex = source.indexOf("(", cursor);
|
|
808
|
+
if (openParenIndex === -1) return null;
|
|
809
|
+
return extractBalancedSegment(source, openParenIndex, "(", ")")?.slice(1, -1).trim() ?? null;
|
|
810
|
+
}
|
|
811
|
+
if (source[cursor] === "{" || source[cursor] === "[") {
|
|
812
|
+
const closingChar = source[cursor] === "{" ? "}" : "]";
|
|
813
|
+
return extractBalancedSegment(source, cursor, source[cursor], closingChar)?.trim() ?? null;
|
|
814
|
+
}
|
|
815
|
+
if (source[cursor] === "\"" || source[cursor] === "'") return extractStringLiteralValue(source, cursor);
|
|
816
|
+
return (source.slice(cursor).match(/^([^,\n]+)/)?.[1])?.trim() ?? null;
|
|
817
|
+
};
|
|
818
|
+
const extractBalancedSegment = (source, startIndex, openChar, closeChar) => {
|
|
819
|
+
let depth = 0;
|
|
820
|
+
let quoteChar = null;
|
|
821
|
+
let isEscaped = false;
|
|
822
|
+
for (let index = startIndex; index < source.length; index += 1) {
|
|
823
|
+
const character = source[index];
|
|
824
|
+
if (quoteChar) {
|
|
825
|
+
if (isEscaped) {
|
|
826
|
+
isEscaped = false;
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
if (character === "\\") {
|
|
830
|
+
isEscaped = true;
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
if (character === quoteChar) quoteChar = null;
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
if (character === "\"" || character === "'") {
|
|
837
|
+
quoteChar = character;
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
if (character === openChar) {
|
|
841
|
+
depth += 1;
|
|
842
|
+
continue;
|
|
843
|
+
}
|
|
844
|
+
if (character === closeChar) {
|
|
845
|
+
depth -= 1;
|
|
846
|
+
if (depth === 0) return source.slice(startIndex, index + 1);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
return null;
|
|
850
|
+
};
|
|
851
|
+
const extractStringLiteralValue = (source, startIndex) => {
|
|
852
|
+
const quoteChar = source[startIndex];
|
|
853
|
+
let value = "";
|
|
854
|
+
let isEscaped = false;
|
|
855
|
+
for (let index = startIndex + 1; index < source.length; index += 1) {
|
|
856
|
+
const character = source[index];
|
|
857
|
+
if (isEscaped) {
|
|
858
|
+
value += character;
|
|
859
|
+
isEscaped = false;
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
if (character === "\\") {
|
|
863
|
+
isEscaped = true;
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
if (character === quoteChar) return value;
|
|
867
|
+
value += character;
|
|
868
|
+
}
|
|
869
|
+
return null;
|
|
870
|
+
};
|
|
871
|
+
const parseLooseStructuredValue = (value) => {
|
|
872
|
+
const trimmed = value.trim();
|
|
873
|
+
if (!/^[[{]/.test(trimmed)) return null;
|
|
874
|
+
const normalized = trimmed.replace(/([{,]\s*)([A-Za-z_$][\w$-]*)(\s*:)/g, "$1\"$2\"$3").replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, (_match, inner) => JSON.stringify(inner.replace(/\\'/g, "'"))).replace(/,\s*([}\]])/g, "$1");
|
|
875
|
+
try {
|
|
876
|
+
return JSON.parse(normalized);
|
|
877
|
+
} catch {
|
|
878
|
+
return null;
|
|
879
|
+
}
|
|
880
|
+
};
|
|
881
|
+
const escapeSelector = (value) => {
|
|
882
|
+
return value.replace(/([#.:[\],=])/g, "\\$1");
|
|
883
|
+
};
|
|
884
|
+
const readText = (element) => {
|
|
885
|
+
const value = element?.textContent?.replace(/\s+/g, " ").trim();
|
|
886
|
+
return value ? value : null;
|
|
887
|
+
};
|
|
888
|
+
const readTexts = (elements) => {
|
|
889
|
+
return Array.from(elements).map((element) => readText(element)).filter((value) => Boolean(value));
|
|
890
|
+
};
|
|
891
|
+
const extractButtonText = (element) => {
|
|
892
|
+
return readTexts(element.querySelectorAll("span, div, code")).filter((text) => text.trim().length > 0).sort((left, right) => right.length - left.length)[0] ?? readText(element);
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
//#endregion
|
|
896
|
+
//#region src/ReadmeCrawler.ts
|
|
897
|
+
const resolveReadmeSidebarUrls = (operation, baseUrl) => {
|
|
898
|
+
const normalizedBaseUrl = new URL(baseUrl);
|
|
899
|
+
const urls = operation.sidebarLinks.map((link) => link.href).filter((href) => Boolean(href)).map((href) => new URL(href, normalizedBaseUrl).toString()).filter((href) => /^https?:\/\//i.test(href));
|
|
900
|
+
return Array.from(new Set(urls));
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
//#endregion
|
|
904
|
+
//#region src/Application.ts
|
|
905
|
+
var Application = class {
|
|
906
|
+
config;
|
|
907
|
+
constructor(config = {}) {
|
|
908
|
+
this.config = defineConfig(config);
|
|
909
|
+
}
|
|
910
|
+
getConfig(config = {}) {
|
|
911
|
+
return {
|
|
912
|
+
...this.config,
|
|
913
|
+
...config
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
configure(config) {
|
|
917
|
+
this.config = defineConfig({
|
|
918
|
+
...this.config,
|
|
919
|
+
...config
|
|
920
|
+
});
|
|
921
|
+
return this.config;
|
|
922
|
+
}
|
|
923
|
+
async crawlReadmeOperations(source, rootOperation, baseUrl) {
|
|
924
|
+
const crawlBaseUrl = this.resolveCrawlBaseUrl(source, baseUrl);
|
|
925
|
+
if (!crawlBaseUrl) throw new Error("Crawl mode requires a remote source URL or --base-url when using a local file");
|
|
926
|
+
const sessionWasActive = Boolean(getBrowserSession());
|
|
927
|
+
if (!sessionWasActive) await startBrowserSession(this.config);
|
|
928
|
+
try {
|
|
929
|
+
const discoveredUrls = resolveReadmeSidebarUrls(rootOperation, crawlBaseUrl);
|
|
930
|
+
const rootSourceUrl = new URL(crawlBaseUrl).toString();
|
|
931
|
+
const rootEntry = this.attachSourceUrl(rootSourceUrl, rootOperation);
|
|
932
|
+
const urlsToFetch = discoveredUrls.filter((url) => url !== rootSourceUrl);
|
|
933
|
+
return {
|
|
934
|
+
rootSource: rootSourceUrl,
|
|
935
|
+
discoveredUrls,
|
|
936
|
+
operations: [rootEntry, ...(await Promise.all(urlsToFetch.map(async (url) => {
|
|
937
|
+
const start = Date.now();
|
|
938
|
+
const operation = extractReadmeOperationFromHtml(await this.loadHtmlSource(url));
|
|
939
|
+
const end = Date.now() - start;
|
|
940
|
+
if (!operation.method) return null;
|
|
941
|
+
_h3ravel_shared.Logger.twoColumnDetail(_h3ravel_shared.Logger.log([["Crawled", "green"], [`${end / 1e3}s`, "gray"]], " ", false), url.replace(crawlBaseUrl, ""));
|
|
942
|
+
return this.attachSourceUrl(url, operation);
|
|
943
|
+
}))).filter((operation) => operation !== null)]
|
|
944
|
+
};
|
|
945
|
+
} finally {
|
|
946
|
+
if (!sessionWasActive) await endBrowserSession();
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
attachSourceUrl(sourceUrl, operation) {
|
|
950
|
+
return {
|
|
951
|
+
sourceUrl,
|
|
952
|
+
...operation
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
resolveCrawlBaseUrl(source, baseUrl) {
|
|
956
|
+
if (baseUrl) return new URL(baseUrl).toString();
|
|
957
|
+
if (/^https?:\/\//i.test(source)) return source;
|
|
958
|
+
return null;
|
|
959
|
+
}
|
|
960
|
+
async loadHtmlSource(source, initial) {
|
|
961
|
+
if (!source) throw new Error("A source path or URL is required");
|
|
962
|
+
if (/^https?:\/\//i.test(source)) return browser(source, this.config, initial);
|
|
963
|
+
return (0, node_fs_promises.readFile)(node_path.default.resolve(process.cwd(), source), "utf8");
|
|
964
|
+
}
|
|
965
|
+
};
|
|
966
|
+
|
|
967
|
+
//#endregion
|
|
968
|
+
//#region src/Commands/InitCommand.ts
|
|
969
|
+
const __filename$1 = (0, url.fileURLToPath)(require("url").pathToFileURL(__filename).href);
|
|
970
|
+
var InitCommand = class extends _h3ravel_musket.Command {
|
|
971
|
+
signature = `init
|
|
972
|
+
{--f|force : Overwrite existing config}
|
|
973
|
+
`;
|
|
974
|
+
description = "Generate a default openapie.config.ts in the current directory";
|
|
975
|
+
async handle() {
|
|
976
|
+
const cwd = process.cwd();
|
|
977
|
+
const configPath = node_path.default.join(cwd, "openapie.config.js");
|
|
978
|
+
const force = Boolean(this.option("force", false));
|
|
979
|
+
const configTemplate = this.buildConfigTemplate();
|
|
980
|
+
try {
|
|
981
|
+
await node_fs_promises.default.access(configPath);
|
|
982
|
+
if (!force) {
|
|
983
|
+
this.error(`Config file already exists at ${configPath}. Use --force to overwrite.`);
|
|
984
|
+
process.exit(1);
|
|
985
|
+
}
|
|
986
|
+
} catch {}
|
|
987
|
+
await node_fs_promises.default.writeFile(configPath, configTemplate, "utf8");
|
|
988
|
+
this.line(`Created ${configPath} `);
|
|
989
|
+
}
|
|
990
|
+
buildConfigTemplate() {
|
|
991
|
+
const def = defaultConfig;
|
|
992
|
+
return [
|
|
993
|
+
`import { defineConfig } from '${__filename$1.includes("node_modules") ? "openapie" : "./src/Manager"}'`,
|
|
994
|
+
"",
|
|
995
|
+
"/**",
|
|
996
|
+
" * See https://oapi-extractor.toneflix.net/configuration for docs",
|
|
997
|
+
" */",
|
|
998
|
+
"export default defineConfig({",
|
|
999
|
+
` outputFormat: '${def.outputFormat}',`,
|
|
1000
|
+
` outputShape: '${def.outputShape}',`,
|
|
1001
|
+
` browser: '${def.browser}',`,
|
|
1002
|
+
` requestTimeout: ${def.requestTimeout},`,
|
|
1003
|
+
` maxRedirects: ${def.maxRedirects},`,
|
|
1004
|
+
` userAgent: '${def.userAgent}',`,
|
|
1005
|
+
` retryCount: ${def.retryCount},`,
|
|
1006
|
+
` retryDelay: ${def.retryDelay},`,
|
|
1007
|
+
"})"
|
|
1008
|
+
].join("\n");
|
|
1009
|
+
}
|
|
1010
|
+
};
|
|
1011
|
+
|
|
1012
|
+
//#endregion
|
|
1013
|
+
//#region src/OpenApiTransform.ts
|
|
1014
|
+
const createOpenApiDocumentFromReadmeOperations = (operations, title = "Extracted API", version = "0.0.0") => {
|
|
1015
|
+
const paths = {};
|
|
1016
|
+
for (const operation of operations) {
|
|
1017
|
+
const normalized = transformReadmeOperationToOpenApi(operation);
|
|
1018
|
+
if (!normalized || shouldSkipNormalizedOperation(normalized)) continue;
|
|
1019
|
+
paths[normalized.path] ??= {};
|
|
1020
|
+
paths[normalized.path][normalized.method] = normalized.operation;
|
|
1021
|
+
}
|
|
1022
|
+
return {
|
|
1023
|
+
openapi: "3.1.0",
|
|
1024
|
+
info: {
|
|
1025
|
+
title,
|
|
1026
|
+
version
|
|
1027
|
+
},
|
|
1028
|
+
paths
|
|
1029
|
+
};
|
|
1030
|
+
};
|
|
1031
|
+
const shouldSkipNormalizedOperation = (normalized) => {
|
|
1032
|
+
return normalized.path === "/" && normalized.method === "get" && normalized.operation.operationId === "get" && Object.keys(normalized.operation.responses).length === 0;
|
|
1033
|
+
};
|
|
1034
|
+
const transformReadmeOperationToOpenApi = (operation) => {
|
|
1035
|
+
if (!operation.method || !operation.url) return null;
|
|
1036
|
+
const url = new URL(operation.url);
|
|
1037
|
+
if (shouldSkipPlaceholderOperation(url, operation)) return null;
|
|
1038
|
+
const method = operation.method.toLowerCase();
|
|
1039
|
+
const path = decodeOpenApiPathname(url.pathname);
|
|
1040
|
+
return {
|
|
1041
|
+
path,
|
|
1042
|
+
method,
|
|
1043
|
+
operation: {
|
|
1044
|
+
summary: operation.sidebarLinks.find((link) => link.active)?.label,
|
|
1045
|
+
description: operation.description ?? void 0,
|
|
1046
|
+
operationId: buildOperationId(method, path),
|
|
1047
|
+
parameters: createParameters(operation.requestParams),
|
|
1048
|
+
requestBody: createRequestBody(operation.requestParams, operation.requestExampleNormalized?.body, hasExtractedBodyParams(operation.requestParams) ? null : resolveFallbackRequestBodyExample(operation)),
|
|
1049
|
+
responses: createResponses(operation.responseSchemas, operation.responseBodies)
|
|
1050
|
+
}
|
|
1051
|
+
};
|
|
1052
|
+
};
|
|
1053
|
+
const shouldSkipPlaceholderOperation = (url, operation) => {
|
|
1054
|
+
if (url.hostname !== "example.com" || url.pathname !== "/") return false;
|
|
1055
|
+
return operation.requestParams.length === 0 && operation.responseSchemas.length === 0 && operation.responseBodies.length === 0 && operation.requestExampleNormalized?.url === "https://example.com/";
|
|
1056
|
+
};
|
|
1057
|
+
const decodeOpenApiPathname = (pathname) => {
|
|
1058
|
+
return pathname.split("/").map((segment) => {
|
|
1059
|
+
if (!segment) return segment;
|
|
1060
|
+
try {
|
|
1061
|
+
return decodeURIComponent(segment);
|
|
1062
|
+
} catch {
|
|
1063
|
+
return segment;
|
|
1064
|
+
}
|
|
1065
|
+
}).join("/");
|
|
1066
|
+
};
|
|
1067
|
+
const hasExtractedBodyParams = (params) => {
|
|
1068
|
+
return params.some((param) => param.in === "body" || param.in === null);
|
|
1069
|
+
};
|
|
1070
|
+
const createParameters = (params) => {
|
|
1071
|
+
const parameters = params.filter((param) => isOpenApiParameterLocation(param.in)).map((param) => createParameter(param));
|
|
1072
|
+
return parameters.length > 0 ? parameters : void 0;
|
|
1073
|
+
};
|
|
1074
|
+
const createRequestBody = (params, example, fallbackExample) => {
|
|
1075
|
+
const bodyParams = params.filter((param) => param.in === "body" || param.in === null);
|
|
1076
|
+
if (bodyParams.length === 0 && example == null) return;
|
|
1077
|
+
const schema = buildRequestBodySchema(bodyParams, example, fallbackExample);
|
|
1078
|
+
return {
|
|
1079
|
+
required: bodyParams.length > 0 ? bodyParams.some((param) => param.required) : false,
|
|
1080
|
+
content: { "application/json": {
|
|
1081
|
+
schema,
|
|
1082
|
+
...example != null ? { example } : {}
|
|
1083
|
+
} }
|
|
1084
|
+
};
|
|
1085
|
+
};
|
|
1086
|
+
const buildRequestBodySchema = (params, example, fallbackExample) => {
|
|
1087
|
+
const schema = mergeOpenApiSchemas(createExampleSchema(example), createExampleSchema(fallbackExample)) ?? { type: "object" };
|
|
1088
|
+
if (example != null) schema.example = example;
|
|
1089
|
+
else if (fallbackExample != null) schema.example = fallbackExample;
|
|
1090
|
+
for (const param of params) insertRequestBodyParam(schema, param);
|
|
1091
|
+
return schema;
|
|
1092
|
+
};
|
|
1093
|
+
const inferSchemaFromExample = (value) => {
|
|
1094
|
+
if (Array.isArray(value)) return {
|
|
1095
|
+
type: "array",
|
|
1096
|
+
items: inferSchemaFromExample(value[0]) ?? {},
|
|
1097
|
+
example: value
|
|
1098
|
+
};
|
|
1099
|
+
if (isRecord(value)) return {
|
|
1100
|
+
type: "object",
|
|
1101
|
+
properties: Object.fromEntries(Object.entries(value).map(([key, entryValue]) => [key, inferSchemaFromExample(entryValue) ?? {}])),
|
|
1102
|
+
example: value
|
|
1103
|
+
};
|
|
1104
|
+
if (typeof value === "string") return {
|
|
1105
|
+
type: "string",
|
|
1106
|
+
example: value
|
|
1107
|
+
};
|
|
1108
|
+
if (typeof value === "number") return {
|
|
1109
|
+
type: Number.isInteger(value) ? "integer" : "number",
|
|
1110
|
+
example: value
|
|
1111
|
+
};
|
|
1112
|
+
if (typeof value === "boolean") return {
|
|
1113
|
+
type: "boolean",
|
|
1114
|
+
example: value
|
|
1115
|
+
};
|
|
1116
|
+
if (value === null) return {};
|
|
1117
|
+
};
|
|
1118
|
+
const insertRequestBodyParam = (rootSchema, param) => {
|
|
1119
|
+
const path = param.path.length > 0 ? param.path : [param.name];
|
|
1120
|
+
let currentSchema = rootSchema;
|
|
1121
|
+
for (const [index, segment] of path.slice(0, -1).entries()) {
|
|
1122
|
+
currentSchema.properties ??= {};
|
|
1123
|
+
currentSchema.properties[segment] ??= { type: "object" };
|
|
1124
|
+
if (param.required) currentSchema.required = Array.from(new Set([...currentSchema.required ?? [], segment]));
|
|
1125
|
+
currentSchema = currentSchema.properties[segment];
|
|
1126
|
+
currentSchema.type ??= "object";
|
|
1127
|
+
if (index === path.length - 2 && param.required) currentSchema.required ??= [];
|
|
1128
|
+
}
|
|
1129
|
+
const leafKey = path[path.length - 1] ?? param.name;
|
|
1130
|
+
currentSchema.properties ??= {};
|
|
1131
|
+
currentSchema.properties[leafKey] = createParameterSchema(param);
|
|
1132
|
+
if (param.required) currentSchema.required = Array.from(new Set([...currentSchema.required ?? [], leafKey]));
|
|
1133
|
+
};
|
|
1134
|
+
const createParameter = (param) => {
|
|
1135
|
+
return {
|
|
1136
|
+
name: param.name,
|
|
1137
|
+
in: param.in,
|
|
1138
|
+
required: param.in === "path" ? true : param.required,
|
|
1139
|
+
description: param.description ?? void 0,
|
|
1140
|
+
schema: createParameterSchema(param),
|
|
1141
|
+
example: param.defaultValue ?? void 0
|
|
1142
|
+
};
|
|
1143
|
+
};
|
|
1144
|
+
const createParameterSchema = (param) => {
|
|
1145
|
+
return {
|
|
1146
|
+
type: param.type ?? void 0,
|
|
1147
|
+
description: param.description ?? void 0,
|
|
1148
|
+
default: param.defaultValue ?? void 0
|
|
1149
|
+
};
|
|
1150
|
+
};
|
|
1151
|
+
const createResponses = (schemas, responseBodies) => {
|
|
1152
|
+
const responses = {};
|
|
1153
|
+
for (const schema of schemas) {
|
|
1154
|
+
if (!schema.statusCode) continue;
|
|
1155
|
+
const content = createResponseContent(responseBodies.filter((body) => body.statusCode === schema.statusCode));
|
|
1156
|
+
responses[schema.statusCode] = {
|
|
1157
|
+
description: schema.description ?? schema.statusCode,
|
|
1158
|
+
...content ? { content } : {}
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
for (const body of responseBodies) {
|
|
1162
|
+
if (!body.statusCode || responses[body.statusCode]) continue;
|
|
1163
|
+
const content = createResponseContent([body]);
|
|
1164
|
+
responses[body.statusCode] = {
|
|
1165
|
+
description: body.label ?? body.statusCode,
|
|
1166
|
+
...content ? { content } : {}
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
return responses;
|
|
1170
|
+
};
|
|
1171
|
+
const createResponseContent = (bodies) => {
|
|
1172
|
+
if (bodies.length === 0) return;
|
|
1173
|
+
const content = {};
|
|
1174
|
+
for (const body of bodies) {
|
|
1175
|
+
const contentType = body.contentType ?? (body.format === "json" ? "application/json" : "text/plain");
|
|
1176
|
+
content[contentType] = {
|
|
1177
|
+
schema: inferSchemaFromBody(body.body, body.format),
|
|
1178
|
+
example: body.body
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
return content;
|
|
1182
|
+
};
|
|
1183
|
+
const inferSchemaFromBody = (body, format) => {
|
|
1184
|
+
if (format === "json") return inferSchemaFromExample(body);
|
|
1185
|
+
if (format === "text") return {
|
|
1186
|
+
type: "string",
|
|
1187
|
+
example: body
|
|
1188
|
+
};
|
|
1189
|
+
};
|
|
1190
|
+
const resolveFallbackRequestBodyExample = (operation) => {
|
|
1191
|
+
const jsonResponseBody = operation.responseBodies.find((body) => body.format === "json")?.body;
|
|
1192
|
+
if (jsonResponseBody != null) return jsonResponseBody;
|
|
1193
|
+
if (typeof operation.responseExample === "object" && operation.responseExample !== null) return operation.responseExample;
|
|
1194
|
+
if (typeof operation.responseExampleRaw === "string") return parsePossiblyTruncatedJson(operation.responseExampleRaw);
|
|
1195
|
+
if (typeof operation.responseExample === "string") return parsePossiblyTruncatedJson(operation.responseExample);
|
|
1196
|
+
return null;
|
|
1197
|
+
};
|
|
1198
|
+
const createExampleSchema = (value) => {
|
|
1199
|
+
if (value == null) return null;
|
|
1200
|
+
return inferSchemaFromExample(value) ?? null;
|
|
1201
|
+
};
|
|
1202
|
+
const mergeOpenApiSchemas = (left, right) => {
|
|
1203
|
+
if (!left) return right;
|
|
1204
|
+
if (!right) return left;
|
|
1205
|
+
const merged = {
|
|
1206
|
+
...right,
|
|
1207
|
+
...left,
|
|
1208
|
+
...left.type || right.type ? { type: left.type ?? right.type } : {},
|
|
1209
|
+
...left.description || right.description ? { description: left.description ?? right.description } : {},
|
|
1210
|
+
...left.default !== void 0 || right.default !== void 0 ? { default: left.default ?? right.default } : {},
|
|
1211
|
+
...left.example !== void 0 || right.example !== void 0 ? { example: left.example ?? right.example } : {}
|
|
1212
|
+
};
|
|
1213
|
+
if (left.properties || right.properties) {
|
|
1214
|
+
const propertyKeys = new Set([...Object.keys(left.properties ?? {}), ...Object.keys(right.properties ?? {})]);
|
|
1215
|
+
merged.properties = Object.fromEntries(Array.from(propertyKeys).map((key) => [key, mergeOpenApiSchemas(left.properties?.[key] ?? null, right.properties?.[key] ?? null) ?? {}]));
|
|
1216
|
+
}
|
|
1217
|
+
if (left.items || right.items) merged.items = mergeOpenApiSchemas(left.items ?? null, right.items ?? null) ?? {};
|
|
1218
|
+
if (left.required || right.required) merged.required = Array.from(new Set([...right.required ?? [], ...left.required ?? []]));
|
|
1219
|
+
return merged;
|
|
1220
|
+
};
|
|
1221
|
+
const buildOperationId = (method, path) => {
|
|
1222
|
+
return `${method}${path.replace(/\{([^}]+)\}/g, "$1").split("/").filter(Boolean).map((segment) => segment.replace(/[^a-zA-Z0-9]+/g, " ")).map((segment) => segment.trim()).filter(Boolean).map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1).replace(/\s+(.)/g, (_match, char) => char.toUpperCase())).join("")}`;
|
|
1223
|
+
};
|
|
1224
|
+
const isOpenApiParameterLocation = (value) => {
|
|
1225
|
+
return value === "query" || value === "header" || value === "path" || value === "cookie";
|
|
1226
|
+
};
|
|
1227
|
+
|
|
1228
|
+
//#endregion
|
|
1229
|
+
//#region src/Commands/ParseCommand.ts
|
|
1230
|
+
var ParseCommand = class extends _h3ravel_musket.Command {
|
|
1231
|
+
signature = `parse
|
|
1232
|
+
{source : Local HTML file path or remote URL}
|
|
1233
|
+
{--O|output=pretty : Output format [pretty,json,js]}
|
|
1234
|
+
{--S|shape=raw : Result shape [raw,openapi]}
|
|
1235
|
+
{--B|browser? : Remote loader [axios,happy-dom,jsdom,puppeteer]}
|
|
1236
|
+
{--c|crawl : Crawl sidebar links and parse every discovered operation}
|
|
1237
|
+
{--b|base-url? : Base URL used to resolve sidebar links when crawling from a local file}
|
|
1238
|
+
`;
|
|
1239
|
+
description = "Parse a saved ReadMe page or remote documentation URL and print normalized output";
|
|
1240
|
+
async handle() {
|
|
1241
|
+
const conf = this.app.getConfig();
|
|
1242
|
+
const source = String(this.argument("source", "")).trim();
|
|
1243
|
+
const output = String(this.option("output", conf.outputFormat)).trim().toLowerCase();
|
|
1244
|
+
const shape = String(this.option("shape", conf.outputShape)).trim().toLowerCase();
|
|
1245
|
+
const browser = String(this.option("browser", conf.browser)).trim().toLowerCase();
|
|
1246
|
+
const crawl = this.option("crawl");
|
|
1247
|
+
const baseUrl = String(this.option("baseUrl", "")).trim() || null;
|
|
1248
|
+
const spinner = this.spinner(`${crawl ? "Crawling and p" : "P"}arsing source...`).start();
|
|
1249
|
+
let startedBrowserSession = false;
|
|
1250
|
+
try {
|
|
1251
|
+
const start = Date.now();
|
|
1252
|
+
if (!isSupportedBrowser(browser)) throw new Error(`Unsupported browser: ${browser}`);
|
|
1253
|
+
this.app.configure({ browser });
|
|
1254
|
+
if (crawl) {
|
|
1255
|
+
await startBrowserSession(this.app.getConfig());
|
|
1256
|
+
startedBrowserSession = true;
|
|
1257
|
+
}
|
|
1258
|
+
const operation = extractReadmeOperationFromHtml(await this.app.loadHtmlSource(source, true));
|
|
1259
|
+
const payload = crawl ? await this.app.crawlReadmeOperations(source, operation, baseUrl) : operation;
|
|
1260
|
+
const normalizedPayload = shape === "openapi" ? this.buildOpenApiPayload(payload) : payload;
|
|
1261
|
+
const serialized = output === "js" ? `export default ${JSON.stringify(normalizedPayload, null, 2)}` : JSON.stringify(normalizedPayload, null, output === "json" ? 0 : 2);
|
|
1262
|
+
const filePath = await this.saveOutputToFile(serialized, source, shape, output);
|
|
1263
|
+
const duration = Date.now() - start;
|
|
1264
|
+
_h3ravel_shared.Logger.twoColumnDetail(_h3ravel_shared.Logger.log([["Output", "green"], [`${duration / 1e3}s`, "gray"]], " ", false), filePath.replace(process.cwd(), "."));
|
|
1265
|
+
spinner.succeed("Parsing completed");
|
|
1266
|
+
} catch (error) {
|
|
1267
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1268
|
+
spinner.fail(`Failed to parse source: ${message}`);
|
|
1269
|
+
process.exitCode = 1;
|
|
1270
|
+
} finally {
|
|
1271
|
+
if (startedBrowserSession) await endBrowserSession();
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
buildOpenApiPayload = (payload) => {
|
|
1275
|
+
if ("operations" in payload) return createOpenApiDocumentFromReadmeOperations(payload.operations, "Extracted API", "0.0.0");
|
|
1276
|
+
return createOpenApiDocumentFromReadmeOperations([payload], "Extracted API", "0.0.0");
|
|
1277
|
+
};
|
|
1278
|
+
saveOutputToFile = async (content, source, shape, outputFormat) => {
|
|
1279
|
+
const ext = {
|
|
1280
|
+
pretty: "txt",
|
|
1281
|
+
json: "json",
|
|
1282
|
+
js: "js"
|
|
1283
|
+
}[outputFormat];
|
|
1284
|
+
const outputDir = node_path.default.resolve(process.cwd(), "output");
|
|
1285
|
+
await node_fs_promises.default.mkdir(outputDir, { recursive: true });
|
|
1286
|
+
const filename = `${source.replace(/[^a-zA-Z0-9_-]+/g, "_").replace(/^_+|_+$/g, "") || "output"}${shape === "openapi" ? ".openapi" : ""}.${ext}`;
|
|
1287
|
+
const filePath = node_path.default.join(outputDir, filename);
|
|
1288
|
+
if (outputFormat === "js") content = await prettier.default.format(content, {
|
|
1289
|
+
parser: "babel",
|
|
1290
|
+
semi: false,
|
|
1291
|
+
singleQuote: true
|
|
1292
|
+
});
|
|
1293
|
+
await node_fs_promises.default.writeFile(filePath, content, "utf8");
|
|
1294
|
+
return filePath;
|
|
1295
|
+
};
|
|
1296
|
+
};
|
|
1297
|
+
|
|
1298
|
+
//#endregion
|
|
1299
|
+
//#region src/ConfigLoader.ts
|
|
1300
|
+
const CONFIG_BASENAMES = [
|
|
1301
|
+
"openapie.config.ts",
|
|
1302
|
+
"openapie.config.js",
|
|
1303
|
+
"openapie.config.cjs"
|
|
1304
|
+
];
|
|
1305
|
+
async function loadUserConfig(rootDir = process.cwd()) {
|
|
1306
|
+
for (const basename of CONFIG_BASENAMES) {
|
|
1307
|
+
const configPath = node_path.default.join(rootDir, basename);
|
|
1308
|
+
try {
|
|
1309
|
+
await node_fs_promises.default.access(configPath);
|
|
1310
|
+
const configModule = await import((0, node_url.pathToFileURL)(configPath).href);
|
|
1311
|
+
const config = configModule.default || configModule.config || configModule;
|
|
1312
|
+
if (typeof config === "object" && config !== null) return config;
|
|
1313
|
+
} catch {
|
|
1314
|
+
continue;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
return null;
|
|
1318
|
+
}
|
|
1319
|
+
async function resolveConfig(cliOverrides = {}) {
|
|
1320
|
+
const userConfig = await loadUserConfig();
|
|
1321
|
+
return {
|
|
1322
|
+
...defaultConfig,
|
|
1323
|
+
...userConfig,
|
|
1324
|
+
...cliOverrides,
|
|
1325
|
+
happyDom: {
|
|
1326
|
+
...defaultConfig.happyDom,
|
|
1327
|
+
...userConfig?.happyDom || {},
|
|
1328
|
+
...cliOverrides.happyDom || {}
|
|
1329
|
+
}
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
//#endregion
|
|
1334
|
+
exports.Application = Application;
|
|
1335
|
+
exports.InitCommand = InitCommand;
|
|
1336
|
+
exports.ParseCommand = ParseCommand;
|
|
1337
|
+
exports.browser = browser;
|
|
1338
|
+
exports.buildOperationId = buildOperationId;
|
|
1339
|
+
exports.buildOperationUrl = buildOperationUrl;
|
|
1340
|
+
exports.buildRequestBodySchema = buildRequestBodySchema;
|
|
1341
|
+
exports.createExampleSchema = createExampleSchema;
|
|
1342
|
+
exports.createOpenApiDocumentFromReadmeOperations = createOpenApiDocumentFromReadmeOperations;
|
|
1343
|
+
exports.createParameter = createParameter;
|
|
1344
|
+
exports.createParameterSchema = createParameterSchema;
|
|
1345
|
+
exports.createParameters = createParameters;
|
|
1346
|
+
exports.createRequestBody = createRequestBody;
|
|
1347
|
+
exports.createResponseContent = createResponseContent;
|
|
1348
|
+
exports.createResponses = createResponses;
|
|
1349
|
+
exports.decodeOpenApiPathname = decodeOpenApiPathname;
|
|
1350
|
+
exports.defaultConfig = defaultConfig;
|
|
1351
|
+
exports.defineConfig = defineConfig;
|
|
1352
|
+
exports.endBrowserSession = endBrowserSession;
|
|
1353
|
+
exports.escapeSelector = escapeSelector;
|
|
1354
|
+
exports.extractBalancedSegment = extractBalancedSegment;
|
|
1355
|
+
exports.extractButtonText = extractButtonText;
|
|
1356
|
+
exports.extractCodeMirrorText = extractCodeMirrorText;
|
|
1357
|
+
exports.extractCodeSnippets = extractCodeSnippets;
|
|
1358
|
+
exports.extractFetchBody = extractFetchBody;
|
|
1359
|
+
exports.extractFetchHeaders = extractFetchHeaders;
|
|
1360
|
+
exports.extractObjectPropertyValue = extractObjectPropertyValue;
|
|
1361
|
+
exports.extractOperationDescription = extractOperationDescription;
|
|
1362
|
+
exports.extractOperationParametersFromOpenApi = extractOperationParametersFromOpenApi;
|
|
1363
|
+
exports.extractParameterDescription = extractParameterDescription;
|
|
1364
|
+
exports.extractReadmeOperationFromHtml = extractReadmeOperationFromHtml;
|
|
1365
|
+
exports.extractReadmeOperationFromSsrProps = extractReadmeOperationFromSsrProps;
|
|
1366
|
+
exports.extractRequestCodeSnippets = extractRequestCodeSnippets;
|
|
1367
|
+
exports.extractRequestParams = extractRequestParams;
|
|
1368
|
+
exports.extractRequestParamsFromOpenApi = extractRequestParamsFromOpenApi;
|
|
1369
|
+
exports.extractRequestSnippetLabel = extractRequestSnippetLabel;
|
|
1370
|
+
exports.extractResponseBodies = extractResponseBodies;
|
|
1371
|
+
exports.extractResponseBodiesFromOpenApi = extractResponseBodiesFromOpenApi;
|
|
1372
|
+
exports.extractResponseContentTypes = extractResponseContentTypes;
|
|
1373
|
+
exports.extractResponseLabels = extractResponseLabels;
|
|
1374
|
+
exports.extractResponseSchemas = extractResponseSchemas;
|
|
1375
|
+
exports.extractResponseSchemasFromOpenApi = extractResponseSchemasFromOpenApi;
|
|
1376
|
+
exports.extractSidebarLinkLabel = extractSidebarLinkLabel;
|
|
1377
|
+
exports.extractSidebarLinks = extractSidebarLinks;
|
|
1378
|
+
exports.extractStringLiteralValue = extractStringLiteralValue;
|
|
1379
|
+
exports.findParameterRoot = findParameterRoot;
|
|
1380
|
+
exports.flattenOpenApiSchemaProperties = flattenOpenApiSchemaProperties;
|
|
1381
|
+
exports.getBrowserSession = getBrowserSession;
|
|
1382
|
+
Object.defineProperty(exports, 'globalConfig', {
|
|
1383
|
+
enumerable: true,
|
|
1384
|
+
get: function () {
|
|
1385
|
+
return globalConfig;
|
|
1386
|
+
}
|
|
1387
|
+
});
|
|
1388
|
+
exports.hasExtractedBodyParams = hasExtractedBodyParams;
|
|
1389
|
+
exports.inferParameterLocation = inferParameterLocation;
|
|
1390
|
+
exports.inferParameterLocationFromText = inferParameterLocationFromText;
|
|
1391
|
+
exports.inferParameterPath = inferParameterPath;
|
|
1392
|
+
exports.inferParameterType = inferParameterType;
|
|
1393
|
+
exports.inferSchemaFromBody = inferSchemaFromBody;
|
|
1394
|
+
exports.inferSchemaFromExample = inferSchemaFromExample;
|
|
1395
|
+
exports.insertRequestBodyParam = insertRequestBodyParam;
|
|
1396
|
+
exports.isOpenApiParameterLocation = isOpenApiParameterLocation;
|
|
1397
|
+
exports.isRecord = isRecord;
|
|
1398
|
+
exports.isRequiredParameter = isRequiredParameter;
|
|
1399
|
+
exports.isSupportedBrowser = isSupportedBrowser;
|
|
1400
|
+
exports.loadUserConfig = loadUserConfig;
|
|
1401
|
+
exports.mergeOpenApiSchemas = mergeOpenApiSchemas;
|
|
1402
|
+
exports.mergeReadmeOperations = mergeReadmeOperations;
|
|
1403
|
+
exports.mergeSsrPropsIntoRenderedHtml = mergeSsrPropsIntoRenderedHtml;
|
|
1404
|
+
exports.normalizeCurlSnippet = normalizeCurlSnippet;
|
|
1405
|
+
exports.normalizeFetchSnippet = normalizeFetchSnippet;
|
|
1406
|
+
exports.normalizeRequestCodeSnippet = normalizeRequestCodeSnippet;
|
|
1407
|
+
exports.normalizeResponseBody = normalizeResponseBody;
|
|
1408
|
+
exports.normalizeStructuredRequestBody = normalizeStructuredRequestBody;
|
|
1409
|
+
exports.parseLooseStructuredValue = parseLooseStructuredValue;
|
|
1410
|
+
exports.parsePossiblyTruncatedJson = parsePossiblyTruncatedJson;
|
|
1411
|
+
exports.readInputValue = readInputValue;
|
|
1412
|
+
exports.readText = readText;
|
|
1413
|
+
exports.readTexts = readTexts;
|
|
1414
|
+
exports.resolveConfig = resolveConfig;
|
|
1415
|
+
exports.resolveFallbackRequestBodyExample = resolveFallbackRequestBodyExample;
|
|
1416
|
+
exports.resolveOpenApiMediaExample = resolveOpenApiMediaExample;
|
|
1417
|
+
exports.resolveParameterInput = resolveParameterInput;
|
|
1418
|
+
exports.resolveReadmeSidebarUrls = resolveReadmeSidebarUrls;
|
|
1419
|
+
exports.resolveSsrOperation = resolveSsrOperation;
|
|
1420
|
+
exports.shouldSkipNormalizedOperation = shouldSkipNormalizedOperation;
|
|
1421
|
+
exports.shouldSkipPlaceholderOperation = shouldSkipPlaceholderOperation;
|
|
1422
|
+
exports.startBrowserSession = startBrowserSession;
|
|
1423
|
+
exports.supportedBrowsers = supportedBrowsers;
|
|
1424
|
+
exports.transformReadmeOperationToOpenApi = transformReadmeOperationToOpenApi;
|