zomoc
Version:
A type-safe API mocking tool for frontend development, powered by axios and Zod.
312 lines (306 loc) • 12.1 kB
JavaScript
// src/bin.ts
import { Command } from "commander";
// src/core.ts
import { glob } from "glob";
import { generate } from "ts-to-zod";
import fs from "fs/promises";
import camelCase from "camelcase";
async function createInterfaceIndex(projectRoot, interfacePaths) {
const interfaceLocationMap = /* @__PURE__ */ new Map();
const files = await glob(interfacePaths, {
cwd: projectRoot,
absolute: true,
ignore: "node_modules/**"
});
const interfaceRegex = /export\s+(?:interface|type)\s+([A-Za-z0-9_]+)/g;
for (const file of files) {
try {
const content = await fs.readFile(file, "utf-8");
let match;
while ((match = interfaceRegex.exec(content)) !== null) {
if (interfaceLocationMap.has(match[1])) {
console.warn(
`[Zomoc] Warning: Duplicate interface name "${match[1]}" found in ${file}. The one in ${interfaceLocationMap.get(
match[1]
)} will be used.`
);
} else {
interfaceLocationMap.set(match[1], file);
}
}
} catch (e) {
console.warn(`[Zomoc] Failed to read or parse ${file}`, e);
}
}
return interfaceLocationMap;
}
async function generateSurvivedSchemas(interfaceLocationMap) {
const survivedSchemaDeclarations = /* @__PURE__ */ new Map();
const uniqueFilePaths = [...new Set(interfaceLocationMap.values())];
for (const filePath of uniqueFilePaths) {
try {
const sourceText = await fs.readFile(filePath, "utf-8");
let sanitizedSourceText = sourceText;
const enumRegex = /export (?:const\s+)?enum\s+(\w+)\s*{([\s\S]+?)}/g;
sanitizedSourceText = sanitizedSourceText.replace(
enumRegex,
(_match, enumName, enumBody) => {
const enumMemberRegex = /['"]([^'"]+)['"]/g;
const enumMembers = [];
let memberMatch;
while ((memberMatch = enumMemberRegex.exec(enumBody)) !== null) {
enumMembers.push(`'${memberMatch[1]}'`);
}
if (enumMembers.length === 0) {
return `/* Zomoc: Non-string enum '${enumName}' is not supported and has been commented out. */`;
}
return `export type ${enumName} = ${enumMembers.join(" | ")};`;
}
);
const genericTypeRegex = /export\s+(interface|type)\s+\w+<.+?>\s*=.+?;|export\s+(interface|type)\s+\w+<.+?>\s*{[\s\S]*?}/gs;
sanitizedSourceText = sanitizedSourceText.replace(
genericTypeRegex,
(match) => `/* Zomoc: Generic type commented out
${match}
*/`
);
const zodSchemaFileContent = generate({
sourceText: sanitizedSourceText
}).getZodSchemasFile("");
const sanitizedContent = zodSchemaFileContent.replace(
/z\.literal\(React\.\w+\)/g,
"z.any()"
);
const declarations = sanitizedContent.split(/\n(?=export const)/g);
for (const declaration of declarations) {
const match = declaration.match(/export const (\w+Schema) =/);
if (match && match[1]) {
const schemaName = match[1];
const trimmedDeclaration = declaration.trim();
if (trimmedDeclaration && !survivedSchemaDeclarations.has(schemaName)) {
survivedSchemaDeclarations.set(schemaName, trimmedDeclaration);
}
}
}
} catch (error) {
console.warn(
`[Zomoc] Warning: Skipped processing file '${filePath}' due to an error.`,
error.message
);
}
}
return survivedSchemaDeclarations;
}
async function buildInterfaceToSchemaNameMap(filePaths) {
const map = /* @__PURE__ */ new Map();
const interfaceRegex = /\bexport\s+(?:interface|type)\s+([A-Z]\w*)/g;
for (const filePath of filePaths) {
try {
const content = await fs.readFile(filePath, "utf-8");
let match;
while ((match = interfaceRegex.exec(content)) !== null) {
const interfaceName = match[1];
const schemaName = camelCase(interfaceName, {
preserveConsecutiveUppercase: true
}) + "Schema";
if (!map.has(interfaceName)) {
map.set(interfaceName, schemaName);
}
}
} catch (e) {
}
}
return map;
}
async function generateRegistryString(projectRoot, options) {
const interfacePaths = options?.interfacePaths || [];
const interfaceLocationMap = await createInterfaceIndex(
projectRoot,
interfacePaths
);
const survivedSchemaDeclarations = await generateSurvivedSchemas(interfaceLocationMap);
const interfaceToSchemaNameMap = await buildInterfaceToSchemaNameMap([
...interfaceLocationMap.values()
]);
const schemaNameToInterfaceNameMap = /* @__PURE__ */ new Map();
for (const [iName, sName] of interfaceToSchemaNameMap.entries()) {
schemaNameToInterfaceNameMap.set(sName, iName);
}
const allInterfaceNames = new Set(interfaceLocationMap.keys());
const allGeneratedSchemaNames = new Set(survivedSchemaDeclarations.keys());
const allExpectedSchemaNames = new Set(interfaceToSchemaNameMap.values());
const failedInterfaceNames = [];
for (const interfaceName of allInterfaceNames) {
const schemaName = `${camelCase(interfaceName, {
preserveConsecutiveUppercase: true
})}Schema`;
if (!allGeneratedSchemaNames.has(schemaName)) {
failedInterfaceNames.push(interfaceName);
}
}
if (failedInterfaceNames.length > 0) {
console.warn(
`\x1B[33m[Zomoc] The following types were skipped (often due to unsupported generics):\x1B[0m`
);
console.warn(failedInterfaceNames.map((name) => ` - ${name}`).join("\n"));
}
const typeMapEntries = [];
for (const schemaName of survivedSchemaDeclarations.keys()) {
const interfaceName = schemaNameToInterfaceNameMap.get(schemaName);
if (interfaceName) {
typeMapEntries.push(`'${interfaceName}': ${schemaName},`);
}
}
const urlMapEntries = [];
const mockPaths = options?.mockPaths || [];
const mockFiles = await glob(mockPaths, {
cwd: projectRoot,
absolute: true,
ignore: "node_modules/**"
});
if (mockFiles.length > 0) {
for (const mockFile of mockFiles) {
try {
const mockContent = await fs.readFile(mockFile, "utf-8");
const mockMap = JSON.parse(mockContent);
for (const [key, value] of Object.entries(mockMap)) {
const mockConfig = value;
if (typeof mockConfig === "object" && mockConfig !== null && "responses" in mockConfig) {
const responseMap = mockConfig;
const activeStatusKey = String(responseMap.status);
let activeResponse = responseMap.responses[activeStatusKey];
let finalStatus = responseMap.status;
if (!activeResponse) {
const fallbackStatusKey = Object.keys(responseMap.responses)[0];
if (fallbackStatusKey) {
activeResponse = responseMap.responses[fallbackStatusKey];
finalStatus = Number(fallbackStatusKey);
console.warn(
`[Zomoc] Warning: status ${activeStatusKey} not found in responses for '${key}'. Falling back to the first available status: ${finalStatus}.`
);
}
}
if (!activeResponse) continue;
const schemaName = activeResponse.responseType ? `${camelCase(activeResponse.responseType, {
preserveConsecutiveUppercase: true
})}Schema` : void 0;
if (schemaName && !survivedSchemaDeclarations.has(schemaName)) {
continue;
}
const registryValueObject = `{
schema: ${schemaName || "undefined"},
responseBody: ${activeResponse.responseBody ? JSON.stringify(activeResponse.responseBody) : "undefined"},
status: ${finalStatus},
pagination: ${activeResponse.pagination ? JSON.stringify(activeResponse.pagination) : "undefined"},
strategy: '${activeResponse.mockingStrategy || "random"}',
repeatCount: ${activeResponse.repeatCount ?? "undefined"}
}`;
const urlEntry = `'${key}': ${registryValueObject},`;
urlMapEntries.push(urlEntry);
} else {
let responseDef = {};
if (typeof mockConfig === "string") {
responseDef = { responseType: mockConfig, status: 200 };
} else if (typeof mockConfig === "object" && mockConfig !== null) {
const { responseBody, ...rest } = mockConfig;
if (responseBody) {
console.warn(
`[Zomoc] Warning: 'responseBody' is not supported in simple mode for '${key}'. Please use the 'responses' map structure.`
);
}
responseDef = { ...rest, status: rest.status || 200 };
} else {
continue;
}
const schemaName = responseDef.responseType ? `${camelCase(responseDef.responseType, {
preserveConsecutiveUppercase: true
})}Schema` : void 0;
if (schemaName && !survivedSchemaDeclarations.has(schemaName)) {
continue;
}
const registryValueObject = `{
schema: ${schemaName || "undefined"},
status: ${responseDef.status},
pagination: ${responseDef.pagination ? JSON.stringify(responseDef.pagination) : "undefined"},
strategy: '${responseDef.mockingStrategy || "random"}',
repeatCount: ${responseDef.repeatCount ?? "undefined"}
}`;
const urlEntry = `'${key}': ${registryValueObject},`;
urlMapEntries.push(urlEntry);
}
}
} catch (e) {
console.error(`[Zomoc] Error processing mock file ${mockFile}:`, e);
}
}
}
let finalRegistryString = `// Zomoc: Auto-generated mock registry. Do not edit.
import { z } from 'zod';
`;
finalRegistryString += [...survivedSchemaDeclarations.values()].join("\n");
const urlSchemaEntries = urlMapEntries.join("\n");
const typeSchemaEntries = typeMapEntries.join("\n");
finalRegistryString += `
export const finalSchemaUrlMap = {
${urlSchemaEntries}
} as const;
`;
finalRegistryString += `
export const finalSchemaTypeMap = {
${typeSchemaEntries}
} as const;
`;
return finalRegistryString;
}
// src/bin.ts
import fs2 from "fs/promises";
import path from "path";
var program = new Command();
program.name("zomoc").description("Zomoc: A Type-Safe Mocking Plugin CLI").version("0.1.0");
program.command("generate").description("Generates the mock registry file (.zomoc/registry.ts)").option(
"-m, --mock-paths <paths...>",
"Glob patterns for mock definition files",
["**/mock.json"]
).option(
"-i, --interface-paths <paths...>",
"Glob patterns for TypeScript interface files",
["**/interface.ts", "**/type.ts"]
).action(async (options) => {
try {
console.log("\u{1F504} Generating mock registry...");
const projectRoot = process.cwd();
const registryContent = await generateRegistryString(projectRoot, {
mockPaths: options.mockPaths,
interfacePaths: options.interfacePaths
});
const zomocDir = path.join(projectRoot, ".zomoc");
const registryPath = path.join(zomocDir, "registry.ts");
await fs2.mkdir(zomocDir, { recursive: true });
await fs2.writeFile(registryPath, registryContent);
console.log(`\u2705 Mock registry generated successfully at: ${registryPath}`);
const gitignorePath = path.join(projectRoot, ".gitignore");
try {
const gitignoreContent = await fs2.readFile(gitignorePath, "utf-8");
if (!gitignoreContent.includes(".zomoc")) {
await fs2.appendFile(
gitignorePath,
"\n\n# Zomoc auto-generated files\n.zomoc\n"
);
console.log("\u{1F4DD} Added .zomoc to .gitignore");
}
} catch (e) {
if (e.code === "ENOENT") {
await fs2.writeFile(
gitignorePath,
"# Zomoc auto-generated files\n.zomoc\n"
);
console.log("\u{1F4DD} Created .gitignore and added .zomoc");
}
}
} catch (error) {
console.error("\u274C An error occurred during registry generation:", error);
process.exit(1);
}
});
program.parse(process.argv);