/** * API Mock Interceptor * * Serves mock data from `frontend/mocking/.json` when `__MOCK__` is enabled. * Supports tibi-server-style filtering, single-item retrieval by ID, sorting, * limit/offset pagination, and projection. * * Activate via build flag: `MOCK=1 yarn start` * For production builds, the entire module is eliminated by dead-code removal. */ // --------------------------------------------------------------------------- // Mock data registry — static imports for tree-shaking compatibility. // Add new collections here as needed. // --------------------------------------------------------------------------- import contentData from "../../mocking/content.json" import medialibData from "../../mocking/medialib.json" import navigationData from "../../mocking/navigation.json" import commentsData from "../../mocking/comments.json" type EJsonObjectId = { $oid: string } const mockRegistry: Record[]> = { content: normalizeMockCollection(contentData as Record[]), medialib: normalizeMockCollection(medialibData as Record[]), navigation: normalizeMockCollection(navigationData as Record[]), comments: normalizeMockCollection(commentsData as Record[]), } function isEJsonObjectId(value: unknown): value is EJsonObjectId { return !!value && typeof value === "object" && "$oid" in value && typeof (value as EJsonObjectId).$oid === "string" } function normalizeMockCollection(entries: Record[]): Record[] { return entries.map((entry) => normalizeMockValue(entry)) } function normalizeMockValue(value: T): T { if (Array.isArray(value)) { return value.map((item) => normalizeMockValue(item)) as T } if (!value || typeof value !== "object") { return value } const normalized: Record = {} for (const [key, nestedValue] of Object.entries(value as Record)) { normalized[key] = normalizeMockValue(nestedValue) } if (isEJsonObjectId(normalized._id)) { normalized._id = normalized._id.$oid } if (typeof normalized._id === "string" && normalized.id === undefined) { normalized.id = normalized._id } return normalized as T } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Attempt to serve the request from mock data. * Returns an `ApiResult`-shaped object or `null` if no mock data is registered. * * @param endpoint Raw endpoint string, e.g. `"content"` or `"content/home"` * @param options ApiOptions (filter, sort, limit, offset, projection) */ export function mockApiRequest(endpoint: string, options?: ApiOptions, _body?: unknown): ApiResult | null { // Strip leading `/api/` or `/` if present (callers may include it) const cleaned = endpoint.replace(/^\/?(api\/)?/, "") // Split into collection name and optional ID: "content/home" → ["content", "home"] const segments = cleaned.split("/").filter(Boolean) const collection = segments[0] const itemId = segments[1] // undefined for list requests const sourceData = mockRegistry[collection] if (!sourceData) return null // no mock registered → interceptor should 404 console.log(`[mock] ${itemId ? "GET" : "LIST"} /${collection}${itemId ? "/" + itemId : ""}`, options?.filter ?? "") // --- Single-item retrieval --- if (itemId) { const item = sourceData.find((e) => e.id === itemId || e._id === itemId) let resultItem = item ? cloneEntry(item) : null if (resultItem) { resultItem = applyAggregate(resultItem, options) resultItem = applyLookups(resultItem, options) } return { data: resultItem, count: resultItem ? 1 : 0, buildTime: null, } } // --- List retrieval with filter / sort / pagination --- let results = [...sourceData] // Filter if (options?.filter) { results = results.filter((entry) => matchesFilter(entry, options.filter!)) } const totalCount = results.length // Sort if (options?.sort) { results = applySort(results, options.sort) } // Pagination if (options?.offset) { results = results.slice(options.offset) } if (options?.limit) { results = results.slice(0, options.limit) } results = results.map((entry) => { let e = cloneEntry(entry) e = applyAggregate(e, options) e = applyLookups(e, options) return e }) // Projection if (options?.projection) { results = applyProjection(results, options.projection) } return { data: results, count: totalCount, buildTime: null, } } // --------------------------------------------------------------------------- // Filter engine — simple MongoDB-style matching // --------------------------------------------------------------------------- function matchesFilter(entry: Record, filter: MongoFilter): boolean { for (const key of Object.keys(filter)) { const condition = filter[key] // Logical operators if (key === "$and") { if (!Array.isArray(condition)) return false return condition.every((sub: MongoFilter) => matchesFilter(entry, sub)) } if (key === "$or") { if (!Array.isArray(condition)) return false return condition.some((sub: MongoFilter) => matchesFilter(entry, sub)) } if (key === "$nor") { if (!Array.isArray(condition)) return false return !condition.some((sub: MongoFilter) => matchesFilter(entry, sub)) } const value = getNestedValue(entry, key) // Operator object: { field: { $in: [...], $ne: "x", ... } } if (condition !== null && typeof condition === "object" && !Array.isArray(condition)) { if (!matchesOperators(value, condition as Record)) return false continue } // Direct equality (or array-contains) if (Array.isArray(value)) { if (!value.includes(condition)) return false } else { if (value !== condition) return false } } return true } function matchesOperators(value: unknown, operators: Record): boolean { for (const op of Object.keys(operators)) { const expected = operators[op] switch (op) { case "$eq": if (value !== expected) return false break case "$ne": if (value === expected) return false break case "$in": if (!Array.isArray(expected) || !expected.includes(value)) return false break case "$nin": if (Array.isArray(expected) && expected.includes(value)) return false break case "$exists": if (expected ? value === undefined : value !== undefined) return false break case "$gt": if (typeof value !== "number" || typeof expected !== "number" || value <= expected) return false break case "$gte": if (typeof value !== "number" || typeof expected !== "number" || value < expected) return false break case "$lt": if (typeof value !== "number" || typeof expected !== "number" || value >= expected) return false break case "$lte": if (typeof value !== "number" || typeof expected !== "number" || value > expected) return false break case "$regex": { const flags = (operators.$options as string) || "" const re = new RegExp(expected as string, flags) if (typeof value !== "string" || !re.test(value)) return false break } default: // Unknown operator — skip gracefully break } } return true } /** Resolve dot-notation paths: `"meta.title"` → `entry.meta.title` */ function getNestedValue(obj: Record, path: string): unknown { return path.split(".").reduce((acc, part) => { if (acc !== null && typeof acc === "object") return (acc as Record)[part] return undefined }, obj) } function cloneEntry(entry: T): T { return JSON.parse(JSON.stringify(entry)) as T } function applyAggregate(entry: Record, options?: ApiOptions): Record { const rawAggregate = options?.aggregate || options?.params?.aggregate if (!rawAggregate) return entry const aggregates = Array.isArray(rawAggregate) ? rawAggregate : typeof rawAggregate === "string" ? rawAggregate.split(",") : [] if (!entry._aggregate) { entry._aggregate = {} } for (const spec of aggregates) { try { // Check for json object first if (spec.startsWith("{")) { // Not supported in this simple mock continue } // "comments:contentId:count" const parts = spec.split(":") if (parts.length < 3) continue const targetCollection = parts[0] const foreignField = parts[1] const operator = parts[2] const lookupSource = mockRegistry[targetCollection] if (!lookupSource) continue const localId = entry._id ? typeof (entry._id as any).$oid === "string" ? (entry._id as any).$oid : entry._id.toString() : null if (operator === "count") { const count = lookupSource.filter((item) => { const itemForeignId = item[foreignField] ? typeof (item[foreignField] as any).$oid === "string" ? (item[foreignField] as any).$oid : (item[foreignField] as any).toString() : null return itemForeignId === localId && item.active !== false // basic mock rule }).length ;(entry._aggregate as any)[`${targetCollection}Count`] = count } } catch (e) { console.warn("Mock aggregate error", e) } } return entry } function applyLookups(entry: Record, options?: ApiOptions): Record { const lookupSpecs = parseLookupSpecs(options) if (!lookupSpecs.length) return entry for (const spec of lookupSpecs) { const [fieldPath, collection] = spec.split(":") if (!fieldPath || !collection) continue const lookupSource = mockRegistry[collection] if (!lookupSource) continue applyLookupAtPath(entry, fieldPath.split("."), lookupSource) } return entry } function parseLookupSpecs(options?: ApiOptions): string[] { const rawLookup = [options?.lookup, options?.params?.lookup] .filter((value): value is string => typeof value === "string" && value.length > 0) .flatMap((value) => value.split(",")) .map((value) => value.trim()) .filter(Boolean) return Array.from(new Set(rawLookup)) } function applyLookupAtPath( current: Record, pathSegments: string[], lookupSource: Record[] ): void { const [segment, ...rest] = pathSegments if (!segment) return const value = current[segment] if (rest.length === 0) { current._lookup = (current._lookup as Record | undefined) || {} ;(current._lookup as Record)[segment] = resolveLookupValue(value, lookupSource) return } if (Array.isArray(value)) { value.forEach((item) => { if (item && typeof item === "object") { applyLookupAtPath(item as Record, rest, lookupSource) } }) return } if (value && typeof value === "object") { applyLookupAtPath(value as Record, rest, lookupSource) } } function resolveLookupValue(value: unknown, lookupSource: Record[]): unknown { if (Array.isArray(value)) { return value.map((entryId) => resolveLookupById(entryId, lookupSource)) } return resolveLookupById(value, lookupSource) } function resolveLookupById(value: unknown, lookupSource: Record[]): Record | null { if (typeof value !== "string") return null return lookupSource.find((entry) => entry.id === value || entry._id === value) || null } // --------------------------------------------------------------------------- // Sort // --------------------------------------------------------------------------- function applySort(data: Record[], sortSpec: string): Record[] { // tibi-server sort format: "field" (asc) or "-field" (desc), comma-separated const fields = sortSpec .split(",") .map((s) => s.trim()) .filter(Boolean) return [...data].sort((a, b) => { for (const field of fields) { const desc = field.startsWith("-") const key = desc ? field.slice(1) : field const aVal = getNestedValue(a, key) const bVal = getNestedValue(b, key) const cmp = compareValues(aVal, bVal) if (cmp !== 0) return desc ? -cmp : cmp } return 0 }) } function compareValues(a: unknown, b: unknown): number { if (a === b) return 0 if (a === undefined || a === null) return -1 if (b === undefined || b === null) return 1 if (typeof a === "number" && typeof b === "number") return a - b return String(a).localeCompare(String(b)) } // --------------------------------------------------------------------------- // Projection // --------------------------------------------------------------------------- function applyProjection(data: Record[], projectionStr: string): Record[] { // projection format: "field1,field2" (include) or "-field1,-field2" (exclude) const fields = projectionStr .split(",") .map((s) => s.trim()) .filter(Boolean) if (fields.length === 0) return data const isExclude = fields[0].startsWith("-") return data.map((entry) => { if (isExclude) { const exclude = new Set(fields.map((f) => f.replace(/^-/, ""))) const result: Record = {} for (const key of Object.keys(entry)) { if (!exclude.has(key)) result[key] = entry[key] } return result } else { const result: Record = {} for (const field of fields) { if (field in entry) result[field] = entry[field] } // Always include id/_id if (typeof entry.id === "string") result.id = entry.id else if (typeof entry._id === "string") result.id = entry._id if (entry._id !== undefined) result._id = entry._id return result } }) }