@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.
@@ -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
- if ("type" in data && typeof data.type === "string") {
80
- if (data.type === "network-error") {
81
- return ("errors" in data &&
82
- Array.isArray(data.errors) &&
83
- "responseStatusCode" in data &&
84
- typeof data.responseStatusCode === "number" &&
85
- "operationName" in data &&
86
- typeof data.operationName === "string");
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
- if (data.type === "operation") {
89
- return ("data" in data &&
90
- typeof data.data === "object" &&
91
- "operationName" in data &&
92
- typeof data.operationName === "string");
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 false;
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("pass to apollo server", {
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
- const rep = await fetch(url, {
178
- method: c.req.method,
179
- headers: c.req.raw.headers,
180
- body: c.req.raw.body,
181
- duplex: "half",
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 (rep.status === 101)
185
- return rep;
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
- const responseBody = (await rep.clone().json());
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: rep.status,
203
- headers: Object.fromEntries(rep.headers),
411
+ status: proxyResponse.status,
412
+ headers: Object.fromEntries(proxyResponse.headers),
204
413
  body: responseBody,
205
414
  },
206
415
  },
207
416
  ]);
208
417
  }
209
- return new Response(rep.body, rep);
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(JSON.stringify({
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
- if (!validateSequenceRegistration(body)) {
241
- return Response.json(JSON.stringify({ ok: false, errors: ["invalid fake body"] }), {
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 = body.operationName;
467
+ const operationName = validationResult.data.operationName;
246
468
  logger.debug("/fake got body type", {
247
469
  sequenceId,
248
- type: body.type,
470
+ type: validationResult.data.type,
471
+ requestCondition: validationResult.data.requestCondition,
249
472
  });
250
- sequenceFakeResponseLruMap.set(createMapKey({
473
+ const baseKey = createMapKey({
251
474
  sequenceId,
252
475
  operationName,
253
- }), body);
254
- return Response.json(JSON.stringify({ ok: true }), {
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 にマッチする CalledResult を返す
520
+ // Return CalledResult matching sequenceId x operationName
260
521
  const sequenceId = c.req.header("sequence-id");
261
522
  if (!sequenceId) {
262
- return Response.json(JSON.stringify({
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からoperationNameを取得
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(JSON.stringify({
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
- const requestTimestamp = Date.now();
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
- if (!requestOperationName)
589
+ }
590
+ if (!requestOperationName) {
591
+ logger.debug("fakeGraphQLQuery: no operationName, passing to Apollo");
323
592
  return passToApollo(c);
324
- const sequence = sequenceFakeResponseLruMap.get(createMapKey({
593
+ }
594
+ const baseKey = createMapKey({
325
595
  sequenceId,
326
596
  operationName: requestOperationName,
327
- }));
328
- logger.debug(`/query: sequence-id: ${sequenceId} x operationName: ${requestOperationName}, sequence exists: ${Boolean(sequence)}`, {
329
- sequence,
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 (!sequence)
627
+ if (!matchedFake) {
628
+ logger.debug("fakeGraphQLQuery: no fake found, passing to Apollo");
334
629
  return passToApollo(c);
335
- if (requestOperationName !== sequence.operationName) {
336
- return Response.json(JSON.stringify({
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 (sequence.type === "network-error") {
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: sequence.errors,
666
+ errors: matchedFake.errors,
347
667
  }), {
348
- status: sequence.responseStatusCode,
668
+ status: matchedFake.responseStatusCode,
349
669
  });
350
670
  }
351
671
  // 3. Send a request to Apollo Server
352
- logger.debug("request to apollo-server", {
672
+ logger.debug("fakeGraphQLQuery: sending request to apollo server", {
353
673
  sequenceId,
354
674
  });
355
- const rep = await fetch(`http://${ENV_HOSTNAME}:${ports.apolloServer}/graphql`, {
356
- method: c.req.method,
357
- headers: c.req.raw.headers,
358
- body: c.req.raw.body,
359
- duplex: "half",
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("/query: response from apollo-server", {
681
+ logger.debug("fakeGraphQLQuery: apollo server response completed", {
362
682
  sequenceId,
363
- rep,
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
- if (rep.status === 101)
366
- return rep;
367
- // 4. Does the request contain a sequence id?
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
- //@ts-expect-error
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: rep.status,
394
- headers: Object.fromEntries(rep.headers),
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
- return Response.json({
402
- data: merged,
403
- }, rep);
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
- const { url } = await startStandaloneServerWithCORS(apolloServer, {
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