vite-plugin-fakery
Version:
Vite plugin to mock frontend APIs with realistic data from Faker
308 lines (302 loc) • 9.78 kB
JavaScript
import fs from 'fs';
import path from 'path';
import { faker } from '@faker-js/faker';
import QuickLRU from 'quick-lru';
function validateConfig(config) {
if (!config.endpoints || !Array.isArray(config.endpoints)) {
throw new Error('"endpoints" are required and must be an array');
}
}
function loadConfigFromFile(configPath) {
const fullPath = path.resolve(configPath);
if (!fs.existsSync(fullPath)) {
throw new Error(`Fakery config file not found at ${fullPath}`);
}
const config = JSON.parse(fs.readFileSync(fullPath, "utf-8"));
validateConfig(config);
return config;
}
function resolveFakerValue(definition) {
if (typeof definition === "undefined") {
throw new Error(`Invalid Faker path: ${definition}`);
}
if (typeof definition === "function") {
return definition(faker);
}
if (typeof definition === "string") {
const pathParts = definition.split(".");
let result = faker;
for (const part of pathParts) {
result = result?.[part];
}
if (typeof result === "undefined") {
throw new Error(`Invalid Faker path: ${definition}`);
}
return typeof result === "function" ? result() : result;
}
if (Array.isArray(definition)) {
return definition.map((def) => resolveFakerValue(def));
}
if (typeof definition === "object" && definition !== null) {
return Object.fromEntries(
Object.entries(definition).map(([key, val]) => [
key,
resolveFakerValue(val)
])
);
}
return definition;
}
const DEFAULT_TOTAL_ITEMS = 10;
const DEFAULT_PER_PAGE = 10;
const cacheStore = new QuickLRU({
maxSize: 100,
maxAge: 5 * 60 * 1e3
// 5 minutes
});
function isValidSortOrder(order) {
return order === "asc" || order === "desc";
}
function resolveResponsePropValue(key, value) {
if (typeof value === "string") {
if (value.includes(".") && !value.includes("..")) {
return [key, resolveFakerValue(value)];
}
return [key, value.replace(/\.\./g, ".")];
}
if (typeof value === "number" || typeof value === "boolean") {
return [key, value];
}
return [key, resolveFakerValue(value)];
}
function sendResponse(res, status, data, delay, transformFn) {
const dispatch = () => {
res.statusCode = status;
res.setHeader("Content-Type", "application/json");
const payload = transformFn ? transformFn(data) : data;
res.end(JSON.stringify(payload));
};
delay ? setTimeout(dispatch, delay) : dispatch();
}
function sendJson(res, status, body) {
res.statusCode = status;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(body));
}
function searchData(data, term) {
const lower = term.toLowerCase();
return data.filter(
(item) => Object.values(item).some(
(v) => typeof v === "string" && v.toLowerCase().includes(lower)
)
);
}
function filterData(data, field, value) {
return data.filter((item) => String(item[field]) === value);
}
function sortData(data, field, order = "asc") {
return [...data].sort((a, b) => {
const av = a[field];
const bv = b[field];
if (av == null || bv == null) return 0;
if (av < bv) return order === "asc" ? -1 : 1;
if (av > bv) return order === "asc" ? 1 : -1;
return 0;
});
}
function parseRequest(req, endpoint) {
const url = new URL(req.url || "", `http://${req.headers.host}`);
const qs = url.searchParams.toString();
return { url, cacheKey: `${endpoint.url}${qs ? `?${qs}` : ""}` };
}
function validateMethod(req, endpoint) {
return !endpoint.methods || endpoint.methods.includes(req.method);
}
function shouldSimulateError(endpoint) {
return typeof endpoint.errorRate === "number" && Math.random() < endpoint.errorRate;
}
function getCachedResponse(cacheKey, endpoint) {
return endpoint.cache && cacheStore.has(cacheKey) ? cacheStore.get(cacheKey) : void 0;
}
function buildParams(url, endpoint) {
const qp = endpoint.queryParams ?? {};
const searchKey = qp.search ?? "q";
const filterKey = qp.filter ?? "filter";
const sortKey = qp.sort ?? "sort";
const order = isValidSortOrder(qp.order) ? qp.order : "asc";
const searchValue = url.searchParams.get(searchKey) ?? "";
const filterField = url.searchParams.get(filterKey) ?? "";
const filterValue = filterField ? url.searchParams.get(filterField) ?? "" : "";
const sortField = url.searchParams.has(sortKey) ? url.searchParams.get(sortKey) : void 0;
const perPageRaw = parseInt(
url.searchParams.get(qp.per_page ?? "per_page") ?? String(endpoint.perPage),
10
);
const parsedRawTotal = parseInt(
url.searchParams.get(qp.total ?? "total") ?? String(endpoint.total),
10
);
const totalRaw = isNaN(parsedRawTotal) ? DEFAULT_TOTAL_ITEMS : parsedRawTotal;
const paginationEnabled = endpoint.pagination || Number.isInteger(perPageRaw) && perPageRaw > 0 && endpoint.pagination !== false;
const perPage = paginationEnabled ? perPageRaw ?? DEFAULT_PER_PAGE : totalRaw;
const total = totalRaw;
const totalPages = Math.max(1, Math.ceil(total / perPage));
const page = Math.min(
Math.max(parseInt(url.searchParams.get("page") ?? "1", 10), 1),
totalPages
);
return {
searchValue,
filterField,
filterValue,
sortField,
order,
page,
perPage,
total,
totalPages
};
}
function pageRange(page, perPage, total) {
const startId = (page - 1) * perPage + 1;
return { startId, endId: Math.min(startId + perPage - 1, total) };
}
function matchCondition(req, url, endpoint) {
return endpoint.conditions?.find((cond) => {
const hdrOk = cond.when.headers ? Object.entries(cond.when.headers).every(
([k, v]) => req.headers[k] === v
) : true;
const qryOk = cond.when.query ? Object.entries(cond.when.query).every(
([k, v]) => url.searchParams.get(k) === v
) : true;
return hdrOk && qryOk;
});
}
function generateData(endpoint, range) {
const count = Math.max(0, range.endId - range.startId + 1);
const responseProps = endpoint.responseProps ?? {};
return Array.from({ length: count }).map((_, i) => {
const id = range.startId + i;
const props = Object.fromEntries(
Object.entries(responseProps).map(
([k, v]) => resolveResponsePropValue(k, v)
)
);
return { id, ...props };
});
}
function applyTransforms(data, params) {
let result = data;
if (params.searchValue) result = searchData(result, params.searchValue);
if (params.filterField)
result = filterData(result, params.filterField, params.filterValue);
if (params.sortField)
result = sortData(result, params.sortField, params.order);
return result;
}
function buildPayload(data, params, endpoint) {
return endpoint.singular ? data[0] ?? {} : {
data,
page: params.page,
per_page: params.perPage,
total: params.total,
total_pages: params.totalPages
};
}
function createEndpointHandler(endpoint) {
return async (req, res) => {
const { url, cacheKey } = parseRequest(req, endpoint);
if (!validateMethod(req, endpoint)) {
res.statusCode = 405;
return res.end();
}
if (shouldSimulateError(endpoint)) {
return sendJson(res, 500, { error: "Simulated server error" });
}
const cached = getCachedResponse(cacheKey, endpoint);
if (cached !== void 0) {
return sendResponse(
res,
endpoint.status ?? 200,
cached,
endpoint.delay,
endpoint.responseFormat
);
}
const params = buildParams(url, endpoint);
if (endpoint.logRequests) console.log(`Request: ${req.method} ${req.url}`);
if (endpoint.seed !== void 0) faker.seed(endpoint.seed);
const condition = matchCondition(req, url, endpoint);
if (condition) {
return sendResponse(
res,
condition.status ?? 200,
condition.staticResponse ?? {},
endpoint.delay,
endpoint.responseFormat
);
}
if (endpoint.staticResponse) {
return sendResponse(
res,
endpoint.status ?? 200,
endpoint.staticResponse,
endpoint.delay,
endpoint.responseFormat
);
}
if (endpoint.singular) {
const rawData2 = generateData(
{ ...endpoint},
{ startId: 1, endId: 1 }
);
const finalData2 = applyTransforms(rawData2, params);
const result = finalData2[0] ?? {};
if (endpoint.cache) cacheStore.set(cacheKey, result);
return sendResponse(
res,
endpoint.status ?? 200,
result,
endpoint.delay,
endpoint.responseFormat
);
}
const { startId, endId } = pageRange(
params.page,
params.perPage,
params.total
);
const rawData = generateData(endpoint, { startId, endId });
const finalData = applyTransforms(rawData, params);
const payload = buildPayload(finalData, params, endpoint);
if (endpoint.cache) cacheStore.set(cacheKey, payload);
return sendResponse(
res,
endpoint.status ?? 200,
payload,
endpoint.delay,
endpoint.responseFormat
);
};
}
const vitePluginFakery = (optionsOrPath) => {
const options = typeof optionsOrPath === "string" ? loadConfigFromFile(optionsOrPath) : optionsOrPath;
if (!Array.isArray(options?.endpoints)) {
throw new Error(
'vite-plugin-fakery: Invalid configuration. The "endpoints" param must be specified.'
);
}
return {
name: "vite-plugin-fakery",
apply: "serve",
configureServer(server) {
for (const endpoint of options.endpoints) {
console.log(
`Mock endpoint registered: ${endpoint.url} ${endpoint.singular ? "(singular)" : "(paginated)"}`
);
server.middlewares.use(endpoint.url, createEndpointHandler(endpoint));
}
}
};
};
export { vitePluginFakery as default };