openapi-mock-gen
Version:
Generate mock definitions with random fake data based on OpenAPI spec.
1,375 lines (1,348 loc) • 68.4 kB
JavaScript
#!/usr/bin/env node
import cac from 'cac';
import pc from 'picocolors';
import fs from 'fs';
import path from 'path';
import { createJiti } from 'jiti';
import vm from 'vm';
import { faker } from '@faker-js/faker';
import Enquirer from 'enquirer';
import ora from 'ora';
import prettier from 'prettier';
import SwaggerParser from '@apidevtools/swagger-parser';
import merge from 'lodash.merge';
import { OpenAPIV3 } from 'openapi-types';
const EXPORT_LANGUAGE = {
TS: 'typescript',
JS: 'javascript'
};
const DEFAULT_CONFIG = {
outputDir: './.mocks',
baseUrl: 'http://localhost:3000',
arrayLength: 5,
language: EXPORT_LANGUAGE.JS,
useExample: true,
dynamic: true
};
const TS_EXTENSION = '.ts';
const JS_EXTENSION = '.js';
const CONFIG_FILE_NAME = 'mock.config';
const MANIFEST_FILE_NAME = 'manifest.json';
const DEFAULT_MIN_NUMBER = 0;
const DEFAULT_MAX_NUMBER = 9999999;
const DEFAULT_MULTIPLE_OF = 1;
const DEFAULT_SEED = 123;
const DEFAULT_API_DIR_NAME = 'api';
const OPENAPI_TYPES_FILE_NAME = 'openapi-types.d.ts';
const DEFAULT_TYPES_FILE_NAME = 'types';
const ADAPTERS = {
MSW: 'msw'
};
// Raw template strings
const GENERATED_COMMENT_FLAG = '@generated by openapi-mock-gen';
const GENERATED_COMMENT = `/**
* ${GENERATED_COMMENT_FLAG}
* If you want to make changes, please remove this entire comment and edit the file directly.
*/`;
const DISABLE_LINTING = '/* eslint-disable */';
const DISABLE_TS_CHECK = '/* tslint:disable */\n// @ts-nocheck';
const DISABLE_ALL_CHECK = `
${DISABLE_LINTING}
${DISABLE_TS_CHECK}`;
const HTTP_METHODS_TYPE = `
type HttpMethods = 'get' | 'post' | 'put' | 'delete' | 'patch' | 'options' | 'head';
`;
const FAKER_MAP_TYPE = `
type FakerMap = Record<string, string | (() => unknown)>;
`;
const ENDPOINT_CONFIG_TYPE = `
interface EndpointConfig {
arrayLength: number;
useExample: boolean;
dynamic: boolean;
fakerMap?: FakerMap;
}`;
const CONFIG_TYPE = `
interface Config extends EndpointConfig {
specPath: string;
outputDir: string;
baseUrl: string;
language: 'typescript' | 'javascript';
endpoints?: Record<string, { [key in HttpMethods]?: Partial<EndpointConfig> }>;
}`;
const FAKER_SEED = 'faker.seed({seed});';
const ESM_IMPORT = "import {module} from '{modulePath}'";
const CJS_IMPORT = "const {module} = require('{modulePath}')";
const ESM_EXPORT = 'export default {exportName}';
const CJS_EXPORT = 'module.exports = {exportName}';
const ESM_EXPORT_NAMED = 'export { {exportName} }';
const CJS_EXPORT_NAMED = 'module.exports = { {exportName} }';
const CONFIG_BODY = `
const config{configType} = {
specPath: '{specPath}',
outputDir: '{outputDir}',
baseUrl: '{baseUrl}',
language: '{language}',
// Global settings
arrayLength: {arrayLength},
useExample: {useExample},
dynamic: {dynamic},
// Use this to customize how specific fields are generated.
// The key can be a string or regex. The value can be a fixed value, a faker expression string, or a function.
// Functions are executed in a sandboxed Node.js environment, so you can access native APIs like Math, Date, etc, plus the faker instance.
// Other dependencies such as self-defined/third-party modules, etc, are not allowed.
// fakerMap: {
// // Eg. by key, using a faker expression string
// id: 'faker.string.uuid()',
// // Eg. by key, using a function with faker (requires importing faker in your config)
// name: () => faker.person.fullName(),
// // Eg. by regex, using a fixed value
// '(?:^|_)image|img|picture|photo(?:_|$)': 'https://picsum.photos/200',
// // Eg. using a custom function with native APIs
// user: () => (Math.random() > 0.5 ? 'John Doe' : 'Jane Doe'),
// },
// If you wish to customize the config for a specific endpoint, you can do so here.
// The config for a specific endpoint will override the global config.
// endpoints: {
// '/users': {
// 'get': {
// arrayLength: 10,
// useExample: false,
// fakerMap: {
// // This will override the global fakerMap for this endpoint
// name: () => 'John Doe',
// },
// }
// }
// }
};`;
const MOCK_DATA_BODY = `
const mockData = (){mockDataType} => ({
{mockData}
});`;
const MANIFEST_BODY = `
{
"manifest": {manifestData}
}`;
// @ts-expect-error Enquirer does have a MultiSelect type, but it's not properly exported.
const { MultiSelect, Input, prompt } = Enquirer;
const ALL = 'All';
async function selectApiGroups(groupedEndpoints) {
const allChoices = Object.keys(groupedEndpoints);
const multiSelect = new MultiSelect({
name: 'apiGroups',
message: 'Select the API groups you want to mock (press space to select, enter to confirm)',
choices: [
ALL,
...allChoices
],
validate (value) {
if (value.length === 0) return 'Please select at least one API group.';
return true;
}
});
return multiSelect.run();
}
async function selectIndividualEndpoints(choices) {
const multiSelect = new MultiSelect({
name: 'apiEndpoints',
message: 'Select the API endpoints you want to mock (press space to select, enter to confirm)',
initial: [
ALL
],
choices: [
ALL,
...new Set(choices)
],
validate (value) {
if (value.length === 0) return 'Please select at least one API endpoint.';
return true;
}
});
return multiSelect.run();
}
async function promptForEndpoints(groupedEndpoints) {
const groupNames = Object.keys(groupedEndpoints);
let selectedGroups = await selectApiGroups(groupedEndpoints);
if (selectedGroups.includes(ALL) && selectedGroups.length === 1) {
selectedGroups = groupNames;
}
const availableEndpoints = selectedGroups.flatMap((group)=>groupedEndpoints[group] || []);
const availableEndpointPaths = availableEndpoints.map((endpoint)=>`${endpoint.method.toUpperCase()} ${endpoint.path}`);
const selectedEndpointPaths = await selectIndividualEndpoints(availableEndpointPaths);
if (selectedEndpointPaths.includes(ALL) && selectedEndpointPaths.length === 1) {
return availableEndpoints;
}
return availableEndpoints.filter((endpoint)=>selectedEndpointPaths.includes(`${endpoint.method.toUpperCase()} ${endpoint.path}`));
}
async function inputSpecPath() {
const input = new Input({
name: 'specPath',
message: 'Please enter the URL or local path to your OpenAPI specification:',
validate (value) {
if (!value) return 'The spec path cannot be empty.';
return true;
}
});
return input.run();
}
async function promptForBaseUrl() {
const input = new Input({
name: 'baseUrl',
message: 'Please enter the base URL for your API:',
initial: 'http://localhost:3000'
});
return input.run();
}
async function promptForGlobalConfig() {
const promtSequence = [
{
type: 'confirm',
name: 'useTypeScript',
message: 'Do you want to generate files in TypeScript?',
initial: true
},
{
type: 'input',
name: 'arrayLength',
message: 'Default array length for mock data:',
initial: '5',
validate (value) {
if (!value) return 'The array length cannot be empty.';
if (Number.isNaN(Number(value))) return 'The array length must be a number.';
return true;
}
},
{
type: 'confirm',
name: 'useExample',
message: 'Use "example" if provided in spec?',
initial: 'true'
},
{
type: 'confirm',
name: 'dynamic',
message: 'Generate dynamic data for each request?',
initial: 'true'
}
];
const { useTypeScript, ...rest } = await prompt(promtSequence);
return {
language: useTypeScript ? EXPORT_LANGUAGE.TS : EXPORT_LANGUAGE.JS,
...rest
};
}
let spinner;
const getSpinner = (text, color)=>{
if (!spinner) {
spinner = ora();
}
if (text) {
spinner.text = text;
}
if (color) {
spinner.color = color;
}
return spinner;
};
const slashToKebabCase = (str)=>str.split('/').filter(Boolean).join('-');
const toCamelCase = (str)=>str.toLowerCase().replace(/[^a-zA-Z0-9]+(.)?/g, (_, chr)=>chr ? chr.toUpperCase() : '');
const replacePathParamsWithBy = (path)=>path.replace(/{(\w+)}/g, 'By-$1');
const getExpressLikePath = (path)=>path.replace(/{(\w+)}/g, ':$1');
const interpolateString = (str, data)=>str.replace(/{(\w+)}/g, (_, key)=>String(data[key] ?? ''));
const executeCode = (code, sandbox = {
faker
})=>{
try {
const script = new vm.Script(`(${code})`);
return script.runInNewContext(sandbox);
} catch (error) {
console.error(error);
console.error('An error occurred while generating the static mock data, please note that external dependencies are not supported except for faker.');
console.error('Falling back to the dynamic mock data generation instead.');
return code;
}
};
async function getIsESM() {
try {
const packageJsonPath = path.join(process.cwd(), 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const isTsConfig = fs.existsSync(path.join(process.cwd(), 'tsconfig.json'));
return packageJson.type === 'module' || isTsConfig;
} catch {
// If package.json doesn't exist or is invalid, default to CommonJS
return false;
}
}
const handleCleanGeneration = async (outputDir)=>{
const spinner = getSpinner();
const mockDir = path.join(process.cwd(), outputDir);
if (!fs.existsSync(mockDir)) {
spinner.fail(`${mockDir} not found`);
return;
}
fs.rmSync(mockDir, {
recursive: true
});
};
const handleGenerateOpenApiTypes = async (config)=>{
const { specPath, outputDir } = config;
const spinner = getSpinner('Generating OpenAPI types with openapi-typescript...', 'yellow').start();
try {
const { default: openapiTS, astToString } = await import('openapi-typescript');
const ast = await openapiTS(specPath);
const code = astToString(ast);
const outPutPath = path.join(process.cwd(), outputDir);
if (!fs.existsSync(outPutPath)) {
fs.mkdirSync(outPutPath, {
recursive: true
});
}
fs.writeFileSync(path.join(outPutPath, OPENAPI_TYPES_FILE_NAME), code);
spinner.succeed(pc.bold('OpenAPI types generated successfully'));
} catch (error) {
console.error(error);
spinner.fail('Something went wrong while generating OpenAPI types using openapi-typescript.');
throw error;
}
};
const getExtension = (language)=>language === EXPORT_LANGUAGE.TS ? TS_EXTENSION : JS_EXTENSION;
const fileNames = (language)=>({
getConfigFileName: (configFileName = CONFIG_FILE_NAME)=>`${configFileName}${getExtension(language)}`,
getMockFileName: (path)=>`${slashToKebabCase(path)}${getExtension(language)}`
});
const collectedErrors = new Map();
const validateExampleAgainstSchema = (info, key)=>{
const { schema, path: endpointPath, method: endpointMethod } = info;
const { type: schemaType } = schema;
const getExampleType = (value)=>{
if (Array.isArray(value)) return 'array';
return typeof value;
};
if (!schemaType) {
return;
}
const checkExampleType = (example)=>{
let typeMismatch = false;
const exampleType = getExampleType(example);
switch(schemaType){
case 'string':
if (exampleType !== 'string') typeMismatch = true;
break;
case 'number':
case 'integer':
if (exampleType !== 'number') typeMismatch = true;
break;
case 'boolean':
if (exampleType !== 'boolean') typeMismatch = true;
break;
case 'array':
if (exampleType !== 'array') typeMismatch = true;
break;
case 'object':
if (exampleType !== 'object') typeMismatch = true;
break;
default:
typeMismatch = true;
break;
}
return {
typeMismatch,
exampleType
};
};
const example = 'exampleObject' in schema ? schema.exampleObject : schema.example;
if (example === undefined) return;
const { typeMismatch, exampleType } = checkExampleType(example);
if (!typeMismatch) return;
const collectedErrorsArray = collectedErrors.get(`${endpointMethod}-${endpointPath}`);
if (!collectedErrorsArray) {
collectedErrors.set(`${endpointMethod}-${endpointPath}`, [
{
method: endpointMethod,
path: endpointPath,
key: key ?? 'root',
schemaType,
exampleType
}
]);
} else {
collectedErrorsArray.push({
method: endpointMethod,
path: endpointPath,
key: key ?? 'root',
schemaType,
exampleType
});
}
};
const handlePrintMismatchedErrors = async ()=>{
const spinner = getSpinner();
const errors = Object.fromEntries(collectedErrors.entries());
const pathKeys = Object.keys(errors).sort();
if (pathKeys.length === 0) {
return;
}
spinner.fail(pc.redBright(pc.bold(`We've found the following mismatched exmaples in the schema: `)));
pathKeys.forEach((pathKey)=>{
const errorInfo = errors[pathKey];
const { method, path } = errorInfo[0];
errorInfo.forEach((error)=>{
const { key, schemaType, exampleType } = error;
console.error(`Found ${pc.cyan(pc.bold(key))} in (${method.toUpperCase()}) - ${pc.cyan(pc.bold(path))} with schema type ${pc.bold(pc.redBright(schemaType))} but received example in type: ${pc.bold(pc.redBright(exampleType))}`);
});
});
};
const createTemplate = (template)=>({
render: (data)=>{
try {
return interpolateString(template, data);
} catch (error) {
throw new Error(`Template rendering failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
});
const MODULE_IMPORT_TEMPLATE = {
cjs: createTemplate(CJS_IMPORT),
esm: createTemplate(ESM_IMPORT)
};
const MODULE_EXPORT_TEMPLATE = {
cjs: createTemplate(CJS_EXPORT),
cjsNamed: createTemplate(CJS_EXPORT_NAMED),
esm: createTemplate(ESM_EXPORT),
esmNamed: createTemplate(ESM_EXPORT_NAMED)
};
const fakerImport = (context)=>{
const { moduleSystem } = context;
const template = moduleSystem === 'esm' ? MODULE_IMPORT_TEMPLATE.esm : MODULE_IMPORT_TEMPLATE.cjs;
return template.render({
module: '{ faker }',
modulePath: '@faker-js/faker'
});
};
const openapiTypesImport = (context)=>{
const { moduleSystem } = context;
const template = moduleSystem === 'esm' ? MODULE_IMPORT_TEMPLATE.esm : MODULE_IMPORT_TEMPLATE.cjs;
return template.render({
module: 'type { paths }',
modulePath: `../${OPENAPI_TYPES_FILE_NAME}`
});
};
// Config file
const configHeader = (context)=>{
const parts = [
GENERATED_COMMENT
];
if (context.language === EXPORT_LANGUAGE.TS) {
parts.push(DISABLE_LINTING);
parts.push(HTTP_METHODS_TYPE, FAKER_MAP_TYPE, ENDPOINT_CONFIG_TYPE, CONFIG_TYPE);
} else {
parts.push(DISABLE_ALL_CHECK);
}
return parts.join('\n\n');
};
const configFooter = (context)=>MODULE_EXPORT_TEMPLATE[context.moduleSystem].render({
exportName: 'config'
});
// Mock data file
const mockDataHeader = (usesFaker, shouldImportPaths, context)=>`
${GENERATED_COMMENT}
${context.language === EXPORT_LANGUAGE.TS ? DISABLE_LINTING : DISABLE_ALL_CHECK}
${usesFaker ? fakerImport(context) : ''}
${context.language === EXPORT_LANGUAGE.TS && shouldImportPaths ? openapiTypesImport(context) : ''}
${usesFaker ? createTemplate(FAKER_SEED).render({
seed: DEFAULT_SEED
}) : ''}
`;
const mockDataFooter = (context)=>MODULE_EXPORT_TEMPLATE[context.moduleSystem].render({
exportName: 'mockData'
});
class TemplateGenerator {
generateConfig(data) {
const header = configHeader(this.context);
const configType = this.context.language === EXPORT_LANGUAGE.TS ? ': Config' : '';
const body = createTemplate(CONFIG_BODY).render({
...data,
configType
});
const footer = configFooter(this.context);
return [
header,
body,
footer
].join('\n\n');
}
generateMockData(usesFaker, mockData, mockDataType) {
const shouldImportPaths = mockDataType?.includes('paths') || false;
const header = mockDataHeader(usesFaker, shouldImportPaths, this.context);
const body = createTemplate(MOCK_DATA_BODY).render({
mockData: mockData,
mockDataType: this.context.language === EXPORT_LANGUAGE.TS && mockDataType ? `: ${mockDataType}` : ''
});
const footer = mockDataFooter(this.context);
return [
header,
body,
footer
].join('\n\n');
}
generateManifest(manifestData) {
return createTemplate(MANIFEST_BODY).render({
manifestData
});
}
getContext() {
return {
...this.context
};
}
constructor(language, isESM){
this.context = {
language,
moduleSystem: isESM ? 'esm' : 'cjs'
};
}
}
const shouldOverwriteFile = (filePath)=>!fs.existsSync(filePath) || fs.readFileSync(filePath, 'utf-8').includes(GENERATED_COMMENT_FLAG);
async function writeFileWithPrettify(filePath, content, parser = 'typescript') {
const directory = path.dirname(filePath);
fs.mkdirSync(directory, {
recursive: true
});
try {
const formattedContent = await prettier.format(content, {
parser
});
fs.writeFileSync(filePath, formattedContent);
} catch (error) {
console.error(error);
fs.writeFileSync(filePath, content);
}
}
function generateMockDataFileContent(mockCodeArray, templateGenerator, endpoint, language) {
const isDynamic = mockCodeArray.every((mockCode)=>typeof mockCode === 'string');
const { path: apiPath, method, responses } = endpoint;
const mockDataProperties = [];
const mockDataTypeProperties = [];
mockCodeArray.forEach((_, i)=>{
const { code, response } = responses[i];
const data = isDynamic ? mockCodeArray[i] : JSON.stringify(mockCodeArray[i], null, 2);
mockDataProperties.push(`'${code}': ${data}`);
if (language === EXPORT_LANGUAGE.TS) {
const hasContent = response?.['application/json'];
if (hasContent) {
const typeString = `paths['${apiPath}']['${method.toLowerCase()}']['responses']['${code}']['content']['application/json']`;
mockDataTypeProperties.push(`'${code}': ${typeString}`);
} else {
mockDataTypeProperties.push(`'${code}': null`);
}
}
});
const mockDataString = mockDataProperties.join(',\n');
const mockDataTypeString = language === EXPORT_LANGUAGE.TS ? `{${mockDataTypeProperties.join(',')}}` : undefined;
const usesFaker = mockDataString.includes('faker.');
return templateGenerator.generateMockData(usesFaker, mockDataString, mockDataTypeString);
}
async function writeMockDataFiles({ config, endpoints, generatedMocks, templateGenerator }) {
const spinner = getSpinner();
const { outputDir, language } = config;
const writePromises = endpoints.map((endpoint, i)=>{
const mockContent = generateMockDataFileContent(generatedMocks[i], templateGenerator, endpoint, language);
const filePath = path.join(outputDir, DEFAULT_API_DIR_NAME, fileNames(language).getMockFileName(endpoint.path));
if (!shouldOverwriteFile(filePath)) {
return Promise.resolve();
}
return writeFileWithPrettify(filePath, mockContent);
});
await Promise.all(writePromises);
spinner.succeed(pc.bold(`Generated ${endpoints.length} mock data files.`));
}
async function writeManifestFile({ config, organizedApiData, templateGenerator }) {
const spinner = getSpinner();
const { outputDir, language } = config;
const { getMockFileName } = fileNames(language);
const newManifestEntries = organizedApiData.map(({ method, path: apiPath, operationId, summary, description, responses })=>({
method: method.toUpperCase(),
path: apiPath,
operationId,
summary,
description,
mockFile: getMockFileName(apiPath),
nullablePaths: responses.flatMap((r)=>r.response?.['application/json']?.['x-nullable-paths'] ?? [])
}));
const manifestPath = path.join(outputDir, MANIFEST_FILE_NAME);
let finalManifestData = newManifestEntries;
if (fs.existsSync(manifestPath)) {
try {
const existingManifestContent = fs.readFileSync(manifestPath, 'utf-8');
const existingManifest = JSON.parse(existingManifestContent);
const existingData = existingManifest.manifest;
if (!Array.isArray(existingData)) throw new Error();
const newEntriesMap = new Map(newManifestEntries.map(({ method, path })=>[
`${method}-${path}`,
{
method,
path
}
]));
const oldEntriesToKeep = existingData.filter(({ path, method })=>path && method && !newEntriesMap.has(`${method}-${path}`));
finalManifestData = [
...oldEntriesToKeep,
...newManifestEntries
];
} catch {
spinner.fail(`The existed ${MANIFEST_FILE_NAME} is corrupted, falling back to overwriting the old file content.`);
}
}
const manifestContent = templateGenerator.generateManifest(JSON.stringify(finalManifestData, null, 2));
await writeFileWithPrettify(manifestPath, manifestContent, 'json');
}
const jiti = createJiti(import.meta.url);
async function loadConfigFromFile() {
const spinner = getSpinner();
spinner.info(pc.bold('Loading config file...'));
const tsConfigPath = path.join(process.cwd(), CONFIG_FILE_NAME + TS_EXTENSION);
const jsConfigPath = path.join(process.cwd(), CONFIG_FILE_NAME + JS_EXTENSION);
let configPath = '';
try {
if (fs.existsSync(tsConfigPath)) {
configPath = tsConfigPath;
} else if (fs.existsSync(jsConfigPath)) {
configPath = jsConfigPath;
} else {
throw new Error(`Config file not found at root directory, please run "openapi-mock-gen init" to create a new config file.`);
}
const configModule = await jiti.import(configPath, {
default: true
});
return {
...DEFAULT_CONFIG,
...configModule ?? {}
};
} catch (error) {
spinner.fail(`Error loading config file at ${configPath}`);
throw error;
}
}
async function initializeConfig(cliOptions) {
const spinner = getSpinner();
const specPath = cliOptions.specPath ?? await inputSpecPath();
const baseUrl = cliOptions.baseUrl ?? await promptForBaseUrl();
const outputDir = cliOptions.outputDir ?? DEFAULT_CONFIG.outputDir;
spinner.succeed(pc.bold('Prompting for a new config…'));
const { language, ...rest } = await promptForGlobalConfig();
const config = {
language,
specPath,
baseUrl,
outputDir,
...rest
};
const isESM = await getIsESM();
const templateGenerator = new TemplateGenerator(language, isESM);
const configFileName = fileNames(language).getConfigFileName();
const configPath = path.join(process.cwd(), configFileName);
const configContent = templateGenerator.generateConfig(config);
await writeFileWithPrettify(configPath, configContent);
spinner.succeed(pc.bold(`${configFileName} generated successfully.`));
return {
...config,
fakerMap: {},
endpoints: {}
};
}
async function bundleSpec(specPathOrUrl) {
const doc = await SwaggerParser.bundle(specPathOrUrl);
if (!('openapi' in doc) || !doc.openapi.startsWith('3')) {
throw new Error('Invalid OpenAPI version. Only OpenAPI v3 is supported.');
}
return doc;
}
async function loadSpec(config) {
const { specPath } = config;
const spinner = getSpinner(`Loading spec from: ${specPath}`, 'yellow').start();
const document = await bundleSpec(specPath);
spinner.succeed(pc.bold('Spec loaded successfully.'));
return document;
}
const FAKER_HELPERS = {
// Date and Time
date: 'faker.date.past().toISOString().substring(0, 10)',
dateTime: 'faker.date.past()',
time: 'new Date().toISOString().substring(11, 16)',
// Internet
email: 'faker.internet.email()',
hostname: 'faker.internet.domainName()',
ipv4: 'faker.internet.ip()',
ipv6: 'faker.internet.ipv6()',
url: 'faker.internet.url()',
token: 'faker.internet.jwt()',
// Location
city: 'faker.location.city()',
country: 'faker.location.country()',
latitude: 'faker.location.latitude()',
longitude: 'faker.location.longitude()',
state: 'faker.location.state()',
street: 'faker.location.streetAddress()',
zip: 'faker.location.zipCode()',
// Names and IDs
name: 'faker.person.fullName()',
uuid: 'faker.string.uuid()',
// Numbers
integer: 'faker.number.int()',
number: 'faker.number.int({ min: {min}, max: {max}, multipleOf: {multipleOf} })',
float: 'faker.number.float({ fractionDigits: {fractionDigits} })',
// Strings
alpha: 'faker.string.alpha({ length: { min: {minLength}, max: {maxLength} } })',
alphaMax: 'faker.string.alpha({ length: { max: {maxLength} } })',
alphaMin: 'faker.string.alpha({ length: { min: {minLength} } })',
string: 'faker.lorem.words()',
// Text
paragraph: 'faker.lorem.paragraph()',
sentence: 'faker.lorem.sentence()',
// Utilities
arrayElement: 'faker.helpers.arrayElement({arrayElement})',
fromRegExp: 'faker.helpers.fromRegExp({pattern})',
boolean: 'faker.datatype.boolean()',
image: 'faker.image.urlLoremFlickr()',
phone: 'faker.phone.number()',
avatar: 'faker.image.avatar()'
};
// see: https://json-schema.org/understanding-json-schema/reference/type#built-in-formats
const JSON_SCHEMA_STANDARD_FORMATS = {
date: FAKER_HELPERS.date,
time: FAKER_HELPERS.time,
'date-time': FAKER_HELPERS.dateTime,
email: FAKER_HELPERS.email,
'idn-email': FAKER_HELPERS.email,
hostname: FAKER_HELPERS.hostname,
'idn-hostname': FAKER_HELPERS.hostname,
ipv4: FAKER_HELPERS.ipv4,
ipv6: FAKER_HELPERS.ipv6,
uuid: FAKER_HELPERS.uuid,
uri: FAKER_HELPERS.url,
iri: FAKER_HELPERS.url,
'uri-reference': FAKER_HELPERS.url,
'iri-reference': FAKER_HELPERS.url,
'uri-template': FAKER_HELPERS.url,
regex: FAKER_HELPERS.fromRegExp
};
const HEURISTIC_STRING_KEY_MAP = {
// General identifiers
'(?:^|_)id$': FAKER_HELPERS.uuid,
'(?:^|_)uuid(?:_|$)': FAKER_HELPERS.uuid,
'(?:^|_)token(?:_|$)': FAKER_HELPERS.token,
// Timestamps
'.+_at$': FAKER_HELPERS.dateTime,
'(?:^|_)timestamp(?:_|$)': FAKER_HELPERS.dateTime,
// locations
'(?:^|_)street(?:_|$)': FAKER_HELPERS.street,
'(?:^|_)city(?:_|$)': FAKER_HELPERS.city,
'(?:^|_)state(?:_|$)': FAKER_HELPERS.state,
'(?:^|_)zip(?:_|$)': FAKER_HELPERS.zip,
'(?:^|_)country(?:_|$)': FAKER_HELPERS.country,
'^postal_code$': FAKER_HELPERS.zip,
'(?:^|_)latitude(?:_|$)': FAKER_HELPERS.latitude,
'(?:^|_)longitude(?:_|$)': FAKER_HELPERS.longitude,
// phone / contact
'(?:^|_)phone(?:_|$)': FAKER_HELPERS.phone,
'(?:^|_)mobile(?:_|$)': FAKER_HELPERS.phone,
// personal info
'(?:^|_)email(?:_|$)': FAKER_HELPERS.email,
'.*name$': FAKER_HELPERS.name,
// urls
'(?:^|_)ur[li]$': FAKER_HELPERS.url,
'\\b(profile|user)_(image|img|photo|picture)\\b|(?:^|_)avatar(?:_|$)': FAKER_HELPERS.avatar,
'(?:^|_)(photo|image|picture|img)(?:_|$)': FAKER_HELPERS.image,
// content / text
'(?:^|_)title(?:_|$)': FAKER_HELPERS.sentence,
'(?:^|_)description(?:_|$)': FAKER_HELPERS.paragraph,
'(?:^|_)content(?:_|$)': FAKER_HELPERS.paragraph,
'(?:^|_)text(?:_|$)': FAKER_HELPERS.paragraph,
'(?:^|_)paragraph(?:_|$)': FAKER_HELPERS.paragraph,
'(?:^|_)comments?(?:_|$)': FAKER_HELPERS.sentence,
'(?:^|_)message(?:_|$)': FAKER_HELPERS.sentence,
'(?:^|_)summary(?:_|$)': FAKER_HELPERS.paragraph
};
const moderateScore = interpolateString(FAKER_HELPERS.number, {
min: 1,
max: 100,
multipleOf: DEFAULT_MULTIPLE_OF
});
const moderateNumber = interpolateString(FAKER_HELPERS.number, {
min: 1,
max: 1000,
multipleOf: DEFAULT_MULTIPLE_OF
});
const moderatePrice = interpolateString(FAKER_HELPERS.number, {
min: 1,
max: 100000,
multipleOf: DEFAULT_MULTIPLE_OF
});
const moderateRating = interpolateString(FAKER_HELPERS.number, {
min: 1,
max: 5,
multipleOf: DEFAULT_MULTIPLE_OF
});
const moderateFloat = interpolateString(FAKER_HELPERS.float, {
fractionDigits: 1
});
const lcg = `(() => {
const seed = (Math.random() * 9301 + 49297) % 233280;
const random = seed / 233280;
return {min} + Math.floor(random * {max});
})()`;
const HEURISTIC_NUMBER_KEY_MAP = {
// Linear Congruential Generator (LCG) formulas for stateless random unique(nearly) numbers
'^id$': interpolateString(lcg, {
min: DEFAULT_MIN_NUMBER,
max: DEFAULT_MAX_NUMBER,
seed: DEFAULT_SEED
}),
'.+_id$': interpolateString(lcg, {
min: DEFAULT_MIN_NUMBER,
max: DEFAULT_MAX_NUMBER,
seed: DEFAULT_SEED
}),
// age / time
'(?:^|_)age(?:_|$)': interpolateString(FAKER_HELPERS.number, {
min: 20,
max: 80,
multipleOf: DEFAULT_MULTIPLE_OF
}),
'(?:^|_)year(?:_|$)': interpolateString(FAKER_HELPERS.number, {
min: 1900,
max: new Date().getFullYear(),
multipleOf: DEFAULT_MULTIPLE_OF
}),
'(?:^|_)month(?:_|$)': interpolateString(FAKER_HELPERS.number, {
min: 1,
max: 12,
multipleOf: DEFAULT_MULTIPLE_OF
}),
'(?:^|_)day(?:_|$)': interpolateString(FAKER_HELPERS.number, {
min: 1,
max: 31,
multipleOf: DEFAULT_MULTIPLE_OF
}),
// quatities/counts
'(?:^|_)counts?(?:_|$)': moderateNumber,
'(?:^|_)quantity(?:_|$)': moderateNumber,
'(?:^|_)amount(?:_|$)': moderateNumber,
'(?:^|_)total(?:_|$)': moderateNumber,
// financial
'(?:^|_)price(?:_|$)': moderatePrice,
'(?:^|_)discounts?(?:_|$)': moderateFloat,
'(?:^|_)tax(?:_|$)': moderatePrice,
'(?:^|_)fee(?:_|$)': moderatePrice,
// dimensions
'(?:^|_)size(?:_|$)': moderateNumber,
'(?:^|_)length(?:_|$)': moderateNumber,
'(?:^|_)width(?:_|$)': moderateNumber,
'(?:^|_)height(?:_|$)': moderateNumber,
'(?:^|_)weight(?:_|$)': moderateNumber,
// ratings
'(?:^|_)ratings?(?:_|$)': moderateRating,
'(?:^|_)stars(?:_|$)': moderateRating,
'(?:^|_)scores?(?:_|$)': moderateScore
};
const transformNumberBasedOnFormat = (schema, key)=>{
const { minimum, maximum, multipleOf, exclusiveMinimum, exclusiveMaximum } = schema;
if (key) {
const heuristicKey = Object.keys(HEURISTIC_NUMBER_KEY_MAP).find((rx)=>new RegExp(rx).test(key));
if (heuristicKey) {
return HEURISTIC_NUMBER_KEY_MAP[heuristicKey];
}
}
const getMinimum = ()=>{
const min = minimum ?? DEFAULT_MIN_NUMBER;
if (exclusiveMinimum) return typeof exclusiveMinimum === 'number' ? exclusiveMinimum + 1 : min + 1;
return min;
};
const getMaximum = ()=>{
const max = maximum ?? DEFAULT_MAX_NUMBER;
if (exclusiveMaximum) return typeof exclusiveMaximum === 'number' ? exclusiveMaximum - 1 : max - 1;
return max;
};
const getMultipleOf = ()=>{
if (multipleOf) return multipleOf;
return DEFAULT_MULTIPLE_OF;
};
const min = getMinimum();
const max = getMaximum();
return interpolateString(FAKER_HELPERS.number, {
// prevent non-sense min max values
min: min > max ? max : min,
max: max < min ? min : max,
multipleOf: getMultipleOf()
});
};
const transformStringBasedOnFormat = (schema, key)=>{
const { format, pattern, minLength, maxLength } = schema;
if (format && JSON_SCHEMA_STANDARD_FORMATS[format]) {
return JSON_SCHEMA_STANDARD_FORMATS[format];
}
if (pattern) {
try {
// check for invalid patterns
new RegExp(pattern);
return interpolateString(FAKER_HELPERS.fromRegExp, {
pattern: `/${pattern}/`
});
} catch {
console.error(`Invalid pattern regex pattern in your openapi schema found: ${pattern}`);
return FAKER_HELPERS.string;
}
}
if (key) {
const heuristicKey = Object.keys(HEURISTIC_STRING_KEY_MAP).find((rx)=>new RegExp(rx).test(key));
if (heuristicKey) {
return HEURISTIC_STRING_KEY_MAP[heuristicKey];
}
}
if (minLength !== undefined || maxLength !== undefined) {
if (minLength !== undefined && maxLength !== undefined) {
return interpolateString(FAKER_HELPERS.alpha, {
minLength: minLength > maxLength ? maxLength : minLength,
maxLength: maxLength < minLength ? minLength : maxLength
});
} else if (minLength) {
return interpolateString(FAKER_HELPERS.alphaMin, {
minLength
});
} else if (maxLength) {
return interpolateString(FAKER_HELPERS.alphaMax, {
maxLength
});
}
}
return FAKER_HELPERS.string;
};
const handleObject = (info, isInsideArray)=>{
const { schema, ...context } = info;
const { properties, additionalProperties } = schema;
if (!properties) {
if (typeof additionalProperties === 'object') {
const value = generateMock({
schema: additionalProperties,
...context
}, undefined, isInsideArray);
return `{ [${FAKER_HELPERS.string}]: ${value} }`;
}
// provide example in case no properties are defined
if (schema.example) {
return JSON.stringify(schema.example);
}
}
const propertiesEntries = Object.entries(properties ?? {}).map(([key, value])=>`'${key}': ${generateMock({
schema: value,
...context
}, key, isInsideArray)}`);
const additionalPropertiesEntries = [];
if (additionalProperties === true) {
additionalPropertiesEntries.push(`[${FAKER_HELPERS.string}]: ${FAKER_HELPERS.string}`);
} else if (typeof additionalProperties === 'object') {
const value = generateMock({
schema: additionalProperties,
...context
}, undefined, isInsideArray);
additionalPropertiesEntries.push(`[${FAKER_HELPERS.string}]: ${value}`);
}
const allEntries = [
...propertiesEntries,
...additionalPropertiesEntries
];
return `{${allEntries.join(',\n')}}`;
};
const handleArray = (info, key)=>{
const { schema, ...context } = info;
const endpointConfig = context.config.endpoints?.[context.path]?.[context.method] ?? {};
const arrayLength = endpointConfig.arrayLength ?? context.config.arrayLength ?? DEFAULT_CONFIG.arrayLength;
// provide example in case no items are defined
if (!schema.items && schema.example) {
return JSON.stringify(schema.example);
}
return `Array.from({ length: ${arrayLength} }, () => (${generateMock({
schema: schema.items,
...context
}, key, true)}))`;
};
const handleFakerMapping = (key, fakerMap)=>{
if (!fakerMap) return null;
const resolveValue = (value)=>{
if (typeof value === 'function') return `(${value.toString()})()`;
if (typeof value === 'string' && value.startsWith('faker.')) {
return value;
}
return JSON.stringify(value);
};
if (fakerMap[key]) {
return resolveValue(fakerMap[key]);
}
for(const rx in fakerMap){
try {
if (new RegExp(rx).test(key)) {
return resolveValue(fakerMap[rx]);
}
} catch {
console.warn(`Invalid regex pattern found: ${rx}`);
}
}
return null;
};
const handleUndefinedType = (info)=>{
const { schema, ...context } = info;
const { additionalProperties } = schema;
if ('properties' in schema || additionalProperties && typeof additionalProperties === 'object') {
return generateMock({
schema: {
...schema,
type: 'object'
},
...context
});
} else if ('items' in schema) {
return generateMock({
schema: {
...schema,
type: 'array'
},
...context
});
} else if ('minimum' in schema || 'maximum' in schema || 'multipleOf' in schema || 'exclusiveMinimum' in schema || 'exclusiveMaximum' in schema) {
return generateMock({
schema: {
...schema,
type: 'number'
},
...context
});
}
return generateMock({
schema: {
...schema,
type: 'string'
},
...context
});
};
function generateMock(info, key, isInsideArray = false // a flag to prevent example being used inside the array
) {
const { schema, ...context } = info;
const { config, path: endpointPath, method: endpointMethod } = context;
const { fakerMap, endpoints, useExample: globalUseExample } = config;
const endpointConfig = endpoints?.[endpointPath]?.[endpointMethod] ?? {};
const useExample = endpointConfig.useExample ?? globalUseExample;
const mergedFakerMap = merge({}, fakerMap, endpointConfig.fakerMap);
if (!schema || '$ref' in schema || 'x-circular-ref' in schema) return 'null';
if (key) {
const mapped = handleFakerMapping(key, mergedFakerMap);
if (mapped) return mapped;
}
if (useExample) {
validateExampleAgainstSchema({
schema,
...context
}, key);
// if mediaType Object examples is provided, use it directly
if ('exampleObject' in schema && schema.exampleObject) {
return JSON.stringify(schema.exampleObject);
}
// case when example is provided in the schemaObject level
if ('example' in schema && !isInsideArray) {
return JSON.stringify(schema.example);
}
}
if (schema.enum) {
return interpolateString(FAKER_HELPERS.arrayElement, {
arrayElement: JSON.stringify(schema.enum)
});
}
if (schema.allOf) {
const { allOf, ...rest } = schema;
return generateMock({
schema: merge({}, ...allOf, rest),
...context
}, key, isInsideArray);
}
if (schema.oneOf || schema.anyOf) {
const schemaObjects = schema.oneOf || schema.anyOf || [];
const arrayElement = schemaObjects.map((schemaObject)=>generateMock({
schema: schemaObject,
...context
}, key, isInsideArray)).join(',');
return interpolateString(FAKER_HELPERS.arrayElement, {
arrayElement: `[${arrayElement}]`
});
}
switch(schema.type){
case 'string':
return transformStringBasedOnFormat(schema, key);
case 'number':
case 'integer':
return transformNumberBasedOnFormat(schema, key);
case 'boolean':
return FAKER_HELPERS.boolean;
case 'object':
return handleObject({
schema,
...context
}, isInsideArray);
case 'array':
return handleArray({
schema,
...context
}, key);
case undefined:
return handleUndefinedType({
schema,
...context
});
default:
return 'null';
}
}
const generateMocks = (organizedApiData, config)=>{
const { dynamic } = config;
return organizedApiData.map((endpoint)=>{
const { path, method, responses } = endpoint;
return responses.map((res)=>{
const schema = res.response?.['application/json'];
const info = {
schema,
config,
path,
method
};
const mockCode = generateMock(info);
return dynamic ? mockCode : executeCode(mockCode);
});
});
};
const extractRelevantFields = (paths)=>Object.entries(paths).flatMap(([pathName, pathItem])=>{
if (!pathItem) return [];
return Object.values(OpenAPIV3.HttpMethods).reduce((acc, method)=>{
const operation = pathItem[method];
if (operation) {
acc.push({
path: pathName,
method,
tags: operation.tags ?? [],
operationId: operation.operationId ?? '',
summary: operation.summary ?? '',
description: operation.description,
parameters: operation.parameters,
responses: operation.responses
});
}
return acc;
}, []);
});
const getNullablePaths = (schema)=>{
const paths = [];
const traverse = (subSchema, path)=>{
if (!subSchema || '$ref' in subSchema || 'x-circular-ref' in subSchema) {
return;
}
if (subSchema.nullable && path) {
paths.push(path);
}
if (subSchema.allOf) subSchema.allOf.forEach((s)=>traverse(s, path));
if (subSchema.oneOf) subSchema.oneOf.forEach((s)=>traverse(s, path));
if (subSchema.anyOf) subSchema.anyOf.forEach((s)=>traverse(s, path));
if (subSchema.type === 'object' && subSchema.properties) {
for (const [key, propSchema] of Object.entries(subSchema.properties)){
const newPath = path ? `${path}.${key}` : key;
traverse(propSchema, newPath);
}
}
if (subSchema.type === 'array' && subSchema.items) {
traverse(subSchema.items, path);
}
};
traverse(schema, '');
return [
...new Set(paths)
];
};
const transformExample = (example, doc, resolvedRefs)=>{
if (!example) return {};
// resolve reference object first if encountered
if ('$ref' in example) {
if (resolvedRefs?.has(example.$ref)) {
console.error('Circular reference detected:', example.$ref);
return {
'x-circular-ref': true
};
}
const scopedResolvedRefs = new Set(resolvedRefs);
scopedResolvedRefs.add(example.$ref);
const name = example.$ref.split('/').pop() ?? '';
return transformExample(doc.components?.examples?.[name], doc, scopedResolvedRefs);
}
// For now, we do not resolve externalValue, we just provide it as is
return example.value ?? example.externalValue;
};
const transformSchema = (schema, doc, resolvedRefs)=>{
if (!schema) return {};
// resolve reference object first if encountered
if ('$ref' in schema) {
if (resolvedRefs?.has(schema.$ref)) {
console.error('Circular reference detected:', schema.$ref);
return {
'x-circular-ref': true
};
}
const scopedResolvedRefs = new Set(resolvedRefs);
scopedResolvedRefs.add(schema.$ref);
const name = schema.$ref.split('/').pop() ?? '';
return transformSchema(doc.components?.schemas?.[name], doc, scopedResolvedRefs);
}
if (schema.type === 'array') {
return {
...schema,
items: schema.items ? transformSchema(schema.items, doc, resolvedRefs) : undefined
};
}
if (schema.type === 'object') {
return {
...schema,
properties: schema.properties ? Object.fromEntries(Object.entries(schema.properties).map(([k, v])=>[
k,
transformSchema(v, doc, resolvedRefs)
])) : undefined
};
}
if (schema.allOf) return {
...schema,
allOf: schema.allOf.map((s)=>transformSchema(s, doc, resolvedRefs))
};
if (schema.oneOf) return {
...schema,
oneOf: schema.oneOf.map((s)=>transformSchema(s, doc, resolvedRefs))
};
if (schema.anyOf) return {
...schema,
anyOf: schema.anyOf.map((s)=>transformSchema(s, doc, resolvedRefs))
};
return schema;
};
const resolveResponse = (response, doc)=>{
// resolve reference object first if encountered
if ('$ref' in response) {
const name = response.$ref.split('/').pop() ?? '';
const realResponse = doc.components?.responses?.[name];
return realResponse ? resolveResponse(realResponse, doc) : {};
}
const { content } = response;
if (!content) return {};
return Object.fromEntries(Object.entries(content).map(([mediaType, mediaObj])=>{
const { schema, examples, example } = mediaObj;
const exampleToUse = examples || example;
const exampleObject = exampleToUse ? Object.fromEntries(Object.entries(exampleToUse).map(([media, example])=>[
media,
transformExample(example, doc)
])) : undefined;
const schemaObject = transformSchema(schema, doc);
const nullablePaths = getNullablePaths(schemaObject);
return [
mediaType,
{
...schemaObject,
exampleObject,
'x-nullable-paths': nullablePaths
}
];
}));
};
const recursiveTransform = (responses, doc)=>{
if (!responses) return [];
return Object.entries(responses).map(([status, response])=>({
code: status,
response: resolveResponse(response, doc)
}));
};
const processApiSpec = (apiEndpoints, doc)=>apiEndpoints.map((apiEndpoint)=>({
method: apiEndpoint.method,
path: apiEndpoint.path,
operationId: apiEndpoint.operationId,
description: apiEndpoint.description,
summary: apiEndpoint.summary,
responses: recursiveTransform(apiEndpoint.responses, doc)
}));
const groupByTags = (apiEndpoints)=>apiEndpoints.reduce((acc, endpoint)=>{
const groupKey = endpoint.tags.length ? `Group(by tags): ${endpoint.tags.join('/')}` : 'Untagged';
if (!acc[groupKey]) {
acc[groupKey] = [];
}
acc[groupKey].push(endpoint);
return acc;
}, {});
const groupByPrefix = (apiEndpoints)=>apiEndpoints.reduce((acc, endpoint)=>{
const segments = endpoint.path.split('/').filter(Boolean);
let targetIndex = 0;
const commonPrefixes = [
'api'
];
for(let i = 0; i < segments.length; i++){
const segment = segments[i].toLowerCase();
const isVersionPattern = /^v\d+$/.test(segment);
const isCommonPrefix = commonPrefixes.includes(segment);
if (!isVersionPattern && !isCommonPrefix) {
targetIndex = i;
break;
}
}
const groupKey = `Group(by prefix): ${segments[targetIndex] || segments[0] || ''}`;
if (!acc[groupKey]) {
acc[groupKey] = [];
}
acc[groupKey].push(endpoint);
return acc;
}, {});
const autoGroup = (apiEndpointInfos)=>{
const allGroups = groupByTags(apiEndpointInfos);
const untaggedEndpoints = allGroups['Untagged'];
if (untaggedEndpoints?.length) {
delete allGroups['Untagged'];
const prefixGroups = groupByPrefix(untaggedEndpoints);
return {
...allGroups,
...prefixGroups
};
}
return allGroups;
};
const selectEndpointsForMocking = async (apiEndpointInfos)=>{
const groupedEndpoints = autoGroup(apiEndpointInfos);
return await promptForEndpoints(groupedEndpoints);
};
const DEFAULT_SERVER_FILE_NAME = 'server';
const DEFAULT_BROWSER_FILE_NAME = 'browser';
const DEFAULT_INDEX_FILE_NAME = 'index';
const DEFAULT_HANDLERS_FILE_NAME = 'handlers';
const DEFAULT_UTILS_FILE_NAME = 'utils';
// Raw template strings
const SINGLE_HANDLER_CONTENT = `{ method: '{method}', url: '{url}', response: {response}, nullablePaths: {nullablePaths} }`;
const HANDLERS_CONTENT = `
${GENERATED_COMMENT}
{disableComment}
{imports}
{handlers}
{exports}
`;
const MSW_TYPES_CONTENT = `
${GENERATED_COMMENT}
{disableComment}
{imports}
export interface MockHttpHandler {
method: Lowercase<HttpMethods>
url: string
response: () => Record<string, JsonBodyType>
nullablePaths: string[]
}
`;
const SERVER_CONTENT = `
${GENERATED_COMMENT}
{disableComment}
{impo