feat: implement API response caching with node-cache

- Add in-memory cache middleware with configurable TTL
- Cache GET endpoints: streets (5min), events (2min), posts (1min), rewards (10min)
- Automatic cache invalidation on POST/PUT/DELETE operations
- Add cache statistics endpoint (GET /api/cache/stats)
- Add cache management endpoint (DELETE /api/cache)
- Cache hit rate tracking and monitoring
- Pattern-based cache invalidation
- Optimized for Raspberry Pi deployment (lightweight in-memory)

🤖 Generated with Claude

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
William Valentin
2025-11-03 13:25:50 -08:00
parent 43c2e76070
commit ae77e30ffb
10 changed files with 301 additions and 1 deletions
+100
View File
@@ -0,0 +1,100 @@
const NodeCache = require('node-cache');
const logger = require('../utils/logger');
// Initialize cache with default TTL of 5 minutes and check period of 2 minutes
const cache = new NodeCache({ stdTTL: 300, checkperiod: 120 });
/**
* Cache middleware factory
* @param {number} ttlSeconds - Time to live in seconds for cached responses
* @returns {Function} Express middleware function
*/
const getCacheMiddleware = (ttlSeconds) => (req, res, next) => {
const key = req.originalUrl;
const cachedResponse = cache.get(key);
if (cachedResponse) {
logger.info(`Cache hit for key: ${key}`);
return res.json(cachedResponse);
}
logger.info(`Cache miss for key: ${key}`);
// Store the original res.json method
const originalJson = res.json.bind(res);
// Override res.json to cache the response
res.json = (body) => {
cache.set(key, body, ttlSeconds);
return originalJson(body);
};
next();
};
/**
* Invalidate cache by exact key(s)
* @param {string|string[]} keys - Cache key or array of keys to invalidate
*/
const invalidateCache = (keys) => {
if (Array.isArray(keys)) {
keys.forEach(key => {
logger.info(`Invalidating cache for key: ${key}`);
cache.del(key);
});
} else {
logger.info(`Invalidating cache for key: ${keys}`);
cache.del(keys);
}
};
/**
* Invalidate all cache keys matching a pattern
* @param {string} pattern - Pattern to match (keys starting with this string)
*/
const invalidateCacheByPattern = (pattern) => {
const keys = cache.keys();
const keysToDelete = keys.filter(key => key.startsWith(pattern));
if (keysToDelete.length > 0) {
logger.info(`Invalidating cache by pattern: ${pattern}, deleted ${keysToDelete.length} keys`);
cache.del(keysToDelete);
} else {
logger.debug(`No cache keys found matching pattern: ${pattern}`);
}
};
/**
* Get cache statistics
* @returns {Object} Cache statistics
*/
const getCacheStats = () => {
const stats = cache.getStats();
const keys = cache.keys();
return {
keys: keys.length,
hits: stats.hits,
misses: stats.misses,
hitRate: stats.hits > 0 ? (stats.hits / (stats.hits + stats.misses) * 100).toFixed(2) + '%' : '0%',
ksize: stats.ksize,
vsize: stats.vsize
};
};
/**
* Clear all cache
*/
const clearCache = () => {
const keyCount = cache.keys().length;
cache.flushAll();
logger.info(`Cleared all cache (${keyCount} keys)`);
};
module.exports = {
getCacheMiddleware,
invalidateCache,
invalidateCacheByPattern,
getCacheStats,
clearCache
};
+49 -1
View File
@@ -15,6 +15,7 @@
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-mongo-sanitize": "^2.2.0",
"express-rate-limit": "^8.2.1",
"express-validator": "^7.3.0",
"globals": "^16.4.0",
@@ -22,8 +23,10 @@
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"nano": "^10.1.4",
"node-cache": "^5.1.2",
"socket.io": "^4.8.1",
"stripe": "^17.7.0"
"stripe": "^17.7.0",
"xss-clean": "^0.1.4"
},
"devDependencies": {
"@types/jest": "^30.0.0",
@@ -2055,6 +2058,15 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/cloudinary": {
"version": "2.8.0",
"license": "MIT",
@@ -2755,6 +2767,15 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-mongo-sanitize": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/express-mongo-sanitize/-/express-mongo-sanitize-2.2.0.tgz",
"integrity": "sha512-PZBs5nwhD6ek9ZuP+W2xmpvcrHwXZxD5GdieX2dsjPbAbH4azOkrHbycBud2QRU+YQF1CT+pki/lZGedHgo/dQ==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/express-rate-limit": {
"version": "8.2.1",
"license": "MIT",
@@ -4396,6 +4417,18 @@
"version": "3.1.1",
"license": "MIT"
},
"node_modules/node-cache": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz",
"integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==",
"license": "MIT",
"dependencies": {
"clone": "2.x"
},
"engines": {
"node": ">= 8.0.0"
}
},
"node_modules/node-int64": {
"version": "0.4.0",
"dev": true,
@@ -5852,6 +5885,21 @@
"node": ">=0.4.0"
}
},
"node_modules/xss-clean": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/xss-clean/-/xss-clean-0.1.4.tgz",
"integrity": "sha512-4hArTFHYxrifK9VXOu/zFvwjTXVjKByPi6woUHb1IqxlX0Z4xtFBRjOhTKuYV/uE1VswbYsIh5vUEYp7MmoISQ==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT",
"dependencies": {
"xss-filters": "1.2.7"
}
},
"node_modules/xss-filters": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/xss-filters/-/xss-filters-1.2.7.tgz",
"integrity": "sha512-KzcmYT/f+YzcYrYRqw6mXxd25BEZCxBQnf+uXTopQDIhrmiaLwO+f+yLsIvvNlPhYvgff8g3igqrBxYh9k8NbQ=="
},
"node_modules/xtend": {
"version": "4.0.2",
"license": "MIT",
+1
View File
@@ -31,6 +31,7 @@
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"nano": "^10.1.4",
"node-cache": "^5.1.2",
"socket.io": "^4.8.1",
"stripe": "^17.7.0",
"xss-clean": "^0.1.4"
+43
View File
@@ -0,0 +1,43 @@
const express = require("express");
const auth = require("../middleware/auth");
const { asyncHandler } = require("../middleware/errorHandler");
const { getCacheStats, clearCache } = require("../middleware/cache");
const router = express.Router();
/**
* GET /api/cache/stats
* Get cache statistics
* @access Private (authenticated users)
*/
router.get(
"/stats",
auth,
asyncHandler(async (req, res) => {
const stats = getCacheStats();
res.json({
status: "operational",
...stats,
timestamp: new Date().toISOString()
});
})
);
/**
* DELETE /api/cache
* Clear all cache
* @access Private (authenticated users - in production should be admin only)
*/
router.delete(
"/",
auth,
asyncHandler(async (req, res) => {
clearCache();
res.json({
msg: "Cache cleared successfully",
timestamp: new Date().toISOString()
});
})
);
module.exports = router;
+5
View File
@@ -9,6 +9,7 @@ const {
} = require("../middleware/validators/eventValidator");
const { paginate, buildPaginatedResponse } = require("../middleware/pagination");
const couchdbService = require("../services/couchdbService");
const { getCacheMiddleware, invalidateCacheByPattern } = require("../middleware/cache");
const router = express.Router();
@@ -16,6 +17,7 @@ const router = express.Router();
router.get(
"/",
paginate,
getCacheMiddleware(120), // Cache for 2 minutes
asyncHandler(async (req, res) => {
const { page, limit } = req.pagination;
@@ -50,6 +52,9 @@ router.post(
location,
});
// Invalidate events cache
invalidateCacheByPattern('/api/events');
res.json(event);
}),
);
+5
View File
@@ -10,6 +10,7 @@ const {
const { upload, handleUploadError } = require("../middleware/upload");
const { uploadImage, deleteImage } = require("../config/cloudinary");
const { paginate, buildPaginatedResponse } = require("../middleware/pagination");
const { getCacheMiddleware, invalidateCacheByPattern } = require("../middleware/cache");
const router = express.Router();
@@ -17,6 +18,7 @@ const router = express.Router();
router.get(
"/",
paginate,
getCacheMiddleware(60), // Cache for 1 minute
asyncHandler(async (req, res) => {
const { skip, limit, page } = req.pagination;
@@ -58,6 +60,9 @@ router.post(
const post = await Post.create(postData);
// Invalidate posts cache
invalidateCacheByPattern('/api/posts');
res.json({
post,
pointsAwarded: 5, // Standard post creation points
+5
View File
@@ -7,6 +7,7 @@ const {
rewardIdValidation,
} = require("../middleware/validators/rewardValidator");
const { paginate, buildPaginatedResponse } = require("../middleware/pagination");
const { getCacheMiddleware, invalidateCacheByPattern } = require("../middleware/cache");
const router = express.Router();
@@ -14,6 +15,7 @@ const router = express.Router();
router.get(
"/",
paginate,
getCacheMiddleware(600), // Cache for 10 minutes
asyncHandler(async (req, res) => {
const { page, limit } = req.pagination;
@@ -37,6 +39,9 @@ router.post(
isPremium,
});
// Invalidate rewards cache
invalidateCacheByPattern('/api/rewards');
res.json(reward);
}),
);
+9
View File
@@ -8,12 +8,14 @@ const {
createStreetValidation,
streetIdValidation,
} = require("../middleware/validators/streetValidator");
const { getCacheMiddleware, invalidateCacheByPattern } = require("../middleware/cache");
const router = express.Router();
// Get all streets (with pagination and filtering)
router.get(
"/",
getCacheMiddleware(300), // Cache for 5 minutes
asyncHandler(async (req, res) => {
const { buildPaginatedResponse } = require("../middleware/pagination");
@@ -81,6 +83,7 @@ router.get(
router.get(
"/:id",
streetIdValidation,
getCacheMiddleware(300), // Cache for 5 minutes
asyncHandler(async (req, res) => {
const street = await Street.findById(req.params.id);
if (!street) {
@@ -109,6 +112,9 @@ router.post(
location,
});
// Invalidate streets cache
invalidateCacheByPattern('/api/streets');
res.json(street);
}),
);
@@ -156,6 +162,9 @@ router.put(
user.stats.streetsAdopted = user.adoptedStreets.length;
await user.save();
// Invalidate streets cache
invalidateCacheByPattern('/api/streets');
// Award points for street adoption using CouchDB service
const updatedUser = await couchdbService.updateUserPoints(
req.user.id,
+2
View File
@@ -136,6 +136,7 @@ const badgesRoutes = require("./routes/badges");
const aiRoutes = require("./routes/ai");
const paymentRoutes = require("./routes/payments");
const userRoutes = require("./routes/users");
const cacheRoutes = require("./routes/cache");
// Apply rate limiters
app.use("/api/auth/register", authLimiter);
@@ -236,6 +237,7 @@ app.use("/api/badges", badgesRoutes);
app.use("/api/ai", aiRoutes);
app.use("/api/payments", paymentRoutes);
app.use("/api/users", userRoutes);
app.use("/api/cache", cacheRoutes);
app.get("/", (req, res) => {
res.send("Street Adoption App Backend");
+82
View File
@@ -0,0 +1,82 @@
/**
* Manual test script for cache middleware
* Run with: node backend/test-cache.js
*/
const { getCacheMiddleware, invalidateCacheByPattern, getCacheStats, clearCache } = require('./middleware/cache');
console.log('Testing Cache Middleware\n');
console.log('=' .repeat(50));
// Test 1: Create mock request/response objects
console.log('\n1. Testing getCacheMiddleware...');
const mockReq = { originalUrl: '/api/streets?page=1' };
let responseSent = null;
const mockRes = {
json: (data) => {
responseSent = data;
console.log(' Response sent:', JSON.stringify(data).substring(0, 50) + '...');
return mockRes;
}
};
const mockNext = () => console.log(' Next middleware called');
// First request - cache miss
console.log('\n First request (should be cache miss):');
const middleware = getCacheMiddleware(60); // 60 second TTL
middleware(mockReq, mockRes, mockNext);
// Simulate response
mockRes.json({ streets: ['Main St', 'Elm St'], count: 2 });
// Test 2: Second request - should hit cache
console.log('\n Second request (should be cache hit):');
responseSent = null;
const mockReq2 = { originalUrl: '/api/streets?page=1' };
const mockRes2 = {
json: (data) => {
responseSent = data;
console.log(' Cached response returned:', JSON.stringify(data).substring(0, 50) + '...');
return mockRes2;
}
};
middleware(mockReq2, mockRes2, () => console.log(' Should NOT call next!'));
// Test 3: Cache statistics
console.log('\n2. Testing getCacheStats...');
const stats = getCacheStats();
console.log(' Cache stats:', JSON.stringify(stats, null, 2));
// Test 4: Pattern invalidation
console.log('\n3. Testing invalidateCacheByPattern...');
console.log(' Invalidating all /api/streets cache...');
invalidateCacheByPattern('/api/streets');
const statsAfterInvalidation = getCacheStats();
console.log(' Cache stats after invalidation:', JSON.stringify(statsAfterInvalidation, null, 2));
// Test 5: Different URL should not be in cache
console.log('\n4. Testing cache miss for different URL...');
const mockReq3 = { originalUrl: '/api/events?page=1' };
const mockRes3 = {
json: (data) => {
console.log(' New URL response sent');
return mockRes3;
}
};
middleware(mockReq3, mockRes3, () => console.log(' Next middleware called (cache miss)'));
// Test 6: Clear all cache
console.log('\n5. Testing clearCache...');
clearCache();
const statsAfterClear = getCacheStats();
console.log(' Cache stats after clear:', JSON.stringify(statsAfterClear, null, 2));
console.log('\n' + '='.repeat(50));
console.log('Cache middleware tests completed!');
console.log('\nIntegration points:');
console.log(' - GET /api/streets (5 min cache)');
console.log(' - GET /api/events (2 min cache)');
console.log(' - GET /api/posts (1 min cache)');
console.log(' - GET /api/rewards (10 min cache)');
console.log(' - GET /api/cache/stats (cache statistics)');
console.log(' - DELETE /api/cache (clear all cache)');