Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions review
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
Pull request overview
This PR introduces an always-on mock data caching layer (MockDataStore) so that generated mock data remains stable across requests (instead of being regenerated each time), and adds a "Rebuild Data" button to the Swagger UI that allows users to clear the cached mock data and regenerate it on the next request.

Changes:

Added MockDataStore class in cache.ts that caches both single-object mocks and array pools, independent of the config.cache setting.
Integrated mockDataStore into the route handler (router.ts) to serve cached mock data on subsequent requests and regenerate only on first access.
Added a /mock-reset POST endpoint and a custom Swagger UI toolbar with a "Rebuild Data" button that triggers cache clearing.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
src/core/cache.ts New MockDataStore class with singles/pools Maps, get/set/clear/getStats methods, and a global mockDataStore singleton.
src/core/router.ts Updated dynamic route handler to check mockDataStore before generating new mock data for both array and single-object endpoints.
src/server.ts Added /swagger-rebuild.js endpoint serving custom JS for the toolbar button, /mock-reset POST endpoint, and wired custom JS into Swagger UI setup.
src/core/swagger.ts Added OpenAPI spec entry for the /mock-reset endpoint.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

src/server.ts
Comment on lines +107 to +108
const cleared = mockDataStore.clear();
res.json({ message: 'Mock data store cleared', cleared });
Copilot AI
5 minutes ago
Bug: When config.cache is enabled, the schemaCache is checked before mockDataStore for single-object endpoints (lines 65-72 of router.ts). The /mock-reset endpoint only clears mockDataStore but does not clear schemaCache. This means after clicking "Rebuild Data", single-object endpoints will continue to serve the old cached data from schemaCache, making the reset appear broken for those endpoints.

You should also call schemaCache.clear() in the /mock-reset handler.

Suggested change
const cleared = mockDataStore.clear();
res.json({ message: 'Mock data store cleared', cleared });
const mockCleared = mockDataStore.clear();
const schemaCleared = schemaCache.clear();
res.json({
message: 'Mock data store cleared',
cleared: {
mockDataStore: mockCleared,
schemaCache: schemaCleared,
},
});
Copilot uses AI. Check for mistakes.
@Many0nne Reply...
src/core/router.ts
Comment on lines +94 to +99
// Use stable pool from the data store; generate and cache on first request
let pool = mockDataStore.getPool(mapping.typeName, filePath);
if (!pool) {
pool = generateMockArray(filePath, mapping.typeName, { arrayLength: POOL_SIZE });
mockDataStore.setPool(mapping.typeName, filePath, pool);
}
Copilot AI
5 minutes ago
Bug: The file watcher invalidates schemaCache on file changes (see src/utils/fileWatcher.ts:32,50) but does not invalidate mockDataStore. When hot-reload is enabled and a type file is modified, mockDataStore will continue serving stale mock data because the mockDataStore cache hit (line 95-99 for arrays, line 115-118 for singles) happens before any generation, and the stale entries are never removed.

mockDataStore should also be invalidated when type files change. Consider adding an invalidateFile method to MockDataStore (similar to SchemaCache.invalidateFile) and calling it from the file watcher.

Copilot uses AI. Check for mistakes.
@Many0nne Reply...
src/core/cache.ts
Comment on lines +132 to +170
export class MockDataStore {
private singles: Map<string, MockEntry<Record<string, unknown>>> = new Map();
private pools: Map<string, MockEntry<Record<string, unknown>[]>> = new Map();

private key(typeName: string, filePath: string): string {
return `${filePath}::${typeName}`;
}

getSingle(typeName: string, filePath: string): Record<string, unknown> | undefined {
return this.singles.get(this.key(typeName, filePath))?.data;
}

setSingle(typeName: string, filePath: string, data: Record<string, unknown>): void {
this.singles.set(this.key(typeName, filePath), { data, createdAt: Date.now() });
}

getPool(typeName: string, filePath: string): Record<string, unknown>[] | undefined {
return this.pools.get(this.key(typeName, filePath))?.data;
}

setPool(typeName: string, filePath: string, data: Record<string, unknown>[]): void {
this.pools.set(this.key(typeName, filePath), { data, createdAt: Date.now() });
}

clear(): { singles: number; pools: number } {
const singles = this.singles.size;
const pools = this.pools.size;
this.singles.clear();
this.pools.clear();
logger.info(`MockDataStore cleared: ${singles} single(s), ${pools} pool(s)`);
return { singles, pools };
}

getStats(): { singles: number; pools: number } {
return { singles: this.singles.size, pools: this.pools.size };
}
}

export const mockDataStore = new MockDataStore();
Copilot AI
5 minutes ago
The MockDataStore class is missing tests. The existing tests/core/cache.test.ts comprehensively tests SchemaCache (get/set, invalidateFile, clear, size, getStats). Since MockDataStore is in the same file and follows a similar pattern, it should have analogous test coverage—especially for getSingle/setSingle, getPool/setPool, clear (including its return value), and getStats.

Copilot uses AI. Check for mistakes.
68 changes: 68 additions & 0 deletions src/core/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,71 @@ export class SchemaCache {

// Global cache instance
export const schemaCache = new SchemaCache();

/**
* Always-on data store for stable mock data across requests.
* Caches both single object mocks and array pools independently of config.cache.
*/
interface MockEntry<T> {
data: T;
createdAt: number;
}

export class MockDataStore {
private singles: Map<string, MockEntry<Record<string, unknown>>> = new Map();
private pools: Map<string, MockEntry<Record<string, unknown>[]>> = new Map();

private key(typeName: string, filePath: string): string {
return `${filePath}::${typeName}`;
}

getSingle(typeName: string, filePath: string): Record<string, unknown> | undefined {
return this.singles.get(this.key(typeName, filePath))?.data;
}

setSingle(typeName: string, filePath: string, data: Record<string, unknown>): void {
this.singles.set(this.key(typeName, filePath), { data, createdAt: Date.now() });
}

getPool(typeName: string, filePath: string): Record<string, unknown>[] | undefined {
return this.pools.get(this.key(typeName, filePath))?.data;
}

setPool(typeName: string, filePath: string, data: Record<string, unknown>[]): void {
this.pools.set(this.key(typeName, filePath), { data, createdAt: Date.now() });
}

invalidateFile(filePath: string): void {
let count = 0;
for (const key of this.singles.keys()) {
if (key.startsWith(`${filePath}::`)) {
this.singles.delete(key);
count++;
}
}
for (const key of this.pools.keys()) {
if (key.startsWith(`${filePath}::`)) {
this.pools.delete(key);
count++;
}
}
if (count > 0) {
logger.info(`MockDataStore invalidated: ${count} entry/entries from ${filePath}`);
}
}

clear(): { singles: number; pools: number } {
const singles = this.singles.size;
const pools = this.pools.size;
this.singles.clear();
this.pools.clear();
logger.info(`MockDataStore cleared: ${singles} single(s), ${pools} pool(s)`);
return { singles, pools };
}

getStats(): { singles: number; pools: number } {
return { singles: this.singles.size, pools: this.pools.size };
}
}

export const mockDataStore = new MockDataStore();
26 changes: 16 additions & 10 deletions src/core/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Request, Response } from 'express';
import { ServerConfig } from '../types/config';
import { findTypeForUrl } from '../utils/typeMapping';
import { generateMockFromInterface, generateMockArray } from './parser';
import { schemaCache } from './cache';
import { schemaCache, mockDataStore } from './cache';
import { logger } from '../utils/logger';
import {
parseQueryParams,
Expand Down Expand Up @@ -91,10 +91,12 @@ export function dynamicRouteHandler(config: ServerConfig) {
return;
}

// Generate a fixed pool to simulate a full dataset
const pool = generateMockArray(filePath, mapping.typeName, {
arrayLength: POOL_SIZE,
});
// Use stable pool from the data store; generate and cache on first request
let pool = mockDataStore.getPool(mapping.typeName, filePath);
if (!pool) {
pool = generateMockArray(filePath, mapping.typeName, { arrayLength: POOL_SIZE });
mockDataStore.setPool(mapping.typeName, filePath, pool);
}

// Validate sort fields against schema keys
if (parsed.sort.length > 0 && pool.length > 0) {
Expand All @@ -109,13 +111,17 @@ export function dynamicRouteHandler(config: ServerConfig) {
res.status(forcedStatus || 200).json(applyPagination(pool, parsed));
return;
} else {
mockData = generateMockFromInterface(
filePath,
mapping.typeName
);
// Use stable single from the data store; generate and cache on first request
const stored = mockDataStore.getSingle(mapping.typeName, filePath);
if (stored) {
res.status(forcedStatus || 200).json(stored);
return;
}
mockData = generateMockFromInterface(filePath, mapping.typeName);
mockDataStore.setSingle(mapping.typeName, filePath, mockData as Record<string, unknown>);
}

// Store in cache if enabled
// Store in schema cache too if enabled
if (config.cache && !mapping.isArray) {
schemaCache.set(
mapping.typeName,
Expand Down
32 changes: 32 additions & 0 deletions src/core/swagger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,38 @@ export function generateOpenAPISpec(config: ServerConfig): Record<string, unknow
};
});

// Add mock-reset endpoint
paths['/mock-reset'] = {
post: {
summary: 'Rebuild mock data',
description:
'Clears all cached mock data (single objects and array pools). ' +
'Fresh data is regenerated on the next requests.',
responses: {
'200': {
description: 'Data store cleared',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
message: { type: 'string' },
cleared: {
type: 'object',
properties: {
singles: { type: 'integer', description: 'Number of single-object entries cleared' },
pools: { type: 'integer', description: 'Number of array pool entries cleared' },
},
},
},
},
},
},
},
},
},
};

// Add health endpoint
paths['/health'] = {
get: {
Expand Down
59 changes: 58 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { latencyMiddleware } from './middlewares/latency';
import { statusOverrideMiddleware } from './middlewares/statusOverride';
import { dynamicRouteHandler } from './core/router';
import { startFileWatcher } from './utils/fileWatcher';
import { schemaCache } from './core/cache';
import { schemaCache, mockDataStore } from './core/cache';
import { generateOpenAPISpec } from './core/swagger';
import type { FSWatcher } from 'chokidar';

Expand Down Expand Up @@ -53,12 +53,69 @@ export function createServer(config: ServerConfig): Express {
});
});

// Custom JS injected into Swagger UI — adds a "Rebuild Data" button
app.get('/swagger-rebuild.js', (_req, res) => {
res.type('js').send(`
window.addEventListener('load', function () {
var interval = setInterval(function () {
var container = document.getElementById('swagger-ui');
if (!container) return;
clearInterval(interval);

var toolbar = document.createElement('div');
toolbar.style.cssText = 'background:#1b1b1b;padding:8px 20px;display:flex;align-items:center;gap:12px;';

var label = document.createElement('span');
label.textContent = 'TS Mock API';
label.style.cssText = 'color:#fff;font-family:sans-serif;font-size:15px;font-weight:700;flex:1;';

var btn = document.createElement('button');
btn.textContent = 'Rebuild Data';
btn.title = 'Clear all cached mock data and regenerate on next requests';
btn.style.cssText = 'background:#49cc90;color:#fff;border:none;padding:6px 18px;border-radius:4px;cursor:pointer;font-size:13px;font-weight:700;font-family:sans-serif;';

btn.addEventListener('click', function () {
btn.disabled = true;
btn.textContent = 'Resetting\u2026';
fetch('/mock-reset', { method: 'POST' })
.then(function (r) { return r.json(); })
.then(function () {
btn.textContent = 'Done! Reloading\u2026';
setTimeout(function () { location.reload(); }, 600);
})
.catch(function () {
btn.style.background = '#f93e3e';
btn.textContent = 'Error \u2014 try again';
btn.disabled = false;
setTimeout(function () {
btn.style.background = '#49cc90';
btn.textContent = 'Rebuild Data';
}, 2500);
});
});

toolbar.appendChild(label);
toolbar.appendChild(btn);
container.parentNode.insertBefore(toolbar, container);
}, 100);
});
`);
});

// Mock data reset endpoint
app.post('/mock-reset', (_req, res) => {
const mockCleared = mockDataStore.clear();
schemaCache.clear();
res.json({ message: 'Mock data store cleared', cleared: mockCleared });
});

// Swagger documentation - use app.locals.swaggerSpec for dynamic updates
app.use('/api-docs', swaggerUi.serve, (req: Request, res: Response, next: NextFunction) => {
const spec = app.locals.swaggerSpec || swaggerSpec;
swaggerUi.setup(spec, {
customCss: '.swagger-ui .topbar { display: none }',
customSiteTitle: 'TS Mock Proxy API Docs',
customJs: '/swagger-rebuild.js',
})(req, res, next);
});

Expand Down
4 changes: 3 additions & 1 deletion src/utils/fileWatcher.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import chokidar from 'chokidar';
import * as path from 'path';
import { logger } from './logger';
import { schemaCache } from '../core/cache';
import { schemaCache, mockDataStore } from '../core/cache';

/**
* Configures and starts the file watcher for hot-reload
Expand Down Expand Up @@ -30,6 +30,7 @@ export function startFileWatcher(

// Invalidate the cache for this file
schemaCache.invalidateFile(filePath);
mockDataStore.invalidateFile(filePath);

// Call the callback if provided
if (onReload) {
Expand All @@ -48,6 +49,7 @@ export function startFileWatcher(

// Invalidate the cache for this deleted file
schemaCache.invalidateFile(filePath);
mockDataStore.invalidateFile(filePath);
})
.on('error', (error) => {
logger.error(`File watcher error: ${error.message}`);
Expand Down
Loading