UNPKG

@newmo/graphql-fake-server

Version:

GraphQL fake server for testing

1,172 lines (1,074 loc) 40.7 kB
import fs from "node:fs/promises"; import http from "node:http"; import { isDeepStrictEqual } from "node:util"; import { ApolloServer } from "@apollo/server"; import { ApolloServerPluginDrainHttpServer } from "@apollo/server/plugin/drainHttpServer"; import { expressMiddleware } from "@as-integrations/express5"; import { addMocksToSchema } from "@graphql-tools/mock"; import { makeExecutableSchema } from "@graphql-tools/schema"; import { serve } from "@hono/node-server"; import { createMock, type MockObject } from "@newmo/graphql-fake-core"; import corsExpress from "cors"; import express from "express"; import type { GraphQLSchema } from "graphql/index.js"; import { buildSchema } from "graphql/utilities/index.js"; // @ts-expect-error -- no types import depthLimit from "graphql-depth-limit"; import { type Context, Hono } from "hono"; import { cors } from "hono/cors"; import { proxy } from "hono/proxy"; import type { RequiredFakeServerConfig } from "./config.js"; import { createLogger, type LogLevel } from "./logger.js"; // @ts-expect-error -- biome error const ENV_HOSTNAME = process.env.HOSTNAME || "0.0.0.0"; export type CreateFakeServerOptions = RequiredFakeServerConfig & { logLevel?: LogLevel; allowedCORSOrigins: string[]; }; type FakeServerInternal = { mockObject: MockObject; schema: GraphQLSchema; ports: { fakeServer: number; apolloServer: number; }; maxQueryDepth: number; maxFieldRecursionDepth: number; maxRegisteredSequences: number; logLevel: LogLevel; allowedCORSOrigins: string[]; }; /** * Custom startStandaloneServer with CORS configuration * This restricts CORS to only allow localhost, internal network connections, and specified allowed origins */ const startStandaloneServerWithCORS = async ( server: ApolloServer, options: { listen: { port: number }; }, allowedCORSOrigins: string[] = [], ) => { // Create Express app with custom CORS configuration const app = express(); const httpServer = http.createServer(app); // Add drain plugin for graceful shutdown server.addPlugin(ApolloServerPluginDrainHttpServer({ httpServer })); // Ensure server is started await server.start(); // Set up Express middleware with strict CORS that only allows localhost app.use( "/", corsExpress({ origin: (origin, callback) => { // Allow requests with no origin (like mobile apps, curl, etc) if (!origin) return callback(null, true); // Allow localhost, loopback addresses, and explicitly allowed origins if (isLocalRequest(origin)) { return callback(null, true); } // Allow explicitly allowed origins from configuration if (allowedCORSOrigins.includes(origin)) { return callback(null, true); } // Deny all other origins return callback(new Error("Not allowed by CORS"), false); }, methods: ["POST", "GET", "OPTIONS"], credentials: false, }), express.json({ limit: "50mb" }), // @ts-expect-error -- express 5 types are not compatible with apollo-server expressMiddleware(server, options), ); // Start the server const port = options.listen.port ?? 4000; await new Promise<void>((resolve) => httpServer.listen({ port }, resolve)); return { url: `http://${ENV_HOSTNAME}:${port}`, httpServer, }; }; const creteApolloServer = async (options: FakeServerInternal) => { const mocks = Object.fromEntries( Object.entries(options.mockObject).map(([key, value]) => { return [key, () => value]; }), ); return new ApolloServer({ schema: addMocksToSchema({ schema: makeExecutableSchema({ typeDefs: options.schema, }), mocks, }), validationRules: [depthLimit(options.maxQueryDepth)], }); }; // Allowed condition types const ALLOWED_CONDITION_TYPES = ["count", "variables"] as const; type AllowedConditionType = (typeof ALLOWED_CONDITION_TYPES)[number]; // Validation result type for better error messages type ValidationResult<T> = { ok: true; data: T } | { ok: false; error: string }; // Condition rules for conditional fake responses export type ConditionRule = | { type: "count"; value: number; } // Match based on call count (nth call) | { type: "variables"; value: Record<string, unknown>; }; // Match based on complete variables object // Called result structure for tracking requests/responses export type CalledResult = { requestTimestamp: number; request: { headers: Record<string, string>; body: Record<string, unknown>; }; response: { status: number; headers: Record<string, string>; body: unknown; }; }; // Response type for the /called endpoint export type CalledResultResponse = { ok: boolean; data: CalledResult[]; }; export type RegisterSequenceNetworkError = { type: "network-error"; operationName: string; responseStatusCode: number; errors: Record<string, unknown>[]; // Add request condition requestCondition?: ConditionRule; }; export type RegisterSequenceOperation = { type: "operation"; operationName: string; data: Record<string, unknown>; // Add request condition requestCondition?: ConditionRule; }; export type RegisterSequenceOptions = RegisterSequenceNetworkError | RegisterSequenceOperation; /** * Check if two condition types are conflicting and return specific error message * Only the following combinations are allowed: * - count + count * - variables + variables * - variables + no condition (undefined) * - no condition (undefined) + no condition (undefined) * All other combinations are conflicting */ const areConditionTypesConflicting = ( conditionType1: ConditionRule["type"] | undefined, conditionType2: ConditionRule["type"] | undefined, ): { isConflicting: boolean; errorMessage?: string } => { // Define allowed combinations with their descriptions const allowedCombinations = new Map<string, string>([ // Multiple count conditions for the same operation (e.g., 1st call, 2nd call) ["count,count", "Multiple count-based conditions are allowed for different call counts"], // Multiple variables conditions for the same operation (e.g., different variable sets) [ "variables,variables", "Multiple variable-based conditions are allowed for different variable sets", ], // Variables condition can coexist with default fallback ["variables,undefined", "Variable-based condition can coexist with default fallback"], // Default fallback can coexist with variables condition ["undefined,variables", "Default fallback can coexist with variable-based condition"], // Multiple default conditions - overwrite with the last one ["undefined,undefined", "Multiple default conditions are allowed (latest will be used)"], ]); const combinationKey = `${conditionType1 ?? "undefined"},${conditionType2 ?? "undefined"}`; // If the combination is allowed, return no conflict if (allowedCombinations.has(combinationKey)) { return { isConflicting: false }; } // Generate specific error message for conflicting combinations const getTypeDescription = (type: ConditionRule["type"] | undefined): string => { switch (type) { case "count": return "count-based condition (e.g., { type: 'count', value: 1 })"; case "variables": return "variables-based condition (e.g., { type: 'variables', value: {...} })"; case undefined: return "default condition (no requestCondition specified)"; default: return `unknown condition type: ${type}`; } }; const type1Desc = getTypeDescription(conditionType1); const type2Desc = getTypeDescription(conditionType2); // Specific error messages for common problematic combinations if ( (conditionType1 === "count" && conditionType2 === "variables") || (conditionType1 === "variables" && conditionType2 === "count") ) { const errorMessage = "Cannot mix count-based and variables-based conditions for the same operation. " + "Use either multiple count conditions (for different call numbers) or multiple variables conditions (for different variable sets), " + `but not both. Current conflict: ${type1Desc} vs ${type2Desc}`; return { isConflicting: true, errorMessage }; } const errorMessage = `Conflicting condition types detected: ${type1Desc} vs ${type2Desc}. ` + "Allowed combinations are: count+count, variables+variables, variables+default, or default+default."; return { isConflicting: true, errorMessage }; }; /** * Get condition type from a RegisterSequenceOptions */ const getConditionType = (fake: RegisterSequenceOptions): ConditionRule["type"] | undefined => { return fake.requestCondition?.type; }; /** * Check for condition conflicts in existing fakes for the same operation */ const checkConditionConflicts = ( newFake: RegisterSequenceOptions, existingConditionalFakes: RegisterSequenceOptions[], existingDefaultFake: RegisterSequenceOptions | undefined, ): string[] => { const errors: string[] = []; const newConditionType = getConditionType(newFake); // Check conflicts with existing conditional fakes for (const existingFake of existingConditionalFakes) { const existingConditionType = getConditionType(existingFake); const conflictResult = areConditionTypesConflicting( newConditionType, existingConditionType, ); if (conflictResult.isConflicting && conflictResult.errorMessage) { errors.push(conflictResult.errorMessage); } } // Check conflicts with existing default fake (no condition) if (existingDefaultFake) { const existingConditionType = getConditionType(existingDefaultFake); const conflictResult = areConditionTypesConflicting( newConditionType, existingConditionType, ); if (conflictResult.isConflicting && conflictResult.errorMessage) { errors.push(conflictResult.errorMessage); } } return errors; }; /** * Validate condition rule structure */ const validateConditionRule = (condition: unknown): ValidationResult<ConditionRule> => { if (typeof condition !== "object" || condition === null) { return { ok: false, error: "Condition must be an object" }; } if (!("type" in condition) || typeof condition.type !== "string") { return { ok: false, error: "Condition must have a 'type' field of type string", }; } // Check if type is in the allow list if (!ALLOWED_CONDITION_TYPES.includes(condition.type as AllowedConditionType)) { return { ok: false, error: `Unknown condition type '${ condition.type }'. Allowed types: ${ALLOWED_CONDITION_TYPES.join(", ")}`, }; } if (!("value" in condition)) { return { ok: false, error: "Condition must have a 'value' field" }; } switch (condition.type) { case "count": if (typeof condition.value !== "number") { return { ok: false, error: "Count condition value must be a number" }; } if (condition.value <= 0) { return { ok: false, error: "Count condition value must be greater than 0", }; } return { ok: true, data: condition as ConditionRule }; case "variables": if (typeof condition.value !== "object" || condition.value === null) { return { ok: false, error: "Variables condition value must be an object", }; } if (Array.isArray(condition.value)) { return { ok: false, error: "Variables condition value must be an object, not an array", }; } return { ok: true, data: condition as ConditionRule }; default: return { ok: false, error: `Unsupported condition type '${condition.type}'`, }; } }; const validateSequenceRegistration = (data: unknown): ValidationResult<RegisterSequenceOptions> => { if (typeof data !== "object" || data === null) { return { ok: false, error: "Request body must be an object" }; } // Validate request condition if ("requestCondition" in data && data.requestCondition !== undefined) { const conditionResult = validateConditionRule(data.requestCondition); if (!conditionResult.ok) { return { ok: false, error: `Invalid request condition: ${conditionResult.error}`, }; } } if (!("type" in data) || typeof data.type !== "string") { return { ok: false, error: "Request body must have a 'type' field of type string", }; } if (!("operationName" in data) || typeof data.operationName !== "string") { return { ok: false, error: "Request body must have an 'operationName' field of type string", }; } if (data.type === "network-error") { if (!("errors" in data) || !Array.isArray(data.errors)) { return { ok: false, error: "Network error type must have an 'errors' field of type array", }; } if (!("responseStatusCode" in data) || typeof data.responseStatusCode !== "number") { return { ok: false, error: "Network error type must have a 'responseStatusCode' field of type number", }; } return { ok: true, data: data as RegisterSequenceOptions }; } if (data.type === "operation") { if (!("data" in data) || typeof data.data !== "object" || data.data === null) { return { ok: false, error: "Operation type must have a 'data' field of type object", }; } return { ok: true, data: data as RegisterSequenceOptions }; } return { ok: false, error: `Unknown request type '${data.type}'. Allowed types: 'operation', 'network-error'`, }; }; class LRUMap<K, V> { private map = new Map<K, V>(); private keys: K[] = []; private maxSize: number; constructor({ maxSize }: { maxSize: number }) { this.maxSize = maxSize; } set(key: K, value: V) { this.map.set(key, value); this.keys.push(key); if (this.keys.length > this.maxSize) { const oldestKey = this.keys.shift(); if (oldestKey) { this.map.delete(oldestKey); } } } get(key: K): V | undefined { return this.map.get(key); } } // Map key is sequenceId x operationName // allow to register multiple operations with the same sequenceId at the same time // However, sequenceId x operationName must be unique // If the same sequenceId x operationName is registered, the previous one is overwritten const createMapKey = ({ sequenceId, operationName, }: { sequenceId: string; operationName: string; }) => { return `${sequenceId}.${operationName}`; }; // Private IP address ranges defined in RFC 1918 // See: https://www.rfc-editor.org/rfc/rfc1918 const privateIPRanges = [ /^192\.168\.\d{1,3}\.\d{1,3}$/, // 192.168.0.0/16 /^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, // 10.0.0.0/8 /^172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}$/, // 172.16.0.0/12 ]; /** * Check if the origin is a local address * @param origin */ const isLocalRequest = (origin: string | null): boolean => { if (!origin) return false; try { const url = new URL(origin); const hostname = url.hostname; // localhost and 127.0.0.1 are standard local addresses if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === ENV_HOSTNAME) { return true; } return privateIPRanges.some((range) => range.test(hostname)); } catch { return false; } }; const createRoutingServer = async ({ logLevel, ports, maxRegisteredSequences, allowedCORSOrigins, }: { logLevel: LogLevel; maxRegisteredSequences: number; ports: { fakeServer: number; apolloServer: number; }; allowedCORSOrigins: string[]; }) => { const logger = createLogger(logLevel); const app = new Hono(); // pass through to apollo server const passToApollo = async (c: Context) => { logger.debug("passToApollo: starting"); // remove prefix // prefix = /app1/*, path = /app1/a/b // => suffix_path = /a/b // let path = new URL(c.req.raw.url).pathname let path = c.req.path; logger.debug("passToApollo: got path", { path, routePath: c.req.routePath, }); path = path.replace(new RegExp(`^${c.req.routePath.replace("*", "")}`), "/"); let url = `http://${ENV_HOSTNAME}:${ports.apolloServer}${path}`; // add params to URL if (c.req.query()) url = `${url}?${new URLSearchParams(c.req.query())}`; logger.debug("passToApollo: built URL", { url }); const sequenceId = c.req.header("sequence-id"); logger.debug("passToApollo: getting request body", { sequenceId }); const requestBody = await c.req.raw.clone().json(); logger.debug("passToApollo: got request body", { requestBody }); const operationName = typeof requestBody === "object" && requestBody !== null && "operationName" in requestBody ? requestBody.operationName : undefined; // request logger.debug("passToApollo: calling proxy", { url, sequenceId, operationName, headers: c.req.header(), }); const proxyResponse = await proxy(url, { raw: c.req.raw, headers: { ...c.req.header(), }, }); logger.debug("passToApollo: proxy call completed", { sequenceId, operationName, status: proxyResponse.status, headers: Object.fromEntries(proxyResponse.headers), }); // log response with pipe if (proxyResponse.status === 101) return proxyResponse; // save request and response for /called api if (sequenceId && typeof operationName === "string") { logger.debug("passToApollo: getting response body for caching"); const responseBody = (await proxyResponse.clone().json()) as Record<string, unknown>; logger.debug("passToApollo: parsed response body", { responseBody, }); const cacheKey = createMapKey({ sequenceId, operationName, }); logger.debug("save called result", { sequenceId, operationName, cacheKey, }); sequenceCalledResultLruMap.set(cacheKey, [ ...(sequenceCalledResultLruMap.get(cacheKey) ?? []), { requestTimestamp: Date.now(), request: { headers: Object.fromEntries(c.req.raw.headers), body: requestBody as Record<string, unknown>, }, response: { status: proxyResponse.status, headers: Object.fromEntries(proxyResponse.headers), body: responseBody, }, }, ]); } logger.debug("passToApollo: returning proxy response", { sequenceId, operationName, status: proxyResponse.status, }); return proxyResponse; }; // sequenceId x operationName -> FakeResponse const sequenceFakeResponseLruMap = new LRUMap<string, RegisterSequenceOptions>({ maxSize: maxRegisteredSequences, }); // Manage conditional fake responses (store multiple conditional responses) const conditionalFakeResponseMap = new LRUMap<string, RegisterSequenceOptions[]>({ maxSize: maxRegisteredSequences, }); // Track call count const callCountMap = new LRUMap<string, number>({ maxSize: maxRegisteredSequences, }); // sequenceId x operationName -> Called Result // CalledResult is first request is index 0, second request is index 1 and so on const sequenceCalledResultLruMap = new LRUMap<string, CalledResult[]>({ maxSize: maxRegisteredSequences, }); // /fake api does not support CORS // because it allows any user to modify the response // If you need to support CORS, implement with checking the origin or something app.post("/fake", async (c) => { logger.debug("/fake"); const sequenceId = c.req.header("sequence-id"); if (!sequenceId) { return Response.json( { ok: false, errors: ["sequence-id is required"], }, { status: 400, }, ); } const body = await c.req.json(); logger.debug("/fake: got fake body", { sequenceId, body, }); const validationResult = validateSequenceRegistration(body); if (!validationResult.ok) { return Response.json( { ok: false, errors: [validationResult.error] }, { status: 400, }, ); } const operationName = validationResult.data.operationName; logger.debug("/fake got body type", { sequenceId, type: validationResult.data.type, requestCondition: validationResult.data.requestCondition, }); const baseKey = createMapKey({ sequenceId, operationName, }); // Check for condition conflicts before registration const existingConditionalFakes = conditionalFakeResponseMap.get(baseKey) || []; const existingDefaultFake = sequenceFakeResponseLruMap.get(baseKey); const conflictErrors = checkConditionConflicts( validationResult.data, existingConditionalFakes, existingDefaultFake, ); if (conflictErrors.length > 0) { return Response.json( { ok: false, errors: conflictErrors }, { status: 400, }, ); } // Register as conditional fake if request condition exists if (validationResult.data.requestCondition) { const existingConditionalFakes = conditionalFakeResponseMap.get(baseKey) || []; // Overwrite if same condition exists, otherwise add new const existingIndex = existingConditionalFakes.findIndex( (fake) => fake.requestCondition && JSON.stringify(fake.requestCondition) === JSON.stringify(validationResult.data.requestCondition), ); if (existingIndex >= 0) { existingConditionalFakes[existingIndex] = validationResult.data; } else { existingConditionalFakes.push(validationResult.data); } // Sort by condition specificity (evaluate more specific conditions first) existingConditionalFakes.sort((a, b) => { const scoreA = a.requestCondition ? calculateConditionSpecificity(a.requestCondition) : 0; const scoreB = b.requestCondition ? calculateConditionSpecificity(b.requestCondition) : 0; return scoreB - scoreA; // Descending order }); conditionalFakeResponseMap.set(baseKey, existingConditionalFakes); } else { // Without condition, use traditional approach sequenceFakeResponseLruMap.set(baseKey, validationResult.data); } return Response.json( { ok: true }, { status: 200, }, ); }); app.use("/fake/called", async (c) => { // Return CalledResult matching sequenceId x operationName const sequenceId = c.req.header("sequence-id"); if (!sequenceId) { return Response.json( { ok: false, errors: ["sequence-id is required"], }, { status: 400, }, ); } // Get operationName from req.body const body = await c.req.json(); const operationName = body.operationName; if (!operationName) { return Response.json( { ok: false, errors: ["operationName is required"], }, { status: 400, }, ); } const key = createMapKey({ sequenceId, operationName, }); // if not found, return empty array const result = sequenceCalledResultLruMap.get(key); if (!result) { return Response.json( { ok: true, data: [] }, { status: 200, }, ); } return Response.json( { ok: true, data: result }, { status: 200, }, ); }); const fakeGraphQLQuery = async (c: Context) => { logger.debug("fakeGraphQLQuery: starting"); const _requestTimestamp = Date.now(); /** * Steps: * 1. Receive a request for a GraphQL query * 2. Does it contain a sequence id? * - if Yes: type is network error → return an error * - if No: Pass through to Apollo Server -> exit * 3. Send a request to Apollo Server * 4. Merge the registration data with the response from 3 * 5. Return the merged data */ const sequenceId = c.req.header("sequence-id"); logger.debug("fakeGraphQLQuery: getting request body", { sequenceId }); const requestBody = await c.req.raw.clone().json(); logger.debug("fakeGraphQLQuery: got request body", { requestBody, }); const requestOperationName = typeof requestBody === "object" && requestBody !== null && "operationName" in requestBody && requestBody.operationName && typeof requestBody.operationName === "string" ? requestBody.operationName : undefined; logger.debug( `fakeGraphQLQuery: operationName: ${requestOperationName} sequenceId: ${sequenceId}`, { sequenceId, }, ); // 2. Does it contain a sequence id? if (!sequenceId) { logger.debug("fakeGraphQLQuery: no sequenceId, passing to Apollo"); return passToApollo(c); } if (!requestOperationName) { logger.debug("fakeGraphQLQuery: no operationName, passing to Apollo"); return passToApollo(c); } const baseKey = createMapKey({ sequenceId, operationName: requestOperationName, }); // Increment call count const currentCallCount = (callCountMap.get(baseKey) || 0) + 1; callCountMap.set(baseKey, currentCallCount); // Get request variables const requestVariables = typeof requestBody === "object" && requestBody !== null && "variables" in requestBody && typeof requestBody.variables === "object" && requestBody.variables !== null ? (requestBody.variables as Record<string, unknown>) : undefined; // Check conditional fakes first const conditionalFakes = conditionalFakeResponseMap.get(baseKey); // Find the first matching conditional fake based on call count and variables // If no conditional fake matches, use the default fake from sequenceFakeResponseLruMap const matchedFake: RegisterSequenceOptions | undefined = findMatchedConditionalFake({ conditionalFakes: conditionalFakes, currentCallCount: currentCallCount, requestVariables: requestVariables, logger: logger, sequenceId: sequenceId, requestOperationName: requestOperationName, }) ?? sequenceFakeResponseLruMap.get(baseKey); logger.debug( `fakeGraphQLQuery: sequence-id: ${sequenceId} x operationName: ${requestOperationName}, fake exists: ${Boolean( matchedFake, )}`, { matchedFake, sequenceId, operationName: requestOperationName, callCount: currentCallCount, }, ); if (!matchedFake) { logger.debug("fakeGraphQLQuery: no fake found, passing to Apollo"); return passToApollo(c); } if (requestOperationName !== matchedFake.operationName) { logger.debug("fakeGraphQLQuery: operationName mismatch, returning error"); return Response.json( { errors: [ `operationName does not match. operationName: ${requestOperationName} sequenceId: ${sequenceId}`, ], }, { status: 400, }, ); } if (matchedFake.type === "network-error") { logger.debug("fakeGraphQLQuery: network-error type, returning error"); // Record call history for error responses as well const cacheKey = createMapKey({ sequenceId, operationName: requestOperationName, }); sequenceCalledResultLruMap.set(cacheKey, [ ...(sequenceCalledResultLruMap.get(cacheKey) ?? []), { requestTimestamp: Date.now(), request: { headers: Object.fromEntries(c.req.raw.headers), body: requestBody as Record<string, unknown>, }, response: { status: matchedFake.responseStatusCode, headers: { "Content-Type": "application/json" }, body: { errors: matchedFake.errors, }, }, }, ]); return new Response( JSON.stringify({ errors: matchedFake.errors, }), { status: matchedFake.responseStatusCode, }, ); } // 3. Send a request to Apollo Server logger.debug("fakeGraphQLQuery: sending request to apollo server", { sequenceId, }); const proxyResponse = await proxy(`http://${ENV_HOSTNAME}:${ports.apolloServer}/graphql`, { raw: c.req.raw, headers: { ...c.req.header(), }, }); logger.debug("fakeGraphQLQuery: apollo server response completed", { sequenceId, status: proxyResponse.status, headers: Object.fromEntries(proxyResponse.headers), }); if (proxyResponse.status === 101) return proxyResponse; // 4. Get response body logger.debug("fakeGraphQLQuery: getting response body"); const responseBody = (await proxyResponse.json()) as Record<string, unknown>; logger.debug("fakeGraphQLQuery: parsed response body", { responseBody, }); // 5. Merge the registration data with the response const data = matchedFake.data; logger.debug(`fakeGraphQLQuery: starting data merge sequence-id: ${sequenceId}`, { data, responseBody, }); // Use bracket notation for properties from index signature const responseData = responseBody["data"] as unknown; const merged = { ...(typeof responseData === "object" && responseData !== null ? responseData : {}), ...data, }; const cacheKey = createMapKey({ sequenceId, operationName: requestOperationName, }); sequenceCalledResultLruMap.set(cacheKey, [ ...(sequenceCalledResultLruMap.get(cacheKey) ?? []), { requestTimestamp: Date.now(), request: { headers: Object.fromEntries(c.req.raw.headers), body: requestBody as Record<string, unknown>, }, response: { status: proxyResponse.status, headers: Object.fromEntries(proxyResponse.headers), body: { data: merged, }, }, }, ]); logger.debug("fakeGraphQLQuery: merge completed, returning response"); // Let the server automatically calculate Content-Length to avoid issues with multi-byte characters const responseJson = JSON.stringify({ data: merged }); return new Response(responseJson, { status: proxyResponse.status, headers: { "Content-Type": "application/json", }, }); }; // graphql api is for browser and need to support CORS app.use( "/graphql", cors({ origin: (origin) => { if (isLocalRequest(origin)) { return origin; } if (origin && allowedCORSOrigins.includes(origin)) { return origin; } return null; }, }), ); app.use( "/query", cors({ origin: (origin) => { if (isLocalRequest(origin)) { return origin; } if (origin && allowedCORSOrigins.includes(origin)) { return origin; } return null; }, }), ); app.use("/graphql", fakeGraphQLQuery); app.use("/query", fakeGraphQLQuery); app.all("*", passToApollo); return app; }; export const createFakeServer = async (options: CreateFakeServerOptions) => { const { logLevel, maxFieldRecursionDepth, maxQueryDepth, maxRegisteredSequences, ports, schemaFilePath, defaultValues, allowedCORSOrigins, } = options; const logger = createLogger(logLevel); const schema = buildSchema(await fs.readFile(schemaFilePath, "utf-8")); const mockResult = await createMock({ schema, maxFieldRecursionDepth, defaultValues, }); if (!mockResult.ok) { logger.error("Failed to create mock data", mockResult); throw new Error("Failed to create mock data", { cause: mockResult.error, }); } logger.debug("created mock code", mockResult.code); logger.debug("created mock data", mockResult.mock); return createFakeServerInternal({ ports, schema, mockObject: mockResult.mock, maxQueryDepth, maxFieldRecursionDepth, maxRegisteredSequences, logLevel: logLevel ?? "info", allowedCORSOrigins, }); }; export const createFakeServerInternal = async (options: FakeServerInternal) => { const apolloServer = await creteApolloServer(options); const routingServer = await createRoutingServer({ logLevel: options.logLevel, ports: options.ports, maxRegisteredSequences: options.maxRegisteredSequences, allowedCORSOrigins: options.allowedCORSOrigins, }); let routerServer: ReturnType<typeof serve> | null = null; return { start: async () => { // Replace startStandaloneServer with our custom implementation await startStandaloneServerWithCORS( apolloServer, { listen: { port: options.ports.apolloServer }, }, options.allowedCORSOrigins, ); routerServer = serve({ fetch: routingServer.fetch, port: options.ports.fakeServer, }); return { urls: { fakeServer: `http://${ENV_HOSTNAME}:${options.ports.fakeServer}`, apolloServer: `http://${ENV_HOSTNAME}:${options.ports.apolloServer}`, }, }; }, stop: () => { apolloServer.stop(); routerServer?.close(); }, }; }; /** * Check if condition rule matches the current request context */ const evaluateCondition = ( condition: ConditionRule, context: { callCount: number; variables?: Record<string, unknown>; }, ): boolean => { switch (condition.type) { case "count": return context.callCount === condition.value; case "variables": if (!context.variables) return false; return isDeepStrictEqual(context.variables, condition.value); default: return false; } }; /** * Calculate condition specificity score (used for matching priority) */ const calculateConditionSpecificity = (condition: ConditionRule): number => { switch (condition.type) { case "count": return 10; // count conditions have medium priority case "variables": return 20; // variables conditions have high priority default: return 0; } }; /** * Find a matching conditional fake based on the current call count and request variables */ const findMatchedConditionalFake = ({ conditionalFakes, currentCallCount, requestVariables, logger, sequenceId, requestOperationName, }: { conditionalFakes: RegisterSequenceOptions[] | undefined; currentCallCount: number; requestVariables: Record<string, unknown> | undefined; logger: ReturnType<typeof createLogger>; sequenceId: string; requestOperationName: string; }): RegisterSequenceOptions | undefined => { if (conditionalFakes && conditionalFakes.length > 0) { // Find matching fake (already sorted by specificity in descending order) for (const fake of conditionalFakes) { if (fake.requestCondition) { const context = { callCount: currentCallCount, ...(requestVariables && { variables: requestVariables }), }; if (evaluateCondition(fake.requestCondition, context)) { logger.debug("fakeGraphQLQuery: matched conditional fake", { sequenceId, operationName: requestOperationName, requestCondition: fake.requestCondition, callCount: currentCallCount, variables: requestVariables, }); return fake; } } } } return undefined; };