@newmo/graphql-fake-server
Version:
GraphQL fake server for testing
348 lines (334 loc) • 11.4 kB
text/typescript
import path from "node:path";
import { pathToFileURL } from "node:url";
import type { MockConfig, RawMockConfig } from "@newmo/graphql-fake-core";
import type { LogLevel } from "./logger.js";
/**
* Server configuration options (user input - all fields optional).
* Controls ports, request limits, and security settings.
*/
export type ServerConfig = {
/**
* The ports for the fake server and Apollo Server.
*/
ports?:
| {
/**
* Fake Server port.
* Default is 4000.
*/
fakeServer?: number | undefined;
/**
* Apollo Server port.
* It provides the GraphQL Playground.
* Default is 4002.
*/
apolloServer?: number | undefined;
}
| undefined;
/**
* The maximum number of registered sequences.
* Default is 1000.
*/
maxRegisteredSequences?: number | undefined;
/**
* The maximum number of depth of complexity of query
* Default is 10
*/
maxQueryDepth?: number | undefined;
/**
* Additional origins to allow for CORS requests.
* By default, only localhost and private IP ranges are allowed.
* This option allows you to specify additional origins to accept.
*/
allowedCORSOrigins?: string[] | undefined;
/**
* Allowed Host headers for the fake server to prevent DNS rebinding attacks.
* - "auto" (default): Automatically generates allowed hosts from CORS origins and localhost addresses
* - string[]: Explicit list of allowed Host headers (e.g., ["localhost:4000", "myapp.local:4000"])
* @default "auto"
*/
allowedHosts?: string[] | "auto" | undefined;
};
/**
* Configuration for the fake server (user input - most fields optional).
*
* @example
* ```js
* export default {
* schemaFilePath: "./api.graphqls",
* logLevel: "debug",
* server: {
* ports: { fakeServer: 4000, apolloServer: 4002 },
* maxQueryDepth: 10,
* },
* mock: {
* maxDepth: 9,
* maxTypeRecursion: 2,
* listLength: 3,
* },
* };
* ```
*/
export type FakeServerConfig = {
/**
* The path to the GraphQL schema file from cwd.
* @required
*/
schemaFilePath: string;
/**
* Log level for the server.
* @default "info"
*/
logLevel?: LogLevel | undefined;
/**
* Server configuration options (ports, limits, security).
* @see ServerConfig
*/
server?: ServerConfig | undefined;
/**
* Mock data generation options (depth limits, default values).
* @see RawMockConfig from @newmo/graphql-fake-core
*/
mock?: RawMockConfig | undefined;
};
/**
* Server configuration (normalized - all fields required).
* @internal
*/
export type RequiredServerConfig = {
ports: {
fakeServer: number;
apolloServer: number;
};
maxRegisteredSequences: number;
maxQueryDepth: number;
allowedCORSOrigins: string[];
allowedHosts: string[] | "auto";
};
/**
* Mock configuration (normalized - all fields required).
* @internal
*/
export type RequiredMockConfig = MockConfig;
/**
* Fake server configuration (normalized - all fields required).
* This is the internal config type with defaults applied.
*
* @see FakeServerConfig for user-facing config with optional fields
* @internal
*/
export type RequiredFakeServerConfig = {
schemaFilePath: string;
logLevel: LogLevel;
server: RequiredServerConfig;
mock: RequiredMockConfig;
};
/**
* Default values for server configuration.
* @internal
*/
const ServerDefaults = {
ports: {
fakeServer: 4000,
apolloServer: 4002,
},
maxRegisteredSequences: 1000,
maxQueryDepth: 10,
allowedCORSOrigins: [] as string[],
allowedHosts: "auto" as const,
} as const;
/**
* Default log level.
* @internal
*/
const LogLevelDefault = "info" as LogLevel;
/**
* Default values for mock generation options.
* @see RawMockConfig
* @internal
*/
const MockDefaultValues = {
maxDepth: 9,
maxTypeRecursion: 2,
listLength: 3,
} as const;
/**
* Default values for GraphQL scalar types.
* @internal
*/
const ScalarDefaults = {
String: "string",
Int: 12,
Float: 12.3,
Boolean: true,
ID: "xxxx-xxxx-xxxx-xxxx",
} as const;
export const normalizeFakeServerConfig = (config: FakeServerConfig): RequiredFakeServerConfig => {
return {
schemaFilePath: config.schemaFilePath,
logLevel: config.logLevel ?? LogLevelDefault,
server: {
ports: {
fakeServer: config.server?.ports?.fakeServer ?? ServerDefaults.ports.fakeServer,
apolloServer:
config.server?.ports?.apolloServer ?? ServerDefaults.ports.apolloServer,
},
maxRegisteredSequences:
config.server?.maxRegisteredSequences ?? ServerDefaults.maxRegisteredSequences,
maxQueryDepth: config.server?.maxQueryDepth ?? ServerDefaults.maxQueryDepth,
allowedCORSOrigins:
config.server?.allowedCORSOrigins ?? ServerDefaults.allowedCORSOrigins,
allowedHosts: config.server?.allowedHosts ?? ServerDefaults.allowedHosts,
},
mock: {
maxDepth: config.mock?.maxDepth ?? MockDefaultValues.maxDepth,
maxTypeRecursion: config.mock?.maxTypeRecursion ?? MockDefaultValues.maxTypeRecursion,
listLength: config.mock?.listLength ?? MockDefaultValues.listLength,
defaultValues: {
String: config.mock?.defaultValues?.String ?? ScalarDefaults.String,
Int: config.mock?.defaultValues?.Int ?? ScalarDefaults.Int,
Float: config.mock?.defaultValues?.Float ?? ScalarDefaults.Float,
Boolean: config.mock?.defaultValues?.Boolean ?? ScalarDefaults.Boolean,
ID: config.mock?.defaultValues?.ID ?? ScalarDefaults.ID,
CustomScalar: config.mock?.defaultValues?.CustomScalar ?? {},
},
},
};
};
export const validateFakeServerConfig = (config: FakeServerConfig): FakeServerConfig => {
if (!config.schemaFilePath) {
throw new Error("The schemaFilePath is required.");
}
if (typeof config.schemaFilePath !== "string") {
throw new Error("The schemaPath must be a string.");
}
// logLevel validation
if (config.logLevel && !["debug", "info", "warn", "error"].includes(config.logLevel)) {
throw new Error("The logLevel must be one of 'debug', 'info', 'warn', 'error'.");
}
// Server validation
if (config.server) {
if (typeof config.server !== "object") {
throw new Error("The server must be an object.");
}
if (config.server.ports) {
if (typeof config.server.ports !== "object") {
throw new Error("The server.ports must be an object.");
}
if (
config.server.ports.fakeServer &&
typeof config.server.ports.fakeServer !== "number"
) {
throw new Error("The server.ports.fakeServer must be a number.");
}
if (
config.server.ports.apolloServer &&
typeof config.server.ports.apolloServer !== "number"
) {
throw new Error("The server.ports.apolloServer must be a number.");
}
}
if (
config.server.maxRegisteredSequences &&
typeof config.server.maxRegisteredSequences !== "number"
) {
throw new Error("The server.maxRegisteredSequences must be a number.");
}
if (config.server.maxQueryDepth && typeof config.server.maxQueryDepth !== "number") {
throw new Error("The server.maxQueryDepth must be a number.");
}
if (config.server.allowedCORSOrigins) {
if (!Array.isArray(config.server.allowedCORSOrigins)) {
throw new Error("The server.allowedCORSOrigins must be an array.");
}
for (const origin of config.server.allowedCORSOrigins) {
if (typeof origin !== "string") {
throw new Error("Each server.allowedCORSOrigin must be a string.");
}
}
}
if (config.server.allowedHosts) {
if (
config.server.allowedHosts !== "auto" &&
!Array.isArray(config.server.allowedHosts)
) {
throw new Error("The server.allowedHosts must be 'auto' or an array of strings.");
}
if (Array.isArray(config.server.allowedHosts)) {
for (const host of config.server.allowedHosts) {
if (typeof host !== "string") {
throw new Error("Each server.allowedHost must be a string.");
}
}
}
}
}
// Mock validation
if (config.mock) {
if (typeof config.mock !== "object") {
throw new Error("The mock must be an object.");
}
if (config.mock.maxDepth !== undefined) {
if (typeof config.mock.maxDepth !== "number") {
throw new Error("The mock.maxDepth must be a number.");
}
if (config.mock.maxDepth < 1) {
throw new Error("The mock.maxDepth must be at least 1.");
}
}
if (config.mock.maxTypeRecursion !== undefined) {
if (typeof config.mock.maxTypeRecursion !== "number") {
throw new Error("The mock.maxTypeRecursion must be a number.");
}
if (config.mock.maxTypeRecursion < 1) {
throw new Error("The mock.maxTypeRecursion must be at least 1.");
}
}
if (config.mock.listLength !== undefined && typeof config.mock.listLength !== "number") {
throw new Error("The mock.listLength must be a number.");
}
if (config.mock.defaultValues) {
if (typeof config.mock.defaultValues !== "object") {
throw new Error("The mock.defaultValues must be an object.");
}
}
}
return config;
};
/**
* Load the fake server configuration from the file.
* @param configPath
*/
export const loadConfig = async (
cwd: string,
configPath: string,
): Promise<RequiredFakeServerConfig> => {
const fileUrl = pathToFileURL(path.resolve(cwd, configPath)).href;
const { default: config } = await import(fileUrl);
const normalizedConfig = normalizeFakeServerConfig(config);
validateFakeServerConfig(normalizedConfig);
return normalizedConfig;
};
/**
* Load the fake server configuration from the CLI flags.
* @param cliFlag
*/
export const loadFakeServerConfigFromCLI = ({
schemaFilePath,
logLevel,
}: {
schemaFilePath?: string | undefined;
logLevel?: LogLevel | undefined;
}): RequiredFakeServerConfig => {
if (!schemaFilePath) {
throw new Error(
"The --schema is required. or pass --config ./fake-server.config.js to load the config file.",
);
}
const normalizedConfig = normalizeFakeServerConfig({
schemaFilePath,
logLevel,
});
validateFakeServerConfig(normalizedConfig);
return normalizedConfig;
};