@newmo/graphql-fake-server 0.19.1 → 0.20.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.
- package/README.md +162 -118
- 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/server.d.ts +23 -20
- package/dist/esm/server.d.ts.map +1 -1
- package/dist/esm/server.js +375 -46
- package/dist/esm/server.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +11 -5
- package/src/server.ts +524 -85
package/src/server.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
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
5
|
import { ApolloServerPluginDrainHttpServer } from "@apollo/server/plugin/drainHttpServer";
|
|
5
6
|
import { expressMiddleware } from "@as-integrations/express5";
|
|
@@ -116,65 +117,302 @@ const creteApolloServer = async (options: FakeServerInternal) => {
|
|
|
116
117
|
validationRules: [depthLimit(options.maxQueryDepth)],
|
|
117
118
|
});
|
|
118
119
|
};
|
|
120
|
+
// Allowed condition types
|
|
121
|
+
const ALLOWED_CONDITION_TYPES = ["count", "variables"] as const;
|
|
122
|
+
type AllowedConditionType = (typeof ALLOWED_CONDITION_TYPES)[number];
|
|
123
|
+
|
|
124
|
+
// Validation result type for better error messages
|
|
125
|
+
type ValidationResult<T> = { ok: true; data: T } | { ok: false; error: string };
|
|
126
|
+
|
|
127
|
+
// Condition rules for conditional fake responses
|
|
128
|
+
export type ConditionRule =
|
|
129
|
+
| {
|
|
130
|
+
type: "count";
|
|
131
|
+
value: number;
|
|
132
|
+
} // Match based on call count (nth call)
|
|
133
|
+
| {
|
|
134
|
+
type: "variables";
|
|
135
|
+
value: Record<string, unknown>;
|
|
136
|
+
}; // Match based on complete variables object
|
|
137
|
+
|
|
138
|
+
// Called result structure for tracking requests/responses
|
|
139
|
+
export type CalledResult = {
|
|
140
|
+
requestTimestamp: number;
|
|
141
|
+
request: {
|
|
142
|
+
headers: Record<string, string>;
|
|
143
|
+
body: Record<string, unknown>;
|
|
144
|
+
};
|
|
145
|
+
response: {
|
|
146
|
+
status: number;
|
|
147
|
+
headers: Record<string, string>;
|
|
148
|
+
body: unknown;
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// Response type for the /called endpoint
|
|
153
|
+
export type CalledResultResponse = {
|
|
154
|
+
ok: boolean;
|
|
155
|
+
data: CalledResult[];
|
|
156
|
+
};
|
|
157
|
+
|
|
119
158
|
export type RegisterSequenceNetworkError = {
|
|
120
159
|
type: "network-error";
|
|
121
160
|
operationName: string;
|
|
122
161
|
responseStatusCode: number;
|
|
123
162
|
errors: Record<string, unknown>[];
|
|
163
|
+
// Add request condition
|
|
164
|
+
requestCondition?: ConditionRule;
|
|
124
165
|
};
|
|
125
166
|
export type RegisterSequenceOperation = {
|
|
126
167
|
type: "operation";
|
|
127
168
|
operationName: string;
|
|
128
169
|
data: Record<string, unknown>;
|
|
170
|
+
// Add request condition
|
|
171
|
+
requestCondition?: ConditionRule;
|
|
129
172
|
};
|
|
130
173
|
export type RegisterSequenceOptions = RegisterSequenceNetworkError | RegisterSequenceOperation;
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Check if two condition types are conflicting and return specific error message
|
|
177
|
+
* Only the following combinations are allowed:
|
|
178
|
+
* - count + count
|
|
179
|
+
* - variables + variables
|
|
180
|
+
* - variables + no condition (undefined)
|
|
181
|
+
* - no condition (undefined) + no condition (undefined)
|
|
182
|
+
* All other combinations are conflicting
|
|
183
|
+
*/
|
|
184
|
+
const areConditionTypesConflicting = (
|
|
185
|
+
conditionType1: ConditionRule["type"] | undefined,
|
|
186
|
+
conditionType2: ConditionRule["type"] | undefined,
|
|
187
|
+
): { isConflicting: boolean; errorMessage?: string } => {
|
|
188
|
+
// Define allowed combinations with their descriptions
|
|
189
|
+
const allowedCombinations = new Map<string, string>([
|
|
190
|
+
// Multiple count conditions for the same operation (e.g., 1st call, 2nd call)
|
|
191
|
+
["count,count", "Multiple count-based conditions are allowed for different call counts"],
|
|
192
|
+
// Multiple variables conditions for the same operation (e.g., different variable sets)
|
|
193
|
+
[
|
|
194
|
+
"variables,variables",
|
|
195
|
+
"Multiple variable-based conditions are allowed for different variable sets",
|
|
196
|
+
],
|
|
197
|
+
// Variables condition can coexist with default fallback
|
|
198
|
+
["variables,undefined", "Variable-based condition can coexist with default fallback"],
|
|
199
|
+
// Default fallback can coexist with variables condition
|
|
200
|
+
["undefined,variables", "Default fallback can coexist with variable-based condition"],
|
|
201
|
+
// Multiple default conditions - overwrite with the last one
|
|
202
|
+
["undefined,undefined", "Multiple default conditions are allowed (latest will be used)"],
|
|
203
|
+
]);
|
|
204
|
+
|
|
205
|
+
const combinationKey = `${conditionType1 ?? "undefined"},${conditionType2 ?? "undefined"}`;
|
|
206
|
+
|
|
207
|
+
// If the combination is allowed, return no conflict
|
|
208
|
+
if (allowedCombinations.has(combinationKey)) {
|
|
209
|
+
return { isConflicting: false };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Generate specific error message for conflicting combinations
|
|
213
|
+
const getTypeDescription = (type: ConditionRule["type"] | undefined): string => {
|
|
214
|
+
switch (type) {
|
|
215
|
+
case "count":
|
|
216
|
+
return "count-based condition (e.g., { type: 'count', value: 1 })";
|
|
217
|
+
case "variables":
|
|
218
|
+
return "variables-based condition (e.g., { type: 'variables', value: {...} })";
|
|
219
|
+
case undefined:
|
|
220
|
+
return "default condition (no requestCondition specified)";
|
|
221
|
+
default:
|
|
222
|
+
return `unknown condition type: ${type}`;
|
|
223
|
+
}
|
|
141
224
|
};
|
|
225
|
+
|
|
226
|
+
const type1Desc = getTypeDescription(conditionType1);
|
|
227
|
+
const type2Desc = getTypeDescription(conditionType2);
|
|
228
|
+
|
|
229
|
+
// Specific error messages for common problematic combinations
|
|
230
|
+
if (
|
|
231
|
+
(conditionType1 === "count" && conditionType2 === "variables") ||
|
|
232
|
+
(conditionType1 === "variables" && conditionType2 === "count")
|
|
233
|
+
) {
|
|
234
|
+
const errorMessage =
|
|
235
|
+
"Cannot mix count-based and variables-based conditions for the same operation. " +
|
|
236
|
+
"Use either multiple count conditions (for different call numbers) or multiple variables conditions (for different variable sets), " +
|
|
237
|
+
`but not both. Current conflict: ${type1Desc} vs ${type2Desc}`;
|
|
238
|
+
return { isConflicting: true, errorMessage };
|
|
239
|
+
}
|
|
240
|
+
const errorMessage =
|
|
241
|
+
`Conflicting condition types detected: ${type1Desc} vs ${type2Desc}. ` +
|
|
242
|
+
"Allowed combinations are: count+count, variables+variables, variables+default, or default+default.";
|
|
243
|
+
return { isConflicting: true, errorMessage };
|
|
142
244
|
};
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Get condition type from a RegisterSequenceOptions
|
|
248
|
+
*/
|
|
249
|
+
const getConditionType = (fake: RegisterSequenceOptions): ConditionRule["type"] | undefined => {
|
|
250
|
+
return fake.requestCondition?.type;
|
|
146
251
|
};
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Check for condition conflicts in existing fakes for the same operation
|
|
255
|
+
*/
|
|
256
|
+
const checkConditionConflicts = (
|
|
257
|
+
newFake: RegisterSequenceOptions,
|
|
258
|
+
existingConditionalFakes: RegisterSequenceOptions[],
|
|
259
|
+
existingDefaultFake: RegisterSequenceOptions | undefined,
|
|
260
|
+
): string[] => {
|
|
261
|
+
const errors: string[] = [];
|
|
262
|
+
const newConditionType = getConditionType(newFake);
|
|
263
|
+
|
|
264
|
+
// Check conflicts with existing conditional fakes
|
|
265
|
+
for (const existingFake of existingConditionalFakes) {
|
|
266
|
+
const existingConditionType = getConditionType(existingFake);
|
|
267
|
+
const conflictResult = areConditionTypesConflicting(
|
|
268
|
+
newConditionType,
|
|
269
|
+
existingConditionType,
|
|
270
|
+
);
|
|
271
|
+
if (conflictResult.isConflicting && conflictResult.errorMessage) {
|
|
272
|
+
errors.push(conflictResult.errorMessage);
|
|
167
273
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Check conflicts with existing default fake (no condition)
|
|
277
|
+
if (existingDefaultFake) {
|
|
278
|
+
const existingConditionType = getConditionType(existingDefaultFake);
|
|
279
|
+
const conflictResult = areConditionTypesConflicting(
|
|
280
|
+
newConditionType,
|
|
281
|
+
existingConditionType,
|
|
282
|
+
);
|
|
283
|
+
if (conflictResult.isConflicting && conflictResult.errorMessage) {
|
|
284
|
+
errors.push(conflictResult.errorMessage);
|
|
175
285
|
}
|
|
176
286
|
}
|
|
177
|
-
|
|
287
|
+
|
|
288
|
+
return errors;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Validate condition rule structure
|
|
293
|
+
*/
|
|
294
|
+
const validateConditionRule = (condition: unknown): ValidationResult<ConditionRule> => {
|
|
295
|
+
if (typeof condition !== "object" || condition === null) {
|
|
296
|
+
return { ok: false, error: "Condition must be an object" };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (!("type" in condition) || typeof condition.type !== "string") {
|
|
300
|
+
return {
|
|
301
|
+
ok: false,
|
|
302
|
+
error: "Condition must have a 'type' field of type string",
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Check if type is in the allow list
|
|
307
|
+
if (!ALLOWED_CONDITION_TYPES.includes(condition.type as AllowedConditionType)) {
|
|
308
|
+
return {
|
|
309
|
+
ok: false,
|
|
310
|
+
error: `Unknown condition type '${
|
|
311
|
+
condition.type
|
|
312
|
+
}'. Allowed types: ${ALLOWED_CONDITION_TYPES.join(", ")}`,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (!("value" in condition)) {
|
|
317
|
+
return { ok: false, error: "Condition must have a 'value' field" };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
switch (condition.type) {
|
|
321
|
+
case "count":
|
|
322
|
+
if (typeof condition.value !== "number") {
|
|
323
|
+
return { ok: false, error: "Count condition value must be a number" };
|
|
324
|
+
}
|
|
325
|
+
if (condition.value <= 0) {
|
|
326
|
+
return {
|
|
327
|
+
ok: false,
|
|
328
|
+
error: "Count condition value must be greater than 0",
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
return { ok: true, data: condition as ConditionRule };
|
|
332
|
+
|
|
333
|
+
case "variables":
|
|
334
|
+
if (typeof condition.value !== "object" || condition.value === null) {
|
|
335
|
+
return {
|
|
336
|
+
ok: false,
|
|
337
|
+
error: "Variables condition value must be an object",
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
if (Array.isArray(condition.value)) {
|
|
341
|
+
return {
|
|
342
|
+
ok: false,
|
|
343
|
+
error: "Variables condition value must be an object, not an array",
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
return { ok: true, data: condition as ConditionRule };
|
|
347
|
+
|
|
348
|
+
default:
|
|
349
|
+
return {
|
|
350
|
+
ok: false,
|
|
351
|
+
error: `Unsupported condition type '${condition.type}'`,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const validateSequenceRegistration = (data: unknown): ValidationResult<RegisterSequenceOptions> => {
|
|
357
|
+
if (typeof data !== "object" || data === null) {
|
|
358
|
+
return { ok: false, error: "Request body must be an object" };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Validate request condition
|
|
362
|
+
if ("requestCondition" in data && data.requestCondition !== undefined) {
|
|
363
|
+
const conditionResult = validateConditionRule(data.requestCondition);
|
|
364
|
+
if (!conditionResult.ok) {
|
|
365
|
+
return {
|
|
366
|
+
ok: false,
|
|
367
|
+
error: `Invalid request condition: ${conditionResult.error}`,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (!("type" in data) || typeof data.type !== "string") {
|
|
373
|
+
return {
|
|
374
|
+
ok: false,
|
|
375
|
+
error: "Request body must have a 'type' field of type string",
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (!("operationName" in data) || typeof data.operationName !== "string") {
|
|
380
|
+
return {
|
|
381
|
+
ok: false,
|
|
382
|
+
error: "Request body must have an 'operationName' field of type string",
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (data.type === "network-error") {
|
|
387
|
+
if (!("errors" in data) || !Array.isArray(data.errors)) {
|
|
388
|
+
return {
|
|
389
|
+
ok: false,
|
|
390
|
+
error: "Network error type must have an 'errors' field of type array",
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
if (!("responseStatusCode" in data) || typeof data.responseStatusCode !== "number") {
|
|
394
|
+
return {
|
|
395
|
+
ok: false,
|
|
396
|
+
error: "Network error type must have a 'responseStatusCode' field of type number",
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
return { ok: true, data: data as RegisterSequenceOptions };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (data.type === "operation") {
|
|
403
|
+
if (!("data" in data) || typeof data.data !== "object" || data.data === null) {
|
|
404
|
+
return {
|
|
405
|
+
ok: false,
|
|
406
|
+
error: "Operation type must have a 'data' field of type object",
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
return { ok: true, data: data as RegisterSequenceOptions };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return {
|
|
413
|
+
ok: false,
|
|
414
|
+
error: `Unknown request type '${data.type}'. Allowed types: 'operation', 'network-error'`,
|
|
415
|
+
};
|
|
178
416
|
};
|
|
179
417
|
|
|
180
418
|
class LRUMap<K, V> {
|
|
@@ -360,6 +598,14 @@ const createRoutingServer = async ({
|
|
|
360
598
|
const sequenceFakeResponseLruMap = new LRUMap<string, RegisterSequenceOptions>({
|
|
361
599
|
maxSize: maxRegisteredSequences,
|
|
362
600
|
});
|
|
601
|
+
// Manage conditional fake responses (store multiple conditional responses)
|
|
602
|
+
const conditionalFakeResponseMap = new LRUMap<string, RegisterSequenceOptions[]>({
|
|
603
|
+
maxSize: maxRegisteredSequences,
|
|
604
|
+
});
|
|
605
|
+
// Track call count
|
|
606
|
+
const callCountMap = new LRUMap<string, number>({
|
|
607
|
+
maxSize: maxRegisteredSequences,
|
|
608
|
+
});
|
|
363
609
|
// sequenceId x operationName -> Called Result
|
|
364
610
|
// CalledResult is first request is index 0, second request is index 1 and so on
|
|
365
611
|
const sequenceCalledResultLruMap = new LRUMap<string, CalledResult[]>({
|
|
@@ -373,10 +619,10 @@ const createRoutingServer = async ({
|
|
|
373
619
|
const sequenceId = c.req.header("sequence-id");
|
|
374
620
|
if (!sequenceId) {
|
|
375
621
|
return Response.json(
|
|
376
|
-
|
|
622
|
+
{
|
|
377
623
|
ok: false,
|
|
378
624
|
errors: ["sequence-id is required"],
|
|
379
|
-
}
|
|
625
|
+
},
|
|
380
626
|
{
|
|
381
627
|
status: 400,
|
|
382
628
|
},
|
|
@@ -387,50 +633,109 @@ const createRoutingServer = async ({
|
|
|
387
633
|
sequenceId,
|
|
388
634
|
body,
|
|
389
635
|
});
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
636
|
+
const validationResult = validateSequenceRegistration(body);
|
|
637
|
+
if (!validationResult.ok) {
|
|
638
|
+
return Response.json(
|
|
639
|
+
{ ok: false, errors: [validationResult.error] },
|
|
640
|
+
{
|
|
641
|
+
status: 400,
|
|
642
|
+
},
|
|
643
|
+
);
|
|
394
644
|
}
|
|
395
|
-
const operationName =
|
|
645
|
+
const operationName = validationResult.data.operationName;
|
|
396
646
|
logger.debug("/fake got body type", {
|
|
397
647
|
sequenceId,
|
|
398
|
-
type:
|
|
648
|
+
type: validationResult.data.type,
|
|
649
|
+
requestCondition: validationResult.data.requestCondition,
|
|
399
650
|
});
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
}),
|
|
405
|
-
body,
|
|
406
|
-
);
|
|
407
|
-
return Response.json(JSON.stringify({ ok: true }), {
|
|
408
|
-
status: 200,
|
|
651
|
+
|
|
652
|
+
const baseKey = createMapKey({
|
|
653
|
+
sequenceId,
|
|
654
|
+
operationName,
|
|
409
655
|
});
|
|
656
|
+
|
|
657
|
+
// Check for condition conflicts before registration
|
|
658
|
+
const existingConditionalFakes = conditionalFakeResponseMap.get(baseKey) || [];
|
|
659
|
+
const existingDefaultFake = sequenceFakeResponseLruMap.get(baseKey);
|
|
660
|
+
|
|
661
|
+
const conflictErrors = checkConditionConflicts(
|
|
662
|
+
validationResult.data,
|
|
663
|
+
existingConditionalFakes,
|
|
664
|
+
existingDefaultFake,
|
|
665
|
+
);
|
|
666
|
+
|
|
667
|
+
if (conflictErrors.length > 0) {
|
|
668
|
+
return Response.json(
|
|
669
|
+
{ ok: false, errors: conflictErrors },
|
|
670
|
+
{
|
|
671
|
+
status: 400,
|
|
672
|
+
},
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Register as conditional fake if request condition exists
|
|
677
|
+
if (validationResult.data.requestCondition) {
|
|
678
|
+
const existingConditionalFakes = conditionalFakeResponseMap.get(baseKey) || [];
|
|
679
|
+
// Overwrite if same condition exists, otherwise add new
|
|
680
|
+
const existingIndex = existingConditionalFakes.findIndex(
|
|
681
|
+
(fake) =>
|
|
682
|
+
fake.requestCondition &&
|
|
683
|
+
JSON.stringify(fake.requestCondition) ===
|
|
684
|
+
JSON.stringify(validationResult.data.requestCondition),
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
if (existingIndex >= 0) {
|
|
688
|
+
existingConditionalFakes[existingIndex] = validationResult.data;
|
|
689
|
+
} else {
|
|
690
|
+
existingConditionalFakes.push(validationResult.data);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Sort by condition specificity (evaluate more specific conditions first)
|
|
694
|
+
existingConditionalFakes.sort((a, b) => {
|
|
695
|
+
const scoreA = a.requestCondition
|
|
696
|
+
? calculateConditionSpecificity(a.requestCondition)
|
|
697
|
+
: 0;
|
|
698
|
+
const scoreB = b.requestCondition
|
|
699
|
+
? calculateConditionSpecificity(b.requestCondition)
|
|
700
|
+
: 0;
|
|
701
|
+
return scoreB - scoreA; // Descending order
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
conditionalFakeResponseMap.set(baseKey, existingConditionalFakes);
|
|
705
|
+
} else {
|
|
706
|
+
// Without condition, use traditional approach
|
|
707
|
+
sequenceFakeResponseLruMap.set(baseKey, validationResult.data);
|
|
708
|
+
}
|
|
709
|
+
return Response.json(
|
|
710
|
+
{ ok: true },
|
|
711
|
+
{
|
|
712
|
+
status: 200,
|
|
713
|
+
},
|
|
714
|
+
);
|
|
410
715
|
});
|
|
411
716
|
app.use("/fake/called", async (c) => {
|
|
412
|
-
// sequenceId x operationName
|
|
717
|
+
// Return CalledResult matching sequenceId x operationName
|
|
413
718
|
const sequenceId = c.req.header("sequence-id");
|
|
414
719
|
if (!sequenceId) {
|
|
415
720
|
return Response.json(
|
|
416
|
-
|
|
721
|
+
{
|
|
417
722
|
ok: false,
|
|
418
723
|
errors: ["sequence-id is required"],
|
|
419
|
-
}
|
|
724
|
+
},
|
|
420
725
|
{
|
|
421
726
|
status: 400,
|
|
422
727
|
},
|
|
423
728
|
);
|
|
424
729
|
}
|
|
425
|
-
// req.body
|
|
730
|
+
// Get operationName from req.body
|
|
426
731
|
const body = await c.req.json();
|
|
427
732
|
const operationName = body.operationName;
|
|
428
733
|
if (!operationName) {
|
|
429
734
|
return Response.json(
|
|
430
|
-
|
|
735
|
+
{
|
|
431
736
|
ok: false,
|
|
432
737
|
errors: ["operationName is required"],
|
|
433
|
-
}
|
|
738
|
+
},
|
|
434
739
|
{
|
|
435
740
|
status: 400,
|
|
436
741
|
},
|
|
@@ -502,49 +807,102 @@ const createRoutingServer = async ({
|
|
|
502
807
|
return passToApollo(c);
|
|
503
808
|
}
|
|
504
809
|
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
810
|
+
const baseKey = createMapKey({
|
|
811
|
+
sequenceId,
|
|
812
|
+
operationName: requestOperationName,
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
// Increment call count
|
|
816
|
+
const currentCallCount = (callCountMap.get(baseKey) || 0) + 1;
|
|
817
|
+
callCountMap.set(baseKey, currentCallCount);
|
|
818
|
+
|
|
819
|
+
// Get request variables
|
|
820
|
+
const requestVariables =
|
|
821
|
+
typeof requestBody === "object" &&
|
|
822
|
+
requestBody !== null &&
|
|
823
|
+
"variables" in requestBody &&
|
|
824
|
+
typeof requestBody.variables === "object" &&
|
|
825
|
+
requestBody.variables !== null
|
|
826
|
+
? (requestBody.variables as Record<string, unknown>)
|
|
827
|
+
: undefined;
|
|
828
|
+
|
|
829
|
+
// Check conditional fakes first
|
|
830
|
+
const conditionalFakes = conditionalFakeResponseMap.get(baseKey);
|
|
831
|
+
// Find the first matching conditional fake based on call count and variables
|
|
832
|
+
// If no conditional fake matches, use the default fake from sequenceFakeResponseLruMap
|
|
833
|
+
const matchedFake: RegisterSequenceOptions | undefined =
|
|
834
|
+
findMatchedConditionalFake({
|
|
835
|
+
conditionalFakes: conditionalFakes,
|
|
836
|
+
currentCallCount: currentCallCount,
|
|
837
|
+
requestVariables: requestVariables,
|
|
838
|
+
logger: logger,
|
|
839
|
+
sequenceId: sequenceId,
|
|
840
|
+
requestOperationName: requestOperationName,
|
|
841
|
+
}) ?? sequenceFakeResponseLruMap.get(baseKey);
|
|
842
|
+
|
|
511
843
|
logger.debug(
|
|
512
|
-
`fakeGraphQLQuery: sequence-id: ${sequenceId} x operationName: ${requestOperationName},
|
|
513
|
-
|
|
844
|
+
`fakeGraphQLQuery: sequence-id: ${sequenceId} x operationName: ${requestOperationName}, fake exists: ${Boolean(
|
|
845
|
+
matchedFake,
|
|
514
846
|
)}`,
|
|
515
847
|
{
|
|
516
|
-
|
|
848
|
+
matchedFake,
|
|
517
849
|
sequenceId,
|
|
518
850
|
operationName: requestOperationName,
|
|
851
|
+
callCount: currentCallCount,
|
|
519
852
|
},
|
|
520
853
|
);
|
|
521
|
-
|
|
522
|
-
|
|
854
|
+
|
|
855
|
+
if (!matchedFake) {
|
|
856
|
+
logger.debug("fakeGraphQLQuery: no fake found, passing to Apollo");
|
|
523
857
|
return passToApollo(c);
|
|
524
858
|
}
|
|
525
859
|
|
|
526
|
-
if (requestOperationName !==
|
|
860
|
+
if (requestOperationName !== matchedFake.operationName) {
|
|
527
861
|
logger.debug("fakeGraphQLQuery: operationName mismatch, returning error");
|
|
528
862
|
return Response.json(
|
|
529
|
-
|
|
863
|
+
{
|
|
530
864
|
errors: [
|
|
531
865
|
`operationName does not match. operationName: ${requestOperationName} sequenceId: ${sequenceId}`,
|
|
532
866
|
],
|
|
533
|
-
}
|
|
867
|
+
},
|
|
534
868
|
{
|
|
535
869
|
status: 400,
|
|
536
870
|
},
|
|
537
871
|
);
|
|
538
872
|
}
|
|
539
873
|
|
|
540
|
-
if (
|
|
874
|
+
if (matchedFake.type === "network-error") {
|
|
541
875
|
logger.debug("fakeGraphQLQuery: network-error type, returning error");
|
|
876
|
+
|
|
877
|
+
// Record call history for error responses as well
|
|
878
|
+
const cacheKey = createMapKey({
|
|
879
|
+
sequenceId,
|
|
880
|
+
operationName: requestOperationName,
|
|
881
|
+
});
|
|
882
|
+
sequenceCalledResultLruMap.set(cacheKey, [
|
|
883
|
+
...(sequenceCalledResultLruMap.get(cacheKey) ?? []),
|
|
884
|
+
{
|
|
885
|
+
requestTimestamp: Date.now(),
|
|
886
|
+
request: {
|
|
887
|
+
headers: Object.fromEntries(c.req.raw.headers),
|
|
888
|
+
body: requestBody as Record<string, unknown>,
|
|
889
|
+
},
|
|
890
|
+
response: {
|
|
891
|
+
status: matchedFake.responseStatusCode,
|
|
892
|
+
headers: { "Content-Type": "application/json" },
|
|
893
|
+
body: {
|
|
894
|
+
errors: matchedFake.errors,
|
|
895
|
+
},
|
|
896
|
+
},
|
|
897
|
+
},
|
|
898
|
+
]);
|
|
899
|
+
|
|
542
900
|
return new Response(
|
|
543
901
|
JSON.stringify({
|
|
544
|
-
errors:
|
|
902
|
+
errors: matchedFake.errors,
|
|
545
903
|
}),
|
|
546
904
|
{
|
|
547
|
-
status:
|
|
905
|
+
status: matchedFake.responseStatusCode,
|
|
548
906
|
},
|
|
549
907
|
);
|
|
550
908
|
}
|
|
@@ -577,13 +935,13 @@ const createRoutingServer = async ({
|
|
|
577
935
|
});
|
|
578
936
|
|
|
579
937
|
// 5. Merge the registration data with the response
|
|
580
|
-
const data =
|
|
938
|
+
const data = matchedFake.data;
|
|
581
939
|
logger.debug(`fakeGraphQLQuery: starting data merge sequence-id: ${sequenceId}`, {
|
|
582
940
|
data,
|
|
583
941
|
responseBody,
|
|
584
942
|
});
|
|
585
943
|
// Use bracket notation for properties from index signature
|
|
586
|
-
const responseData = responseBody["data"] as
|
|
944
|
+
const responseData = responseBody["data"] as unknown;
|
|
587
945
|
const merged = {
|
|
588
946
|
...(typeof responseData === "object" && responseData !== null ? responseData : {}),
|
|
589
947
|
...data,
|
|
@@ -612,13 +970,12 @@ const createRoutingServer = async ({
|
|
|
612
970
|
]);
|
|
613
971
|
|
|
614
972
|
logger.debug("fakeGraphQLQuery: merge completed, returning response");
|
|
615
|
-
//
|
|
973
|
+
// Let the server automatically calculate Content-Length to avoid issues with multi-byte characters
|
|
616
974
|
const responseJson = JSON.stringify({ data: merged });
|
|
617
975
|
return new Response(responseJson, {
|
|
618
976
|
status: proxyResponse.status,
|
|
619
977
|
headers: {
|
|
620
978
|
"Content-Type": "application/json",
|
|
621
|
-
"Content-Length": responseJson.length.toString(),
|
|
622
979
|
},
|
|
623
980
|
});
|
|
624
981
|
};
|
|
@@ -730,3 +1087,85 @@ export const createFakeServerInternal = async (options: FakeServerInternal) => {
|
|
|
730
1087
|
},
|
|
731
1088
|
};
|
|
732
1089
|
};
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Check if condition rule matches the current request context
|
|
1093
|
+
*/
|
|
1094
|
+
const evaluateCondition = (
|
|
1095
|
+
condition: ConditionRule,
|
|
1096
|
+
context: {
|
|
1097
|
+
callCount: number;
|
|
1098
|
+
variables?: Record<string, unknown>;
|
|
1099
|
+
},
|
|
1100
|
+
): boolean => {
|
|
1101
|
+
switch (condition.type) {
|
|
1102
|
+
case "count":
|
|
1103
|
+
return context.callCount === condition.value;
|
|
1104
|
+
|
|
1105
|
+
case "variables":
|
|
1106
|
+
if (!context.variables) return false;
|
|
1107
|
+
return isDeepStrictEqual(context.variables, condition.value);
|
|
1108
|
+
|
|
1109
|
+
default:
|
|
1110
|
+
return false;
|
|
1111
|
+
}
|
|
1112
|
+
};
|
|
1113
|
+
|
|
1114
|
+
/**
|
|
1115
|
+
* Calculate condition specificity score (used for matching priority)
|
|
1116
|
+
*/
|
|
1117
|
+
const calculateConditionSpecificity = (condition: ConditionRule): number => {
|
|
1118
|
+
switch (condition.type) {
|
|
1119
|
+
case "count":
|
|
1120
|
+
return 10; // count conditions have medium priority
|
|
1121
|
+
|
|
1122
|
+
case "variables":
|
|
1123
|
+
return 20; // variables conditions have high priority
|
|
1124
|
+
|
|
1125
|
+
default:
|
|
1126
|
+
return 0;
|
|
1127
|
+
}
|
|
1128
|
+
};
|
|
1129
|
+
|
|
1130
|
+
/**
|
|
1131
|
+
* Find a matching conditional fake based on the current call count and request variables
|
|
1132
|
+
*/
|
|
1133
|
+
const findMatchedConditionalFake = ({
|
|
1134
|
+
conditionalFakes,
|
|
1135
|
+
currentCallCount,
|
|
1136
|
+
requestVariables,
|
|
1137
|
+
logger,
|
|
1138
|
+
sequenceId,
|
|
1139
|
+
requestOperationName,
|
|
1140
|
+
}: {
|
|
1141
|
+
conditionalFakes: RegisterSequenceOptions[] | undefined;
|
|
1142
|
+
currentCallCount: number;
|
|
1143
|
+
requestVariables: Record<string, unknown> | undefined;
|
|
1144
|
+
logger: ReturnType<typeof createLogger>;
|
|
1145
|
+
sequenceId: string;
|
|
1146
|
+
requestOperationName: string;
|
|
1147
|
+
}): RegisterSequenceOptions | undefined => {
|
|
1148
|
+
if (conditionalFakes && conditionalFakes.length > 0) {
|
|
1149
|
+
// Find matching fake (already sorted by specificity in descending order)
|
|
1150
|
+
for (const fake of conditionalFakes) {
|
|
1151
|
+
if (fake.requestCondition) {
|
|
1152
|
+
const context = {
|
|
1153
|
+
callCount: currentCallCount,
|
|
1154
|
+
...(requestVariables && { variables: requestVariables }),
|
|
1155
|
+
};
|
|
1156
|
+
|
|
1157
|
+
if (evaluateCondition(fake.requestCondition, context)) {
|
|
1158
|
+
logger.debug("fakeGraphQLQuery: matched conditional fake", {
|
|
1159
|
+
sequenceId,
|
|
1160
|
+
operationName: requestOperationName,
|
|
1161
|
+
requestCondition: fake.requestCondition,
|
|
1162
|
+
callCount: currentCallCount,
|
|
1163
|
+
variables: requestVariables,
|
|
1164
|
+
});
|
|
1165
|
+
return fake;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
return undefined;
|
|
1171
|
+
};
|