340 lines
7.9 KiB
Plaintext
340 lines
7.9 KiB
Plaintext
/**
|
|
* API Response Optimization Middleware
|
|
* Implements response batching, field filtering, and pagination
|
|
*/
|
|
const logger = require("../config/logger");
|
|
|
|
/**
|
|
* Enable response compression for API endpoints
|
|
*/
|
|
const enableCompression = (req, res, next) => {
|
|
// Already handled by global compression middleware
|
|
next();
|
|
};
|
|
|
|
/**
|
|
* Add cache headers for GET requests
|
|
*/
|
|
const addCacheHeaders = (maxAge = 300) => {
|
|
return (req, res, next) => {
|
|
if (req.method === "GET" && !res.headersSent) {
|
|
try {
|
|
res.set({
|
|
"Cache-Control": `public, max-age=${maxAge}`,
|
|
Vary: "Accept-Encoding",
|
|
});
|
|
} catch (error) {
|
|
logger.warn("Failed to set cache headers", { error: error.message });
|
|
}
|
|
}
|
|
next();
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Field filtering middleware
|
|
* Allows clients to request only specific fields: ?fields=id,name,price
|
|
* SAFEGUARD: Validates field names to prevent injection attacks
|
|
*/
|
|
const fieldFilter = (req, res, next) => {
|
|
const originalJson = res.json.bind(res);
|
|
|
|
res.json = function (data) {
|
|
const fields = req.query.fields;
|
|
|
|
if (!fields || !data || res.headersSent) {
|
|
return originalJson(data);
|
|
}
|
|
|
|
try {
|
|
// SAFEGUARD: Validate field names (alphanumeric, underscore, dot only)
|
|
if (!/^[a-zA-Z0-9_.,\s]+$/.test(fields)) {
|
|
logger.warn("Invalid field filter attempted", { fields });
|
|
return originalJson(data);
|
|
}
|
|
|
|
const fieldList = fields.split(",").map((f) => f.trim()).filter(Boolean);
|
|
|
|
// SAFEGUARD: Limit number of fields
|
|
if (fieldList.length > 50) {
|
|
logger.warn("Too many fields requested", { count: fieldList.length });
|
|
return originalJson(data);
|
|
}
|
|
|
|
const filterObject = (obj) => {
|
|
if (!obj || typeof obj !== "object") return obj;
|
|
|
|
const filtered = {};
|
|
fieldList.forEach((field) => {
|
|
if (field in obj) {
|
|
filtered[field] = obj[field];
|
|
}
|
|
});
|
|
return filtered;
|
|
};
|
|
|
|
if (Array.isArray(data)) {
|
|
data = data.map(filterObject);
|
|
} else if (data.success !== undefined && data.data) {
|
|
// Handle wrapped responses
|
|
if (Array.isArray(data.data)) {
|
|
data.data = data.data.map(filterObject);
|
|
} else {
|
|
data.data = filterObject(data.data);
|
|
}
|
|
} else {
|
|
data = filterObject(data);
|
|
}
|
|
|
|
return originalJson(data);
|
|
} catch (error) {
|
|
logger.error("Field filter error", { error: error.message });
|
|
return originalJson(data);
|
|
}
|
|
};
|
|
|
|
next();
|
|
};
|
|
|
|
/**
|
|
* Pagination middleware
|
|
* Adds pagination support: ?page=1&limit=20
|
|
*/
|
|
const paginate = (defaultLimit = 20, maxLimit = 100) => {
|
|
return (req, res, next) => {
|
|
const page = Math.max(1, parseInt(req.query.page) || 1);
|
|
const limit = Math.min(
|
|
maxLimit,
|
|
Math.max(1, parseInt(req.query.limit) || defaultLimit)
|
|
);
|
|
const offset = (page - 1) * limit;
|
|
|
|
req.pagination = {
|
|
page,
|
|
limit,
|
|
offset,
|
|
maxLimit,
|
|
};
|
|
|
|
// Helper to add pagination info to response
|
|
res.paginate = (data, total) => {
|
|
const totalPages = Math.ceil(total / limit);
|
|
return res.json({
|
|
success: true,
|
|
data,
|
|
pagination: {
|
|
page,
|
|
limit,
|
|
total,
|
|
totalPages,
|
|
hasNext: page < totalPages,
|
|
hasPrev: page > 1,
|
|
},
|
|
});
|
|
};
|
|
|
|
next();
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Response time tracking
|
|
*/
|
|
const trackResponseTime = (req, res, next) => {
|
|
const start = Date.now();
|
|
|
|
res.on("finish", () => {
|
|
const duration = Date.now() - start;
|
|
|
|
// Log slow requests
|
|
if (duration > 1000) {
|
|
logger.warn("Slow API request", {
|
|
method: req.method,
|
|
path: req.path,
|
|
duration: `${duration}ms`,
|
|
status: res.statusCode,
|
|
});
|
|
}
|
|
|
|
// Add response time header only if headers haven't been sent
|
|
if (!res.headersSent) {
|
|
res.set("X-Response-Time", `${duration}ms`);
|
|
}
|
|
});
|
|
|
|
next();
|
|
};
|
|
|
|
/**
|
|
* ETag generation for GET requests
|
|
* SAFEGUARD: Checks headersSent before setting headers
|
|
*/
|
|
const generateETag = (req, res, next) => {
|
|
if (req.method !== "GET") {
|
|
return next();
|
|
}
|
|
|
|
const originalJson = res.json.bind(res);
|
|
|
|
res.json = function (data) {
|
|
try {
|
|
// SAFEGUARD: Don't process if headers already sent
|
|
if (res.headersSent) {
|
|
return originalJson(data);
|
|
}
|
|
|
|
// Generate simple ETag from stringified data
|
|
const dataStr = JSON.stringify(data);
|
|
const etag = `W/"${Buffer.from(dataStr).length.toString(16)}"`;
|
|
|
|
// Check if client has cached version
|
|
if (req.headers["if-none-match"] === etag) {
|
|
res.status(304).end();
|
|
return;
|
|
}
|
|
|
|
res.set("ETag", etag);
|
|
return originalJson(data);
|
|
} catch (error) {
|
|
logger.error("ETag generation error", { error: error.message });
|
|
return originalJson(data);
|
|
}
|
|
};
|
|
|
|
next();
|
|
};
|
|
|
|
/**
|
|
* JSON response size optimization
|
|
* Removes null values and compacts responses
|
|
*/
|
|
const optimizeJSON = (req, res, next) => {
|
|
const originalJson = res.json.bind(res);
|
|
|
|
res.json = function (data) {
|
|
if (data && typeof data === "object") {
|
|
data = removeNulls(data);
|
|
}
|
|
return originalJson(data);
|
|
};
|
|
|
|
next();
|
|
};
|
|
|
|
function removeNulls(obj) {
|
|
if (Array.isArray(obj)) {
|
|
return obj.map(removeNulls);
|
|
}
|
|
|
|
if (obj !== null && typeof obj === "object") {
|
|
return Object.entries(obj).reduce((acc, [key, value]) => {
|
|
if (value !== null && value !== undefined) {
|
|
acc[key] = removeNulls(value);
|
|
}
|
|
return acc;
|
|
}, {});
|
|
}
|
|
|
|
return obj;
|
|
}
|
|
|
|
/**
|
|
* Batch request handler
|
|
* Allows multiple API calls in a single request
|
|
* POST /api/batch with body: { requests: [{ method, url, body }] }
|
|
* SAFEGUARD: Enhanced validation and error handling
|
|
*/
|
|
const batchHandler = async (req, res) => {
|
|
try {
|
|
const { requests } = req.body;
|
|
|
|
// SAFEGUARD: Validate requests array
|
|
if (!Array.isArray(requests) || requests.length === 0) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: "Invalid batch request format",
|
|
});
|
|
}
|
|
|
|
// SAFEGUARD: Limit batch size
|
|
if (requests.length > 10) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: "Maximum 10 requests per batch",
|
|
});
|
|
}
|
|
|
|
// SAFEGUARD: Validate each request structure
|
|
const isValid = requests.every(req =>
|
|
req && typeof req === 'object' &&
|
|
req.method && req.url &&
|
|
['GET', 'POST', 'PUT', 'DELETE'].includes(req.method.toUpperCase())
|
|
);
|
|
|
|
if (!isValid) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: "Invalid request format in batch",
|
|
});
|
|
}
|
|
|
|
const results = await Promise.allSettled(
|
|
requests.map(async (request) => {
|
|
try {
|
|
// This would require implementation of internal request handling
|
|
// For now, return a placeholder
|
|
return {
|
|
status: 200,
|
|
data: { message: "Batch processing not fully implemented" },
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
status: 500,
|
|
error: error.message,
|
|
};
|
|
}
|
|
})
|
|
);
|
|
|
|
// SAFEGUARD: Check if response already sent
|
|
if (res.headersSent) {
|
|
logger.warn("Response already sent in batch handler");
|
|
return;
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
results: results.map((result, index) => ({
|
|
...requests[index],
|
|
...result,
|
|
})),
|
|
});
|
|
} catch (error) {
|
|
logger.error("Batch handler error", { error: error.message, stack: error.stack });
|
|
if (!res.headersSent) {
|
|
res.status(500).json({
|
|
success: false,
|
|
error: "Batch processing failed",
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
results: results.map((result, index) => ({
|
|
...requests[index],
|
|
...result,
|
|
})),
|
|
});
|
|
};
|
|
|
|
module.exports = {
|
|
enableCompression,
|
|
addCacheHeaders,
|
|
fieldFilter,
|
|
paginate,
|
|
trackResponseTime,
|
|
generateETag,
|
|
optimizeJSON,
|
|
batchHandler,
|
|
};
|