swaggerizer 0.0.0-stage → 1.0.1

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