swaggerizer 0.0.0-stage → 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 +21 -0
- package/README.md +359 -2
- package/dist/admin.d.ts +35 -0
- package/dist/admin.d.ts.map +1 -0
- package/dist/catalogue.d.ts +22 -0
- package/dist/catalogue.d.ts.map +1 -0
- package/dist/chunks/scenario-5a1VnDmg.mjs +345 -0
- package/dist/chunks/scenario-5a1VnDmg.mjs.map +1 -0
- package/dist/chunks/scenario-BeXAfctR.mjs +347 -0
- package/dist/chunks/scenario-BeXAfctR.mjs.map +1 -0
- package/dist/chunks/scenario-CGR-NuEy.mjs +371 -0
- package/dist/chunks/scenario-CGR-NuEy.mjs.map +1 -0
- package/dist/chunks/scenario-DKCQMD5-.js +364 -0
- package/dist/chunks/scenario-DKCQMD5-.js.map +1 -0
- package/dist/chunks/scenario-DXI-TbMN.mjs +371 -0
- package/dist/chunks/scenario-DXI-TbMN.mjs.map +1 -0
- package/dist/chunks/scenario-_ZBu5fJX.js +391 -0
- package/dist/chunks/scenario-_ZBu5fJX.js.map +1 -0
- package/dist/chunks/scenario-yqSnVp0L.js +362 -0
- package/dist/chunks/scenario-yqSnVp0L.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +2977 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +100 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +129 -0
- package/dist/config.js.map +1 -0
- package/dist/config.mjs +120 -0
- package/dist/config.mjs.map +1 -0
- package/dist/contract.d.ts +60 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/debug-menu.d.ts +20 -0
- package/dist/debug-menu.d.ts.map +1 -0
- package/dist/debug-menu.js +238 -0
- package/dist/debug-menu.js.map +1 -0
- package/dist/debug-menu.mjs +236 -0
- package/dist/debug-menu.mjs.map +1 -0
- package/dist/generate.d.ts +74 -0
- package/dist/generate.d.ts.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2397 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2343 -0
- package/dist/index.mjs.map +1 -0
- package/dist/load-config.d.ts +15 -0
- package/dist/load-config.d.ts.map +1 -0
- package/dist/msw.d.ts +48 -0
- package/dist/msw.d.ts.map +1 -0
- package/dist/msw.js +324 -0
- package/dist/msw.js.map +1 -0
- package/dist/msw.mjs +317 -0
- package/dist/msw.mjs.map +1 -0
- package/dist/paths.d.ts +14 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/pull.d.ts +31 -0
- package/dist/pull.d.ts.map +1 -0
- package/dist/scenario.d.ts +86 -0
- package/dist/scenario.d.ts.map +1 -0
- package/dist/schema.d.ts +13 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/server.d.ts +83 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/spec.d.ts +36 -0
- package/dist/spec.d.ts.map +1 -0
- package/dist/types.d.ts +175 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/ui/app.css +1119 -0
- package/dist/ui/app.js +806 -0
- package/dist/ui/index.html +77 -0
- package/dist/ui-assets.d.ts +9 -0
- package/dist/ui-assets.d.ts.map +1 -0
- package/package.json +99 -4
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2343 @@
|
|
|
1
|
+
import { H as HTTP_METHODS, o as operationKey, E as EMPTY, G as GENERATED, a as applySelection, b as buildOperationStates, m as matchOperation, p as parseScenarioHeader, S as SCENARIO_HEADER, c as candidatePaths, e as encodeBody, r as resolveResponse, d as relativeToBase } from './chunks/scenario-DXI-TbMN.mjs';
|
|
2
|
+
export { f as applyAliases, g as effectiveExample, v as variantsOf } from './chunks/scenario-DXI-TbMN.mjs';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import http from 'node:http';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import SwaggerParser from '@apidevtools/swagger-parser';
|
|
8
|
+
import { applyOverlays, unmatchedKeys, lookupByOperation, extractParams, createHandlerContext, isHandlerResult } from './config.mjs';
|
|
9
|
+
export { defineConfig, result } from './config.mjs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
|
|
12
|
+
const CONTENT_TYPES = {
|
|
13
|
+
".html": "text/html; charset=utf-8",
|
|
14
|
+
".js": "text/javascript; charset=utf-8",
|
|
15
|
+
".css": "text/css; charset=utf-8",
|
|
16
|
+
".svg": "image/svg+xml"
|
|
17
|
+
};
|
|
18
|
+
const BASE_PLACEHOLDER = /__SWAGGERIZER_BASE__/g;
|
|
19
|
+
function escapeHtmlAttr(str) {
|
|
20
|
+
return str.replace(/["&'<>]/g, c => {
|
|
21
|
+
switch (c) {
|
|
22
|
+
case '"': return """;
|
|
23
|
+
case "&": return "&";
|
|
24
|
+
case "'": return "'";
|
|
25
|
+
case "<": return "<";
|
|
26
|
+
case ">": return ">";
|
|
27
|
+
default: return c;
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The UI files live in `src/ui` during development and are copied to `dist/ui` by the build, next
|
|
33
|
+
* to the bundle that is executing (`__dirname` in CommonJS, `import.meta.url` in the ES build).
|
|
34
|
+
*/
|
|
35
|
+
function currentDir() {
|
|
36
|
+
if (typeof __dirname !== "undefined")
|
|
37
|
+
return __dirname;
|
|
38
|
+
try {
|
|
39
|
+
return path.dirname(fileURLToPath(import.meta.url));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return process.cwd();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function resolveUiDir() {
|
|
46
|
+
const here = currentDir();
|
|
47
|
+
const candidates = [
|
|
48
|
+
path.join(here, "ui"),
|
|
49
|
+
path.join(here, "..", "src", "ui"),
|
|
50
|
+
path.join(here, "..", "dist", "ui")
|
|
51
|
+
];
|
|
52
|
+
for (const candidate of candidates) {
|
|
53
|
+
if (fs.existsSync(path.join(candidate, "index.html")))
|
|
54
|
+
return candidate;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
function createUiAssetServer(basePath, uiDir = resolveUiDir()) {
|
|
59
|
+
const cache = new Map();
|
|
60
|
+
function load(asset) {
|
|
61
|
+
if (!uiDir)
|
|
62
|
+
return null;
|
|
63
|
+
const cached = cache.get(asset);
|
|
64
|
+
if (cached)
|
|
65
|
+
return cached;
|
|
66
|
+
try {
|
|
67
|
+
let content = fs.readFileSync(path.join(uiDir, asset));
|
|
68
|
+
if (asset === "index.html") {
|
|
69
|
+
const safeBase = escapeHtmlAttr(basePath === "/" ? "" : basePath);
|
|
70
|
+
content = Buffer.from(content.toString("utf8").replace(BASE_PLACEHOLDER, safeBase), "utf8");
|
|
71
|
+
}
|
|
72
|
+
cache.set(asset, content);
|
|
73
|
+
return content;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function serve(asset, res) {
|
|
80
|
+
const content = load(asset);
|
|
81
|
+
if (!content) {
|
|
82
|
+
res.statusCode = 404;
|
|
83
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
84
|
+
res.end("swaggerizer admin UI assets were not found. Rebuild with `npm run build`.");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
res.statusCode = 200;
|
|
88
|
+
res.setHeader("Content-Type", CONTENT_TYPES[path.extname(asset)] || "application/octet-stream");
|
|
89
|
+
res.setHeader("Content-Length", content.length);
|
|
90
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
91
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
92
|
+
if (asset === "index.html") {
|
|
93
|
+
res.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'");
|
|
94
|
+
}
|
|
95
|
+
res.end(content);
|
|
96
|
+
}
|
|
97
|
+
return { available: Boolean(uiDir), serve };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function sendJson(res, status, payload) {
|
|
101
|
+
res.statusCode = status;
|
|
102
|
+
res.setHeader("Content-Type", "application/json");
|
|
103
|
+
res.setHeader("Cache-Control", "no-store");
|
|
104
|
+
res.end(JSON.stringify(payload));
|
|
105
|
+
}
|
|
106
|
+
function blank(value) {
|
|
107
|
+
return value === undefined || value === null || value === "";
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Turns a flat record (query string, form post or JSON) into a selection. Accepts the ramlizer
|
|
111
|
+
* names (`nextResponseCode`, `nextExampleName`) and flat custom fields (`status`, `body`, `contentType`).
|
|
112
|
+
*/
|
|
113
|
+
function toSelection(raw) {
|
|
114
|
+
const selection = {
|
|
115
|
+
method: raw?.method,
|
|
116
|
+
route: raw?.route,
|
|
117
|
+
code: raw?.code ?? raw?.nextResponseCode,
|
|
118
|
+
example: raw?.example ?? raw?.nextExampleName
|
|
119
|
+
};
|
|
120
|
+
if (!blank(raw?.mode))
|
|
121
|
+
selection.mode = raw.mode;
|
|
122
|
+
if (!blank(raw?.delay))
|
|
123
|
+
selection.delay = Number(raw.delay);
|
|
124
|
+
if (raw?.custom && typeof raw.custom === "object") {
|
|
125
|
+
selection.custom = raw.custom;
|
|
126
|
+
}
|
|
127
|
+
else if (!blank(raw?.status)) {
|
|
128
|
+
selection.custom = {
|
|
129
|
+
status: Number(raw.status),
|
|
130
|
+
...(raw.body !== undefined ? { body: raw.body } : {}),
|
|
131
|
+
...(!blank(raw.contentType) ? { contentType: raw.contentType } : {})
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
for (const key of Object.keys(selection)) {
|
|
135
|
+
if (blank(selection[key]))
|
|
136
|
+
delete selection[key];
|
|
137
|
+
}
|
|
138
|
+
return selection;
|
|
139
|
+
}
|
|
140
|
+
function registerAdmin(ctx) {
|
|
141
|
+
const assets = createUiAssetServer(ctx.basePath);
|
|
142
|
+
const assetFor = {
|
|
143
|
+
"/": "index.html",
|
|
144
|
+
"/index.html": "index.html",
|
|
145
|
+
"/app.js": "app.js",
|
|
146
|
+
"/app.css": "app.css"
|
|
147
|
+
};
|
|
148
|
+
async function handle(req, res) {
|
|
149
|
+
const { method, path, query } = req;
|
|
150
|
+
const isForm = Boolean(req.contentType && /x-www-form-urlencoded/i.test(req.contentType));
|
|
151
|
+
// Form posts from a plain HTML page can ask to be sent back somewhere afterwards.
|
|
152
|
+
const finish = (payload) => {
|
|
153
|
+
const redirect = (req.body && req.body.redirect) || query.get("redirect");
|
|
154
|
+
if (isForm && typeof redirect === "string" && redirect.startsWith("/")) {
|
|
155
|
+
res.statusCode = 303;
|
|
156
|
+
res.setHeader("Location", redirect);
|
|
157
|
+
res.end();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
sendJson(res, 200, payload);
|
|
161
|
+
};
|
|
162
|
+
try {
|
|
163
|
+
// ramlizer-compatible configuration endpoint.
|
|
164
|
+
if (method === "POST" && path === "/") {
|
|
165
|
+
const operations = ctx.select(toSelection(req.body || {}));
|
|
166
|
+
finish({ route: req.body?.route, operations });
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
if (method === "GET" && path === "/api/state") {
|
|
170
|
+
sendJson(res, 200, ctx.getState());
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
if (path === "/api/select" && (method === "GET" || method === "POST")) {
|
|
174
|
+
const source = method === "GET" ? Object.fromEntries(query) : req.body;
|
|
175
|
+
const selection = Array.isArray(source) ? source.map(toSelection) : toSelection(source || {});
|
|
176
|
+
finish({ operations: ctx.select(selection) });
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
if (path === "/api/reset" && (method === "GET" || method === "POST")) {
|
|
180
|
+
const source = method === "GET" ? Object.fromEntries(query) : req.body || {};
|
|
181
|
+
const reset = ctx.reset({ method: source.method || undefined, route: source.route || undefined });
|
|
182
|
+
finish({ reset, state: ctx.getState() });
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
if (method === "POST" && path === "/api/spec") {
|
|
186
|
+
const { id } = req.body || {};
|
|
187
|
+
const enabled = req.body?.enabled === true || req.body?.enabled === "true";
|
|
188
|
+
if (typeof id !== "string" || (req.body?.enabled === undefined)) {
|
|
189
|
+
throw new Error('Expected a body of {"id": string, "enabled": boolean}.');
|
|
190
|
+
}
|
|
191
|
+
const spec = ctx.setSpecEnabled(id, enabled);
|
|
192
|
+
finish({ spec, state: ctx.getState() });
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
if (method === "POST" && path === "/api/reload") {
|
|
196
|
+
sendJson(res, 200, await ctx.reload());
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
if (method === "GET" && path === "/api/changes") {
|
|
200
|
+
sendJson(res, 200, { changes: ctx.getState().changes });
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
if (path === "/api/log") {
|
|
204
|
+
if (method === "GET") {
|
|
205
|
+
const since = Number(query.get("since") || 0);
|
|
206
|
+
sendJson(res, 200, { entries: ctx.getRequestLog(Number.isFinite(since) ? since : 0) });
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
if (method === "POST") {
|
|
210
|
+
// Lets the MSW runtime report requests it answered in the browser or in tests.
|
|
211
|
+
const items = Array.isArray(req.body) ? req.body : [req.body];
|
|
212
|
+
const entries = items.slice(0, 50).map(item => ctx.recordRequest({
|
|
213
|
+
method: String(item?.method || "GET").toUpperCase(),
|
|
214
|
+
path: String(item?.path || ""),
|
|
215
|
+
...(item?.route ? { route: String(item.route) } : {}),
|
|
216
|
+
status: Number(item?.status) || 0,
|
|
217
|
+
example: String(item?.example || ""),
|
|
218
|
+
...(item?.specId ? { specId: String(item.specId) } : {}),
|
|
219
|
+
...(item?.ms !== undefined ? { ms: Number(item.ms) } : {}),
|
|
220
|
+
source: "msw"
|
|
221
|
+
}));
|
|
222
|
+
sendJson(res, 200, { recorded: entries.length });
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const openapi = /^\/api\/openapi\/([^/]+?)(\.json)?$/.exec(path);
|
|
227
|
+
if (method === "GET" && openapi) {
|
|
228
|
+
const doc = ctx.getSpecDocument(decodeURIComponent(openapi[1]));
|
|
229
|
+
if (!doc) {
|
|
230
|
+
sendJson(res, 404, { error: `No spec with id '${openapi[1]}' is loaded.` });
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
sendJson(res, 200, doc);
|
|
234
|
+
}
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
if (ctx.ui && method === "GET" && assetFor[path]) {
|
|
238
|
+
assets.serve(assetFor[path], res);
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
sendJson(res, 400, { error: err?.message ? String(err.message) : String(err) });
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
if (path.startsWith("/api/")) {
|
|
247
|
+
sendJson(res, 404, { error: `Unknown admin route ${method} ${ctx.basePath}${path}` });
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
return { handle, uiAvailable: ctx.ui && assets.available };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/*
|
|
256
|
+
* JSON-pointer resolution and deterministic sample generation for OpenAPI 3 schemas.
|
|
257
|
+
* Specs are bundled (not dereferenced), so internal `$ref`s are followed lazily here with cycle
|
|
258
|
+
* protection — that keeps documents JSON-serialisable even when schemas are recursive.
|
|
259
|
+
*/
|
|
260
|
+
function decodePointerSegment(segment) {
|
|
261
|
+
return decodeURIComponent(segment).replace(/~1/g, "/").replace(/~0/g, "~");
|
|
262
|
+
}
|
|
263
|
+
/** Resolves an internal `#/a/b` reference against `doc`. Returns undefined for anything else. */
|
|
264
|
+
function resolvePointer(doc, ref) {
|
|
265
|
+
if (typeof ref !== "string" || !ref.startsWith("#"))
|
|
266
|
+
return undefined;
|
|
267
|
+
const parts = ref.replace(/^#\/?/, "").split("/").filter(p => p !== "");
|
|
268
|
+
let node = doc;
|
|
269
|
+
for (const part of parts) {
|
|
270
|
+
if (node === null || typeof node !== "object")
|
|
271
|
+
return undefined;
|
|
272
|
+
node = node[decodePointerSegment(part)];
|
|
273
|
+
}
|
|
274
|
+
return node;
|
|
275
|
+
}
|
|
276
|
+
/** Follows `$ref` chains on `node`, returning the target and the last ref name seen. */
|
|
277
|
+
function deref(doc, node, seen = new Set()) {
|
|
278
|
+
let current = node;
|
|
279
|
+
let lastRef;
|
|
280
|
+
while (current && typeof current === "object" && typeof current.$ref === "string") {
|
|
281
|
+
const ref = current.$ref;
|
|
282
|
+
if (seen.has(ref))
|
|
283
|
+
return { node: current, ref };
|
|
284
|
+
seen.add(ref);
|
|
285
|
+
lastRef = ref;
|
|
286
|
+
current = resolvePointer(doc, ref);
|
|
287
|
+
}
|
|
288
|
+
return { node: current, ref: lastRef };
|
|
289
|
+
}
|
|
290
|
+
function refName(ref) {
|
|
291
|
+
if (!ref)
|
|
292
|
+
return undefined;
|
|
293
|
+
const parts = ref.split("/");
|
|
294
|
+
return decodePointerSegment(parts[parts.length - 1]);
|
|
295
|
+
}
|
|
296
|
+
const FORMAT_SAMPLES = {
|
|
297
|
+
"date-time": "2024-01-01T12:00:00Z",
|
|
298
|
+
date: "2024-01-01",
|
|
299
|
+
time: "12:00:00",
|
|
300
|
+
email: "user@example.com",
|
|
301
|
+
uuid: "3fa85f64-5717-4562-b3fc-2c963f66afa6",
|
|
302
|
+
uri: "https://example.com",
|
|
303
|
+
url: "https://example.com",
|
|
304
|
+
hostname: "example.com",
|
|
305
|
+
ipv4: "192.168.0.1",
|
|
306
|
+
ipv6: "::1",
|
|
307
|
+
byte: "U3dhZ2dlcml6ZXI=",
|
|
308
|
+
binary: "",
|
|
309
|
+
password: "********"
|
|
310
|
+
};
|
|
311
|
+
function primaryType(schema) {
|
|
312
|
+
const t = schema?.type;
|
|
313
|
+
if (Array.isArray(t))
|
|
314
|
+
return t.find((x) => x !== "null") ?? t[0];
|
|
315
|
+
if (typeof t === "string")
|
|
316
|
+
return t;
|
|
317
|
+
if (schema?.properties || schema?.additionalProperties)
|
|
318
|
+
return "object";
|
|
319
|
+
if (schema?.items)
|
|
320
|
+
return "array";
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
const MAX_DEPTH$1 = 8;
|
|
324
|
+
function sample(schemaIn, ctx) {
|
|
325
|
+
if (schemaIn === true || schemaIn === undefined || schemaIn === null)
|
|
326
|
+
return {};
|
|
327
|
+
if (ctx.depth > MAX_DEPTH$1)
|
|
328
|
+
return undefined;
|
|
329
|
+
let schema = schemaIn;
|
|
330
|
+
let pushed;
|
|
331
|
+
if (schema && typeof schema.$ref === "string") {
|
|
332
|
+
if (ctx.stack.includes(schema.$ref))
|
|
333
|
+
return undefined; // recursive schema: stop here
|
|
334
|
+
const ref = schema.$ref;
|
|
335
|
+
pushed = ref;
|
|
336
|
+
ctx.stack.push(ref);
|
|
337
|
+
schema = resolvePointer(ctx.doc, ref) ?? {};
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
if (schema.example !== undefined)
|
|
341
|
+
return schema.example;
|
|
342
|
+
if (Array.isArray(schema.examples) && schema.examples.length > 0)
|
|
343
|
+
return schema.examples[0];
|
|
344
|
+
if (schema.const !== undefined)
|
|
345
|
+
return schema.const;
|
|
346
|
+
if (schema.default !== undefined)
|
|
347
|
+
return schema.default;
|
|
348
|
+
if (Array.isArray(schema.enum) && schema.enum.length > 0)
|
|
349
|
+
return schema.enum[0];
|
|
350
|
+
const next = { ...ctx, depth: ctx.depth + 1 };
|
|
351
|
+
if (Array.isArray(schema.allOf) && schema.allOf.length > 0) {
|
|
352
|
+
let merged = undefined;
|
|
353
|
+
for (const part of schema.allOf) {
|
|
354
|
+
const value = sample(part, next);
|
|
355
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
356
|
+
merged = { ...(merged || {}), ...value };
|
|
357
|
+
}
|
|
358
|
+
else if (merged === undefined) {
|
|
359
|
+
merged = value;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (schema.properties) {
|
|
363
|
+
merged = { ...(merged || {}), ...sampleObject(schema, next) };
|
|
364
|
+
}
|
|
365
|
+
return merged;
|
|
366
|
+
}
|
|
367
|
+
const variants = schema.oneOf || schema.anyOf;
|
|
368
|
+
if (Array.isArray(variants) && variants.length > 0) {
|
|
369
|
+
const base = schema.properties ? sampleObject(schema, next) : undefined;
|
|
370
|
+
const chosen = sample(variants[0], next);
|
|
371
|
+
return base && chosen && typeof chosen === "object" ? { ...base, ...chosen } : chosen ?? base;
|
|
372
|
+
}
|
|
373
|
+
switch (primaryType(schema)) {
|
|
374
|
+
case "object":
|
|
375
|
+
return sampleObject(schema, next);
|
|
376
|
+
case "array": {
|
|
377
|
+
const item = sample(schema.items, next);
|
|
378
|
+
if (item === undefined)
|
|
379
|
+
return [];
|
|
380
|
+
const count = Math.max(1, Math.min(Number(schema.minItems) || 1, 3));
|
|
381
|
+
return Array.from({ length: count }, () => item);
|
|
382
|
+
}
|
|
383
|
+
case "string": {
|
|
384
|
+
if (schema.format && schema.format in FORMAT_SAMPLES)
|
|
385
|
+
return FORMAT_SAMPLES[schema.format];
|
|
386
|
+
const min = Number(schema.minLength) || 0;
|
|
387
|
+
const base = "string";
|
|
388
|
+
return min > base.length ? base.padEnd(min, "x") : base;
|
|
389
|
+
}
|
|
390
|
+
case "integer":
|
|
391
|
+
return clampNumber(schema, 1, true);
|
|
392
|
+
case "number":
|
|
393
|
+
return clampNumber(schema, 1.5, false);
|
|
394
|
+
case "boolean":
|
|
395
|
+
return true;
|
|
396
|
+
case "null":
|
|
397
|
+
return null;
|
|
398
|
+
default:
|
|
399
|
+
return schema.properties ? sampleObject(schema, next) : {};
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
finally {
|
|
403
|
+
if (pushed)
|
|
404
|
+
ctx.stack.pop();
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
function clampNumber(schema, fallback, integer) {
|
|
408
|
+
let value = fallback;
|
|
409
|
+
if (typeof schema.minimum === "number")
|
|
410
|
+
value = Math.max(value, schema.minimum);
|
|
411
|
+
if (typeof schema.exclusiveMinimum === "number")
|
|
412
|
+
value = Math.max(value, schema.exclusiveMinimum + 1);
|
|
413
|
+
if (typeof schema.maximum === "number")
|
|
414
|
+
value = Math.min(value, schema.maximum);
|
|
415
|
+
if (typeof schema.exclusiveMaximum === "number")
|
|
416
|
+
value = Math.min(value, schema.exclusiveMaximum - 1);
|
|
417
|
+
return integer ? Math.round(value) : value;
|
|
418
|
+
}
|
|
419
|
+
function sampleObject(schema, ctx) {
|
|
420
|
+
const out = {};
|
|
421
|
+
for (const [name, prop] of Object.entries(schema.properties || {})) {
|
|
422
|
+
if (prop && (prop.writeOnly === true || prop.readOnly === "never"))
|
|
423
|
+
continue;
|
|
424
|
+
const value = sample(prop, ctx);
|
|
425
|
+
if (value !== undefined)
|
|
426
|
+
out[name] = value;
|
|
427
|
+
}
|
|
428
|
+
if (Object.keys(out).length === 0 && schema.additionalProperties && typeof schema.additionalProperties === "object") {
|
|
429
|
+
const value = sample(schema.additionalProperties, ctx);
|
|
430
|
+
if (value !== undefined)
|
|
431
|
+
out.key = value;
|
|
432
|
+
}
|
|
433
|
+
return out;
|
|
434
|
+
}
|
|
435
|
+
/** Builds a deterministic, schema-valid-ish example payload for `schema`. */
|
|
436
|
+
function sampleFromSchema(schema, doc = {}) {
|
|
437
|
+
const value = sample(schema, { doc, stack: [], depth: 0 });
|
|
438
|
+
return value === undefined ? {} : value;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/*
|
|
442
|
+
* Contract snapshots and change detection.
|
|
443
|
+
*
|
|
444
|
+
* A contract is a normalised, acyclic summary of an OpenAPI document: every operation with its
|
|
445
|
+
* parameters, request body and response schemas. Swaggerizer saves one next to the generated code
|
|
446
|
+
* and compares new specs against it, so you are warned about new, removed and changed APIs —
|
|
447
|
+
* and which of those changes break the generated client — before you regenerate.
|
|
448
|
+
*/
|
|
449
|
+
/* ------------------------------------------------------------------ normalisation */
|
|
450
|
+
const MAX_DEPTH = 12;
|
|
451
|
+
function normaliseSchema(doc, raw, stack = [], depth = 0) {
|
|
452
|
+
if (raw === undefined || raw === null || raw === true)
|
|
453
|
+
return undefined;
|
|
454
|
+
if (depth > MAX_DEPTH)
|
|
455
|
+
return { circular: "max-depth" };
|
|
456
|
+
let schema = raw;
|
|
457
|
+
let pushed;
|
|
458
|
+
if (typeof raw.$ref === "string") {
|
|
459
|
+
if (stack.includes(raw.$ref))
|
|
460
|
+
return { circular: refName(raw.$ref) ?? raw.$ref };
|
|
461
|
+
const ref = raw.$ref;
|
|
462
|
+
pushed = ref;
|
|
463
|
+
stack.push(ref);
|
|
464
|
+
schema = deref(doc, raw).node ?? {};
|
|
465
|
+
}
|
|
466
|
+
try {
|
|
467
|
+
const next = (s) => normaliseSchema(doc, s, stack, depth + 1);
|
|
468
|
+
const out = {};
|
|
469
|
+
let type;
|
|
470
|
+
let nullable = schema.nullable === true;
|
|
471
|
+
if (Array.isArray(schema.type)) {
|
|
472
|
+
nullable = nullable || schema.type.includes("null");
|
|
473
|
+
const nonNull = schema.type.filter((t) => t !== "null");
|
|
474
|
+
type = nonNull.length === 1 ? nonNull[0] : nonNull.sort().join("|") || undefined;
|
|
475
|
+
}
|
|
476
|
+
else if (typeof schema.type === "string") {
|
|
477
|
+
type = schema.type;
|
|
478
|
+
}
|
|
479
|
+
// allOf: merge into a single object shape.
|
|
480
|
+
if (Array.isArray(schema.allOf) && schema.allOf.length > 0) {
|
|
481
|
+
const merged = { type: "object", properties: {}, required: [] };
|
|
482
|
+
for (const part of schema.allOf) {
|
|
483
|
+
const p = next(part);
|
|
484
|
+
if (!p)
|
|
485
|
+
continue;
|
|
486
|
+
if (p.properties)
|
|
487
|
+
Object.assign(merged.properties, p.properties);
|
|
488
|
+
if (p.required)
|
|
489
|
+
merged.required.push(...p.required);
|
|
490
|
+
if (p.type && p.type !== "object")
|
|
491
|
+
merged.type = p.type;
|
|
492
|
+
if (p.nullable)
|
|
493
|
+
merged.nullable = true;
|
|
494
|
+
}
|
|
495
|
+
const own = schema.properties ? next({ ...schema, allOf: undefined }) : undefined;
|
|
496
|
+
if (own?.properties)
|
|
497
|
+
Object.assign(merged.properties, own.properties);
|
|
498
|
+
if (own?.required)
|
|
499
|
+
merged.required.push(...own.required);
|
|
500
|
+
merged.required = [...new Set(merged.required)].sort();
|
|
501
|
+
if (nullable)
|
|
502
|
+
merged.nullable = true;
|
|
503
|
+
return tidy(merged);
|
|
504
|
+
}
|
|
505
|
+
const variants = schema.oneOf || schema.anyOf;
|
|
506
|
+
if (Array.isArray(variants) && variants.length > 0) {
|
|
507
|
+
out.oneOf = variants.map((v) => next(v) ?? {});
|
|
508
|
+
if (variants.some((v) => v?.type === "null"))
|
|
509
|
+
nullable = true;
|
|
510
|
+
}
|
|
511
|
+
if (!type) {
|
|
512
|
+
if (schema.properties || schema.additionalProperties)
|
|
513
|
+
type = "object";
|
|
514
|
+
else if (schema.items)
|
|
515
|
+
type = "array";
|
|
516
|
+
}
|
|
517
|
+
if (type)
|
|
518
|
+
out.type = type;
|
|
519
|
+
if (typeof schema.format === "string")
|
|
520
|
+
out.format = schema.format;
|
|
521
|
+
if (nullable)
|
|
522
|
+
out.nullable = true;
|
|
523
|
+
if (Array.isArray(schema.enum))
|
|
524
|
+
out.enum = [...schema.enum].filter(v => v !== null);
|
|
525
|
+
if (type === "array" && schema.items)
|
|
526
|
+
out.items = next(schema.items) ?? {};
|
|
527
|
+
if (schema.properties && typeof schema.properties === "object") {
|
|
528
|
+
out.properties = {};
|
|
529
|
+
for (const [name, prop] of Object.entries(schema.properties)) {
|
|
530
|
+
out.properties[name] = next(prop) ?? {};
|
|
531
|
+
}
|
|
532
|
+
out.required = Array.isArray(schema.required) ? [...schema.required].sort() : [];
|
|
533
|
+
}
|
|
534
|
+
if (schema.additionalProperties !== undefined) {
|
|
535
|
+
out.additionalProperties =
|
|
536
|
+
typeof schema.additionalProperties === "object"
|
|
537
|
+
? next(schema.additionalProperties) ?? true
|
|
538
|
+
: Boolean(schema.additionalProperties);
|
|
539
|
+
}
|
|
540
|
+
return tidy(out);
|
|
541
|
+
}
|
|
542
|
+
finally {
|
|
543
|
+
if (pushed)
|
|
544
|
+
stack.pop();
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
function tidy(schema) {
|
|
548
|
+
if (schema.required && schema.required.length === 0)
|
|
549
|
+
delete schema.required;
|
|
550
|
+
if (schema.properties && Object.keys(schema.properties).length === 0 && schema.type !== "object") {
|
|
551
|
+
delete schema.properties;
|
|
552
|
+
}
|
|
553
|
+
return schema;
|
|
554
|
+
}
|
|
555
|
+
function preferredMedia(content) {
|
|
556
|
+
const types = Object.keys(content || {}).sort();
|
|
557
|
+
const preferred = types.find(t => /json/i.test(t)) || types[0];
|
|
558
|
+
return { types, media: preferred ? content[preferred] : undefined };
|
|
559
|
+
}
|
|
560
|
+
/** Normalises route templates so renaming `{id}` to `{petId}` is not reported as remove + add. */
|
|
561
|
+
function contractKey(method, route) {
|
|
562
|
+
return `${method.toUpperCase()} ${route.replace(/\{[^}]+\}/g, "{}")}`;
|
|
563
|
+
}
|
|
564
|
+
function buildContract(doc, specId) {
|
|
565
|
+
const operations = {};
|
|
566
|
+
for (const [route, rawItem] of Object.entries(doc?.paths || {})) {
|
|
567
|
+
const item = deref(doc, rawItem).node;
|
|
568
|
+
if (!item || typeof item !== "object")
|
|
569
|
+
continue;
|
|
570
|
+
const pathParams = Array.isArray(item.parameters) ? item.parameters : [];
|
|
571
|
+
for (const method of HTTP_METHODS) {
|
|
572
|
+
const op = item[method];
|
|
573
|
+
if (!op || typeof op !== "object")
|
|
574
|
+
continue;
|
|
575
|
+
const parameters = {};
|
|
576
|
+
const routeParamNames = [...route.matchAll(/\{([^}]+)\}/g)].map(m => m[1]);
|
|
577
|
+
for (const rawParam of [...pathParams, ...(Array.isArray(op.parameters) ? op.parameters : [])]) {
|
|
578
|
+
const p = deref(doc, rawParam).node;
|
|
579
|
+
if (!p || typeof p !== "object" || !p.name || !p.in)
|
|
580
|
+
continue;
|
|
581
|
+
// Path parameters are positional: key them by position so renames are not changes.
|
|
582
|
+
const index = p.in === "path" ? routeParamNames.indexOf(p.name) : -1;
|
|
583
|
+
const id = p.in === "path" && index >= 0 ? `path:#${index}` : `${p.in}:${p.name}`;
|
|
584
|
+
parameters[id] = {
|
|
585
|
+
name: p.name,
|
|
586
|
+
in: p.in,
|
|
587
|
+
required: p.in === "path" ? true : Boolean(p.required),
|
|
588
|
+
schema: normaliseSchema(doc, p.schema ?? (p.content ? preferredMedia(p.content).media?.schema : undefined))
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
let requestBody;
|
|
592
|
+
if (op.requestBody) {
|
|
593
|
+
const body = deref(doc, op.requestBody).node;
|
|
594
|
+
const { types, media } = preferredMedia(body?.content);
|
|
595
|
+
requestBody = {
|
|
596
|
+
required: Boolean(body?.required),
|
|
597
|
+
contentTypes: types,
|
|
598
|
+
schema: normaliseSchema(doc, media?.schema)
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
const responses = {};
|
|
602
|
+
for (const [code, rawResponse] of Object.entries(op.responses || {})) {
|
|
603
|
+
const response = deref(doc, rawResponse).node;
|
|
604
|
+
const { types, media } = preferredMedia(response?.content);
|
|
605
|
+
responses[code] = { contentTypes: types, schema: normaliseSchema(doc, media?.schema) };
|
|
606
|
+
}
|
|
607
|
+
operations[contractKey(method, route)] = {
|
|
608
|
+
method: method.toUpperCase(),
|
|
609
|
+
route,
|
|
610
|
+
...(typeof op.operationId === "string" ? { operationId: op.operationId } : {}),
|
|
611
|
+
...(op.deprecated ? { deprecated: true } : {}),
|
|
612
|
+
parameters,
|
|
613
|
+
...(requestBody ? { requestBody } : {}),
|
|
614
|
+
responses
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return {
|
|
619
|
+
swaggerizer: 1,
|
|
620
|
+
specId,
|
|
621
|
+
title: typeof doc?.info?.title === "string" ? doc.info.title : undefined,
|
|
622
|
+
version: doc?.info?.version !== undefined ? String(doc.info.version) : undefined,
|
|
623
|
+
operations
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
class Collector {
|
|
627
|
+
operation;
|
|
628
|
+
changes = [];
|
|
629
|
+
constructor(operation) {
|
|
630
|
+
this.operation = operation;
|
|
631
|
+
}
|
|
632
|
+
add(severity, kind, message, location) {
|
|
633
|
+
this.changes.push({ severity, kind, operation: this.operation, ...(location ? { location } : {}), message });
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
function describeType(s) {
|
|
637
|
+
if (!s)
|
|
638
|
+
return "any";
|
|
639
|
+
if (s.circular)
|
|
640
|
+
return s.circular;
|
|
641
|
+
if (s.oneOf)
|
|
642
|
+
return `oneOf(${s.oneOf.length})`;
|
|
643
|
+
const base = s.type === "array" ? `${describeType(s.items)}[]` : s.type || "any";
|
|
644
|
+
return s.nullable ? `${base} | null` : base;
|
|
645
|
+
}
|
|
646
|
+
function compareSchema(a, b, dir, location, out, kind) {
|
|
647
|
+
if (!a && !b)
|
|
648
|
+
return;
|
|
649
|
+
if (!a || !b) {
|
|
650
|
+
if (!a)
|
|
651
|
+
out.add(dir === "response" ? "info" : "warning", kind, `Schema added`, location);
|
|
652
|
+
else
|
|
653
|
+
out.add("breaking", kind, `Schema removed`, location);
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
if (a.circular || b.circular) {
|
|
657
|
+
if (a.circular !== b.circular)
|
|
658
|
+
out.add("warning", kind, `Recursive type changed from ${describeType(a)} to ${describeType(b)}`, location);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
if ((a.type || "any") !== (b.type || "any")) {
|
|
662
|
+
out.add("breaking", kind, `Type changed from ${describeType(a)} to ${describeType(b)}`, location);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
if ((a.format || "") !== (b.format || "")) {
|
|
666
|
+
out.add("warning", kind, `Format changed from '${a.format ?? "none"}' to '${b.format ?? "none"}'`, location);
|
|
667
|
+
}
|
|
668
|
+
if (!a.nullable && b.nullable) {
|
|
669
|
+
out.add(dir === "response" ? "breaking" : "info", kind, `Can now be null`, location);
|
|
670
|
+
}
|
|
671
|
+
else if (a.nullable && !b.nullable) {
|
|
672
|
+
out.add(dir === "request" ? "breaking" : "info", kind, `Can no longer be null`, location);
|
|
673
|
+
}
|
|
674
|
+
if (a.enum || b.enum) {
|
|
675
|
+
const before = new Set((a.enum || []).map(v => JSON.stringify(v)));
|
|
676
|
+
const after = new Set((b.enum || []).map(v => JSON.stringify(v)));
|
|
677
|
+
const removed = [...before].filter(v => !after.has(v));
|
|
678
|
+
const added = [...after].filter(v => !before.has(v));
|
|
679
|
+
if (!a.enum && b.enum) {
|
|
680
|
+
out.add(dir === "request" ? "breaking" : "info", kind, `Now restricted to ${[...after].join(", ")}`, location);
|
|
681
|
+
}
|
|
682
|
+
else if (a.enum && !b.enum) {
|
|
683
|
+
out.add(dir === "response" ? "warning" : "info", kind, `No longer restricted to a fixed set of values`, location);
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
if (removed.length) {
|
|
687
|
+
out.add(dir === "request" ? "breaking" : "info", kind, `Enum value(s) removed: ${removed.join(", ")}`, location);
|
|
688
|
+
}
|
|
689
|
+
if (added.length) {
|
|
690
|
+
out.add(dir === "response" ? "warning" : "info", kind, `Enum value(s) added: ${added.join(", ")}`, location);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
if (a.oneOf || b.oneOf) {
|
|
695
|
+
const la = a.oneOf?.length ?? 0;
|
|
696
|
+
const lb = b.oneOf?.length ?? 0;
|
|
697
|
+
if (la !== lb) {
|
|
698
|
+
out.add(lb > la && dir === "response" ? "warning" : lb < la && dir === "request" ? "breaking" : "info", kind, `Number of oneOf/anyOf variants changed from ${la} to ${lb}`, location);
|
|
699
|
+
}
|
|
700
|
+
const n = Math.min(la, lb);
|
|
701
|
+
for (let i = 0; i < n; i++)
|
|
702
|
+
compareSchema(a.oneOf[i], b.oneOf[i], dir, `${location}<${i}>`, out, kind);
|
|
703
|
+
}
|
|
704
|
+
if (a.type === "array" || b.type === "array") {
|
|
705
|
+
compareSchema(a.items, b.items, dir, `${location}[]`, out, kind);
|
|
706
|
+
}
|
|
707
|
+
if (a.properties || b.properties) {
|
|
708
|
+
const pa = a.properties || {};
|
|
709
|
+
const pb = b.properties || {};
|
|
710
|
+
const ra = new Set(a.required || []);
|
|
711
|
+
const rb = new Set(b.required || []);
|
|
712
|
+
for (const name of Object.keys(pa)) {
|
|
713
|
+
const where = `${location}.${name}`;
|
|
714
|
+
if (!(name in pb)) {
|
|
715
|
+
out.add(dir === "response" ? "breaking" : "warning", kind, `Property '${name}' removed`, where);
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
if (ra.has(name) && !rb.has(name)) {
|
|
719
|
+
out.add(dir === "response" ? "breaking" : "info", kind, `Property '${name}' is no longer required`, where);
|
|
720
|
+
}
|
|
721
|
+
else if (!ra.has(name) && rb.has(name)) {
|
|
722
|
+
out.add(dir === "request" ? "breaking" : "info", kind, `Property '${name}' is now required`, where);
|
|
723
|
+
}
|
|
724
|
+
compareSchema(pa[name], pb[name], dir, where, out, kind);
|
|
725
|
+
}
|
|
726
|
+
for (const name of Object.keys(pb)) {
|
|
727
|
+
if (name in pa)
|
|
728
|
+
continue;
|
|
729
|
+
const required = rb.has(name);
|
|
730
|
+
out.add(dir === "request" && required ? "breaking" : "info", kind, `${required ? "Required" : "Optional"} property '${name}' added`, `${location}.${name}`);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
function isSuccess(code) {
|
|
735
|
+
return /^2/.test(code);
|
|
736
|
+
}
|
|
737
|
+
function compareOperation(key, a, b) {
|
|
738
|
+
const label = `${b.method} ${b.route}`;
|
|
739
|
+
const out = new Collector(label);
|
|
740
|
+
if ((a.operationId || "") !== (b.operationId || "")) {
|
|
741
|
+
out.add("breaking", "operation-renamed", `operationId changed from '${a.operationId ?? "none"}' to '${b.operationId ?? "none"}' — generated function names will change`);
|
|
742
|
+
}
|
|
743
|
+
if (!a.deprecated && b.deprecated)
|
|
744
|
+
out.add("warning", "operation-deprecated", "Operation is now deprecated");
|
|
745
|
+
for (const [id, pa] of Object.entries(a.parameters)) {
|
|
746
|
+
const pb = b.parameters[id];
|
|
747
|
+
const where = `${pa.in} parameter '${pa.name}'`;
|
|
748
|
+
if (!pb) {
|
|
749
|
+
out.add(pa.in === "path" ? "breaking" : "warning", "parameter-removed", `Parameter removed`, where);
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
if (!pa.required && pb.required)
|
|
753
|
+
out.add("breaking", "parameter-changed", "Parameter is now required", where);
|
|
754
|
+
if (pa.required && !pb.required)
|
|
755
|
+
out.add("info", "parameter-changed", "Parameter is now optional", where);
|
|
756
|
+
compareSchema(pa.schema, pb.schema, "request", where, out, "parameter-changed");
|
|
757
|
+
}
|
|
758
|
+
for (const [id, pb] of Object.entries(b.parameters)) {
|
|
759
|
+
if (a.parameters[id])
|
|
760
|
+
continue;
|
|
761
|
+
out.add(pb.required ? "breaking" : "info", "parameter-added", `${pb.required ? "Required" : "Optional"} parameter added`, `${pb.in} parameter '${pb.name}'`);
|
|
762
|
+
}
|
|
763
|
+
if (!a.requestBody && b.requestBody) {
|
|
764
|
+
out.add(b.requestBody.required ? "breaking" : "info", "request-body-added", `${b.requestBody.required ? "Required" : "Optional"} request body added`, "request body");
|
|
765
|
+
}
|
|
766
|
+
else if (a.requestBody && !b.requestBody) {
|
|
767
|
+
out.add("warning", "request-body-removed", "Request body removed", "request body");
|
|
768
|
+
}
|
|
769
|
+
else if (a.requestBody && b.requestBody) {
|
|
770
|
+
if (!a.requestBody.required && b.requestBody.required) {
|
|
771
|
+
out.add("breaking", "request-body-changed", "Request body is now required", "request body");
|
|
772
|
+
}
|
|
773
|
+
const lost = a.requestBody.contentTypes.filter(t => !b.requestBody.contentTypes.includes(t));
|
|
774
|
+
if (lost.length)
|
|
775
|
+
out.add("breaking", "request-body-changed", `Content type(s) no longer accepted: ${lost.join(", ")}`, "request body");
|
|
776
|
+
compareSchema(a.requestBody.schema, b.requestBody.schema, "request", "request body", out, "request-body-changed");
|
|
777
|
+
}
|
|
778
|
+
for (const [code, ra] of Object.entries(a.responses)) {
|
|
779
|
+
const rb = b.responses[code];
|
|
780
|
+
if (!rb) {
|
|
781
|
+
out.add(isSuccess(code) ? "breaking" : "warning", "response-removed", `Response ${code} removed`, `response ${code}`);
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
const lost = ra.contentTypes.filter(t => !rb.contentTypes.includes(t));
|
|
785
|
+
if (lost.length)
|
|
786
|
+
out.add("breaking", "response-changed", `Content type(s) no longer returned: ${lost.join(", ")}`, `response ${code}`);
|
|
787
|
+
compareSchema(ra.schema, rb.schema, "response", `response ${code} body`, out, "response-changed");
|
|
788
|
+
}
|
|
789
|
+
for (const code of Object.keys(b.responses)) {
|
|
790
|
+
if (a.responses[code])
|
|
791
|
+
continue;
|
|
792
|
+
out.add(isSuccess(code) ? "warning" : "info", "response-added", `Response ${code} added`, `response ${code}`);
|
|
793
|
+
}
|
|
794
|
+
return out.changes;
|
|
795
|
+
}
|
|
796
|
+
const SEVERITY_ORDER = { breaking: 0, warning: 1, info: 2 };
|
|
797
|
+
/** Compares two contracts and reports added, removed and changed operations. */
|
|
798
|
+
function diffContracts(before, after, baseline = "snapshot") {
|
|
799
|
+
const a = before?.operations || {};
|
|
800
|
+
const b = after.operations;
|
|
801
|
+
const changes = [];
|
|
802
|
+
const added = [];
|
|
803
|
+
const removed = [];
|
|
804
|
+
const changed = [];
|
|
805
|
+
for (const [key, op] of Object.entries(a)) {
|
|
806
|
+
if (!b[key]) {
|
|
807
|
+
removed.push(`${op.method} ${op.route}`);
|
|
808
|
+
changes.push({ severity: "breaking", kind: "operation-removed", operation: `${op.method} ${op.route}`, message: "Operation removed" });
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
for (const [key, op] of Object.entries(b)) {
|
|
812
|
+
const label = `${op.method} ${op.route}`;
|
|
813
|
+
if (!a[key]) {
|
|
814
|
+
if (before) {
|
|
815
|
+
added.push(label);
|
|
816
|
+
changes.push({ severity: "info", kind: "operation-added", operation: label, message: "New operation" });
|
|
817
|
+
}
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
const opChanges = compareOperation(key, a[key], op);
|
|
821
|
+
if (opChanges.length) {
|
|
822
|
+
changed.push(label);
|
|
823
|
+
changes.push(...opChanges);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
changes.sort((x, y) => SEVERITY_ORDER[x.severity] - SEVERITY_ORDER[y.severity] || x.operation.localeCompare(y.operation));
|
|
827
|
+
const counts = { breaking: 0, warning: 0, info: 0 };
|
|
828
|
+
for (const c of changes)
|
|
829
|
+
counts[c.severity]++;
|
|
830
|
+
return {
|
|
831
|
+
specId: after.specId,
|
|
832
|
+
title: after.title,
|
|
833
|
+
baseline,
|
|
834
|
+
...(before?.version !== undefined ? { fromVersion: before.version } : {}),
|
|
835
|
+
...(after.version !== undefined ? { toVersion: after.version } : {}),
|
|
836
|
+
added,
|
|
837
|
+
removed,
|
|
838
|
+
changed,
|
|
839
|
+
changes,
|
|
840
|
+
counts
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
function hasChanges(report) {
|
|
844
|
+
return report.changes.length > 0;
|
|
845
|
+
}
|
|
846
|
+
/** True when the report contains changes at or above the given threshold. */
|
|
847
|
+
function exceedsThreshold(report, failOn) {
|
|
848
|
+
switch (failOn) {
|
|
849
|
+
case "none":
|
|
850
|
+
return false;
|
|
851
|
+
case "any":
|
|
852
|
+
return report.changes.length > 0;
|
|
853
|
+
case "warning":
|
|
854
|
+
return report.counts.breaking + report.counts.warning > 0;
|
|
855
|
+
default:
|
|
856
|
+
return report.counts.breaking > 0;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
/** Plain-text rendering of a change report, for terminals and CI logs. */
|
|
860
|
+
function formatReport(report, options = {}) {
|
|
861
|
+
const c = options.color
|
|
862
|
+
? {
|
|
863
|
+
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
864
|
+
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
865
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
866
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
867
|
+
bold: (s) => `\x1b[1m${s}\x1b[0m`
|
|
868
|
+
}
|
|
869
|
+
: { red: (s) => s, yellow: (s) => s, green: (s) => s, dim: (s) => s, bold: (s) => s };
|
|
870
|
+
const lines = [];
|
|
871
|
+
const version = report.fromVersion !== undefined && report.toVersion !== undefined && report.fromVersion !== report.toVersion
|
|
872
|
+
? ` (${report.fromVersion} → ${report.toVersion})`
|
|
873
|
+
: "";
|
|
874
|
+
lines.push(c.bold(`${report.title ?? report.specId}${version}`));
|
|
875
|
+
if (report.changes.length === 0) {
|
|
876
|
+
lines.push(c.green(" No contract changes."));
|
|
877
|
+
return lines.join("\n");
|
|
878
|
+
}
|
|
879
|
+
lines.push(` ${c.red(`${report.counts.breaking} breaking`)}, ${c.yellow(`${report.counts.warning} warning(s)`)}, ${report.counts.info} info · ` +
|
|
880
|
+
`${report.added.length} new, ${report.removed.length} removed, ${report.changed.length} changed operation(s)`);
|
|
881
|
+
const icon = { breaking: c.red("✖ BREAKING"), warning: c.yellow("▲ WARNING "), info: c.dim("● info ") };
|
|
882
|
+
for (const change of report.changes) {
|
|
883
|
+
if (change.severity === "info" && !options.verbose && change.kind !== "operation-added")
|
|
884
|
+
continue;
|
|
885
|
+
const where = change.location ? c.dim(` [${change.location}]`) : "";
|
|
886
|
+
lines.push(` ${icon[change.severity]} ${change.operation} — ${change.message}${where}`);
|
|
887
|
+
}
|
|
888
|
+
const hiddenInfo = options.verbose ? 0 : report.changes.filter(x => x.severity === "info" && x.kind !== "operation-added").length;
|
|
889
|
+
if (hiddenInfo)
|
|
890
|
+
lines.push(c.dim(` …and ${hiddenInfo} non-breaking detail(s); use --verbose to list them.`));
|
|
891
|
+
return lines.join("\n");
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/*
|
|
895
|
+
* Flattens an OpenAPI 3 document into one entry per method + route, recording every response code
|
|
896
|
+
* and named example so they can be listed and switched from the admin page and debug menu.
|
|
897
|
+
*/
|
|
898
|
+
function text(value) {
|
|
899
|
+
return typeof value === "string" && value.trim() !== "" ? value : undefined;
|
|
900
|
+
}
|
|
901
|
+
/** HTTP status to send for a response key: "404" → 404, "2XX" → 200, "default" → 500. */
|
|
902
|
+
function statusForCode(code) {
|
|
903
|
+
if (/^\d{3}$/.test(code))
|
|
904
|
+
return Number(code);
|
|
905
|
+
const range = /^([1-5])XX$/i.exec(code);
|
|
906
|
+
if (range)
|
|
907
|
+
return Number(range[1]) * 100;
|
|
908
|
+
return 500;
|
|
909
|
+
}
|
|
910
|
+
function preferredContentType(types) {
|
|
911
|
+
return (types.find(t => /^application\/json/i.test(t)) ||
|
|
912
|
+
types.find(t => /\+json/i.test(t)) ||
|
|
913
|
+
types.find(t => /json/i.test(t)) ||
|
|
914
|
+
types[0]);
|
|
915
|
+
}
|
|
916
|
+
function extractExamples(media, doc) {
|
|
917
|
+
if (!media || typeof media !== "object")
|
|
918
|
+
return [];
|
|
919
|
+
const examples = [];
|
|
920
|
+
if (media.examples && typeof media.examples === "object") {
|
|
921
|
+
for (const [name, raw] of Object.entries(media.examples)) {
|
|
922
|
+
const { node } = deref(doc, raw);
|
|
923
|
+
if (node && typeof node === "object" && "value" in node) {
|
|
924
|
+
examples.push({ name, summary: text(node.summary), value: node.value });
|
|
925
|
+
}
|
|
926
|
+
else if (node && typeof node === "object" && node.externalValue) {
|
|
927
|
+
examples.push({ name, summary: text(node.summary) ?? `external: ${node.externalValue}`, value: null });
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
if (examples.length === 0 && media.example !== undefined) {
|
|
932
|
+
examples.push({ name: "example", value: media.example });
|
|
933
|
+
}
|
|
934
|
+
if (examples.length === 0 && media.schema) {
|
|
935
|
+
const { node: schema } = deref(doc, media.schema);
|
|
936
|
+
if (schema && schema.example !== undefined) {
|
|
937
|
+
examples.push({ name: "example", value: schema.example });
|
|
938
|
+
}
|
|
939
|
+
else if (schema && Array.isArray(schema.examples) && schema.examples.length > 0) {
|
|
940
|
+
schema.examples.forEach((value, i) => examples.push({ name: i === 0 ? "example" : `example${i + 1}`, value }));
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
return examples;
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Media types as they are served. springdoc writes `*\/*` for JSON responses; serving that as a
|
|
947
|
+
* Content-Type confuses clients, so wildcards become `application/json`.
|
|
948
|
+
*/
|
|
949
|
+
function normaliseMediaType(type) {
|
|
950
|
+
const t = type.trim().toLowerCase();
|
|
951
|
+
return t === "*/*" || t === "*" || t === "application/*" ? "application/json" : type;
|
|
952
|
+
}
|
|
953
|
+
function buildResponse(code, raw, doc) {
|
|
954
|
+
const { node: response } = deref(doc, raw);
|
|
955
|
+
const content = response?.content && typeof response.content === "object" ? response.content : {};
|
|
956
|
+
const contentTypes = [...new Set(Object.keys(content).map(normaliseMediaType))];
|
|
957
|
+
const keys = Object.keys(content);
|
|
958
|
+
const preferred = preferredContentType(keys) ?? keys[0];
|
|
959
|
+
const media = preferred ? content[preferred] : keys.length ? content[keys[0]] : undefined;
|
|
960
|
+
const examples = extractExamples(media, doc);
|
|
961
|
+
const result = {
|
|
962
|
+
code,
|
|
963
|
+
status: statusForCode(code),
|
|
964
|
+
description: text(response?.description),
|
|
965
|
+
contentTypes,
|
|
966
|
+
hasBody: contentTypes.length > 0,
|
|
967
|
+
examples
|
|
968
|
+
};
|
|
969
|
+
if (media && examples.length === 0) {
|
|
970
|
+
result.generated = media.schema ? sampleFromSchema(media.schema, doc) : {};
|
|
971
|
+
}
|
|
972
|
+
return result;
|
|
973
|
+
}
|
|
974
|
+
function sortCodes(a, b) {
|
|
975
|
+
return statusForCode(a) - statusForCode(b) || a.localeCompare(b);
|
|
976
|
+
}
|
|
977
|
+
function defaultVariant(responses) {
|
|
978
|
+
const sorted = [...responses].sort((a, b) => sortCodes(a.code, b.code));
|
|
979
|
+
const response = sorted.find(r => r.status >= 200 && r.status < 300) || sorted[0];
|
|
980
|
+
if (!response)
|
|
981
|
+
return { code: "200", example: EMPTY };
|
|
982
|
+
return { code: response.code, example: firstVariantName(response) };
|
|
983
|
+
}
|
|
984
|
+
function firstVariantName(response) {
|
|
985
|
+
if (response.examples.length > 0)
|
|
986
|
+
return response.examples[0].name;
|
|
987
|
+
return response.hasBody ? GENERATED : EMPTY;
|
|
988
|
+
}
|
|
989
|
+
function buildCatalogue(doc, specId) {
|
|
990
|
+
const operations = [];
|
|
991
|
+
for (const [route, rawItem] of Object.entries(doc?.paths || {})) {
|
|
992
|
+
const { node: item } = deref(doc, rawItem);
|
|
993
|
+
if (!item || typeof item !== "object")
|
|
994
|
+
continue;
|
|
995
|
+
for (const method of HTTP_METHODS) {
|
|
996
|
+
const op = item[method];
|
|
997
|
+
if (!op || typeof op !== "object")
|
|
998
|
+
continue;
|
|
999
|
+
const responses = Object.entries(op.responses || {})
|
|
1000
|
+
.sort(([a], [b]) => sortCodes(a, b))
|
|
1001
|
+
.map(([code, raw]) => buildResponse(code, raw, doc));
|
|
1002
|
+
const def = defaultVariant(responses);
|
|
1003
|
+
operations.push({
|
|
1004
|
+
key: operationKey(method, route),
|
|
1005
|
+
method,
|
|
1006
|
+
route,
|
|
1007
|
+
specId,
|
|
1008
|
+
operationId: text(op.operationId),
|
|
1009
|
+
summary: text(op.summary),
|
|
1010
|
+
description: text(op.description) ?? text(item.description),
|
|
1011
|
+
tags: Array.isArray(op.tags) ? op.tags.map(String) : [],
|
|
1012
|
+
...(op.deprecated ? { deprecated: true } : {}),
|
|
1013
|
+
responses,
|
|
1014
|
+
defaultCode: def.code,
|
|
1015
|
+
defaultExample: def.example
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
return operations;
|
|
1020
|
+
}
|
|
1021
|
+
/** Path prefixes from `servers`, e.g. `https://api.example.com/v1` → `/v1`. */
|
|
1022
|
+
function basePathsOf(doc) {
|
|
1023
|
+
const out = new Set();
|
|
1024
|
+
for (const server of Array.isArray(doc?.servers) ? doc.servers : []) {
|
|
1025
|
+
const url = typeof server?.url === "string" ? server.url : "";
|
|
1026
|
+
// Substitute server variables with their defaults.
|
|
1027
|
+
const resolved = url.replace(/\{([^}]+)\}/g, (_m, name) => {
|
|
1028
|
+
const value = server?.variables?.[name]?.default;
|
|
1029
|
+
return value !== undefined ? String(value) : "";
|
|
1030
|
+
});
|
|
1031
|
+
let pathname;
|
|
1032
|
+
try {
|
|
1033
|
+
pathname = new URL(resolved, "http://localhost").pathname;
|
|
1034
|
+
}
|
|
1035
|
+
catch {
|
|
1036
|
+
continue;
|
|
1037
|
+
}
|
|
1038
|
+
pathname = pathname.replace(/\/+$/, "");
|
|
1039
|
+
if (pathname && pathname !== "/")
|
|
1040
|
+
out.add(pathname);
|
|
1041
|
+
}
|
|
1042
|
+
return [...out];
|
|
1043
|
+
}
|
|
1044
|
+
/** The first absolute http(s) server URL, used as the default proxy target. */
|
|
1045
|
+
function serverUrlOf(doc) {
|
|
1046
|
+
for (const server of Array.isArray(doc?.servers) ? doc.servers : []) {
|
|
1047
|
+
const url = typeof server?.url === "string" ? server.url : "";
|
|
1048
|
+
const resolved = url.replace(/\{([^}]+)\}/g, (_m, name) => String(server?.variables?.[name]?.default ?? ""));
|
|
1049
|
+
if (/^https?:\/\//i.test(resolved))
|
|
1050
|
+
return resolved.replace(/\/+$/, "");
|
|
1051
|
+
}
|
|
1052
|
+
return undefined;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/** File names Swaggerizer writes next to generated code. */
|
|
1056
|
+
const CONTRACT_FILE = "swaggerizer.contract.json";
|
|
1057
|
+
const WRAPPER_FILE = "swaggerizer.ts";
|
|
1058
|
+
const INDEX_FILE = "index.ts";
|
|
1059
|
+
const isUrl = (input) => /^https?:\/\//i.test(input);
|
|
1060
|
+
/** Folders a spec's generated code lives in: client/models/contract, and mocks. */
|
|
1061
|
+
function outputDirs(id, root, spec = {}) {
|
|
1062
|
+
const dir = path.resolve(spec.output ?? path.join(root ?? "src/api", id));
|
|
1063
|
+
return { dir, mocksDir: path.resolve(spec.mocksOutput ?? dir) };
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/* Loading Swagger 2.0 / OpenAPI 3.x files into a bundled OpenAPI 3 document (Node only). */
|
|
1067
|
+
const SPEC_FILE_PATTERN = /\.(ya?ml|json)$/i;
|
|
1068
|
+
/**
|
|
1069
|
+
* Reads spec URLs (and relative $refs on the same origin) with fetch. The parser's own HTTP
|
|
1070
|
+
* resolver refuses localhost and private addresses, which is where api-docs usually live
|
|
1071
|
+
* (`http://localhost:8080/v3/api-docs`, internal gateways), and cannot send auth headers.
|
|
1072
|
+
* $refs to any other origin still go through the parser's guarded resolver.
|
|
1073
|
+
*/
|
|
1074
|
+
function specUrlResolver(root, options) {
|
|
1075
|
+
const origin = new URL(root).origin;
|
|
1076
|
+
return {
|
|
1077
|
+
order: 1,
|
|
1078
|
+
canRead: (file) => {
|
|
1079
|
+
try {
|
|
1080
|
+
return /^https?:/i.test(file.url) && new URL(file.url).origin === origin;
|
|
1081
|
+
}
|
|
1082
|
+
catch {
|
|
1083
|
+
return false;
|
|
1084
|
+
}
|
|
1085
|
+
},
|
|
1086
|
+
read: async (file) => {
|
|
1087
|
+
const url = file.url.split("#")[0];
|
|
1088
|
+
let response;
|
|
1089
|
+
try {
|
|
1090
|
+
response = await fetch(url, {
|
|
1091
|
+
headers: { Accept: "application/json, application/yaml;q=0.9, */*;q=0.5", ...(options.headers || {}) },
|
|
1092
|
+
signal: AbortSignal.timeout(options.timeout ?? 30000)
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
catch (err) {
|
|
1096
|
+
const reason = err?.name === "TimeoutError" ? "timed out" : err?.cause?.code || err?.message || String(err);
|
|
1097
|
+
throw new Error(`Could not fetch ${url}: ${reason}`);
|
|
1098
|
+
}
|
|
1099
|
+
if (!response.ok)
|
|
1100
|
+
throw new Error(`${url} answered ${response.status} ${response.statusText}`.trim());
|
|
1101
|
+
return response.text();
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
async function convertSwagger2(api) {
|
|
1106
|
+
const mod = await import('swagger2openapi');
|
|
1107
|
+
const converter = mod.convertObj ? mod : mod.default;
|
|
1108
|
+
const options = { patch: true, warnOnly: true, resolve: false };
|
|
1109
|
+
const result = await converter.convertObj(api, options);
|
|
1110
|
+
const warnings = (options.warnings || result?.warnings || []).map((w) => (w?.message ? String(w.message) : String(w)));
|
|
1111
|
+
renameSwagger2Examples(result.openapi);
|
|
1112
|
+
return { doc: result.openapi, warnings };
|
|
1113
|
+
}
|
|
1114
|
+
/** swagger2openapi names a Swagger 2 response example "response"; call it "example" like OAS3's singular form. */
|
|
1115
|
+
function renameSwagger2Examples(doc) {
|
|
1116
|
+
const visitResponses = (responses) => {
|
|
1117
|
+
for (const response of Object.values(responses || {})) {
|
|
1118
|
+
for (const media of Object.values(response?.content || {})) {
|
|
1119
|
+
const keys = media?.examples ? Object.keys(media.examples) : [];
|
|
1120
|
+
if (keys.length === 1 && keys[0] === "response") {
|
|
1121
|
+
media.examples = { example: media.examples.response };
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
};
|
|
1126
|
+
for (const item of Object.values(doc?.paths || {})) {
|
|
1127
|
+
for (const op of Object.values(item || {})) {
|
|
1128
|
+
if (op && typeof op === "object" && op.responses)
|
|
1129
|
+
visitResponses(op.responses);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
visitResponses(doc?.components?.responses);
|
|
1133
|
+
}
|
|
1134
|
+
/** Quick check used when scanning folders: is this YAML/JSON file an OpenAPI or Swagger document? */
|
|
1135
|
+
async function looksLikeSpec(file) {
|
|
1136
|
+
try {
|
|
1137
|
+
const head = (await fs.promises.readFile(file, "utf8")).slice(0, 4096);
|
|
1138
|
+
return /["']?(openapi|swagger)["']?\s*:/.test(head);
|
|
1139
|
+
}
|
|
1140
|
+
catch {
|
|
1141
|
+
return false;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
async function listSpecFiles(folder) {
|
|
1145
|
+
const absolute = path.resolve(folder);
|
|
1146
|
+
const entries = await fs.promises.readdir(absolute, { withFileTypes: true });
|
|
1147
|
+
const files = entries
|
|
1148
|
+
.filter(e => e.isFile() && SPEC_FILE_PATTERN.test(e.name) && !e.name.startsWith("."))
|
|
1149
|
+
.map(e => path.join(absolute, e.name))
|
|
1150
|
+
.sort();
|
|
1151
|
+
const specs = [];
|
|
1152
|
+
for (const file of files)
|
|
1153
|
+
if (await looksLikeSpec(file))
|
|
1154
|
+
specs.push(file);
|
|
1155
|
+
return specs;
|
|
1156
|
+
}
|
|
1157
|
+
async function loadSpecFile(file, options = {}) {
|
|
1158
|
+
const remote = isUrl(file);
|
|
1159
|
+
const absolute = remote ? file : path.resolve(file);
|
|
1160
|
+
// bundle() resolves external $refs into internal ones but keeps the document acyclic.
|
|
1161
|
+
const bundled = remote
|
|
1162
|
+
? await SwaggerParser.bundle(absolute, { resolve: { swaggerizer: specUrlResolver(absolute, options) } })
|
|
1163
|
+
: await SwaggerParser.bundle(absolute);
|
|
1164
|
+
const raw = remote ? structuredClone(bundled) : undefined;
|
|
1165
|
+
const warnings = [];
|
|
1166
|
+
let doc = bundled;
|
|
1167
|
+
let specVersion;
|
|
1168
|
+
if (typeof bundled?.swagger === "string") {
|
|
1169
|
+
specVersion = bundled.swagger;
|
|
1170
|
+
const converted = await convertSwagger2(bundled);
|
|
1171
|
+
doc = converted.doc;
|
|
1172
|
+
warnings.push(...converted.warnings);
|
|
1173
|
+
}
|
|
1174
|
+
else if (typeof bundled?.openapi === "string") {
|
|
1175
|
+
specVersion = bundled.openapi;
|
|
1176
|
+
}
|
|
1177
|
+
else {
|
|
1178
|
+
throw new Error("Not an OpenAPI or Swagger document (missing `openapi` / `swagger` field).");
|
|
1179
|
+
}
|
|
1180
|
+
try {
|
|
1181
|
+
// Validate a copy: validation dereferences in place.
|
|
1182
|
+
await SwaggerParser.validate(structuredClone(bundled));
|
|
1183
|
+
}
|
|
1184
|
+
catch (err) {
|
|
1185
|
+
const details = Array.isArray(err?.details) ? err.details : [];
|
|
1186
|
+
if (details.length) {
|
|
1187
|
+
warnings.push(...details.slice(0, 10).map(d => `${d.message ?? d}${d.path ? ` at ${[].concat(d.path).join(".")}` : ""}`));
|
|
1188
|
+
}
|
|
1189
|
+
else {
|
|
1190
|
+
warnings.push(String(err?.message ?? err).split("\n")[0]);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
return { file: absolute, specVersion, doc, ...(raw ? { raw } : {}), warnings: warnings.slice(0, 20) };
|
|
1194
|
+
}
|
|
1195
|
+
/** Stable id for a spec file: its basename without extensions (`users.swagger.json` → `users`). */
|
|
1196
|
+
function specIdFor(file) {
|
|
1197
|
+
const name = isUrl(file) ? new URL(file).pathname.split("/").filter(Boolean).pop() || new URL(file).hostname : file;
|
|
1198
|
+
return path
|
|
1199
|
+
.basename(name)
|
|
1200
|
+
.replace(SPEC_FILE_PATTERN, "")
|
|
1201
|
+
.replace(/\.(swagger|openapi|oas|spec)$/i, "")
|
|
1202
|
+
.replace(/[^A-Za-z0-9_-]+/g, "-");
|
|
1203
|
+
}
|
|
1204
|
+
async function analyseSpecFile(file, id = specIdFor(file), options = {}) {
|
|
1205
|
+
const loaded = await loadSpecFile(file, options);
|
|
1206
|
+
return {
|
|
1207
|
+
...loaded,
|
|
1208
|
+
id,
|
|
1209
|
+
operations: buildCatalogue(loaded.doc, id),
|
|
1210
|
+
contract: buildContract(loaded.doc, id),
|
|
1211
|
+
basePaths: basePathsOf(loaded.doc),
|
|
1212
|
+
serverUrl: serverUrlOf(loaded.doc)
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
/** Accepts a mix of spec files and folders and returns every spec file, folder contents sorted. */
|
|
1216
|
+
async function resolveSpecInputs(inputs) {
|
|
1217
|
+
const out = [];
|
|
1218
|
+
for (const input of inputs) {
|
|
1219
|
+
if (isUrl(input)) {
|
|
1220
|
+
out.push(input);
|
|
1221
|
+
continue;
|
|
1222
|
+
}
|
|
1223
|
+
const absolute = path.resolve(input);
|
|
1224
|
+
const stat = await fs.promises.stat(absolute);
|
|
1225
|
+
if (stat.isDirectory())
|
|
1226
|
+
out.push(...(await listSpecFiles(absolute)));
|
|
1227
|
+
else
|
|
1228
|
+
out.push(absolute);
|
|
1229
|
+
}
|
|
1230
|
+
return [...new Set(out)];
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/* The Swaggerizer mock server: serves every loaded spec and exposes the admin API + page. */
|
|
1234
|
+
const REQUEST_LOG_LIMIT = 300;
|
|
1235
|
+
const MAX_BODY_BYTES = 5 * 1024 * 1024;
|
|
1236
|
+
function log(silent, message) {
|
|
1237
|
+
if (!silent)
|
|
1238
|
+
console.log(`\x1b[36m[swaggerizer]\x1b[0m ${message}`);
|
|
1239
|
+
}
|
|
1240
|
+
function readBody(req) {
|
|
1241
|
+
return new Promise((resolve, reject) => {
|
|
1242
|
+
const chunks = [];
|
|
1243
|
+
let size = 0;
|
|
1244
|
+
req.on("data", (chunk) => {
|
|
1245
|
+
size += chunk.length;
|
|
1246
|
+
if (size > MAX_BODY_BYTES) {
|
|
1247
|
+
reject(new Error("Request body too large"));
|
|
1248
|
+
req.destroy();
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
chunks.push(chunk);
|
|
1252
|
+
});
|
|
1253
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
1254
|
+
req.on("error", reject);
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
function parseRequestBody(raw, contentType) {
|
|
1258
|
+
const text = raw.toString("utf8");
|
|
1259
|
+
if (!text.trim())
|
|
1260
|
+
return {};
|
|
1261
|
+
if (contentType && /application\/x-www-form-urlencoded/i.test(contentType)) {
|
|
1262
|
+
return Object.fromEntries(new URLSearchParams(text));
|
|
1263
|
+
}
|
|
1264
|
+
try {
|
|
1265
|
+
return JSON.parse(text);
|
|
1266
|
+
}
|
|
1267
|
+
catch {
|
|
1268
|
+
throw new Error("Request body is not valid JSON.");
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
1272
|
+
function createSwaggerizer(options = {}) {
|
|
1273
|
+
const silent = Boolean(options.silent);
|
|
1274
|
+
const endpoint = (options.endpoint || "swaggerizer").replace(/^\/+|\/+$/g, "") || "swaggerizer";
|
|
1275
|
+
const basePath = `/${endpoint}`;
|
|
1276
|
+
const specs = new Map();
|
|
1277
|
+
const sources = [];
|
|
1278
|
+
const scenarios = {};
|
|
1279
|
+
const requestLog = [];
|
|
1280
|
+
const previousReports = new Map();
|
|
1281
|
+
let snapshotReports = [];
|
|
1282
|
+
let requestSeq = 0;
|
|
1283
|
+
let lastReload;
|
|
1284
|
+
let reloadChain = Promise.resolve();
|
|
1285
|
+
const watchers = [];
|
|
1286
|
+
const servers = new Set();
|
|
1287
|
+
const specConfigs = { ...(options.specConfigs || {}) };
|
|
1288
|
+
const store = new Map();
|
|
1289
|
+
let configHeaders = {};
|
|
1290
|
+
const configFor = (id) => specConfigs[id] || {};
|
|
1291
|
+
/* ------------------------------------------------------------------ persistence */
|
|
1292
|
+
function restoreState() {
|
|
1293
|
+
if (!options.stateFile || !fs.existsSync(options.stateFile))
|
|
1294
|
+
return;
|
|
1295
|
+
try {
|
|
1296
|
+
const saved = JSON.parse(fs.readFileSync(options.stateFile, "utf8"));
|
|
1297
|
+
Object.assign(scenarios, saved?.scenarios || {});
|
|
1298
|
+
log(silent, `Restored ${Object.keys(scenarios).length} scenario(s) from ${options.stateFile}`);
|
|
1299
|
+
}
|
|
1300
|
+
catch (err) {
|
|
1301
|
+
log(silent, `Could not read state file ${options.stateFile}: ${err?.message ?? err}`);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
function saveState() {
|
|
1305
|
+
if (!options.stateFile)
|
|
1306
|
+
return;
|
|
1307
|
+
const disabled = [...specs.values()].filter(s => !s.enabled).map(s => s.id);
|
|
1308
|
+
fs.mkdirSync(path.dirname(path.resolve(options.stateFile)), { recursive: true });
|
|
1309
|
+
fs.writeFileSync(options.stateFile, JSON.stringify({ scenarios, disabledSpecs: disabled }, null, 2) + "\n");
|
|
1310
|
+
}
|
|
1311
|
+
function savedDisabledSpecs() {
|
|
1312
|
+
if (!options.stateFile || !fs.existsSync(options.stateFile))
|
|
1313
|
+
return new Set();
|
|
1314
|
+
try {
|
|
1315
|
+
return new Set(JSON.parse(fs.readFileSync(options.stateFile, "utf8"))?.disabledSpecs || []);
|
|
1316
|
+
}
|
|
1317
|
+
catch {
|
|
1318
|
+
return new Set();
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
/* ------------------------------------------------------------------ state */
|
|
1322
|
+
const ordered = () => [...specs.values()];
|
|
1323
|
+
const states = () => buildOperationStates(ordered(), scenarios, { defaultDelay: options.delay });
|
|
1324
|
+
function summarise(spec) {
|
|
1325
|
+
return {
|
|
1326
|
+
id: spec.id,
|
|
1327
|
+
file: spec.file,
|
|
1328
|
+
name: path.basename(spec.file),
|
|
1329
|
+
title: spec.doc?.info?.title,
|
|
1330
|
+
version: spec.doc?.info?.version !== undefined ? String(spec.doc.info.version) : undefined,
|
|
1331
|
+
specVersion: spec.specVersion,
|
|
1332
|
+
enabled: spec.enabled,
|
|
1333
|
+
loadedAt: spec.loadedAt,
|
|
1334
|
+
...(spec.error ? { error: spec.error } : {}),
|
|
1335
|
+
warnings: spec.warnings,
|
|
1336
|
+
basePaths: spec.basePaths,
|
|
1337
|
+
...(configFor(spec.id).aliases ? { aliases: configFor(spec.id).aliases } : {}),
|
|
1338
|
+
...(isUrl(spec.file) ? { source: spec.file } : {}),
|
|
1339
|
+
proxyTarget: configFor(spec.id).proxy || options.proxy || spec.serverUrl,
|
|
1340
|
+
operationCount: spec.operations.length
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
function getState() {
|
|
1344
|
+
const changes = [
|
|
1345
|
+
...snapshotReports,
|
|
1346
|
+
...[...previousReports.values()].filter(r => r.changes.length > 0)
|
|
1347
|
+
];
|
|
1348
|
+
return {
|
|
1349
|
+
endpoint,
|
|
1350
|
+
...(options.delay ? { defaultDelay: options.delay } : {}),
|
|
1351
|
+
...(options.fallback ? { fallback: options.fallback } : {}),
|
|
1352
|
+
specs: ordered().map(summarise),
|
|
1353
|
+
operations: states(),
|
|
1354
|
+
lastReload,
|
|
1355
|
+
changes
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
function servingOperation(method, route) {
|
|
1359
|
+
const key = operationKey(method, route);
|
|
1360
|
+
const all = states();
|
|
1361
|
+
return all.find(s => s.key === key && s.active) || all.find(s => s.key === key);
|
|
1362
|
+
}
|
|
1363
|
+
function select(selection) {
|
|
1364
|
+
const list = Array.isArray(selection) ? selection : [selection];
|
|
1365
|
+
if (list.length === 0)
|
|
1366
|
+
throw new Error("Selection list cannot be empty.");
|
|
1367
|
+
// Validate everything before applying anything.
|
|
1368
|
+
const next = [];
|
|
1369
|
+
for (const item of list) {
|
|
1370
|
+
if (!item || typeof item.method !== "string" || typeof item.route !== "string") {
|
|
1371
|
+
throw new Error("Each selection needs a string `method` and `route`.");
|
|
1372
|
+
}
|
|
1373
|
+
const op = servingOperation(item.method, item.route);
|
|
1374
|
+
if (!op)
|
|
1375
|
+
throw new Error(`No operation ${item.method.toUpperCase()} ${item.route} is loaded.`);
|
|
1376
|
+
next.push([op.key, applySelection(op, item, scenarios[op.key])]);
|
|
1377
|
+
}
|
|
1378
|
+
for (const [key, scenario] of next)
|
|
1379
|
+
scenarios[key] = scenario;
|
|
1380
|
+
saveState();
|
|
1381
|
+
const after = states();
|
|
1382
|
+
return next.map(([key]) => after.find(s => s.key === key && s.active) || after.find(s => s.key === key));
|
|
1383
|
+
}
|
|
1384
|
+
function reset(filter = {}) {
|
|
1385
|
+
let count = 0;
|
|
1386
|
+
for (const key of Object.keys(scenarios)) {
|
|
1387
|
+
const [method, ...rest] = key.split(":");
|
|
1388
|
+
const route = rest.join(":");
|
|
1389
|
+
if (filter.method && method !== String(filter.method).toLowerCase())
|
|
1390
|
+
continue;
|
|
1391
|
+
if (filter.route && route !== filter.route)
|
|
1392
|
+
continue;
|
|
1393
|
+
delete scenarios[key];
|
|
1394
|
+
count++;
|
|
1395
|
+
}
|
|
1396
|
+
// "Reset all" also empties the state custom handlers keep.
|
|
1397
|
+
if (!filter.method && !filter.route)
|
|
1398
|
+
store.clear();
|
|
1399
|
+
saveState();
|
|
1400
|
+
return count;
|
|
1401
|
+
}
|
|
1402
|
+
function setSpecEnabled(id, enabled) {
|
|
1403
|
+
const spec = specs.get(id);
|
|
1404
|
+
if (!spec)
|
|
1405
|
+
throw new Error(`No spec with id '${id}' is loaded.`);
|
|
1406
|
+
spec.enabled = Boolean(enabled);
|
|
1407
|
+
saveState();
|
|
1408
|
+
log(silent, `${spec.id} ${spec.enabled ? "enabled" : "disabled"}`);
|
|
1409
|
+
return summarise(spec);
|
|
1410
|
+
}
|
|
1411
|
+
function recordRequest(entry) {
|
|
1412
|
+
const full = { id: ++requestSeq, time: new Date().toISOString(), ...entry };
|
|
1413
|
+
requestLog.push(full);
|
|
1414
|
+
if (requestLog.length > REQUEST_LOG_LIMIT)
|
|
1415
|
+
requestLog.splice(0, requestLog.length - REQUEST_LOG_LIMIT);
|
|
1416
|
+
return full;
|
|
1417
|
+
}
|
|
1418
|
+
function getRequestLog(sinceId = 0) {
|
|
1419
|
+
if (sinceId > 0 && requestLog.length > 0 && requestLog[requestLog.length - 1].id < sinceId)
|
|
1420
|
+
return [];
|
|
1421
|
+
return requestLog.filter(e => e.id > sinceId);
|
|
1422
|
+
}
|
|
1423
|
+
/* ------------------------------------------------------------------ loading */
|
|
1424
|
+
function readSnapshot(specId) {
|
|
1425
|
+
const cfg = configFor(specId);
|
|
1426
|
+
if (!options.generated && !cfg.output)
|
|
1427
|
+
return undefined;
|
|
1428
|
+
const file = path.join(outputDirs(specId, options.generated, cfg).dir, CONTRACT_FILE);
|
|
1429
|
+
if (!fs.existsSync(file))
|
|
1430
|
+
return undefined;
|
|
1431
|
+
try {
|
|
1432
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
1433
|
+
}
|
|
1434
|
+
catch {
|
|
1435
|
+
return undefined;
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
function refreshSnapshotReports() {
|
|
1439
|
+
snapshotReports = [];
|
|
1440
|
+
for (const spec of ordered()) {
|
|
1441
|
+
if (spec.error)
|
|
1442
|
+
continue;
|
|
1443
|
+
const snapshot = readSnapshot(spec.id);
|
|
1444
|
+
if (!snapshot)
|
|
1445
|
+
continue;
|
|
1446
|
+
const report = diffContracts(snapshot, spec.contract, "snapshot");
|
|
1447
|
+
if (report.changes.length)
|
|
1448
|
+
snapshotReports.push(report);
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
async function wantedFiles() {
|
|
1452
|
+
const files = [];
|
|
1453
|
+
for (const source of sources) {
|
|
1454
|
+
if (source.kind === "folder") {
|
|
1455
|
+
try {
|
|
1456
|
+
files.push(...(await listSpecFiles(source.path)).map(file => ({ file })));
|
|
1457
|
+
}
|
|
1458
|
+
catch {
|
|
1459
|
+
// folder vanished
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
else if (source.kind === "url" || fs.existsSync(source.path)) {
|
|
1463
|
+
files.push({ file: source.path, id: source.id });
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
const seen = new Set();
|
|
1467
|
+
return files.filter(f => (seen.has(f.file) ? false : (seen.add(f.file), true)));
|
|
1468
|
+
}
|
|
1469
|
+
async function doReload() {
|
|
1470
|
+
const previous = new Map(ordered().map(s => [s.file, s]));
|
|
1471
|
+
const disabled = savedDisabledSpecs();
|
|
1472
|
+
const next = new Map();
|
|
1473
|
+
const usedIds = new Set();
|
|
1474
|
+
for (const { file, id: configuredId } of await wantedFiles()) {
|
|
1475
|
+
const before = previous.get(file);
|
|
1476
|
+
let id = configuredId ?? before?.id ?? specIdFor(file);
|
|
1477
|
+
for (let n = 2; usedIds.has(id); n++)
|
|
1478
|
+
id = `${specIdFor(file)}-${n}`;
|
|
1479
|
+
usedIds.add(id);
|
|
1480
|
+
try {
|
|
1481
|
+
const analysed = await analyseSpecFile(file, id, { headers: { ...configHeaders, ...(options.headers || {}), ...(configFor(id).headers || {}) } });
|
|
1482
|
+
const cfg = configFor(id);
|
|
1483
|
+
analysed.operations = applyOverlays(analysed.operations, cfg.scenarios);
|
|
1484
|
+
for (const key of unmatchedKeys(cfg.scenarios, analysed.operations)) {
|
|
1485
|
+
analysed.warnings.push(`Scenario overlay '${key}' matches no operation`);
|
|
1486
|
+
}
|
|
1487
|
+
for (const key of unmatchedKeys(cfg.handlers, analysed.operations)) {
|
|
1488
|
+
analysed.warnings.push(`Custom handler '${key}' matches no operation`);
|
|
1489
|
+
}
|
|
1490
|
+
const enabled = before ? before.enabled : !disabled.has(id);
|
|
1491
|
+
next.set(id, { ...analysed, enabled, loadedAt: new Date().toISOString() });
|
|
1492
|
+
if (before && !before.error) {
|
|
1493
|
+
const report = diffContracts(before.contract, analysed.contract, "previous-load");
|
|
1494
|
+
if (report.changes.length) {
|
|
1495
|
+
previousReports.set(id, report);
|
|
1496
|
+
log(silent, `${id}: ${report.counts.breaking} breaking, ${report.counts.warning} warning(s) since last load`);
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
log(silent, `Loaded ${isUrl(file) ? file : path.basename(file)} (${analysed.specVersion}) – ${analysed.operations.length} operation(s)`);
|
|
1500
|
+
}
|
|
1501
|
+
catch (err) {
|
|
1502
|
+
const message = err?.message ? String(err.message).split("\n")[0] : String(err);
|
|
1503
|
+
log(silent, `Could not load ${file}: ${message}`);
|
|
1504
|
+
next.set(id, before
|
|
1505
|
+
? { ...before, error: message }
|
|
1506
|
+
: {
|
|
1507
|
+
id,
|
|
1508
|
+
file,
|
|
1509
|
+
doc: {},
|
|
1510
|
+
warnings: [],
|
|
1511
|
+
operations: [],
|
|
1512
|
+
contract: { swaggerizer: 1, specId: id, operations: {} },
|
|
1513
|
+
basePaths: [],
|
|
1514
|
+
enabled: true,
|
|
1515
|
+
loadedAt: new Date().toISOString(),
|
|
1516
|
+
error: message
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
specs.clear();
|
|
1521
|
+
for (const [id, spec] of next)
|
|
1522
|
+
specs.set(id, spec);
|
|
1523
|
+
for (const id of previousReports.keys())
|
|
1524
|
+
if (!specs.has(id))
|
|
1525
|
+
previousReports.delete(id);
|
|
1526
|
+
refreshSnapshotReports();
|
|
1527
|
+
for (const report of snapshotReports) {
|
|
1528
|
+
log(silent, `${report.specId} differs from the generated client: ${report.counts.breaking} breaking, ${report.counts.warning} warning(s), ${report.added.length} new, ${report.removed.length} removed`);
|
|
1529
|
+
}
|
|
1530
|
+
lastReload = new Date().toISOString();
|
|
1531
|
+
return getState();
|
|
1532
|
+
}
|
|
1533
|
+
function reload() {
|
|
1534
|
+
const run = reloadChain.then(doReload, doReload);
|
|
1535
|
+
reloadChain = run.catch(() => undefined);
|
|
1536
|
+
return run;
|
|
1537
|
+
}
|
|
1538
|
+
function watch(target) {
|
|
1539
|
+
if (!options.watch)
|
|
1540
|
+
return;
|
|
1541
|
+
let timer;
|
|
1542
|
+
try {
|
|
1543
|
+
const watcher = fs.watch(target, () => {
|
|
1544
|
+
clearTimeout(timer);
|
|
1545
|
+
timer = setTimeout(() => {
|
|
1546
|
+
log(silent, "Spec change detected – reloading");
|
|
1547
|
+
reload().catch(() => undefined);
|
|
1548
|
+
}, 200);
|
|
1549
|
+
});
|
|
1550
|
+
watchers.push(watcher);
|
|
1551
|
+
}
|
|
1552
|
+
catch {
|
|
1553
|
+
// watching is best effort
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
async function load(inputs) {
|
|
1557
|
+
for (const input of [].concat(inputs)) {
|
|
1558
|
+
if (isUrl(input)) {
|
|
1559
|
+
if (!sources.some(s => s.path === input))
|
|
1560
|
+
sources.push({ kind: "url", path: input });
|
|
1561
|
+
continue;
|
|
1562
|
+
}
|
|
1563
|
+
const absolute = path.resolve(input);
|
|
1564
|
+
const stat = await fs.promises.stat(absolute);
|
|
1565
|
+
const kind = stat.isDirectory() ? "folder" : "file";
|
|
1566
|
+
if (!sources.some(s => s.path === absolute)) {
|
|
1567
|
+
sources.push({ kind, path: absolute });
|
|
1568
|
+
watch(absolute);
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
return reload();
|
|
1572
|
+
}
|
|
1573
|
+
async function loadConfig(config) {
|
|
1574
|
+
configHeaders = { ...(config.headers || {}) };
|
|
1575
|
+
for (const [id, spec] of Object.entries(config.specs || {})) {
|
|
1576
|
+
specConfigs[id] = spec;
|
|
1577
|
+
const local = spec.file ? path.resolve(spec.file) : undefined;
|
|
1578
|
+
if (local && fs.existsSync(local)) {
|
|
1579
|
+
if (!sources.some(s => s.path === local)) {
|
|
1580
|
+
sources.push({ kind: "file", path: local, id });
|
|
1581
|
+
watch(local);
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
else if (spec.source) {
|
|
1585
|
+
if (!sources.some(s => s.path === spec.source))
|
|
1586
|
+
sources.push({ kind: "url", path: spec.source, id });
|
|
1587
|
+
}
|
|
1588
|
+
else {
|
|
1589
|
+
throw new Error(`Spec '${id}' needs a \`file\` that exists or a \`source\` URL (run \`swaggerizer pull\`).`);
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
return reload();
|
|
1593
|
+
}
|
|
1594
|
+
/* ------------------------------------------------------------------ mock serving */
|
|
1595
|
+
async function proxy(req, res, target, relativePath, search) {
|
|
1596
|
+
const upstream = target.replace(/\/+$/, "") + relativePath + search;
|
|
1597
|
+
const headers = {};
|
|
1598
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
1599
|
+
if (value === undefined)
|
|
1600
|
+
continue;
|
|
1601
|
+
if (["host", "connection", "content-length", "accept-encoding", SCENARIO_HEADER].includes(name))
|
|
1602
|
+
continue;
|
|
1603
|
+
headers[name] = Array.isArray(value) ? value.join(", ") : value;
|
|
1604
|
+
}
|
|
1605
|
+
const method = (req.method || "GET").toUpperCase();
|
|
1606
|
+
const body = method === "GET" || method === "HEAD" ? undefined : await readBody(req);
|
|
1607
|
+
const response = await fetch(upstream, { method, headers, body: body && body.length ? new Uint8Array(body) : undefined, redirect: "manual" });
|
|
1608
|
+
const out = Buffer.from(await response.arrayBuffer());
|
|
1609
|
+
res.statusCode = response.status;
|
|
1610
|
+
response.headers.forEach((value, name) => {
|
|
1611
|
+
if (["content-encoding", "content-length", "transfer-encoding", "connection"].includes(name))
|
|
1612
|
+
return;
|
|
1613
|
+
if (name.startsWith("access-control-"))
|
|
1614
|
+
return;
|
|
1615
|
+
res.setHeader(name, value);
|
|
1616
|
+
});
|
|
1617
|
+
res.setHeader("X-Swaggerizer-Proxied", upstream);
|
|
1618
|
+
res.end(out);
|
|
1619
|
+
return response.status;
|
|
1620
|
+
}
|
|
1621
|
+
async function serveMock(req, res, url) {
|
|
1622
|
+
const started = Date.now();
|
|
1623
|
+
const method = (req.method || "GET").toLowerCase();
|
|
1624
|
+
const enabledSpecs = ordered().filter(s => s.enabled && !s.error);
|
|
1625
|
+
const allBasePaths = [...new Set(enabledSpecs.flatMap(s => s.basePaths))];
|
|
1626
|
+
const allAliases = Object.assign({}, ...enabledSpecs.map(s => configFor(s.id).aliases || {}));
|
|
1627
|
+
const op = matchOperation(states(), method, url.pathname, allBasePaths, allAliases);
|
|
1628
|
+
if (!op)
|
|
1629
|
+
return false;
|
|
1630
|
+
const spec = specs.get(op.specId);
|
|
1631
|
+
const cfg = configFor(op.specId);
|
|
1632
|
+
const override = parseScenarioHeader(req.headers[SCENARIO_HEADER]);
|
|
1633
|
+
const entry = { method: method.toUpperCase(), path: url.pathname + url.search, route: op.route, specId: op.specId, source: "server" };
|
|
1634
|
+
const custom = lookupByOperation(cfg.handlers, op);
|
|
1635
|
+
if (custom && !override && !op.modified) {
|
|
1636
|
+
const raw = method === "get" || method === "head" ? undefined : (await readBody(req)).toString("utf8");
|
|
1637
|
+
let body = raw;
|
|
1638
|
+
if (raw !== undefined) {
|
|
1639
|
+
try {
|
|
1640
|
+
body = raw.trim() ? JSON.parse(raw) : undefined;
|
|
1641
|
+
}
|
|
1642
|
+
catch {
|
|
1643
|
+
body = raw;
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
const params = candidatePaths(url.pathname, spec.basePaths, cfg.aliases || {})
|
|
1647
|
+
.map(p => extractParams(op.route, p))
|
|
1648
|
+
.find(p => Object.keys(p).length > 0) ?? {};
|
|
1649
|
+
const headers = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : String(v ?? "")]));
|
|
1650
|
+
let outcome;
|
|
1651
|
+
try {
|
|
1652
|
+
outcome = await custom(createHandlerContext({ operation: op, method: method.toUpperCase(), path: url.pathname, params, query: url.searchParams, headers, body, store }));
|
|
1653
|
+
}
|
|
1654
|
+
catch (err) {
|
|
1655
|
+
sendJson(res, 500, { error: `Custom handler for ${op.operationId ?? op.key} threw: ${err?.message ?? err}` });
|
|
1656
|
+
recordRequest({ ...entry, status: 500, example: "handler error", ms: Date.now() - started });
|
|
1657
|
+
return true;
|
|
1658
|
+
}
|
|
1659
|
+
if (isHandlerResult(outcome)) {
|
|
1660
|
+
if (op.delay > 0)
|
|
1661
|
+
await sleep(op.delay);
|
|
1662
|
+
const contentType = outcome.contentType ?? (outcome.body !== undefined ? (typeof outcome.body === "string" ? "text/plain" : "application/json") : undefined);
|
|
1663
|
+
res.statusCode = outcome.status;
|
|
1664
|
+
for (const [name, value] of Object.entries(outcome.headers || {}))
|
|
1665
|
+
res.setHeader(name, value);
|
|
1666
|
+
res.setHeader("X-Swaggerizer-Scenario", `${outcome.status}:handler`);
|
|
1667
|
+
const payload = outcome.status === 204 || outcome.status === 304 ? undefined : encodeBody(outcome.body, contentType);
|
|
1668
|
+
if (payload !== undefined && contentType)
|
|
1669
|
+
res.setHeader("Content-Type", contentType);
|
|
1670
|
+
res.end(payload);
|
|
1671
|
+
recordRequest({ ...entry, status: outcome.status, example: "handler", contentType, ms: Date.now() - started });
|
|
1672
|
+
return true;
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
const resolved = resolveResponse(op, override);
|
|
1676
|
+
if (resolved.delay > 0)
|
|
1677
|
+
await sleep(resolved.delay);
|
|
1678
|
+
if (resolved.kind === "proxy") {
|
|
1679
|
+
const target = cfg.proxy || options.proxy || spec.serverUrl;
|
|
1680
|
+
if (!target) {
|
|
1681
|
+
sendJson(res, 502, { error: `No upstream for ${op.specId}: pass --proxy or declare an absolute URL in servers.` });
|
|
1682
|
+
recordRequest({ ...entry, status: 502, example: "proxy", ms: Date.now() - started });
|
|
1683
|
+
return true;
|
|
1684
|
+
}
|
|
1685
|
+
const relative = relativeToBase(url.pathname, spec.basePaths, cfg.aliases || {});
|
|
1686
|
+
try {
|
|
1687
|
+
const status = await proxy(req, res, target, relative, url.search);
|
|
1688
|
+
recordRequest({ ...entry, status, example: "proxy", ms: Date.now() - started });
|
|
1689
|
+
}
|
|
1690
|
+
catch (err) {
|
|
1691
|
+
sendJson(res, 502, { error: `Proxy to ${target} failed: ${err?.message ?? err}` });
|
|
1692
|
+
recordRequest({ ...entry, status: 502, example: "proxy", ms: Date.now() - started });
|
|
1693
|
+
}
|
|
1694
|
+
return true;
|
|
1695
|
+
}
|
|
1696
|
+
res.statusCode = resolved.status;
|
|
1697
|
+
for (const [name, value] of Object.entries(resolved.headers || {}))
|
|
1698
|
+
res.setHeader(name, value);
|
|
1699
|
+
res.setHeader("X-Swaggerizer-Scenario", `${resolved.code}:${resolved.variant}`);
|
|
1700
|
+
const payload = resolved.status === 204 || resolved.status === 304 || method === "head"
|
|
1701
|
+
? undefined
|
|
1702
|
+
: encodeBody(resolved.body, resolved.contentType);
|
|
1703
|
+
if (payload !== undefined && resolved.contentType)
|
|
1704
|
+
res.setHeader("Content-Type", resolved.contentType);
|
|
1705
|
+
res.end(payload);
|
|
1706
|
+
recordRequest({ ...entry, status: resolved.status, example: resolved.variant, contentType: resolved.contentType, ms: Date.now() - started });
|
|
1707
|
+
return true;
|
|
1708
|
+
}
|
|
1709
|
+
function sendJson(res, status, payload) {
|
|
1710
|
+
res.statusCode = status;
|
|
1711
|
+
res.setHeader("Content-Type", "application/json");
|
|
1712
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1713
|
+
res.end(JSON.stringify(payload));
|
|
1714
|
+
}
|
|
1715
|
+
const admin = registerAdmin({
|
|
1716
|
+
basePath,
|
|
1717
|
+
ui: options.ui !== false,
|
|
1718
|
+
getState,
|
|
1719
|
+
select,
|
|
1720
|
+
reset,
|
|
1721
|
+
setSpecEnabled,
|
|
1722
|
+
reload,
|
|
1723
|
+
getRequestLog,
|
|
1724
|
+
recordRequest,
|
|
1725
|
+
getSpecDocument: id => specs.get(id)?.doc
|
|
1726
|
+
});
|
|
1727
|
+
function handler(req, res, next) {
|
|
1728
|
+
if (options.cors !== false) {
|
|
1729
|
+
res.setHeader("Access-Control-Allow-Origin", req.headers.origin || "*");
|
|
1730
|
+
res.setHeader("Vary", "Origin");
|
|
1731
|
+
res.setHeader("Access-Control-Allow-Credentials", "true");
|
|
1732
|
+
res.setHeader("Access-Control-Expose-Headers", "X-Swaggerizer-Scenario, X-Swaggerizer-Proxied");
|
|
1733
|
+
if (req.method === "OPTIONS" && req.headers["access-control-request-method"]) {
|
|
1734
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS");
|
|
1735
|
+
res.setHeader("Access-Control-Allow-Headers", req.headers["access-control-request-headers"] || "*");
|
|
1736
|
+
res.setHeader("Access-Control-Max-Age", "600");
|
|
1737
|
+
res.statusCode = 204;
|
|
1738
|
+
res.end();
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
const url = new URL(req.url || "/", "http://localhost");
|
|
1743
|
+
const isAdmin = url.pathname === basePath || url.pathname.startsWith(basePath + "/");
|
|
1744
|
+
(async () => {
|
|
1745
|
+
if (isAdmin) {
|
|
1746
|
+
let body = {};
|
|
1747
|
+
if (req.method === "POST" || req.method === "PUT") {
|
|
1748
|
+
body = parseRequestBody(await readBody(req), req.headers["content-type"]);
|
|
1749
|
+
}
|
|
1750
|
+
const request = {
|
|
1751
|
+
method: (req.method || "GET").toUpperCase(),
|
|
1752
|
+
path: url.pathname.slice(basePath.length) || "/",
|
|
1753
|
+
query: url.searchParams,
|
|
1754
|
+
body,
|
|
1755
|
+
contentType: req.headers["content-type"]
|
|
1756
|
+
};
|
|
1757
|
+
if (await admin.handle(request, res))
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
else if (await serveMock(req, res, url)) {
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1763
|
+
else if (options.fallback) {
|
|
1764
|
+
const started = Date.now();
|
|
1765
|
+
const method = (req.method || "GET").toUpperCase();
|
|
1766
|
+
try {
|
|
1767
|
+
const status = await proxy(req, res, options.fallback, url.pathname, url.search);
|
|
1768
|
+
recordRequest({ method, path: url.pathname + url.search, status, example: "fallback", source: "server", ms: Date.now() - started });
|
|
1769
|
+
}
|
|
1770
|
+
catch (err) {
|
|
1771
|
+
sendJson(res, 502, { error: `Fallback to ${options.fallback} failed: ${err?.message ?? err}` });
|
|
1772
|
+
recordRequest({ method, path: url.pathname + url.search, status: 502, example: "fallback", source: "server", ms: Date.now() - started });
|
|
1773
|
+
}
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
if (next)
|
|
1777
|
+
return next();
|
|
1778
|
+
const method = (req.method || "GET").toUpperCase();
|
|
1779
|
+
sendJson(res, 404, { error: `No operation matches ${method} ${url.pathname}` });
|
|
1780
|
+
if (!isAdmin)
|
|
1781
|
+
recordRequest({ method, path: url.pathname + url.search, status: 404, example: "unmatched", source: "server" });
|
|
1782
|
+
})().catch(err => {
|
|
1783
|
+
if (!res.headersSent)
|
|
1784
|
+
sendJson(res, 400, { error: err?.message ? String(err.message) : String(err) });
|
|
1785
|
+
else
|
|
1786
|
+
res.end();
|
|
1787
|
+
});
|
|
1788
|
+
}
|
|
1789
|
+
function listen(port = 8080, host) {
|
|
1790
|
+
return new Promise((resolve, reject) => {
|
|
1791
|
+
const server = http.createServer((req, res) => handler(req, res));
|
|
1792
|
+
server.once("error", reject);
|
|
1793
|
+
server.listen(port, host, () => {
|
|
1794
|
+
servers.add(server);
|
|
1795
|
+
const address = server.address();
|
|
1796
|
+
const actual = typeof address === "object" && address ? address.port : port;
|
|
1797
|
+
log(silent, `Mock API on http://localhost:${actual}`);
|
|
1798
|
+
if (options.ui !== false)
|
|
1799
|
+
log(silent, `Admin page at http://localhost:${actual}${basePath}/`);
|
|
1800
|
+
resolve(server);
|
|
1801
|
+
});
|
|
1802
|
+
});
|
|
1803
|
+
}
|
|
1804
|
+
async function close() {
|
|
1805
|
+
for (const w of watchers.splice(0))
|
|
1806
|
+
w.close();
|
|
1807
|
+
await Promise.all([...servers].map(s => new Promise(resolve => {
|
|
1808
|
+
s.closeAllConnections?.();
|
|
1809
|
+
s.close(() => resolve());
|
|
1810
|
+
})));
|
|
1811
|
+
servers.clear();
|
|
1812
|
+
}
|
|
1813
|
+
restoreState();
|
|
1814
|
+
return {
|
|
1815
|
+
handler,
|
|
1816
|
+
load,
|
|
1817
|
+
loadConfig,
|
|
1818
|
+
reload,
|
|
1819
|
+
getState,
|
|
1820
|
+
select,
|
|
1821
|
+
reset,
|
|
1822
|
+
setSpecEnabled,
|
|
1823
|
+
getRequestLog,
|
|
1824
|
+
recordRequest,
|
|
1825
|
+
getSpecDocument: id => specs.get(id)?.doc,
|
|
1826
|
+
listen,
|
|
1827
|
+
close,
|
|
1828
|
+
endpoint,
|
|
1829
|
+
scenarios
|
|
1830
|
+
};
|
|
1831
|
+
}
|
|
1832
|
+
/** CLI entry point: load specs and listen. */
|
|
1833
|
+
async function startSwaggerizer(options) {
|
|
1834
|
+
const swaggerizer = createSwaggerizer(options);
|
|
1835
|
+
if (options.config)
|
|
1836
|
+
await swaggerizer.loadConfig(options.config);
|
|
1837
|
+
if (options.specs)
|
|
1838
|
+
await swaggerizer.load(options.specs);
|
|
1839
|
+
const server = await swaggerizer.listen(options.port ?? 8080, options.host);
|
|
1840
|
+
return { ...swaggerizer, server };
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
/*
|
|
1844
|
+
* `swaggerizer generate`: runs orval for every spec (client + typed models + MSW handlers), then
|
|
1845
|
+
* writes a Swaggerizer wrapper that makes those MSW handlers follow the admin's scenario choices,
|
|
1846
|
+
* plus a contract snapshot used to warn about API changes before the next regeneration.
|
|
1847
|
+
*/
|
|
1848
|
+
function readJson(file) {
|
|
1849
|
+
try {
|
|
1850
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
1851
|
+
}
|
|
1852
|
+
catch {
|
|
1853
|
+
return undefined;
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
function pascal(text) {
|
|
1857
|
+
const out = text
|
|
1858
|
+
.replace(/[^A-Za-z0-9]+/g, " ")
|
|
1859
|
+
.trim()
|
|
1860
|
+
.split(/\s+/)
|
|
1861
|
+
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
|
1862
|
+
.join("");
|
|
1863
|
+
return /^[0-9]/.test(out) ? `Api${out}` : out || "Api";
|
|
1864
|
+
}
|
|
1865
|
+
function camel(text) {
|
|
1866
|
+
const p = pascal(text);
|
|
1867
|
+
return p.charAt(0).toLowerCase() + p.slice(1);
|
|
1868
|
+
}
|
|
1869
|
+
/** Snapshots of specs that were generated before but are no longer among the inputs. */
|
|
1870
|
+
function orphanSnapshots(output, ids) {
|
|
1871
|
+
if (!fs.existsSync(output))
|
|
1872
|
+
return [];
|
|
1873
|
+
const out = [];
|
|
1874
|
+
for (const entry of fs.readdirSync(output, { withFileTypes: true })) {
|
|
1875
|
+
if (!entry.isDirectory() || ids.has(entry.name))
|
|
1876
|
+
continue;
|
|
1877
|
+
const snapshot = readJson(path.join(output, entry.name, CONTRACT_FILE));
|
|
1878
|
+
if (snapshot?.operations && Object.keys(snapshot.operations).length > 0)
|
|
1879
|
+
out.push(snapshot);
|
|
1880
|
+
}
|
|
1881
|
+
return out;
|
|
1882
|
+
}
|
|
1883
|
+
/** Every spec to work on: those declared in the config plus any extra files, folders or URLs. */
|
|
1884
|
+
async function resolveTargets(specInputs, config) {
|
|
1885
|
+
const targets = [];
|
|
1886
|
+
for (const [id, spec] of Object.entries(config?.specs || {})) {
|
|
1887
|
+
const input = spec.file && fs.existsSync(spec.file) ? spec.file : spec.source;
|
|
1888
|
+
if (!input)
|
|
1889
|
+
throw new Error(`Spec '${id}' needs a \`file\` that exists or a \`source\` URL (run \`swaggerizer pull\`).`);
|
|
1890
|
+
targets.push({ id, input, config: spec });
|
|
1891
|
+
}
|
|
1892
|
+
const extra = specInputs === undefined ? [] : [].concat(specInputs);
|
|
1893
|
+
if (extra.length) {
|
|
1894
|
+
const files = await resolveSpecInputs(extra);
|
|
1895
|
+
if (files.length === 0)
|
|
1896
|
+
throw new Error(`No OpenAPI / Swagger files found in ${extra.join(", ")}.`);
|
|
1897
|
+
for (const file of files) {
|
|
1898
|
+
const id = specIdFor(file);
|
|
1899
|
+
if (!targets.some(t => t.id === id))
|
|
1900
|
+
targets.push({ id, input: file, config: config?.specs?.[id] || {} });
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
if (targets.length === 0)
|
|
1904
|
+
throw new Error("No specs given: pass --folder or create swaggerizer.config.ts.");
|
|
1905
|
+
return targets;
|
|
1906
|
+
}
|
|
1907
|
+
async function analyseTarget(target, headers) {
|
|
1908
|
+
const spec = await analyseSpecFile(target.input, target.id, { headers: { ...(headers || {}), ...(target.config.headers || {}) } });
|
|
1909
|
+
spec.operations = applyOverlays(spec.operations, target.config.scenarios);
|
|
1910
|
+
for (const key of unmatchedKeys(target.config.scenarios, spec.operations)) {
|
|
1911
|
+
spec.warnings.push(`Scenario overlay '${key}' matches no operation`);
|
|
1912
|
+
}
|
|
1913
|
+
return { ...spec, config: target.config };
|
|
1914
|
+
}
|
|
1915
|
+
/** Compares every spec with the snapshot saved at the last generation. */
|
|
1916
|
+
async function checkChanges(specInputs, output, config, headers = config?.headers) {
|
|
1917
|
+
const targets = await resolveTargets(specInputs, config);
|
|
1918
|
+
const specs = [];
|
|
1919
|
+
for (const target of targets)
|
|
1920
|
+
specs.push(await analyseTarget(target, headers));
|
|
1921
|
+
const root = path.resolve(output ?? config?.output ?? "src/api");
|
|
1922
|
+
const reports = [];
|
|
1923
|
+
for (const spec of specs) {
|
|
1924
|
+
const snapshot = readJson(path.join(outputDirs(spec.id, root, spec.config).dir, CONTRACT_FILE));
|
|
1925
|
+
if (!snapshot) {
|
|
1926
|
+
reports.push({
|
|
1927
|
+
specId: spec.id,
|
|
1928
|
+
title: spec.contract.title,
|
|
1929
|
+
baseline: "snapshot",
|
|
1930
|
+
toVersion: spec.contract.version,
|
|
1931
|
+
added: Object.values(spec.contract.operations).map(o => `${o.method} ${o.route}`),
|
|
1932
|
+
removed: [],
|
|
1933
|
+
changed: [],
|
|
1934
|
+
changes: Object.values(spec.contract.operations).map(o => ({
|
|
1935
|
+
severity: "info",
|
|
1936
|
+
kind: "operation-added",
|
|
1937
|
+
operation: `${o.method} ${o.route}`,
|
|
1938
|
+
message: "New operation (first generation)"
|
|
1939
|
+
})),
|
|
1940
|
+
counts: { breaking: 0, warning: 0, info: Object.keys(spec.contract.operations).length }
|
|
1941
|
+
});
|
|
1942
|
+
continue;
|
|
1943
|
+
}
|
|
1944
|
+
reports.push(diffContracts(snapshot, spec.contract, "snapshot"));
|
|
1945
|
+
}
|
|
1946
|
+
for (const orphan of orphanSnapshots(root, new Set(specs.map(s => s.id)))) {
|
|
1947
|
+
// The spec file is gone: everything it declared will disappear from the generated code.
|
|
1948
|
+
const report = diffContracts(orphan, { ...orphan, operations: {} }, "snapshot");
|
|
1949
|
+
report.title = `${orphan.title ?? orphan.specId} (spec file removed)`;
|
|
1950
|
+
reports.push(report);
|
|
1951
|
+
}
|
|
1952
|
+
return { specs, reports };
|
|
1953
|
+
}
|
|
1954
|
+
function mergeOrval(...parts) {
|
|
1955
|
+
const out = {};
|
|
1956
|
+
for (const part of parts) {
|
|
1957
|
+
for (const [key, value] of Object.entries(part || {})) {
|
|
1958
|
+
out[key] =
|
|
1959
|
+
value && typeof value === "object" && !Array.isArray(value) && out[key] && typeof out[key] === "object"
|
|
1960
|
+
? mergeOrval(out[key], value)
|
|
1961
|
+
: value;
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
return out;
|
|
1965
|
+
}
|
|
1966
|
+
async function runOrval(spec, dir, options) {
|
|
1967
|
+
const mod = await import('orval');
|
|
1968
|
+
const generate = mod.generate ?? mod.default;
|
|
1969
|
+
const output = mergeOrval({
|
|
1970
|
+
target: path.join(dir, `${spec.id}.ts`),
|
|
1971
|
+
schemas: path.join(dir, "model"),
|
|
1972
|
+
client: options.client || options.config?.client || "fetch",
|
|
1973
|
+
mode: "split",
|
|
1974
|
+
mock: options.mock !== false,
|
|
1975
|
+
clean: true,
|
|
1976
|
+
...(options.baseUrl ? { baseUrl: options.baseUrl } : {})
|
|
1977
|
+
}, options.orval, spec.config.orval);
|
|
1978
|
+
// A spec URL is handed to orval as the copy already fetched (with any auth headers and
|
|
1979
|
+
// same-origin $refs bundled in), so orval never needs network access of its own.
|
|
1980
|
+
const tmp = spec.raw ? fs.mkdtempSync(path.join(os.tmpdir(), "swaggerizer-")) : undefined;
|
|
1981
|
+
const input = tmp ? path.join(tmp, `${spec.id}.json`) : spec.file;
|
|
1982
|
+
if (tmp)
|
|
1983
|
+
fs.writeFileSync(input, JSON.stringify(spec.raw));
|
|
1984
|
+
try {
|
|
1985
|
+
await generate({ input: { target: input }, output }, process.cwd(), { logLevel: options.silent ? "silent" : "warn" });
|
|
1986
|
+
}
|
|
1987
|
+
finally {
|
|
1988
|
+
if (tmp)
|
|
1989
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
1990
|
+
}
|
|
1991
|
+
return output;
|
|
1992
|
+
}
|
|
1993
|
+
function relImport(fromDir, target) {
|
|
1994
|
+
const rel = path.relative(fromDir, target).split(path.sep).join("/");
|
|
1995
|
+
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
1996
|
+
}
|
|
1997
|
+
/**
|
|
1998
|
+
* Moves orval's MSW + faker files into the mocks folder (Nx-style repos keep mocks apart from
|
|
1999
|
+
* shipped code) and points their imports back at the models.
|
|
2000
|
+
*/
|
|
2001
|
+
function moveMocks(id, dir, mocksDir) {
|
|
2002
|
+
fs.mkdirSync(mocksDir, { recursive: true });
|
|
2003
|
+
const modelImport = relImport(mocksDir, path.join(dir, "model"));
|
|
2004
|
+
for (const name of [`${id}.msw.ts`, `${id}.faker.ts`]) {
|
|
2005
|
+
const from = path.join(dir, name);
|
|
2006
|
+
if (!fs.existsSync(from))
|
|
2007
|
+
continue;
|
|
2008
|
+
const source = fs.readFileSync(from, "utf8").replace(/from (['"])\.\/model\1/g, `from $1${modelImport}$1`);
|
|
2009
|
+
fs.writeFileSync(path.join(mocksDir, name), source);
|
|
2010
|
+
fs.rmSync(from);
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
/** Name of orval's aggregate `get<Title>Mock()` export in the generated `.msw.ts` file. */
|
|
2014
|
+
function findMockAggregate(source) {
|
|
2015
|
+
const match = /export const (get\w*Mock)\s*=\s*\([^)]*\)\s*=>\s*\[/.exec(source);
|
|
2016
|
+
return match ? match[1] : undefined;
|
|
2017
|
+
}
|
|
2018
|
+
function renderWrapper(spec, manifest, mockModule, aggregate, runtimeImport) {
|
|
2019
|
+
const name = pascal(spec.contract.title || spec.id);
|
|
2020
|
+
const lines = [
|
|
2021
|
+
"/**",
|
|
2022
|
+
" * Generated by Swaggerizer — do not edit by hand. Re-run `swaggerizer generate` instead.",
|
|
2023
|
+
` * ${spec.contract.title ?? spec.id}${spec.contract.version ? ` v${spec.contract.version}` : ""}`,
|
|
2024
|
+
" */",
|
|
2025
|
+
`import { createSwaggerizerMocks, type SwaggerizerManifest, type SwaggerizerMswOptions } from "${runtimeImport}";`
|
|
2026
|
+
];
|
|
2027
|
+
if (mockModule && aggregate)
|
|
2028
|
+
lines.push(`import { ${aggregate} } from "./${mockModule}";`);
|
|
2029
|
+
lines.push("", `export const manifest: SwaggerizerManifest = ${JSON.stringify(manifest, null, 2)};`, "", "/** orval's faker-based MSW handlers, used whenever a \"generated\" response is selected. */", mockModule && aggregate ? `export const fallbackHandlers = () => ${aggregate}();` : "export const fallbackHandlers = () => [];", "", `/** MSW handlers for ${spec.contract.title ?? spec.id} that follow the Swaggerizer scenario choices. */`, `export function create${name}Mocks(options: SwaggerizerMswOptions = {}) {`, " return createSwaggerizerMocks({", " ...options,", " manifests: [manifest, ...(options.manifests ?? [])],", " fallbackHandlers: [...fallbackHandlers(), ...(options.fallbackHandlers ?? [])]", " });", "}", "");
|
|
2030
|
+
return lines.join("\n");
|
|
2031
|
+
}
|
|
2032
|
+
function renderIndex(entries, runtimeImport) {
|
|
2033
|
+
const lines = [
|
|
2034
|
+
"/** Generated by Swaggerizer — do not edit by hand. Re-run `swaggerizer generate` instead. */",
|
|
2035
|
+
`import { createSwaggerizerMocks, type SwaggerizerMswOptions } from "${runtimeImport}";`
|
|
2036
|
+
];
|
|
2037
|
+
for (const e of entries) {
|
|
2038
|
+
lines.push(`import { manifest as ${camel(e.id)}Manifest, fallbackHandlers as ${camel(e.id)}Fallback } from "./${e.id}/swaggerizer";`);
|
|
2039
|
+
}
|
|
2040
|
+
lines.push("");
|
|
2041
|
+
for (const e of entries)
|
|
2042
|
+
lines.push(`export * as ${camel(e.id)} from "./${e.id}/${e.clientModule}";`);
|
|
2043
|
+
lines.push("", `export const manifests = [${entries.map(e => `${camel(e.id)}Manifest`).join(", ")}];`, "", "/**", " * One set of MSW handlers for every spec. Pass `server: \"http://localhost:8080/swaggerizer\"` to follow", " * the choices made in a running Swaggerizer admin page.", " */", "export function createMocks(options: SwaggerizerMswOptions = {}) {", " return createSwaggerizerMocks({", " ...options,", " manifests: [...manifests, ...(options.manifests ?? [])],", ` fallbackHandlers: [${entries.map(e => `...${camel(e.id)}Fallback()`).join(", ")}, ...(options.fallbackHandlers ?? [])]`, " });", "}", "");
|
|
2044
|
+
return lines.join("\n");
|
|
2045
|
+
}
|
|
2046
|
+
/** Bare module names pass through; relative paths are resolved from the cwd and re-relativised per file. */
|
|
2047
|
+
function runtimeImportFor(runtimeImport, fromDir) {
|
|
2048
|
+
if (!runtimeImport.startsWith("."))
|
|
2049
|
+
return runtimeImport;
|
|
2050
|
+
const rel = path.relative(fromDir, path.resolve(runtimeImport)).split(path.sep).join("/");
|
|
2051
|
+
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
2052
|
+
}
|
|
2053
|
+
function scenariosFor(spec, saved) {
|
|
2054
|
+
if (!saved)
|
|
2055
|
+
return undefined;
|
|
2056
|
+
const keys = new Set(spec.operations.map(o => o.key));
|
|
2057
|
+
const picked = Object.fromEntries(Object.entries(saved).filter(([key]) => keys.has(key)));
|
|
2058
|
+
return Object.keys(picked).length ? picked : undefined;
|
|
2059
|
+
}
|
|
2060
|
+
async function generate(options) {
|
|
2061
|
+
const say = (message) => {
|
|
2062
|
+
if (!options.silent)
|
|
2063
|
+
console.log(message);
|
|
2064
|
+
};
|
|
2065
|
+
const output = path.resolve(options.output ?? options.config?.output ?? "src/api");
|
|
2066
|
+
const failOn = options.failOn ?? "breaking";
|
|
2067
|
+
const runtimeImport = options.runtimeImport ?? options.config?.runtimeImport ?? "swaggerizer/msw";
|
|
2068
|
+
const { specs, reports } = await checkChanges(options.specs, output, options.config, options.headers ?? options.config?.headers);
|
|
2069
|
+
for (const report of reports)
|
|
2070
|
+
say(formatReport(report, { color: options.color, verbose: options.verbose }) + "\n");
|
|
2071
|
+
const blocked = reports.some(r => exceedsThreshold(r, failOn));
|
|
2072
|
+
if (options.check)
|
|
2073
|
+
return { reports, generated: [], skipped: true, blocked };
|
|
2074
|
+
if (blocked && !options.yes) {
|
|
2075
|
+
const ok = options.confirm ? await options.confirm(reports) : false;
|
|
2076
|
+
if (!ok) {
|
|
2077
|
+
say(`Generation cancelled: contract changes at or above '${failOn}' were not confirmed. Re-run with --yes to accept them.`);
|
|
2078
|
+
return { reports, generated: [], skipped: true, blocked };
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
const saved = options.stateFile ? readJson(options.stateFile)?.scenarios : undefined;
|
|
2082
|
+
const generated = [];
|
|
2083
|
+
const entries = [];
|
|
2084
|
+
for (const spec of specs) {
|
|
2085
|
+
const { dir, mocksDir } = outputDirs(spec.id, output, spec.config);
|
|
2086
|
+
for (const warning of spec.warnings.filter(w => /matches no operation/.test(w)))
|
|
2087
|
+
say(` ! ${spec.id}: ${warning}`);
|
|
2088
|
+
say(`Generating ${spec.id} → ${path.relative(process.cwd(), dir) || dir}`);
|
|
2089
|
+
const orvalOutput = await runOrval(spec, dir, options);
|
|
2090
|
+
if (mocksDir !== dir) {
|
|
2091
|
+
if (orvalOutput.mode === "split")
|
|
2092
|
+
moveMocks(spec.id, dir, mocksDir);
|
|
2093
|
+
else
|
|
2094
|
+
say(` ! ${spec.id}: mocksOutput only works with orval mode "split"; mocks left in ${dir}.`);
|
|
2095
|
+
}
|
|
2096
|
+
const mockFile = path.join(mocksDir, `${spec.id}.msw.ts`);
|
|
2097
|
+
const aggregate = fs.existsSync(mockFile) ? findMockAggregate(fs.readFileSync(mockFile, "utf8")) : undefined;
|
|
2098
|
+
if (options.mock !== false && !aggregate) {
|
|
2099
|
+
say(` ! Could not find orval's MSW handler list in ${mockFile}; "generated" responses will use Swaggerizer samples.`);
|
|
2100
|
+
}
|
|
2101
|
+
const manifest = {
|
|
2102
|
+
specId: spec.id,
|
|
2103
|
+
title: spec.contract.title,
|
|
2104
|
+
version: spec.contract.version,
|
|
2105
|
+
basePaths: spec.basePaths,
|
|
2106
|
+
operations: spec.operations,
|
|
2107
|
+
...(spec.config.aliases ? { aliases: spec.config.aliases } : {})
|
|
2108
|
+
};
|
|
2109
|
+
const scenarios = scenariosFor(spec, saved);
|
|
2110
|
+
if (scenarios)
|
|
2111
|
+
manifest.scenarios = scenarios;
|
|
2112
|
+
fs.mkdirSync(mocksDir, { recursive: true });
|
|
2113
|
+
fs.writeFileSync(path.join(mocksDir, WRAPPER_FILE), renderWrapper(spec, manifest, aggregate ? `${spec.id}.msw` : undefined, aggregate, runtimeImportFor(runtimeImport, mocksDir)));
|
|
2114
|
+
fs.writeFileSync(path.join(dir, CONTRACT_FILE), JSON.stringify({ ...spec.contract, generatedAt: new Date().toISOString() }, null, 2) + "\n");
|
|
2115
|
+
generated.push(spec.id);
|
|
2116
|
+
// Only specs in the default layout (<output>/<id>/) go in the aggregate index.
|
|
2117
|
+
if (!spec.config.output && !spec.config.mocksOutput)
|
|
2118
|
+
entries.push({ id: spec.id, clientModule: spec.id });
|
|
2119
|
+
}
|
|
2120
|
+
// Specs whose file was removed: the removal has now been accepted, so record it in their
|
|
2121
|
+
// snapshot (it stops being reported) and leave the stale folder for the user to delete.
|
|
2122
|
+
for (const orphan of orphanSnapshots(output, new Set(specs.map(s => s.id)))) {
|
|
2123
|
+
const file = path.join(output, orphan.specId, CONTRACT_FILE);
|
|
2124
|
+
fs.writeFileSync(file, JSON.stringify({ ...orphan, operations: {}, removedAt: new Date().toISOString() }, null, 2) + "\n");
|
|
2125
|
+
say(` ${orphan.specId}: spec file removed — ${path.join(path.relative(process.cwd(), output), orphan.specId)} is no longer used and can be deleted.`);
|
|
2126
|
+
}
|
|
2127
|
+
if (entries.length) {
|
|
2128
|
+
fs.mkdirSync(output, { recursive: true });
|
|
2129
|
+
fs.writeFileSync(path.join(output, INDEX_FILE), renderIndex(entries, runtimeImportFor(runtimeImport, output)));
|
|
2130
|
+
}
|
|
2131
|
+
say(entries.length === generated.length
|
|
2132
|
+
? `Done: ${generated.length} spec(s) generated in ${path.relative(process.cwd(), output) || output}`
|
|
2133
|
+
: `Done: ${generated.length} spec(s) generated.`);
|
|
2134
|
+
return { reports, generated, skipped: false, blocked };
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
/* Finding and loading swaggerizer.config.{ts,mts,js,mjs,json} (Node only). */
|
|
2138
|
+
const CONFIG_FILES = [
|
|
2139
|
+
"swaggerizer.config.ts",
|
|
2140
|
+
"swaggerizer.config.mts",
|
|
2141
|
+
"swaggerizer.config.js",
|
|
2142
|
+
"swaggerizer.config.mjs",
|
|
2143
|
+
"swaggerizer.config.json"
|
|
2144
|
+
];
|
|
2145
|
+
function findConfigFile(cwd = process.cwd()) {
|
|
2146
|
+
for (const name of CONFIG_FILES) {
|
|
2147
|
+
const file = path.join(cwd, name);
|
|
2148
|
+
if (fs.existsSync(file))
|
|
2149
|
+
return file;
|
|
2150
|
+
}
|
|
2151
|
+
return undefined;
|
|
2152
|
+
}
|
|
2153
|
+
/** Strips // and /* *\/ comments and trailing commas outside strings, so tsconfig files parse as JSON. */
|
|
2154
|
+
function parseJsonc(text) {
|
|
2155
|
+
let out = "";
|
|
2156
|
+
let inString = false;
|
|
2157
|
+
for (let i = 0; i < text.length; i++) {
|
|
2158
|
+
const c = text[i];
|
|
2159
|
+
const next = text[i + 1];
|
|
2160
|
+
if (inString) {
|
|
2161
|
+
out += c;
|
|
2162
|
+
if (c === "\\") {
|
|
2163
|
+
out += next ?? "";
|
|
2164
|
+
i++;
|
|
2165
|
+
}
|
|
2166
|
+
else if (c === '"')
|
|
2167
|
+
inString = false;
|
|
2168
|
+
continue;
|
|
2169
|
+
}
|
|
2170
|
+
if (c === '"') {
|
|
2171
|
+
inString = true;
|
|
2172
|
+
out += c;
|
|
2173
|
+
}
|
|
2174
|
+
else if (c === "/" && next === "/") {
|
|
2175
|
+
while (i < text.length && text[i] !== "\n")
|
|
2176
|
+
i++;
|
|
2177
|
+
out += "\n";
|
|
2178
|
+
}
|
|
2179
|
+
else if (c === "/" && next === "*") {
|
|
2180
|
+
i += 2;
|
|
2181
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
|
|
2182
|
+
i++;
|
|
2183
|
+
i++;
|
|
2184
|
+
}
|
|
2185
|
+
else {
|
|
2186
|
+
out += c;
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
|
|
2190
|
+
}
|
|
2191
|
+
/**
|
|
2192
|
+
* Module aliases from a tsconfig's `compilerOptions.paths`, so a config file can import fixtures
|
|
2193
|
+
* through workspace aliases such as `@online-banking/data-access/...` (Nx, path-mapped monorepos).
|
|
2194
|
+
*/
|
|
2195
|
+
function aliasesFromTsconfig(tsconfigFile) {
|
|
2196
|
+
if (!fs.existsSync(tsconfigFile))
|
|
2197
|
+
return {};
|
|
2198
|
+
let json;
|
|
2199
|
+
try {
|
|
2200
|
+
json = parseJsonc(fs.readFileSync(tsconfigFile, "utf8"));
|
|
2201
|
+
}
|
|
2202
|
+
catch {
|
|
2203
|
+
return {};
|
|
2204
|
+
}
|
|
2205
|
+
const options = json?.compilerOptions || {};
|
|
2206
|
+
const baseDir = path.resolve(path.dirname(tsconfigFile), options.baseUrl || ".");
|
|
2207
|
+
const aliases = {};
|
|
2208
|
+
for (const [key, targets] of Object.entries(options.paths || {})) {
|
|
2209
|
+
const target = Array.isArray(targets) ? targets[0] : undefined;
|
|
2210
|
+
if (!target)
|
|
2211
|
+
continue;
|
|
2212
|
+
if (key.endsWith("/*") && target.endsWith("/*")) {
|
|
2213
|
+
aliases[key.slice(0, -2)] = path.resolve(baseDir, target.slice(0, -2));
|
|
2214
|
+
}
|
|
2215
|
+
else if (!key.includes("*")) {
|
|
2216
|
+
aliases[key] = path.resolve(baseDir, target);
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
return aliases;
|
|
2220
|
+
}
|
|
2221
|
+
async function loadConfig(file, cwd = process.cwd()) {
|
|
2222
|
+
const configFile = file ? path.resolve(cwd, file) : findConfigFile(cwd);
|
|
2223
|
+
if (!configFile)
|
|
2224
|
+
return undefined;
|
|
2225
|
+
if (!fs.existsSync(configFile))
|
|
2226
|
+
throw new Error(`Config file not found: ${configFile}`);
|
|
2227
|
+
let config;
|
|
2228
|
+
if (configFile.endsWith(".json")) {
|
|
2229
|
+
config = JSON.parse(fs.readFileSync(configFile, "utf8"));
|
|
2230
|
+
}
|
|
2231
|
+
else {
|
|
2232
|
+
const dir = path.dirname(configFile);
|
|
2233
|
+
const peek = fs.readFileSync(configFile, "utf8");
|
|
2234
|
+
const declared = /tsconfig\s*:\s*["'`]([^"'`]+)["'`]/.exec(peek)?.[1];
|
|
2235
|
+
const tsconfig = [declared && path.resolve(dir, declared), path.join(dir, "tsconfig.base.json"), path.join(dir, "tsconfig.json")]
|
|
2236
|
+
.filter((f) => Boolean(f))
|
|
2237
|
+
.find(f => fs.existsSync(f));
|
|
2238
|
+
const { createJiti } = await import('jiti');
|
|
2239
|
+
const jiti = createJiti(configFile, {
|
|
2240
|
+
alias: tsconfig ? aliasesFromTsconfig(tsconfig) : {},
|
|
2241
|
+
interopDefault: true,
|
|
2242
|
+
moduleCache: false
|
|
2243
|
+
});
|
|
2244
|
+
const mod = await jiti.import(configFile);
|
|
2245
|
+
config = mod?.default ?? mod;
|
|
2246
|
+
}
|
|
2247
|
+
if (!config || typeof config !== "object" || !config.specs || typeof config.specs !== "object") {
|
|
2248
|
+
throw new Error(`${configFile} must export a config with a \`specs\` object (use defineConfig).`);
|
|
2249
|
+
}
|
|
2250
|
+
// Paths in the config are relative to the config file, not the shell.
|
|
2251
|
+
const dir = path.dirname(configFile);
|
|
2252
|
+
const rel = (p) => (p && !/^https?:\/\//i.test(p) ? path.resolve(dir, p) : p);
|
|
2253
|
+
for (const spec of Object.values(config.specs)) {
|
|
2254
|
+
spec.file = rel(spec.file);
|
|
2255
|
+
spec.output = rel(spec.output);
|
|
2256
|
+
spec.mocksOutput = rel(spec.mocksOutput);
|
|
2257
|
+
}
|
|
2258
|
+
config.output = rel(config.output);
|
|
2259
|
+
config.stateFile = rel(config.stateFile);
|
|
2260
|
+
return { config: config, file: configFile };
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
/* `swaggerizer pull`: refresh committed spec files from their source URLs (Node only). */
|
|
2264
|
+
/** JSON is re-indented so diffs of committed specs stay readable; YAML is kept as served. */
|
|
2265
|
+
function normaliseSpecText(text) {
|
|
2266
|
+
const trimmed = text.trim();
|
|
2267
|
+
if (trimmed.startsWith("{")) {
|
|
2268
|
+
return { text: JSON.stringify(JSON.parse(trimmed), null, 2) + "\n", ext: "json" };
|
|
2269
|
+
}
|
|
2270
|
+
return { text: trimmed + "\n", ext: "yaml" };
|
|
2271
|
+
}
|
|
2272
|
+
async function pull(options) {
|
|
2273
|
+
const say = (m) => {
|
|
2274
|
+
if (!options.silent)
|
|
2275
|
+
console.log(m);
|
|
2276
|
+
};
|
|
2277
|
+
const result = { pulled: [], failed: [] };
|
|
2278
|
+
const entries = Object.entries(options.config.specs).filter(([id, spec]) => spec.source && (!options.only?.length || options.only.includes(id)));
|
|
2279
|
+
if (entries.length === 0) {
|
|
2280
|
+
say("Nothing to pull: no spec in the config has a `source` URL.");
|
|
2281
|
+
return result;
|
|
2282
|
+
}
|
|
2283
|
+
for (const [id, spec] of entries) {
|
|
2284
|
+
try {
|
|
2285
|
+
const response = await fetch(spec.source, {
|
|
2286
|
+
headers: {
|
|
2287
|
+
Accept: "application/json, application/yaml;q=0.9, */*;q=0.5",
|
|
2288
|
+
...(options.config.headers || {}),
|
|
2289
|
+
...(spec.headers || {}),
|
|
2290
|
+
...(options.headers || {})
|
|
2291
|
+
},
|
|
2292
|
+
signal: AbortSignal.timeout(30000)
|
|
2293
|
+
});
|
|
2294
|
+
if (!response.ok)
|
|
2295
|
+
throw new Error(`${spec.source} answered ${response.status}`);
|
|
2296
|
+
const { text, ext } = normaliseSpecText(await response.text());
|
|
2297
|
+
const file = path.resolve(spec.file ?? path.join("specs", `${id}.${ext}`));
|
|
2298
|
+
const before = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : undefined;
|
|
2299
|
+
if (before === text) {
|
|
2300
|
+
say(`${id}: unchanged`);
|
|
2301
|
+
result.pulled.push({ id, file, changed: false });
|
|
2302
|
+
continue;
|
|
2303
|
+
}
|
|
2304
|
+
// Compare contracts so a pull says what moved, not just that bytes changed.
|
|
2305
|
+
// Same extension as the target so the parser picks the right format.
|
|
2306
|
+
const tmp = path.join(path.dirname(file), `.pulling-${path.basename(file)}`);
|
|
2307
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
2308
|
+
fs.writeFileSync(tmp, text);
|
|
2309
|
+
let report;
|
|
2310
|
+
try {
|
|
2311
|
+
const next = await analyseSpecFile(tmp, id);
|
|
2312
|
+
if (before !== undefined) {
|
|
2313
|
+
try {
|
|
2314
|
+
const previous = await analyseSpecFile(file, id);
|
|
2315
|
+
report = diffContracts(previous.contract, next.contract, "previous-load");
|
|
2316
|
+
}
|
|
2317
|
+
catch {
|
|
2318
|
+
// The old file did not parse: nothing meaningful to compare against.
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
catch (err) {
|
|
2323
|
+
// Never replace a working spec with one that does not parse.
|
|
2324
|
+
fs.rmSync(tmp, { force: true });
|
|
2325
|
+
throw err;
|
|
2326
|
+
}
|
|
2327
|
+
fs.renameSync(tmp, file);
|
|
2328
|
+
say(before === undefined ? `${id}: saved ${path.relative(process.cwd(), file)}` : `${id}: updated ${path.relative(process.cwd(), file)}`);
|
|
2329
|
+
if (report)
|
|
2330
|
+
say(formatReport(report, { color: options.color, verbose: options.verbose }).replace(/^/gm, " ") + "\n");
|
|
2331
|
+
result.pulled.push({ id, file, changed: true, ...(report ? { report } : {}) });
|
|
2332
|
+
}
|
|
2333
|
+
catch (err) {
|
|
2334
|
+
const error = err?.message ? String(err.message) : String(err);
|
|
2335
|
+
say(`${id}: could not pull – ${error}`);
|
|
2336
|
+
result.failed.push({ id, error });
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
return result;
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
export { CONTRACT_FILE, EMPTY, GENERATED, HTTP_METHODS, SCENARIO_HEADER, WRAPPER_FILE, aliasesFromTsconfig, analyseSpecFile, applyOverlays, applySelection, basePathsOf, buildCatalogue, buildContract, buildOperationStates, candidatePaths, checkChanges, contractKey, createHandlerContext, createSwaggerizer, diffContracts, exceedsThreshold, extractParams, findConfigFile, findMockAggregate, formatReport, generate, hasChanges, isHandlerResult, listSpecFiles, loadConfig, loadSpecFile, lookupByOperation, matchOperation, moveMocks, normaliseMediaType, operationKey, outputDirs, parseScenarioHeader, pull, relativeToBase, resolveResponse, resolveSpecInputs, resolveTargets, sampleFromSchema, serverUrlOf, specIdFor, startSwaggerizer, statusForCode, unmatchedKeys };
|
|
2343
|
+
//# sourceMappingURL=index.mjs.map
|