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