feat: implement mock data support with API interceptor and update documentation

This commit is contained in:
2026-02-26 02:37:01 +00:00
parent 30501f5f4c
commit 20eaa50935
9 changed files with 410 additions and 5 deletions

View File

@@ -0,0 +1,76 @@
[
{
"id": "home",
"_id": "home",
"active": true,
"type": "page",
"lang": "de",
"translationKey": "home",
"name": "Startseite",
"path": "/",
"blocks": [
{
"type": "hero",
"headline": "Willkommen",
"headlineH1": true,
"subline": "Demo-Startseite des Starter-Templates",
"heroImage": {
"image": "demo-hero"
}
},
{
"type": "richtext",
"headline": "Über uns",
"text": "<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>"
}
],
"meta": {
"title": "Startseite",
"description": "Demo-Startseite"
}
},
{
"id": "about",
"_id": "about",
"active": true,
"type": "page",
"lang": "de",
"translationKey": "about",
"name": "Über uns",
"path": "/ueber-uns",
"blocks": [
{
"type": "richtext",
"headline": "Über uns",
"headlineH1": true,
"text": "<p>Wir sind ein Demo-Unternehmen.</p>"
}
],
"meta": {
"title": "Über uns",
"description": "Erfahren Sie mehr über uns"
}
},
{
"id": "contact",
"_id": "contact",
"active": true,
"type": "page",
"lang": "de",
"translationKey": "contact",
"name": "Kontakt",
"path": "/kontakt",
"blocks": [
{
"type": "richtext",
"headline": "Kontakt",
"headlineH1": true,
"text": "<p>Schreiben Sie uns eine Nachricht.</p>"
}
],
"meta": {
"title": "Kontakt",
"description": "Kontaktieren Sie uns"
}
}
]

View File

@@ -0,0 +1,38 @@
[
{
"id": "header-de",
"_id": "header-de",
"language": "de",
"type": "header",
"elements": [
{
"name": "Startseite",
"page": "home"
},
{
"name": "Über uns",
"page": "about"
},
{
"name": "Kontakt",
"page": "contact"
}
]
},
{
"id": "footer-de",
"_id": "footer-de",
"language": "de",
"type": "footer",
"elements": [
{
"name": "Impressum",
"page": "imprint"
},
{
"name": "Datenschutz",
"page": "privacy"
}
]
}
]

View File

@@ -16,6 +16,7 @@ import { apiBaseOverride } from "./store"
import { incrementRequests, decrementRequests } from "./requestsStore"
import { setServerBuildTime } from "./serverBuildInfo"
import { checkBuildVersion } from "./versionCheck"
import { mockApiRequest } from "./mock"
// ---------------------------------------------------------------------------
// Request deduplication
@@ -84,6 +85,14 @@ export const api = async <T>(
}
incrementRequests()
// ── Mock interceptor (tree-shaken when __MOCK__ is false) ──
if (__MOCK__) {
const mockResult = mockApiRequest(endpoint, mergedOptions, body)
if (mockResult) return mockResult as ApiResult<T>
// No mock data for this endpoint → 404
throw { response: { status: 404 }, data: { error: `[mock] No mock data for "${endpoint}"` } }
}
const data = await apiRequest(_apiBaseOverride + endpoint, mergedOptions, body, sentry)
// Build-version check only on GETs (don't reload mid-write)

252
frontend/src/lib/mock.ts Normal file
View File

@@ -0,0 +1,252 @@
/**
* 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 navigationData from "../../mocking/navigation.json"
const mockRegistry: Record<string, Record<string, unknown>[]> = {
content: contentData as Record<string, unknown>[],
navigation: navigationData as Record<string, unknown>[],
}
// ---------------------------------------------------------------------------
// 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)
return {
data: item ?? null,
count: item ? 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)
}
// 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)
}
// ---------------------------------------------------------------------------
// 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 (entry.id !== undefined) result.id = entry.id
if (entry._id !== undefined) result._id = entry._id
return result
}
})
}