UNPKG

@newmo/graphql-fake-server

Version:
970 lines 38 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 } from "@newmo/graphql-fake-core"; import corsExpress from "cors"; import express from "express"; import { isInterfaceType, isListType, isNonNullType, isObjectType, isUnionType, } from "graphql/index.js"; import { buildSchema } from "graphql/utilities/index.js"; // @ts-expect-error -- no types import depthLimit from "graphql-depth-limit"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { proxy } from "hono/proxy"; import { createLogger } from "./logger.js"; // @ts-expect-error -- biome error const ENV_HOSTNAME = process.env.HOSTNAME || "0.0.0.0"; // Default localhost addresses const DEFAULT_LOCALHOST_HOSTNAMES = ["localhost", "127.0.0.1", "[::1]", "0.0.0.0"]; // Private IP ranges (RFC 1918) const PRIVATE_IP_RANGES = [ /^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 ]; /** * Generate allowed hosts based on server port and CORS origins */ export const generateAllowedHosts = ({ serverPort, allowedCORSOrigins = [], allowedHosts = "auto", }) => { if (allowedHosts !== "auto") { // Use explicitly specified hosts return new Set(allowedHosts); } // "auto" mode: generate from default localhost addresses and CORS origins const hosts = new Set(); // Add default localhost addresses with server port DEFAULT_LOCALHOST_HOSTNAMES.forEach((hostname) => { hosts.add(`${hostname}:${serverPort}`); }); // Add ENV_HOSTNAME if it's different from default if (ENV_HOSTNAME && !DEFAULT_LOCALHOST_HOSTNAMES.includes(ENV_HOSTNAME)) { hosts.add(`${ENV_HOSTNAME}:${serverPort}`); } // Extract hosts from CORS origins allowedCORSOrigins.forEach((origin) => { try { const url = new URL(origin); // Add original host:port from CORS origin hosts.add(url.host); // Also add same hostname with server port // (for cases where frontend and backend use different ports) if (url.port !== String(serverPort)) { hosts.add(`${url.hostname}:${serverPort}`); } } catch (_e) { // Invalid URL, skip } }); return hosts; }; /** * Custom startStandaloneServer with CORS configuration * This restricts CORS to only allow localhost, internal network connections, and specified allowed origins */ const startStandaloneServerWithCORS = async (server, options, allowedCORSOrigins = [], allowedHosts = "auto") => { // Create Express app with custom CORS configuration const app = express(); const httpServer = http.createServer(app); const logger = createLogger(options.logLevel || "info"); // Add drain plugin for graceful shutdown server.addPlugin(ApolloServerPluginDrainHttpServer({ httpServer })); // Ensure server is started await server.start(); // Generate allowed hosts const port = options.listen.port ?? 4000; const validHosts = generateAllowedHosts({ serverPort: port, allowedCORSOrigins: allowedCORSOrigins, allowedHosts: allowedHosts, }); // Security middleware: Host header validation and CORS // 1. Host header validation (DNS rebinding protection) app.use((req, res, next) => { const hostHeader = req.headers.host; if (!hostHeader || !validHosts.has(hostHeader)) { logger.warn(`Rejected request with invalid Host header: ${hostHeader}`); logger.debug(`Allowed hosts: ${Array.from(validHosts).join(", ")}`); res.status(400).send("Bad Request: Invalid Host header"); return; } next(); }); // 2. CORS configuration (origin validation) const corsOptions = { 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, }; // Error handling middleware app.use((err, req, res, _next) => { logger.error("[ApolloServer] Request error:", { error: err instanceof Error ? err.message : err, stack: err instanceof Error ? err.stack : undefined, method: req.method, url: req.url, headers: req.headers, }); // Check for specific network errors const errorCode = err?.code; if (errorCode === "ECONNRESET") { logger.warn("[ApolloServer] Connection reset by client"); } else if (errorCode === "EPIPE") { logger.warn("[ApolloServer] Broken pipe error"); } res.status(500).json({ error: "Internal server error" }); }); // Apply middleware stack app.use("/", corsExpress(corsOptions), express.json({ limit: "50mb" }), // @ts-expect-error -- express 5 types are not compatible with apollo-server expressMiddleware(server, options)); // Start the server await new Promise((resolve) => httpServer.listen({ port }, resolve)); return { url: `http://${ENV_HOSTNAME}:${port}`, httpServer, }; }; /** * Get the inner named type from a possibly wrapped type (NonNull, List). */ const getInnerNamedType = (type) => { if (isNonNullType(type)) { return getInnerNamedType(type.ofType); } if (isListType(type)) { return getInnerNamedType(type.ofType); } return type; }; /** * Check if a type is a list type (possibly wrapped in NonNull). */ const isListFieldType = (type) => { if (isNonNullType(type)) { return isListFieldType(type.ofType); } return isListType(type); }; // Depth value that exceeds any maxDepth config, causing factories to return scalar-only fields. const SCALAR_ONLY_DEPTH = Number.MAX_SAFE_INTEGER; // Mock resolution strategy: // // To avoid OOM from eagerly expanding deeply nested mock trees (listLength^depth), // mock generation is split into two layers: // // 1. `mocks` — Provides scalar field values. // Factory functions registered per type return only scalar fields // (depth = SCALAR_ONLY_DEPTH), so no nested objects are created upfront. // @graphql-tools/mock's MockStore references these when resolving scalar fields. // // 2. `resolvers` — Lazy generation of nested object field values at query time. // When a query traverses into a nested field, the resolver invokes // the target type's factory at that point. For list fields, it creates // `listLength` instances. Only fields actually requested by the query // are materialized. // // Fields marked with @error directive (tracked in emptyListFields) are excluded // from resolver generation so they remain as empty arrays []. const createApolloServer = async (options) => { const executableSchema = makeExecutableSchema({ typeDefs: options.schema, }); // Layer 1: type-level mocks — scalar fields only, no nested expansion const mocks = {}; for (const [typeName, factory] of Object.entries(options.mockFactories)) { mocks[typeName] = () => factory({ depth: SCALAR_ONLY_DEPTH }); } // Layer 2: field-level resolvers — lazy generation of nested object fields at query time const objectFieldResolvers = {}; const typeMap = executableSchema.getTypeMap(); for (const [typeName, graphqlType] of Object.entries(typeMap)) { // Skip introspection types (__Schema, __Type, etc.) if (!isObjectType(graphqlType) || typeName.startsWith("__")) continue; const fields = graphqlType.getFields(); const fieldResolvers = {}; const emptyFields = options.emptyListFields.get(typeName); for (const [fieldName, field] of Object.entries(fields)) { const innerType = getInnerNamedType(field.type); // Skip scalar/enum fields — their values are provided by `mocks` (layer 1) if (!isObjectType(innerType) && !isInterfaceType(innerType) && !isUnionType(innerType)) continue; const innerTypeName = innerType.name; const innerFactory = options.mockFactories[innerTypeName]; if (!innerFactory) continue; if (isListFieldType(field.type)) { // Skip list fields intentionally set to empty arrays (e.g., @error directive) if (emptyFields?.has(fieldName)) continue; fieldResolvers[fieldName] = () => { return Array.from({ length: options.listLength }, () => innerFactory({ depth: SCALAR_ONLY_DEPTH })); }; } else { // Single object field fieldResolvers[fieldName] = () => { return innerFactory({ depth: SCALAR_ONLY_DEPTH }); }; } } if (Object.keys(fieldResolvers).length > 0) { objectFieldResolvers[typeName] = fieldResolvers; } } return new ApolloServer({ schema: addMocksToSchema({ schema: executableSchema, mocks, resolvers: () => objectFieldResolvers, }), validationRules: [depthLimit(options.maxQueryDepth)], }); }; // Allowed condition types const ALLOWED_CONDITION_TYPES = ["always", "variables"]; /** * Validate condition rule structure */ const validateConditionRule = (condition) => { 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)) { return { ok: false, error: `Unknown condition type '${condition.type}'. Allowed types: ${ALLOWED_CONDITION_TYPES.join(", ")}`, }; } switch (condition.type) { case "always": // Always condition doesn't need a value return { ok: true, data: condition }; case "variables": if (!("value" in condition)) { return { ok: false, error: "Variables condition must have a 'value' field" }; } 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 }; default: return { ok: false, error: `Unsupported condition type '${condition.type}'`, }; } }; const validateSequenceRegistration = (data) => { if (typeof data !== "object" || data === null) { return { ok: false, error: "Request body must be an object" }; } // Validate request condition (default to "always" if not provided) const requestCondition = "requestCondition" in data ? data.requestCondition : { type: "always" }; if (requestCondition !== undefined) { const conditionResult = validateConditionRule(requestCondition); if (!conditionResult.ok) { return { ok: false, error: `Invalid request conditions: ${conditionResult.error}`, }; } return { ok: true, data: { ...data, requestCondition: conditionResult.data, }, }; } 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 }; } 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", }; } if (Array.isArray(data.data)) { return { ok: false, error: "Array responses are no longer supported. Use single object responses instead.", }; } return { ok: true, data: data }; } return { ok: false, error: `Unknown request type '${data.type}'. Allowed types: 'operation', 'network-error'`, }; }; class LRUMap { map = new Map(); keys = []; maxSize; constructor({ maxSize }) { this.maxSize = maxSize; } set(key, value) { 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) { 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, }) => { return `${sequenceId}.${operationName}`; }; /** * Check if the origin is a local address * @param origin */ const isLocalRequest = (origin) => { if (!origin) return false; try { const url = new URL(origin); const hostname = url.hostname; // Check if it's a default localhost address if (DEFAULT_LOCALHOST_HOSTNAMES.includes(hostname)) { return true; } // Check ENV_HOSTNAME if (hostname === ENV_HOSTNAME) { return true; } // Check if it's a private IP range return PRIVATE_IP_RANGES.some((range) => range.test(hostname)); } catch { return false; } }; const createRoutingServer = async ({ logLevel, ports, maxRegisteredSequences, allowedCORSOrigins, allowedHosts = "auto", }) => { const logger = createLogger(logLevel); const app = new Hono(); // Security configuration const validHosts = generateAllowedHosts({ serverPort: ports.fakeServer, allowedCORSOrigins: allowedCORSOrigins, allowedHosts: allowedHosts, }); // Security middleware: Host header validation (must be before CORS) app.use("*", async (c, next) => { const hostHeader = c.req.header("host"); if (!hostHeader || !validHosts.has(hostHeader)) { logger.warn(`Rejected request with invalid Host header: ${hostHeader}`); logger.debug(`Allowed hosts: ${Array.from(validHosts).join(", ")}`); return c.text("Bad Request: Invalid Host header", 400); } return await next(); }); // pass through to apollo server const passToApollo = async (c) => { 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 warning for unsuccessful responses if (proxyResponse.status >= 500) { logger.warn("[passToApollo] Server error from Apollo:", { sequenceId, operationName, status: proxyResponse.status, }); } // 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()); 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, }, 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({ maxSize: maxRegisteredSequences, }); // Manage conditional fake responses (store multiple conditional responses) const conditionalFakeResponseMap = new LRUMap({ 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({ 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) { logger.warn("[/fake] Missing sequence-id header"); return Response.json({ ok: false, errors: ["sequence-id is required"], }, { status: 400, }); } let body; try { body = await c.req.json(); } catch (error) { logger.error("[/fake] Failed to parse request body:", { sequenceId, error: error instanceof Error ? error.message : error, }); return Response.json({ ok: false, errors: ["Invalid JSON in request body"], }, { status: 400, }); } 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, }); // Determine if this has specific conditions (not just "always") const hasSpecificConditions = validationResult.data.requestCondition.type !== "always"; if (hasSpecificConditions) { const existingConditionalFakes = conditionalFakeResponseMap.get(baseKey) || []; // Overwrite if same condition exists, otherwise add new const existingIndex = existingConditionalFakes.findIndex((fake) => 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 for deterministic ordering existingConditionalFakes.sort((a, b) => { const scoreA = calculateConditionSpecificity(a.requestCondition); const scoreB = calculateConditionSpecificity(b.requestCondition); // Sort by specificity (descending) return scoreB - scoreA; }); conditionalFakeResponseMap.set(baseKey, existingConditionalFakes); logger.debug("[/fake] Registered conditional fake response:", { sequenceId, operationName, conditionType: validationResult.data.requestCondition.type, totalConditions: existingConditionalFakes.length, }); } else { // Without condition or with "always" condition, use traditional approach sequenceFakeResponseLruMap.set(baseKey, validationResult.data); logger.debug("[/fake] Registered fake response:", { sequenceId, operationName, type: validationResult.data.type, }); } 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) => { 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. Return the fake data directly */ 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, }); // Get request variables const requestVariables = typeof requestBody === "object" && requestBody !== null && "variables" in requestBody && typeof requestBody.variables === "object" && requestBody.variables !== null ? requestBody.variables : undefined; // Check conditional fakes first const conditionalFakes = conditionalFakeResponseMap.get(baseKey); logger.debug("fakeGraphQLQuery: conditional fakes check", { sequenceId, operationName: requestOperationName, conditionalFakesCount: conditionalFakes?.length || 0, conditionalFakes: conditionalFakes?.map((fake) => ({ type: fake.type, requestCondition: fake.requestCondition, })), requestVariables, }); // Find the first matching conditional fake based on variables // If no conditional fake matches, use the default fake from sequenceFakeResponseLruMap const matchedFake = findMatchedConditionalFake({ conditionalFakes: conditionalFakes, 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, }); 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, }, 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. Return the fake data directly (no need to call Apollo Server) const fakeData = matchedFake.data; logger.debug(`fakeGraphQLQuery: returning fake data sequence-id: ${sequenceId}`, { fakeData, }); // Handle single response const responseData = fakeData; 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, }, response: { status: 200, headers: { "Content-Type": "application/json" }, body: { data: responseData, }, }, }, ]); logger.debug("fakeGraphQLQuery: returning fake response"); // Let the server automatically calculate Content-Length to avoid issues with multi-byte characters const responseJson = JSON.stringify({ data: responseData }); return new Response(responseJson, { status: 200, headers: { "Content-Type": "application/json", }, }); }; // CORS configuration for GraphQL endpoints const corsOptions = { origin: (origin) => { if (isLocalRequest(origin)) { return origin; } if (origin && allowedCORSOrigins.includes(origin)) { return origin; } return null; }, }; // Apply CORS and route handlers to GraphQL endpoints app.use("/graphql", cors(corsOptions), fakeGraphQLQuery); app.use("/query", cors(corsOptions), fakeGraphQLQuery); app.all("*", (c) => passToApollo(c)); return app; }; export const createFakeServer = async (options) => { const { schemaFilePath, logLevel, server, mock } = options; const logger = createLogger(logLevel); const schema = buildSchema(await fs.readFile(schemaFilePath, "utf-8")); const mockResult = await createMock({ schema, mock, }); 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); return createFakeServerInternal({ ports: server.ports, schema, mockFactories: mockResult.factories, emptyListFields: mockResult.emptyListFields, maxQueryDepth: server.maxQueryDepth, maxRegisteredSequences: server.maxRegisteredSequences, listLength: mock.listLength, logLevel: logLevel, allowedCORSOrigins: server.allowedCORSOrigins, allowedHosts: server.allowedHosts, }); }; export const createFakeServerInternal = async (options) => { const apolloServer = await createApolloServer(options); const routingServer = await createRoutingServer({ logLevel: options.logLevel, ports: options.ports, maxRegisteredSequences: options.maxRegisteredSequences, allowedCORSOrigins: options.allowedCORSOrigins, allowedHosts: options.allowedHosts, }); let routerServer = null; return { start: async () => { // Replace startStandaloneServer with our custom implementation await startStandaloneServerWithCORS(apolloServer, { listen: { port: options.ports.apolloServer }, logLevel: options.logLevel, }, options.allowedCORSOrigins, options.allowedHosts); 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, context) => { switch (condition.type) { case "always": return true; 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) => { switch (condition.type) { case "always": return 0; // always conditions have lowest priority case "variables": return 20; // variables conditions have high priority default: return 0; } }; /** * Find a matching conditional fake based on the request variables */ const findMatchedConditionalFake = ({ conditionalFakes, requestVariables, logger, sequenceId, requestOperationName, }) => { if (conditionalFakes && conditionalFakes.length > 0) { // Find matching fake (already sorted by specificity in descending order) for (const fake of conditionalFakes) { const context = { ...(requestVariables && { variables: requestVariables }), }; if (evaluateCondition(fake.requestCondition, context)) { logger.debug("fakeGraphQLQuery: matched conditional fake", { sequenceId, operationName: requestOperationName, requestCondition: fake.requestCondition, variables: requestVariables, evaluationContext: context, }); return fake; } logger.debug("fakeGraphQLQuery: conditional fake did not match", { sequenceId, operationName: requestOperationName, requestCondition: fake.requestCondition, variables: requestVariables, evaluationContext: context, }); } } return undefined; }; //# sourceMappingURL=server.js.map