UNPKG

openapi-mock-gen

Version:

Generate mock definitions with random fake data based on OpenAPI spec.

1,375 lines (1,348 loc) 68.4 kB
#!/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