439 lines
15 KiB
TypeScript
439 lines
15 KiB
TypeScript
/**
|
|
* API Mock Interceptor
|
|
*
|
|
* Serves mock data from `frontend/mocking/<endpoint>.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<string, Record<string, unknown>[]> = {
|
|
content: normalizeMockCollection(contentData as Record<string, unknown>[]),
|
|
medialib: normalizeMockCollection(medialibData as Record<string, unknown>[]),
|
|
navigation: normalizeMockCollection(navigationData as Record<string, unknown>[]),
|
|
comments: normalizeMockCollection(commentsData as Record<string, unknown>[]),
|
|
}
|
|
|
|
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<string, unknown>[]): Record<string, unknown>[] {
|
|
return entries.map((entry) => normalizeMockValue(entry))
|
|
}
|
|
|
|
function normalizeMockValue<T>(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<string, unknown> = {}
|
|
for (const [key, nestedValue] of Object.entries(value as Record<string, unknown>)) {
|
|
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<unknown> | 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<string, unknown>, 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<string, unknown>)) 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<string, unknown>): 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<string, unknown>, path: string): unknown {
|
|
return path.split(".").reduce<unknown>((acc, part) => {
|
|
if (acc !== null && typeof acc === "object") return (acc as Record<string, unknown>)[part]
|
|
return undefined
|
|
}, obj)
|
|
}
|
|
|
|
function cloneEntry<T>(entry: T): T {
|
|
return JSON.parse(JSON.stringify(entry)) as T
|
|
}
|
|
|
|
function applyAggregate(entry: Record<string, unknown>, options?: ApiOptions): Record<string, unknown> {
|
|
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<string, unknown>, options?: ApiOptions): Record<string, unknown> {
|
|
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<string, unknown>,
|
|
pathSegments: string[],
|
|
lookupSource: Record<string, unknown>[]
|
|
): void {
|
|
const [segment, ...rest] = pathSegments
|
|
if (!segment) return
|
|
|
|
const value = current[segment]
|
|
|
|
if (rest.length === 0) {
|
|
current._lookup = (current._lookup as Record<string, unknown> | undefined) || {}
|
|
;(current._lookup as Record<string, unknown>)[segment] = resolveLookupValue(value, lookupSource)
|
|
return
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
value.forEach((item) => {
|
|
if (item && typeof item === "object") {
|
|
applyLookupAtPath(item as Record<string, unknown>, rest, lookupSource)
|
|
}
|
|
})
|
|
return
|
|
}
|
|
|
|
if (value && typeof value === "object") {
|
|
applyLookupAtPath(value as Record<string, unknown>, rest, lookupSource)
|
|
}
|
|
}
|
|
|
|
function resolveLookupValue(value: unknown, lookupSource: Record<string, unknown>[]): unknown {
|
|
if (Array.isArray(value)) {
|
|
return value.map((entryId) => resolveLookupById(entryId, lookupSource))
|
|
}
|
|
|
|
return resolveLookupById(value, lookupSource)
|
|
}
|
|
|
|
function resolveLookupById(value: unknown, lookupSource: Record<string, unknown>[]): Record<string, unknown> | null {
|
|
if (typeof value !== "string") return null
|
|
|
|
return lookupSource.find((entry) => entry.id === value || entry._id === value) || null
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sort
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function applySort(data: Record<string, unknown>[], sortSpec: string): Record<string, unknown>[] {
|
|
// 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<string, unknown>[], projectionStr: string): Record<string, unknown>[] {
|
|
// 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<string, unknown> = {}
|
|
for (const key of Object.keys(entry)) {
|
|
if (!exclude.has(key)) result[key] = entry[key]
|
|
}
|
|
return result
|
|
} else {
|
|
const result: Record<string, unknown> = {}
|
|
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
|
|
}
|
|
})
|
|
}
|