@newmo/graphql-fake-server
Version:
GraphQL fake server for testing
970 lines • 38 kB
JavaScript
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