contract-drift-detection 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,721 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import process from "process";
5
+
6
+ // src/config.ts
7
+ import { mkdir, writeFile } from "fs/promises";
8
+ import path from "path";
9
+ import { Command } from "commander";
10
+ function createCli() {
11
+ const program = new Command();
12
+ program.name("contract-drift-detection").description("Stateful OpenAPI mock server with contract drift detection").version("0.1.0");
13
+ program.command("serve").description("Start the mock engine").requiredOption("--spec <path>", "Path to an OpenAPI 3.x file").option("--port <port>", "Port to bind the server", "4010").option("--host <host>", "Host to bind the server", "0.0.0.0").option("--db <path>", "JSON database path", ".mock-db.json").option("--cors-origin <origin>", "CORS origin (default is *)", "*").option("--drift-check <url>", "Forward traffic to a real backend and validate responses").option("--fallback-to-mock", "Fallback to mock responses when proxying fails", false).option("--verbose", "Enable verbose logging", false);
14
+ program.command("init").description("Create a starter config for the current workspace").option("--spec <path>", "Default OpenAPI path", "openapi.yaml").option("--db <path>", "Default JSON database path", ".mock-db.json").option("--port <port>", "Default port", "4010").option("--host <host>", "Default host", "0.0.0.0");
15
+ return program;
16
+ }
17
+ function toServeConfig(options) {
18
+ return {
19
+ specPath: String(options.spec),
20
+ port: Number(options.port ?? 4010),
21
+ host: String(options.host ?? "0.0.0.0"),
22
+ dbPath: String(options.db ?? ".mock-db.json"),
23
+ corsOrigin: String(options.corsOrigin ?? "*"),
24
+ driftCheckTarget: options.driftCheck ? String(options.driftCheck) : void 0,
25
+ fallbackToMockOnProxyError: Boolean(options.fallbackToMock),
26
+ verbose: Boolean(options.verbose)
27
+ };
28
+ }
29
+ async function writeStarterConfig(cwd, initConfig) {
30
+ const targetPath = path.join(cwd, "contract-drift.config.json");
31
+ await mkdir(cwd, { recursive: true });
32
+ await writeFile(
33
+ targetPath,
34
+ `${JSON.stringify(
35
+ {
36
+ spec: initConfig.spec,
37
+ db: initConfig.db,
38
+ host: initConfig.host,
39
+ port: initConfig.port
40
+ },
41
+ null,
42
+ 2
43
+ )}
44
+ `,
45
+ "utf8"
46
+ );
47
+ return targetPath;
48
+ }
49
+
50
+ // src/server.ts
51
+ import path4 from "path";
52
+ import Fastify from "fastify";
53
+ import cors from "@fastify/cors";
54
+
55
+ // src/utils.ts
56
+ import path2 from "path";
57
+ function isSchemaObject(schema) {
58
+ return Boolean(schema) && !("$ref" in schema);
59
+ }
60
+ function normalizeCollectionName(raw) {
61
+ return raw.replace(/[{}]/g, "").split("/").filter(Boolean).at(-1)?.replace(/[^a-zA-Z0-9]+/g, "_").toLowerCase() ?? "items";
62
+ }
63
+ function toFastifyPath(openApiPath) {
64
+ return openApiPath.replace(/\{([^}]+)\}/g, ":$1");
65
+ }
66
+ function singularize(value) {
67
+ if (value.endsWith("ies")) {
68
+ return `${value.slice(0, -3)}y`;
69
+ }
70
+ if (value.endsWith("s")) {
71
+ return value.slice(0, -1);
72
+ }
73
+ return value;
74
+ }
75
+ function resolveFile(baseDir, filePath) {
76
+ return path2.isAbsolute(filePath) ? filePath : path2.join(baseDir, filePath);
77
+ }
78
+ function getOperationId(method, openApiPath, operation) {
79
+ if (operation.operationId) {
80
+ return operation.operationId;
81
+ }
82
+ const sanitizedPath = openApiPath.replace(/[{}]/g, "").split("/").filter(Boolean).join("_");
83
+ return `${method}_${sanitizedPath || "root"}`;
84
+ }
85
+ function inferPathParamName(openApiPath) {
86
+ const match = openApiPath.match(/\{([^}]+)\}/);
87
+ return match?.[1];
88
+ }
89
+ function deepClone(value) {
90
+ return JSON.parse(JSON.stringify(value));
91
+ }
92
+ function deepMerge(base, patch) {
93
+ const output = { ...base };
94
+ for (const [key, value] of Object.entries(patch)) {
95
+ if (value && typeof value === "object" && !Array.isArray(value) && output[key] && typeof output[key] === "object" && !Array.isArray(output[key])) {
96
+ output[key] = deepMerge(
97
+ output[key],
98
+ value
99
+ );
100
+ } else {
101
+ output[key] = value;
102
+ }
103
+ }
104
+ return output;
105
+ }
106
+ function readContentType(headers) {
107
+ return headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() ?? "";
108
+ }
109
+ function isJsonLikeContentType(contentType) {
110
+ return contentType === "application/json" || contentType.endsWith("+json");
111
+ }
112
+
113
+ // src/dsl.ts
114
+ function resolvePathExpression(source, expression) {
115
+ return expression.split(".").reduce((value, segment) => value && typeof value === "object" ? value[segment] : void 0, source);
116
+ }
117
+ function evaluateTemplate(value, request) {
118
+ if (typeof value === "string") {
119
+ const match = value.match(/^\{\{\s*(.+?)\s*\}\}$/);
120
+ if (!match) {
121
+ return value;
122
+ }
123
+ return resolvePathExpression(
124
+ {
125
+ params: request.params,
126
+ query: request.query,
127
+ body: request.body,
128
+ headers: request.headers
129
+ },
130
+ match[1]
131
+ );
132
+ }
133
+ if (Array.isArray(value)) {
134
+ return value.map((entry) => evaluateTemplate(entry, request));
135
+ }
136
+ if (value && typeof value === "object") {
137
+ return Object.fromEntries(
138
+ Object.entries(value).map(([key, entry]) => [key, evaluateTemplate(entry, request)])
139
+ );
140
+ }
141
+ return value;
142
+ }
143
+ function applyDslMutation(extension, request, collection, defaultIdKey) {
144
+ const idKey = extension.find_by ?? defaultIdKey;
145
+ const targetId = request.params[idKey] ?? request.params.id;
146
+ const evaluatedAssign = evaluateTemplate(extension.assign ?? {}, request) ?? {};
147
+ const evaluatedSet = evaluateTemplate(extension.set ?? {}, request) ?? {};
148
+ switch (extension.action) {
149
+ case "create": {
150
+ const candidate = {
151
+ ...request.body ?? {},
152
+ ...evaluatedAssign,
153
+ ...evaluatedSet
154
+ };
155
+ collection.push(candidate);
156
+ return candidate;
157
+ }
158
+ case "append": {
159
+ const item = {
160
+ ...evaluatedAssign,
161
+ ...evaluatedSet
162
+ };
163
+ collection.push(item);
164
+ return collection;
165
+ }
166
+ case "replace": {
167
+ const nextValue = {
168
+ ...request.body ?? {},
169
+ ...evaluatedAssign,
170
+ ...evaluatedSet
171
+ };
172
+ const index = collection.findIndex((entry) => String(entry[idKey]) === String(targetId));
173
+ if (index >= 0) {
174
+ collection[index] = nextValue;
175
+ }
176
+ return nextValue;
177
+ }
178
+ case "delete": {
179
+ const index = collection.findIndex((entry) => String(entry[idKey]) === String(targetId));
180
+ if (index >= 0) {
181
+ const [removed] = collection.splice(index, 1);
182
+ return removed;
183
+ }
184
+ return null;
185
+ }
186
+ case "update":
187
+ default: {
188
+ const entity = collection.find((entry) => String(entry[idKey]) === String(targetId));
189
+ if (!entity) {
190
+ return null;
191
+ }
192
+ const merged = deepMerge(entity, {
193
+ ...evaluatedAssign,
194
+ ...evaluatedSet
195
+ });
196
+ Object.assign(entity, merged);
197
+ return entity;
198
+ }
199
+ }
200
+ }
201
+
202
+ // src/drift-detector.ts
203
+ import Ajv from "ajv";
204
+ import addFormats from "ajv-formats";
205
+ import pc from "picocolors";
206
+ function formatErrors(errors) {
207
+ return (errors ?? []).map((error) => {
208
+ const location = error.instancePath || "/";
209
+ return `${location} ${error.message ?? "failed validation"}`.trim();
210
+ });
211
+ }
212
+ var DriftDetector = class {
213
+ constructor(logger) {
214
+ this.logger = logger;
215
+ addFormats(this.ajv);
216
+ }
217
+ ajv = new Ajv({ allErrors: true, strict: false });
218
+ validate(method, path5, statusCode, schema, body) {
219
+ if (!schema || body === void 0 || body === null) {
220
+ return null;
221
+ }
222
+ const validate = this.ajv.compile(schema);
223
+ const valid = validate(body);
224
+ if (valid) {
225
+ return null;
226
+ }
227
+ const issue = {
228
+ method: method.toUpperCase(),
229
+ path: path5,
230
+ statusCode,
231
+ message: `Drift detected for ${method.toUpperCase()} ${path5} (${statusCode})`,
232
+ errors: formatErrors(validate.errors)
233
+ };
234
+ this.logger.error(
235
+ `${pc.red("DRIFT DETECTED")} ${issue.method} ${issue.path} -> ${issue.errors.join("; ")}`
236
+ );
237
+ return issue;
238
+ }
239
+ };
240
+
241
+ // src/route-context.ts
242
+ var SUPPORTED_METHODS = ["get", "post", "put", "patch", "delete"];
243
+ function getRequestBodySchema(operation) {
244
+ const content = operation.requestBody && !("$ref" in operation.requestBody) ? operation.requestBody.content?.["application/json"] : void 0;
245
+ return isSchemaObject(content?.schema) ? content.schema : void 0;
246
+ }
247
+ function getSuccessResponse(operation) {
248
+ const preferredCodes = ["200", "201", "202", "204"];
249
+ for (const code of preferredCodes) {
250
+ const response = operation.responses?.[code];
251
+ if (!response || "$ref" in response) {
252
+ continue;
253
+ }
254
+ const schema = response.content?.["application/json"]?.schema;
255
+ return {
256
+ statusCode: Number(code),
257
+ schema: isSchemaObject(schema) ? schema : void 0
258
+ };
259
+ }
260
+ return void 0;
261
+ }
262
+ function buildRouteContexts(document) {
263
+ const routes = [];
264
+ for (const [openApiPath, pathItem] of Object.entries(document.paths ?? {})) {
265
+ if (!pathItem || "$ref" in pathItem) {
266
+ continue;
267
+ }
268
+ for (const method of SUPPORTED_METHODS) {
269
+ const operation = pathItem[method];
270
+ if (!operation || "$ref" in operation) {
271
+ continue;
272
+ }
273
+ const pathParamName = inferPathParamName(openApiPath);
274
+ const resourceName = normalizeCollectionName(
275
+ pathParamName ? openApiPath.replace(/\/\{[^}]+\}$/, "") : openApiPath
276
+ );
277
+ const route = {
278
+ method,
279
+ path: openApiPath,
280
+ fastifyPath: toFastifyPath(openApiPath),
281
+ operation,
282
+ operationId: getOperationId(method, openApiPath, operation),
283
+ resourceName,
284
+ isCollection: !pathParamName,
285
+ pathParamName,
286
+ requestBodySchema: getRequestBodySchema(operation),
287
+ successResponse: getSuccessResponse(operation)
288
+ };
289
+ routes.push(route);
290
+ }
291
+ }
292
+ return routes;
293
+ }
294
+
295
+ // src/schema-seeder.ts
296
+ import { faker } from "@faker-js/faker";
297
+ function seedPrimitive(schema) {
298
+ if (schema.enum?.length) {
299
+ return schema.enum[0];
300
+ }
301
+ switch (schema.type) {
302
+ case "string": {
303
+ if (schema.format === "email") {
304
+ return faker.internet.email();
305
+ }
306
+ if (schema.format === "date-time") {
307
+ return faker.date.recent().toISOString();
308
+ }
309
+ if (schema.format === "uuid") {
310
+ return faker.string.uuid();
311
+ }
312
+ return faker.lorem.words(2);
313
+ }
314
+ case "integer":
315
+ return faker.number.int({ min: 1, max: 1e3 });
316
+ case "number":
317
+ return faker.number.float({ min: 1, max: 1e3, fractionDigits: 2 });
318
+ case "boolean":
319
+ return faker.datatype.boolean();
320
+ default:
321
+ return null;
322
+ }
323
+ }
324
+ function seedFromSchema(schema, depth = 0) {
325
+ if (!schema || depth > 4) {
326
+ return null;
327
+ }
328
+ if (schema.oneOf?.length && isSchemaObject(schema.oneOf[0])) {
329
+ return seedFromSchema(schema.oneOf[0], depth + 1);
330
+ }
331
+ if (schema.anyOf?.length && isSchemaObject(schema.anyOf[0])) {
332
+ return seedFromSchema(schema.anyOf[0], depth + 1);
333
+ }
334
+ if (schema.allOf?.length) {
335
+ return schema.allOf.reduce((accumulator, item) => {
336
+ if (!isSchemaObject(item)) {
337
+ return accumulator;
338
+ }
339
+ const seeded = seedFromSchema(item, depth + 1);
340
+ if (seeded && typeof seeded === "object" && !Array.isArray(seeded)) {
341
+ Object.assign(accumulator, seeded);
342
+ }
343
+ return accumulator;
344
+ }, {});
345
+ }
346
+ if (schema.type === "array") {
347
+ const item = isSchemaObject(schema.items) ? schema.items : void 0;
348
+ return [seedFromSchema(item, depth + 1), seedFromSchema(item, depth + 1)].filter(
349
+ (value) => value !== null
350
+ );
351
+ }
352
+ if (schema.type === "object" || schema.properties) {
353
+ const entries = Object.entries(schema.properties ?? {}).map(([key, value]) => {
354
+ if (!isSchemaObject(value)) {
355
+ return [key, null];
356
+ }
357
+ return [key, seedFromSchema(value, depth + 1)];
358
+ });
359
+ return Object.fromEntries(entries);
360
+ }
361
+ return seedPrimitive(schema);
362
+ }
363
+ function inferSeedCollections(document) {
364
+ const collections = {};
365
+ for (const [schemaName, schemaValue] of Object.entries(document.components?.schemas ?? {})) {
366
+ if (!isSchemaObject(schemaValue) || schemaValue.type !== "object") {
367
+ continue;
368
+ }
369
+ const collectionName = `${singularize(schemaName).toLowerCase()}s`;
370
+ collections[collectionName] = Array.from({ length: 3 }, () => seedFromSchema(schemaValue));
371
+ }
372
+ return collections;
373
+ }
374
+
375
+ // src/spec-loader.ts
376
+ import SwaggerParser from "@apidevtools/swagger-parser";
377
+ async function loadOpenApiDocument(specPath) {
378
+ const document = await SwaggerParser.dereference(specPath);
379
+ if (!document.openapi?.startsWith("3.")) {
380
+ throw new Error(`Only OpenAPI 3.x specs are supported. Received: ${document.openapi}`);
381
+ }
382
+ return document;
383
+ }
384
+
385
+ // src/state-store.ts
386
+ import { mkdir as mkdir2, readFile, writeFile as writeFile2 } from "fs/promises";
387
+ import path3 from "path";
388
+ function normalizeDatabase(input) {
389
+ return {
390
+ collections: input?.collections ?? {},
391
+ counters: input?.counters ?? {}
392
+ };
393
+ }
394
+ var JsonStateStore = class {
395
+ filePath;
396
+ constructor(filePath) {
397
+ this.filePath = filePath;
398
+ }
399
+ async initialize(seedCollections) {
400
+ await mkdir2(path3.dirname(this.filePath), { recursive: true });
401
+ try {
402
+ const existing = await this.read();
403
+ let changed = false;
404
+ for (const [collectionName, items] of Object.entries(seedCollections)) {
405
+ if (!existing.collections[collectionName]) {
406
+ existing.collections[collectionName] = deepClone(items);
407
+ existing.counters[collectionName] = items.length;
408
+ changed = true;
409
+ }
410
+ }
411
+ if (changed) {
412
+ await this.write(existing);
413
+ }
414
+ return existing;
415
+ } catch {
416
+ const initial = {
417
+ collections: deepClone(seedCollections),
418
+ counters: Object.fromEntries(
419
+ Object.entries(seedCollections).map(([name, items]) => [name, items.length])
420
+ )
421
+ };
422
+ await this.write(initial);
423
+ return initial;
424
+ }
425
+ }
426
+ async read() {
427
+ const raw = await readFile(this.filePath, "utf8");
428
+ return normalizeDatabase(JSON.parse(raw));
429
+ }
430
+ async write(database) {
431
+ await writeFile2(this.filePath, `${JSON.stringify(database, null, 2)}
432
+ `, "utf8");
433
+ }
434
+ async withDatabase(updater) {
435
+ const database = await this.read();
436
+ const result = await updater(database);
437
+ await this.write(database);
438
+ return result;
439
+ }
440
+ };
441
+
442
+ // src/proxy.ts
443
+ async function proxyRequest(targetBaseUrl, path5, init) {
444
+ const response = await fetch(new URL(path5, targetBaseUrl), init);
445
+ const contentType = readContentType(response.headers);
446
+ let body;
447
+ let rawBody;
448
+ if (response.status !== 204) {
449
+ rawBody = await response.text();
450
+ if (rawBody && isJsonLikeContentType(contentType)) {
451
+ body = JSON.parse(rawBody);
452
+ } else if (rawBody) {
453
+ body = rawBody;
454
+ }
455
+ }
456
+ return {
457
+ ok: response.ok,
458
+ statusCode: response.status,
459
+ headers: response.headers,
460
+ body,
461
+ rawBody
462
+ };
463
+ }
464
+
465
+ // src/server.ts
466
+ function sendResponse(reply, statusCode, body) {
467
+ if (statusCode === 204) {
468
+ return reply.code(204).send();
469
+ }
470
+ return reply.code(statusCode).send(body);
471
+ }
472
+ function defaultStatusCode(route) {
473
+ if (route.method === "post") {
474
+ return 201;
475
+ }
476
+ if (route.method === "delete") {
477
+ return 204;
478
+ }
479
+ return 200;
480
+ }
481
+ function getRequestBody(request) {
482
+ if (!request.body || typeof request.body !== "object" || Array.isArray(request.body)) {
483
+ return {};
484
+ }
485
+ return request.body;
486
+ }
487
+ function getCollection(database, route) {
488
+ if (!database.collections[route.resourceName]) {
489
+ database.collections[route.resourceName] = [];
490
+ database.counters[route.resourceName] = 0;
491
+ }
492
+ return database.collections[route.resourceName];
493
+ }
494
+ function getCollectionByName(database, collectionName) {
495
+ if (!database.collections[collectionName]) {
496
+ database.collections[collectionName] = [];
497
+ database.counters[collectionName] = 0;
498
+ }
499
+ return database.collections[collectionName];
500
+ }
501
+ function computeIdKey(route) {
502
+ return route.pathParamName ?? "id";
503
+ }
504
+ function normalizeComparable(value) {
505
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
506
+ return String(value);
507
+ }
508
+ return void 0;
509
+ }
510
+ function nextId(database, route) {
511
+ const current = database.counters[route.resourceName] ?? 0;
512
+ const next = current + 1;
513
+ database.counters[route.resourceName] = next;
514
+ return next;
515
+ }
516
+ function allocateUniqueId(database, route, collection, idKey) {
517
+ let candidate = nextId(database, route);
518
+ const hasCollision = (value) => collection.some((item) => normalizeComparable(item[idKey]) === String(value));
519
+ while (hasCollision(candidate)) {
520
+ candidate = nextId(database, route);
521
+ }
522
+ return candidate;
523
+ }
524
+ function resolveEntity(route, collection, request) {
525
+ const idKey = computeIdKey(route);
526
+ const rawId = normalizeComparable(
527
+ request.params[idKey] ?? request.params.id
528
+ );
529
+ return collection.find((item) => normalizeComparable(item[idKey]) === rawId);
530
+ }
531
+ function materializeMockBody(route, request) {
532
+ const requestBody = getRequestBody(request);
533
+ const seeded = route.requestBodySchema ? seedFromSchema(route.requestBodySchema) : {};
534
+ return deepMerge(
535
+ seeded && typeof seeded === "object" && !Array.isArray(seeded) ? seeded : {},
536
+ requestBody
537
+ );
538
+ }
539
+ function getNotFoundResponse(route) {
540
+ return {
541
+ statusCode: 404,
542
+ body: { message: `${route.resourceName} not found` }
543
+ };
544
+ }
545
+ function handleReadRoute(route, collection, request) {
546
+ if (route.isCollection) {
547
+ return { statusCode: 200, body: collection };
548
+ }
549
+ const entity = resolveEntity(route, collection, request);
550
+ return entity ? { statusCode: 200, body: entity } : getNotFoundResponse(route);
551
+ }
552
+ function handleCreateRoute(database, route, collection, request) {
553
+ const entity = materializeMockBody(route, request);
554
+ const idKey = computeIdKey(route);
555
+ if (entity[idKey] === void 0) {
556
+ entity[idKey] = allocateUniqueId(database, route, collection, idKey);
557
+ }
558
+ collection.push(entity);
559
+ return { statusCode: route.successResponse?.statusCode ?? 201, body: entity };
560
+ }
561
+ function handleUpdateRoute(route, collection, request) {
562
+ const current = resolveEntity(route, collection, request);
563
+ if (!current) {
564
+ return getNotFoundResponse(route);
565
+ }
566
+ const merged = route.method === "put" ? materializeMockBody(route, request) : deepMerge(current, getRequestBody(request));
567
+ Object.assign(current, merged);
568
+ return { statusCode: 200, body: current };
569
+ }
570
+ function handleDeleteRoute(route, collection, request) {
571
+ const entity = resolveEntity(route, collection, request);
572
+ if (!entity) {
573
+ return getNotFoundResponse(route);
574
+ }
575
+ const index = collection.indexOf(entity);
576
+ collection.splice(index, 1);
577
+ return { statusCode: 204 };
578
+ }
579
+ async function handleMockRoute(store, route, request) {
580
+ return store.withDatabase(async (database) => {
581
+ const collection = getCollection(database, route);
582
+ const extension = route.operation["x-mock-state"];
583
+ if (extension) {
584
+ const targetCollection = extension.target ? getCollectionByName(database, extension.target) : collection;
585
+ const response = applyDslMutation(extension, request, targetCollection, computeIdKey(route));
586
+ if (extension.response === "none") {
587
+ return { statusCode: 204 };
588
+ }
589
+ if (extension.response === "collection") {
590
+ return { statusCode: route.successResponse?.statusCode ?? 200, body: targetCollection };
591
+ }
592
+ return { statusCode: route.successResponse?.statusCode ?? defaultStatusCode(route), body: response };
593
+ }
594
+ switch (route.method) {
595
+ case "get": {
596
+ return handleReadRoute(route, collection, request);
597
+ }
598
+ case "post": {
599
+ return handleCreateRoute(database, route, collection, request);
600
+ }
601
+ case "put":
602
+ case "patch": {
603
+ return handleUpdateRoute(route, collection, request);
604
+ }
605
+ case "delete": {
606
+ return handleDeleteRoute(route, collection, request);
607
+ }
608
+ }
609
+ });
610
+ }
611
+ async function handleProxyRoute(config, route, detector, request) {
612
+ const rawBody = request.body ? JSON.stringify(request.body) : void 0;
613
+ const targetBaseUrl = config.driftCheckTarget;
614
+ if (!targetBaseUrl) {
615
+ throw new Error("Proxy target is not configured");
616
+ }
617
+ const result = await proxyRequest(targetBaseUrl, request.url, {
618
+ method: request.method,
619
+ headers: {
620
+ "content-type": request.headers["content-type"] ?? "application/json"
621
+ },
622
+ body: rawBody
623
+ });
624
+ detector.validate(
625
+ route.method,
626
+ route.path,
627
+ result.statusCode,
628
+ route.successResponse?.schema,
629
+ result.body
630
+ );
631
+ return {
632
+ statusCode: result.statusCode,
633
+ headers: Object.fromEntries(result.headers.entries()),
634
+ body: result.body
635
+ };
636
+ }
637
+ async function registerRoutes(app, document, config, store) {
638
+ const routes = buildRouteContexts(document);
639
+ const detector = new DriftDetector(app.log);
640
+ for (const route of routes) {
641
+ app.route({
642
+ method: route.method.toUpperCase(),
643
+ url: route.fastifyPath,
644
+ handler: async (request, reply) => {
645
+ if (config.driftCheckTarget) {
646
+ try {
647
+ const proxied = await handleProxyRoute(config, route, detector, request);
648
+ for (const [headerName, headerValue] of Object.entries(proxied.headers)) {
649
+ if (headerName.toLowerCase() === "content-length") {
650
+ continue;
651
+ }
652
+ reply.header(headerName, headerValue);
653
+ }
654
+ return reply.code(proxied.statusCode).send(proxied.body);
655
+ } catch (error) {
656
+ app.log.error({ error }, `Proxy execution failed for ${route.method.toUpperCase()} ${route.path}`);
657
+ if (!config.fallbackToMockOnProxyError) {
658
+ throw error;
659
+ }
660
+ }
661
+ }
662
+ const mockResponse = await handleMockRoute(store, route, request);
663
+ return sendResponse(reply, mockResponse.statusCode, mockResponse.body);
664
+ }
665
+ });
666
+ }
667
+ }
668
+ async function createServer(config) {
669
+ const app = Fastify({
670
+ logger: {
671
+ level: config.verbose ? "debug" : "error"
672
+ }
673
+ });
674
+ await app.register(cors, {
675
+ origin: config.corsOrigin === "*" ? true : config.corsOrigin,
676
+ methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
677
+ allowedHeaders: ["content-type", "authorization"],
678
+ credentials: false
679
+ });
680
+ const document = await loadOpenApiDocument(config.specPath);
681
+ const seedCollections = inferSeedCollections(document);
682
+ const dbPath = resolveFile(path4.dirname(config.specPath), config.dbPath);
683
+ const store = new JsonStateStore(dbPath);
684
+ await store.initialize(seedCollections);
685
+ app.get("/__health", async () => ({ status: "ok" }));
686
+ app.get("/__spec", async () => document);
687
+ await registerRoutes(app, document, config, store);
688
+ return app;
689
+ }
690
+
691
+ // src/cli.ts
692
+ async function main() {
693
+ const cli = createCli();
694
+ const serveCommand = cli.commands.find((command) => command.name() === "serve");
695
+ const initCommand = cli.commands.find((command) => command.name() === "init");
696
+ serveCommand?.action(async function() {
697
+ const config = toServeConfig(this.opts());
698
+ const server = await createServer(config);
699
+ await server.listen({ port: config.port, host: config.host });
700
+ process.stdout.write(`Mock engine listening on http://${config.host}:${config.port}
701
+ `);
702
+ });
703
+ initCommand?.action(async function() {
704
+ const options = this.opts();
705
+ const targetPath = await writeStarterConfig(process.cwd(), {
706
+ spec: String(options.spec),
707
+ db: String(options.db),
708
+ host: String(options.host),
709
+ port: Number(options.port)
710
+ });
711
+ process.stdout.write(`Created ${targetPath}
712
+ `);
713
+ });
714
+ await cli.parseAsync(process.argv);
715
+ }
716
+ await main().catch((error) => {
717
+ process.stderr.write(`${error instanceof Error ? error.stack : String(error)}
718
+ `);
719
+ process.exitCode = 1;
720
+ });
721
+ //# sourceMappingURL=cli.js.map