@newmo/graphql-fake-server 0.19.0 → 0.20.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/README.md +162 -118
- package/dist/esm/cli.d.ts.map +1 -1
- package/dist/esm/cli.js +1 -1
- package/dist/esm/cli.js.map +1 -1
- package/dist/esm/config.d.ts.map +1 -1
- package/dist/esm/createMock.d.ts +0 -0
- package/dist/esm/createMock.d.ts.map +0 -0
- package/dist/esm/createMock.js +39 -0
- package/dist/esm/createMock.js.map +0 -0
- package/dist/esm/index.d.ts +3 -3
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/logger.d.ts.map +1 -1
- package/dist/esm/server.d.ts +23 -20
- package/dist/esm/server.d.ts.map +1 -1
- package/dist/esm/server.js +473 -85
- package/dist/esm/server.js.map +1 -1
- package/package.json +81 -80
- package/src/cli.ts +2 -7
- package/src/index.ts +12 -6
- package/src/server.ts +656 -128
package/dist/esm/server.js
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import http from "node:http";
|
|
3
|
+
import { isDeepStrictEqual } from "node:util";
|
|
3
4
|
import { ApolloServer } from "@apollo/server";
|
|
4
|
-
import { expressMiddleware } from "@apollo/server/express4";
|
|
5
5
|
import { ApolloServerPluginDrainHttpServer } from "@apollo/server/plugin/drainHttpServer";
|
|
6
|
+
import { expressMiddleware } from "@as-integrations/express5";
|
|
6
7
|
import { addMocksToSchema } from "@graphql-tools/mock";
|
|
7
8
|
import { makeExecutableSchema } from "@graphql-tools/schema";
|
|
8
9
|
import { serve } from "@hono/node-server";
|
|
9
10
|
import { createMock } from "@newmo/graphql-fake-core";
|
|
10
11
|
import corsExpress from "cors";
|
|
11
12
|
import express from "express";
|
|
13
|
+
import { buildSchema } from "graphql/utilities/index.js";
|
|
12
14
|
// @ts-expect-error -- no types
|
|
13
15
|
import depthLimit from "graphql-depth-limit";
|
|
14
|
-
import { buildSchema } from "graphql/utilities/index.js";
|
|
15
16
|
import { Hono } from "hono";
|
|
16
17
|
import { cors } from "hono/cors";
|
|
18
|
+
import { proxy } from "hono/proxy";
|
|
17
19
|
import { createLogger } from "./logger.js";
|
|
18
20
|
// @ts-expect-error -- biome error
|
|
19
21
|
const ENV_HOSTNAME = process.env.HOSTNAME || "0.0.0.0";
|
|
@@ -55,7 +57,7 @@ const startStandaloneServerWithCORS = async (server, options, allowedCORSOrigins
|
|
|
55
57
|
const port = options.listen.port ?? 4000;
|
|
56
58
|
await new Promise((resolve) => httpServer.listen({ port }, resolve));
|
|
57
59
|
return {
|
|
58
|
-
url: `http://${ENV_HOSTNAME}:${port}
|
|
60
|
+
url: `http://${ENV_HOSTNAME}:${port}`,
|
|
59
61
|
httpServer,
|
|
60
62
|
};
|
|
61
63
|
};
|
|
@@ -73,26 +75,206 @@ const creteApolloServer = async (options) => {
|
|
|
73
75
|
validationRules: [depthLimit(options.maxQueryDepth)],
|
|
74
76
|
});
|
|
75
77
|
};
|
|
78
|
+
// Allowed condition types
|
|
79
|
+
const ALLOWED_CONDITION_TYPES = ["count", "variables"];
|
|
80
|
+
/**
|
|
81
|
+
* Check if two condition types are conflicting and return specific error message
|
|
82
|
+
* Only the following combinations are allowed:
|
|
83
|
+
* - count + count
|
|
84
|
+
* - variables + variables
|
|
85
|
+
* - variables + no condition (undefined)
|
|
86
|
+
* - no condition (undefined) + no condition (undefined)
|
|
87
|
+
* All other combinations are conflicting
|
|
88
|
+
*/
|
|
89
|
+
const areConditionTypesConflicting = (conditionType1, conditionType2) => {
|
|
90
|
+
// Define allowed combinations with their descriptions
|
|
91
|
+
const allowedCombinations = new Map([
|
|
92
|
+
// Multiple count conditions for the same operation (e.g., 1st call, 2nd call)
|
|
93
|
+
["count,count", "Multiple count-based conditions are allowed for different call counts"],
|
|
94
|
+
// Multiple variables conditions for the same operation (e.g., different variable sets)
|
|
95
|
+
[
|
|
96
|
+
"variables,variables",
|
|
97
|
+
"Multiple variable-based conditions are allowed for different variable sets",
|
|
98
|
+
],
|
|
99
|
+
// Variables condition can coexist with default fallback
|
|
100
|
+
["variables,undefined", "Variable-based condition can coexist with default fallback"],
|
|
101
|
+
// Default fallback can coexist with variables condition
|
|
102
|
+
["undefined,variables", "Default fallback can coexist with variable-based condition"],
|
|
103
|
+
// Multiple default conditions - overwrite with the last one
|
|
104
|
+
["undefined,undefined", "Multiple default conditions are allowed (latest will be used)"],
|
|
105
|
+
]);
|
|
106
|
+
const combinationKey = `${conditionType1 ?? "undefined"},${conditionType2 ?? "undefined"}`;
|
|
107
|
+
// If the combination is allowed, return no conflict
|
|
108
|
+
if (allowedCombinations.has(combinationKey)) {
|
|
109
|
+
return { isConflicting: false };
|
|
110
|
+
}
|
|
111
|
+
// Generate specific error message for conflicting combinations
|
|
112
|
+
const getTypeDescription = (type) => {
|
|
113
|
+
switch (type) {
|
|
114
|
+
case "count":
|
|
115
|
+
return "count-based condition (e.g., { type: 'count', value: 1 })";
|
|
116
|
+
case "variables":
|
|
117
|
+
return "variables-based condition (e.g., { type: 'variables', value: {...} })";
|
|
118
|
+
case undefined:
|
|
119
|
+
return "default condition (no requestCondition specified)";
|
|
120
|
+
default:
|
|
121
|
+
return `unknown condition type: ${type}`;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
const type1Desc = getTypeDescription(conditionType1);
|
|
125
|
+
const type2Desc = getTypeDescription(conditionType2);
|
|
126
|
+
// Specific error messages for common problematic combinations
|
|
127
|
+
if ((conditionType1 === "count" && conditionType2 === "variables") ||
|
|
128
|
+
(conditionType1 === "variables" && conditionType2 === "count")) {
|
|
129
|
+
const errorMessage = "Cannot mix count-based and variables-based conditions for the same operation. " +
|
|
130
|
+
"Use either multiple count conditions (for different call numbers) or multiple variables conditions (for different variable sets), " +
|
|
131
|
+
`but not both. Current conflict: ${type1Desc} vs ${type2Desc}`;
|
|
132
|
+
return { isConflicting: true, errorMessage };
|
|
133
|
+
}
|
|
134
|
+
const errorMessage = `Conflicting condition types detected: ${type1Desc} vs ${type2Desc}. ` +
|
|
135
|
+
"Allowed combinations are: count+count, variables+variables, variables+default, or default+default.";
|
|
136
|
+
return { isConflicting: true, errorMessage };
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* Get condition type from a RegisterSequenceOptions
|
|
140
|
+
*/
|
|
141
|
+
const getConditionType = (fake) => {
|
|
142
|
+
return fake.requestCondition?.type;
|
|
143
|
+
};
|
|
144
|
+
/**
|
|
145
|
+
* Check for condition conflicts in existing fakes for the same operation
|
|
146
|
+
*/
|
|
147
|
+
const checkConditionConflicts = (newFake, existingConditionalFakes, existingDefaultFake) => {
|
|
148
|
+
const errors = [];
|
|
149
|
+
const newConditionType = getConditionType(newFake);
|
|
150
|
+
// Check conflicts with existing conditional fakes
|
|
151
|
+
for (const existingFake of existingConditionalFakes) {
|
|
152
|
+
const existingConditionType = getConditionType(existingFake);
|
|
153
|
+
const conflictResult = areConditionTypesConflicting(newConditionType, existingConditionType);
|
|
154
|
+
if (conflictResult.isConflicting && conflictResult.errorMessage) {
|
|
155
|
+
errors.push(conflictResult.errorMessage);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// Check conflicts with existing default fake (no condition)
|
|
159
|
+
if (existingDefaultFake) {
|
|
160
|
+
const existingConditionType = getConditionType(existingDefaultFake);
|
|
161
|
+
const conflictResult = areConditionTypesConflicting(newConditionType, existingConditionType);
|
|
162
|
+
if (conflictResult.isConflicting && conflictResult.errorMessage) {
|
|
163
|
+
errors.push(conflictResult.errorMessage);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return errors;
|
|
167
|
+
};
|
|
168
|
+
/**
|
|
169
|
+
* Validate condition rule structure
|
|
170
|
+
*/
|
|
171
|
+
const validateConditionRule = (condition) => {
|
|
172
|
+
if (typeof condition !== "object" || condition === null) {
|
|
173
|
+
return { ok: false, error: "Condition must be an object" };
|
|
174
|
+
}
|
|
175
|
+
if (!("type" in condition) || typeof condition.type !== "string") {
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
error: "Condition must have a 'type' field of type string",
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
// Check if type is in the allow list
|
|
182
|
+
if (!ALLOWED_CONDITION_TYPES.includes(condition.type)) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
error: `Unknown condition type '${condition.type}'. Allowed types: ${ALLOWED_CONDITION_TYPES.join(", ")}`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
if (!("value" in condition)) {
|
|
189
|
+
return { ok: false, error: "Condition must have a 'value' field" };
|
|
190
|
+
}
|
|
191
|
+
switch (condition.type) {
|
|
192
|
+
case "count":
|
|
193
|
+
if (typeof condition.value !== "number") {
|
|
194
|
+
return { ok: false, error: "Count condition value must be a number" };
|
|
195
|
+
}
|
|
196
|
+
if (condition.value <= 0) {
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
error: "Count condition value must be greater than 0",
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
return { ok: true, data: condition };
|
|
203
|
+
case "variables":
|
|
204
|
+
if (typeof condition.value !== "object" || condition.value === null) {
|
|
205
|
+
return {
|
|
206
|
+
ok: false,
|
|
207
|
+
error: "Variables condition value must be an object",
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
if (Array.isArray(condition.value)) {
|
|
211
|
+
return {
|
|
212
|
+
ok: false,
|
|
213
|
+
error: "Variables condition value must be an object, not an array",
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
return { ok: true, data: condition };
|
|
217
|
+
default:
|
|
218
|
+
return {
|
|
219
|
+
ok: false,
|
|
220
|
+
error: `Unsupported condition type '${condition.type}'`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
};
|
|
76
224
|
const validateSequenceRegistration = (data) => {
|
|
77
|
-
if (typeof data !== "object" || data === null)
|
|
78
|
-
return false;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
225
|
+
if (typeof data !== "object" || data === null) {
|
|
226
|
+
return { ok: false, error: "Request body must be an object" };
|
|
227
|
+
}
|
|
228
|
+
// Validate request condition
|
|
229
|
+
if ("requestCondition" in data && data.requestCondition !== undefined) {
|
|
230
|
+
const conditionResult = validateConditionRule(data.requestCondition);
|
|
231
|
+
if (!conditionResult.ok) {
|
|
232
|
+
return {
|
|
233
|
+
ok: false,
|
|
234
|
+
error: `Invalid request condition: ${conditionResult.error}`,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (!("type" in data) || typeof data.type !== "string") {
|
|
239
|
+
return {
|
|
240
|
+
ok: false,
|
|
241
|
+
error: "Request body must have a 'type' field of type string",
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
if (!("operationName" in data) || typeof data.operationName !== "string") {
|
|
245
|
+
return {
|
|
246
|
+
ok: false,
|
|
247
|
+
error: "Request body must have an 'operationName' field of type string",
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
if (data.type === "network-error") {
|
|
251
|
+
if (!("errors" in data) || !Array.isArray(data.errors)) {
|
|
252
|
+
return {
|
|
253
|
+
ok: false,
|
|
254
|
+
error: "Network error type must have an 'errors' field of type array",
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
if (!("responseStatusCode" in data) || typeof data.responseStatusCode !== "number") {
|
|
258
|
+
return {
|
|
259
|
+
ok: false,
|
|
260
|
+
error: "Network error type must have a 'responseStatusCode' field of type number",
|
|
261
|
+
};
|
|
87
262
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
263
|
+
return { ok: true, data: data };
|
|
264
|
+
}
|
|
265
|
+
if (data.type === "operation") {
|
|
266
|
+
if (!("data" in data) || typeof data.data !== "object" || data.data === null) {
|
|
267
|
+
return {
|
|
268
|
+
ok: false,
|
|
269
|
+
error: "Operation type must have a 'data' field of type object",
|
|
270
|
+
};
|
|
93
271
|
}
|
|
272
|
+
return { ok: true, data: data };
|
|
94
273
|
}
|
|
95
|
-
return
|
|
274
|
+
return {
|
|
275
|
+
ok: false,
|
|
276
|
+
error: `Unknown request type '${data.type}'. Allowed types: 'operation', 'network-error'`,
|
|
277
|
+
};
|
|
96
278
|
};
|
|
97
279
|
class LRUMap {
|
|
98
280
|
map = new Map();
|
|
@@ -151,45 +333,72 @@ const isLocalRequest = (origin) => {
|
|
|
151
333
|
};
|
|
152
334
|
const createRoutingServer = async ({ logLevel, ports, maxRegisteredSequences, allowedCORSOrigins, }) => {
|
|
153
335
|
const logger = createLogger(logLevel);
|
|
336
|
+
const app = new Hono();
|
|
154
337
|
// pass through to apollo server
|
|
155
338
|
const passToApollo = async (c) => {
|
|
339
|
+
logger.debug("passToApollo: starting");
|
|
156
340
|
// remove prefix
|
|
157
341
|
// prefix = /app1/*, path = /app1/a/b
|
|
158
342
|
// => suffix_path = /a/b
|
|
159
343
|
// let path = new URL(c.req.raw.url).pathname
|
|
160
344
|
let path = c.req.path;
|
|
161
|
-
logger.debug("
|
|
345
|
+
logger.debug("passToApollo: got path", {
|
|
162
346
|
path,
|
|
347
|
+
routePath: c.req.routePath,
|
|
163
348
|
});
|
|
164
349
|
path = path.replace(new RegExp(`^${c.req.routePath.replace("*", "")}`), "/");
|
|
165
350
|
let url = `http://${ENV_HOSTNAME}:${ports.apolloServer}${path}`;
|
|
166
351
|
// add params to URL
|
|
167
352
|
if (c.req.query())
|
|
168
353
|
url = `${url}?${new URLSearchParams(c.req.query())}`;
|
|
354
|
+
logger.debug("passToApollo: built URL", { url });
|
|
169
355
|
const sequenceId = c.req.header("sequence-id");
|
|
356
|
+
logger.debug("passToApollo: getting request body", { sequenceId });
|
|
170
357
|
const requestBody = await c.req.raw.clone().json();
|
|
358
|
+
logger.debug("passToApollo: got request body", { requestBody });
|
|
171
359
|
const operationName = typeof requestBody === "object" &&
|
|
172
360
|
requestBody !== null &&
|
|
173
361
|
"operationName" in requestBody
|
|
174
362
|
? requestBody.operationName
|
|
175
363
|
: undefined;
|
|
176
364
|
// request
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
365
|
+
logger.debug("passToApollo: calling proxy", {
|
|
366
|
+
url,
|
|
367
|
+
sequenceId,
|
|
368
|
+
operationName,
|
|
369
|
+
headers: c.req.header(),
|
|
370
|
+
});
|
|
371
|
+
const proxyResponse = await proxy(url, {
|
|
372
|
+
raw: c.req.raw,
|
|
373
|
+
headers: {
|
|
374
|
+
...c.req.header(),
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
logger.debug("passToApollo: proxy call completed", {
|
|
378
|
+
sequenceId,
|
|
379
|
+
operationName,
|
|
380
|
+
status: proxyResponse.status,
|
|
381
|
+
headers: Object.fromEntries(proxyResponse.headers),
|
|
182
382
|
});
|
|
183
383
|
// log response with pipe
|
|
184
|
-
if (
|
|
185
|
-
return
|
|
384
|
+
if (proxyResponse.status === 101)
|
|
385
|
+
return proxyResponse;
|
|
186
386
|
// save request and response for /called api
|
|
187
387
|
if (sequenceId && typeof operationName === "string") {
|
|
188
|
-
|
|
388
|
+
logger.debug("passToApollo: getting response body for caching");
|
|
389
|
+
const responseBody = (await proxyResponse.clone().json());
|
|
390
|
+
logger.debug("passToApollo: parsed response body", {
|
|
391
|
+
responseBody,
|
|
392
|
+
});
|
|
189
393
|
const cacheKey = createMapKey({
|
|
190
394
|
sequenceId,
|
|
191
395
|
operationName,
|
|
192
396
|
});
|
|
397
|
+
logger.debug("save called result", {
|
|
398
|
+
sequenceId,
|
|
399
|
+
operationName,
|
|
400
|
+
cacheKey,
|
|
401
|
+
});
|
|
193
402
|
sequenceCalledResultLruMap.set(cacheKey, [
|
|
194
403
|
...(sequenceCalledResultLruMap.get(cacheKey) ?? []),
|
|
195
404
|
{
|
|
@@ -199,25 +408,37 @@ const createRoutingServer = async ({ logLevel, ports, maxRegisteredSequences, al
|
|
|
199
408
|
body: requestBody,
|
|
200
409
|
},
|
|
201
410
|
response: {
|
|
202
|
-
status:
|
|
203
|
-
headers: Object.fromEntries(
|
|
411
|
+
status: proxyResponse.status,
|
|
412
|
+
headers: Object.fromEntries(proxyResponse.headers),
|
|
204
413
|
body: responseBody,
|
|
205
414
|
},
|
|
206
415
|
},
|
|
207
416
|
]);
|
|
208
417
|
}
|
|
209
|
-
|
|
418
|
+
logger.debug("passToApollo: returning proxy response", {
|
|
419
|
+
sequenceId,
|
|
420
|
+
operationName,
|
|
421
|
+
status: proxyResponse.status,
|
|
422
|
+
});
|
|
423
|
+
return proxyResponse;
|
|
210
424
|
};
|
|
211
425
|
// sequenceId x operationName -> FakeResponse
|
|
212
426
|
const sequenceFakeResponseLruMap = new LRUMap({
|
|
213
427
|
maxSize: maxRegisteredSequences,
|
|
214
428
|
});
|
|
429
|
+
// Manage conditional fake responses (store multiple conditional responses)
|
|
430
|
+
const conditionalFakeResponseMap = new LRUMap({
|
|
431
|
+
maxSize: maxRegisteredSequences,
|
|
432
|
+
});
|
|
433
|
+
// Track call count
|
|
434
|
+
const callCountMap = new LRUMap({
|
|
435
|
+
maxSize: maxRegisteredSequences,
|
|
436
|
+
});
|
|
215
437
|
// sequenceId x operationName -> Called Result
|
|
216
438
|
// CalledResult is first request is index 0, second request is index 1 and so on
|
|
217
439
|
const sequenceCalledResultLruMap = new LRUMap({
|
|
218
440
|
maxSize: maxRegisteredSequences,
|
|
219
441
|
});
|
|
220
|
-
const app = new Hono();
|
|
221
442
|
// /fake api does not support CORS
|
|
222
443
|
// because it allows any user to modify the response
|
|
223
444
|
// If you need to support CORS, implement with checking the origin or something
|
|
@@ -225,10 +446,10 @@ const createRoutingServer = async ({ logLevel, ports, maxRegisteredSequences, al
|
|
|
225
446
|
logger.debug("/fake");
|
|
226
447
|
const sequenceId = c.req.header("sequence-id");
|
|
227
448
|
if (!sequenceId) {
|
|
228
|
-
return Response.json(
|
|
449
|
+
return Response.json({
|
|
229
450
|
ok: false,
|
|
230
451
|
errors: ["sequence-id is required"],
|
|
231
|
-
}
|
|
452
|
+
}, {
|
|
232
453
|
status: 400,
|
|
233
454
|
});
|
|
234
455
|
}
|
|
@@ -237,43 +458,83 @@ const createRoutingServer = async ({ logLevel, ports, maxRegisteredSequences, al
|
|
|
237
458
|
sequenceId,
|
|
238
459
|
body,
|
|
239
460
|
});
|
|
240
|
-
|
|
241
|
-
|
|
461
|
+
const validationResult = validateSequenceRegistration(body);
|
|
462
|
+
if (!validationResult.ok) {
|
|
463
|
+
return Response.json({ ok: false, errors: [validationResult.error] }, {
|
|
242
464
|
status: 400,
|
|
243
465
|
});
|
|
244
466
|
}
|
|
245
|
-
const operationName =
|
|
467
|
+
const operationName = validationResult.data.operationName;
|
|
246
468
|
logger.debug("/fake got body type", {
|
|
247
469
|
sequenceId,
|
|
248
|
-
type:
|
|
470
|
+
type: validationResult.data.type,
|
|
471
|
+
requestCondition: validationResult.data.requestCondition,
|
|
249
472
|
});
|
|
250
|
-
|
|
473
|
+
const baseKey = createMapKey({
|
|
251
474
|
sequenceId,
|
|
252
475
|
operationName,
|
|
253
|
-
})
|
|
254
|
-
|
|
476
|
+
});
|
|
477
|
+
// Check for condition conflicts before registration
|
|
478
|
+
const existingConditionalFakes = conditionalFakeResponseMap.get(baseKey) || [];
|
|
479
|
+
const existingDefaultFake = sequenceFakeResponseLruMap.get(baseKey);
|
|
480
|
+
const conflictErrors = checkConditionConflicts(validationResult.data, existingConditionalFakes, existingDefaultFake);
|
|
481
|
+
if (conflictErrors.length > 0) {
|
|
482
|
+
return Response.json({ ok: false, errors: conflictErrors }, {
|
|
483
|
+
status: 400,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
// Register as conditional fake if request condition exists
|
|
487
|
+
if (validationResult.data.requestCondition) {
|
|
488
|
+
const existingConditionalFakes = conditionalFakeResponseMap.get(baseKey) || [];
|
|
489
|
+
// Overwrite if same condition exists, otherwise add new
|
|
490
|
+
const existingIndex = existingConditionalFakes.findIndex((fake) => fake.requestCondition &&
|
|
491
|
+
JSON.stringify(fake.requestCondition) ===
|
|
492
|
+
JSON.stringify(validationResult.data.requestCondition));
|
|
493
|
+
if (existingIndex >= 0) {
|
|
494
|
+
existingConditionalFakes[existingIndex] = validationResult.data;
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
existingConditionalFakes.push(validationResult.data);
|
|
498
|
+
}
|
|
499
|
+
// Sort by condition specificity (evaluate more specific conditions first)
|
|
500
|
+
existingConditionalFakes.sort((a, b) => {
|
|
501
|
+
const scoreA = a.requestCondition
|
|
502
|
+
? calculateConditionSpecificity(a.requestCondition)
|
|
503
|
+
: 0;
|
|
504
|
+
const scoreB = b.requestCondition
|
|
505
|
+
? calculateConditionSpecificity(b.requestCondition)
|
|
506
|
+
: 0;
|
|
507
|
+
return scoreB - scoreA; // Descending order
|
|
508
|
+
});
|
|
509
|
+
conditionalFakeResponseMap.set(baseKey, existingConditionalFakes);
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
512
|
+
// Without condition, use traditional approach
|
|
513
|
+
sequenceFakeResponseLruMap.set(baseKey, validationResult.data);
|
|
514
|
+
}
|
|
515
|
+
return Response.json({ ok: true }, {
|
|
255
516
|
status: 200,
|
|
256
517
|
});
|
|
257
518
|
});
|
|
258
519
|
app.use("/fake/called", async (c) => {
|
|
259
|
-
// sequenceId x operationName
|
|
520
|
+
// Return CalledResult matching sequenceId x operationName
|
|
260
521
|
const sequenceId = c.req.header("sequence-id");
|
|
261
522
|
if (!sequenceId) {
|
|
262
|
-
return Response.json(
|
|
523
|
+
return Response.json({
|
|
263
524
|
ok: false,
|
|
264
525
|
errors: ["sequence-id is required"],
|
|
265
|
-
}
|
|
526
|
+
}, {
|
|
266
527
|
status: 400,
|
|
267
528
|
});
|
|
268
529
|
}
|
|
269
|
-
// req.body
|
|
530
|
+
// Get operationName from req.body
|
|
270
531
|
const body = await c.req.json();
|
|
271
532
|
const operationName = body.operationName;
|
|
272
533
|
if (!operationName) {
|
|
273
|
-
return Response.json(
|
|
534
|
+
return Response.json({
|
|
274
535
|
ok: false,
|
|
275
536
|
errors: ["operationName is required"],
|
|
276
|
-
}
|
|
537
|
+
}, {
|
|
277
538
|
status: 400,
|
|
278
539
|
});
|
|
279
540
|
}
|
|
@@ -293,7 +554,8 @@ const createRoutingServer = async ({ logLevel, ports, maxRegisteredSequences, al
|
|
|
293
554
|
});
|
|
294
555
|
});
|
|
295
556
|
const fakeGraphQLQuery = async (c) => {
|
|
296
|
-
|
|
557
|
+
logger.debug("fakeGraphQLQuery: starting");
|
|
558
|
+
const _requestTimestamp = Date.now();
|
|
297
559
|
/**
|
|
298
560
|
* Steps:
|
|
299
561
|
* 1. Receive a request for a GraphQL query
|
|
@@ -305,7 +567,11 @@ const createRoutingServer = async ({ logLevel, ports, maxRegisteredSequences, al
|
|
|
305
567
|
* 5. Return the merged data
|
|
306
568
|
*/
|
|
307
569
|
const sequenceId = c.req.header("sequence-id");
|
|
570
|
+
logger.debug("fakeGraphQLQuery: getting request body", { sequenceId });
|
|
308
571
|
const requestBody = await c.req.raw.clone().json();
|
|
572
|
+
logger.debug("fakeGraphQLQuery: got request body", {
|
|
573
|
+
requestBody,
|
|
574
|
+
});
|
|
309
575
|
const requestOperationName = typeof requestBody === "object" &&
|
|
310
576
|
requestBody !== null &&
|
|
311
577
|
"operationName" in requestBody &&
|
|
@@ -313,68 +579,128 @@ const createRoutingServer = async ({ logLevel, ports, maxRegisteredSequences, al
|
|
|
313
579
|
typeof requestBody.operationName === "string"
|
|
314
580
|
? requestBody.operationName
|
|
315
581
|
: undefined;
|
|
316
|
-
logger.debug(`operationName: ${requestOperationName} sequenceId: ${sequenceId}`, {
|
|
582
|
+
logger.debug(`fakeGraphQLQuery: operationName: ${requestOperationName} sequenceId: ${sequenceId}`, {
|
|
317
583
|
sequenceId,
|
|
318
584
|
});
|
|
319
585
|
// 2. Does it contain a sequence id?
|
|
320
|
-
if (!sequenceId)
|
|
586
|
+
if (!sequenceId) {
|
|
587
|
+
logger.debug("fakeGraphQLQuery: no sequenceId, passing to Apollo");
|
|
321
588
|
return passToApollo(c);
|
|
322
|
-
|
|
589
|
+
}
|
|
590
|
+
if (!requestOperationName) {
|
|
591
|
+
logger.debug("fakeGraphQLQuery: no operationName, passing to Apollo");
|
|
323
592
|
return passToApollo(c);
|
|
324
|
-
|
|
593
|
+
}
|
|
594
|
+
const baseKey = createMapKey({
|
|
325
595
|
sequenceId,
|
|
326
596
|
operationName: requestOperationName,
|
|
327
|
-
})
|
|
328
|
-
|
|
329
|
-
|
|
597
|
+
});
|
|
598
|
+
// Increment call count
|
|
599
|
+
const currentCallCount = (callCountMap.get(baseKey) || 0) + 1;
|
|
600
|
+
callCountMap.set(baseKey, currentCallCount);
|
|
601
|
+
// Get request variables
|
|
602
|
+
const requestVariables = typeof requestBody === "object" &&
|
|
603
|
+
requestBody !== null &&
|
|
604
|
+
"variables" in requestBody &&
|
|
605
|
+
typeof requestBody.variables === "object" &&
|
|
606
|
+
requestBody.variables !== null
|
|
607
|
+
? requestBody.variables
|
|
608
|
+
: undefined;
|
|
609
|
+
// Check conditional fakes first
|
|
610
|
+
const conditionalFakes = conditionalFakeResponseMap.get(baseKey);
|
|
611
|
+
// Find the first matching conditional fake based on call count and variables
|
|
612
|
+
// If no conditional fake matches, use the default fake from sequenceFakeResponseLruMap
|
|
613
|
+
const matchedFake = findMatchedConditionalFake({
|
|
614
|
+
conditionalFakes: conditionalFakes,
|
|
615
|
+
currentCallCount: currentCallCount,
|
|
616
|
+
requestVariables: requestVariables,
|
|
617
|
+
logger: logger,
|
|
618
|
+
sequenceId: sequenceId,
|
|
619
|
+
requestOperationName: requestOperationName,
|
|
620
|
+
}) ?? sequenceFakeResponseLruMap.get(baseKey);
|
|
621
|
+
logger.debug(`fakeGraphQLQuery: sequence-id: ${sequenceId} x operationName: ${requestOperationName}, fake exists: ${Boolean(matchedFake)}`, {
|
|
622
|
+
matchedFake,
|
|
330
623
|
sequenceId,
|
|
331
624
|
operationName: requestOperationName,
|
|
625
|
+
callCount: currentCallCount,
|
|
332
626
|
});
|
|
333
|
-
if (!
|
|
627
|
+
if (!matchedFake) {
|
|
628
|
+
logger.debug("fakeGraphQLQuery: no fake found, passing to Apollo");
|
|
334
629
|
return passToApollo(c);
|
|
335
|
-
|
|
336
|
-
|
|
630
|
+
}
|
|
631
|
+
if (requestOperationName !== matchedFake.operationName) {
|
|
632
|
+
logger.debug("fakeGraphQLQuery: operationName mismatch, returning error");
|
|
633
|
+
return Response.json({
|
|
337
634
|
errors: [
|
|
338
635
|
`operationName does not match. operationName: ${requestOperationName} sequenceId: ${sequenceId}`,
|
|
339
636
|
],
|
|
340
|
-
}
|
|
637
|
+
}, {
|
|
341
638
|
status: 400,
|
|
342
639
|
});
|
|
343
640
|
}
|
|
344
|
-
if (
|
|
641
|
+
if (matchedFake.type === "network-error") {
|
|
642
|
+
logger.debug("fakeGraphQLQuery: network-error type, returning error");
|
|
643
|
+
// Record call history for error responses as well
|
|
644
|
+
const cacheKey = createMapKey({
|
|
645
|
+
sequenceId,
|
|
646
|
+
operationName: requestOperationName,
|
|
647
|
+
});
|
|
648
|
+
sequenceCalledResultLruMap.set(cacheKey, [
|
|
649
|
+
...(sequenceCalledResultLruMap.get(cacheKey) ?? []),
|
|
650
|
+
{
|
|
651
|
+
requestTimestamp: Date.now(),
|
|
652
|
+
request: {
|
|
653
|
+
headers: Object.fromEntries(c.req.raw.headers),
|
|
654
|
+
body: requestBody,
|
|
655
|
+
},
|
|
656
|
+
response: {
|
|
657
|
+
status: matchedFake.responseStatusCode,
|
|
658
|
+
headers: { "Content-Type": "application/json" },
|
|
659
|
+
body: {
|
|
660
|
+
errors: matchedFake.errors,
|
|
661
|
+
},
|
|
662
|
+
},
|
|
663
|
+
},
|
|
664
|
+
]);
|
|
345
665
|
return new Response(JSON.stringify({
|
|
346
|
-
errors:
|
|
666
|
+
errors: matchedFake.errors,
|
|
347
667
|
}), {
|
|
348
|
-
status:
|
|
668
|
+
status: matchedFake.responseStatusCode,
|
|
349
669
|
});
|
|
350
670
|
}
|
|
351
671
|
// 3. Send a request to Apollo Server
|
|
352
|
-
logger.debug("request to apollo
|
|
672
|
+
logger.debug("fakeGraphQLQuery: sending request to apollo server", {
|
|
353
673
|
sequenceId,
|
|
354
674
|
});
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
headers:
|
|
358
|
-
|
|
359
|
-
|
|
675
|
+
const proxyResponse = await proxy(`http://${ENV_HOSTNAME}:${ports.apolloServer}/graphql`, {
|
|
676
|
+
raw: c.req.raw,
|
|
677
|
+
headers: {
|
|
678
|
+
...c.req.header(),
|
|
679
|
+
},
|
|
360
680
|
});
|
|
361
|
-
logger.debug("
|
|
681
|
+
logger.debug("fakeGraphQLQuery: apollo server response completed", {
|
|
362
682
|
sequenceId,
|
|
363
|
-
|
|
683
|
+
status: proxyResponse.status,
|
|
684
|
+
headers: Object.fromEntries(proxyResponse.headers),
|
|
685
|
+
});
|
|
686
|
+
if (proxyResponse.status === 101)
|
|
687
|
+
return proxyResponse;
|
|
688
|
+
// 4. Get response body
|
|
689
|
+
logger.debug("fakeGraphQLQuery: getting response body");
|
|
690
|
+
const responseBody = (await proxyResponse.json());
|
|
691
|
+
logger.debug("fakeGraphQLQuery: parsed response body", {
|
|
692
|
+
responseBody,
|
|
364
693
|
});
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
const responseBody = await rep.json();
|
|
369
|
-
// 5. Merge the registration data with the response from 2
|
|
370
|
-
const data = sequence.data;
|
|
371
|
-
logger.debug(`/query: merge sequence-id: ${sequenceId}`, {
|
|
694
|
+
// 5. Merge the registration data with the response
|
|
695
|
+
const data = matchedFake.data;
|
|
696
|
+
logger.debug(`fakeGraphQLQuery: starting data merge sequence-id: ${sequenceId}`, {
|
|
372
697
|
data,
|
|
373
698
|
responseBody,
|
|
374
699
|
});
|
|
700
|
+
// Use bracket notation for properties from index signature
|
|
701
|
+
const responseData = responseBody["data"];
|
|
375
702
|
const merged = {
|
|
376
|
-
|
|
377
|
-
...responseBody.data,
|
|
703
|
+
...(typeof responseData === "object" && responseData !== null ? responseData : {}),
|
|
378
704
|
...data,
|
|
379
705
|
};
|
|
380
706
|
const cacheKey = createMapKey({
|
|
@@ -390,17 +716,24 @@ const createRoutingServer = async ({ logLevel, ports, maxRegisteredSequences, al
|
|
|
390
716
|
body: requestBody,
|
|
391
717
|
},
|
|
392
718
|
response: {
|
|
393
|
-
status:
|
|
394
|
-
headers: Object.fromEntries(
|
|
719
|
+
status: proxyResponse.status,
|
|
720
|
+
headers: Object.fromEntries(proxyResponse.headers),
|
|
395
721
|
body: {
|
|
396
722
|
data: merged,
|
|
397
723
|
},
|
|
398
724
|
},
|
|
399
725
|
},
|
|
400
726
|
]);
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
}
|
|
727
|
+
logger.debug("fakeGraphQLQuery: merge completed, returning response");
|
|
728
|
+
// "content-length" should be matched from the response body length
|
|
729
|
+
const responseJson = JSON.stringify({ data: merged });
|
|
730
|
+
return new Response(responseJson, {
|
|
731
|
+
status: proxyResponse.status,
|
|
732
|
+
headers: {
|
|
733
|
+
"Content-Type": "application/json",
|
|
734
|
+
"Content-Length": responseJson.length.toString(),
|
|
735
|
+
},
|
|
736
|
+
});
|
|
404
737
|
};
|
|
405
738
|
// graphql api is for browser and need to support CORS
|
|
406
739
|
app.use("/graphql", cors({
|
|
@@ -470,7 +803,7 @@ export const createFakeServerInternal = async (options) => {
|
|
|
470
803
|
return {
|
|
471
804
|
start: async () => {
|
|
472
805
|
// Replace startStandaloneServer with our custom implementation
|
|
473
|
-
|
|
806
|
+
await startStandaloneServerWithCORS(apolloServer, {
|
|
474
807
|
listen: { port: options.ports.apolloServer },
|
|
475
808
|
}, options.allowedCORSOrigins);
|
|
476
809
|
routerServer = serve({
|
|
@@ -490,4 +823,59 @@ export const createFakeServerInternal = async (options) => {
|
|
|
490
823
|
},
|
|
491
824
|
};
|
|
492
825
|
};
|
|
826
|
+
/**
|
|
827
|
+
* Check if condition rule matches the current request context
|
|
828
|
+
*/
|
|
829
|
+
const evaluateCondition = (condition, context) => {
|
|
830
|
+
switch (condition.type) {
|
|
831
|
+
case "count":
|
|
832
|
+
return context.callCount === condition.value;
|
|
833
|
+
case "variables":
|
|
834
|
+
if (!context.variables)
|
|
835
|
+
return false;
|
|
836
|
+
return isDeepStrictEqual(context.variables, condition.value);
|
|
837
|
+
default:
|
|
838
|
+
return false;
|
|
839
|
+
}
|
|
840
|
+
};
|
|
841
|
+
/**
|
|
842
|
+
* Calculate condition specificity score (used for matching priority)
|
|
843
|
+
*/
|
|
844
|
+
const calculateConditionSpecificity = (condition) => {
|
|
845
|
+
switch (condition.type) {
|
|
846
|
+
case "count":
|
|
847
|
+
return 10; // count conditions have medium priority
|
|
848
|
+
case "variables":
|
|
849
|
+
return 20; // variables conditions have high priority
|
|
850
|
+
default:
|
|
851
|
+
return 0;
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
/**
|
|
855
|
+
* Find a matching conditional fake based on the current call count and request variables
|
|
856
|
+
*/
|
|
857
|
+
const findMatchedConditionalFake = ({ conditionalFakes, currentCallCount, requestVariables, logger, sequenceId, requestOperationName, }) => {
|
|
858
|
+
if (conditionalFakes && conditionalFakes.length > 0) {
|
|
859
|
+
// Find matching fake (already sorted by specificity in descending order)
|
|
860
|
+
for (const fake of conditionalFakes) {
|
|
861
|
+
if (fake.requestCondition) {
|
|
862
|
+
const context = {
|
|
863
|
+
callCount: currentCallCount,
|
|
864
|
+
...(requestVariables && { variables: requestVariables }),
|
|
865
|
+
};
|
|
866
|
+
if (evaluateCondition(fake.requestCondition, context)) {
|
|
867
|
+
logger.debug("fakeGraphQLQuery: matched conditional fake", {
|
|
868
|
+
sequenceId,
|
|
869
|
+
operationName: requestOperationName,
|
|
870
|
+
requestCondition: fake.requestCondition,
|
|
871
|
+
callCount: currentCallCount,
|
|
872
|
+
variables: requestVariables,
|
|
873
|
+
});
|
|
874
|
+
return fake;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
return undefined;
|
|
880
|
+
};
|
|
493
881
|
//# sourceMappingURL=server.js.map
|