UNPKG

vite-plugin-fakery

Version:

Vite plugin to mock frontend APIs with realistic data from Faker

308 lines (302 loc) 9.78 kB
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 };