UNPKG

serverless-api-gateway-caching

Version:

A plugin for the serverless framework which helps with configuring caching for API Gateway endpoints.

282 lines (248 loc) 9.73 kB
const isEmpty = require('lodash.isempty'); const { retrieveRestApiId } = require('./restApiId'); const MAX_PATCH_OPERATIONS_PER_STAGE_UPDATE = 80; const BASE_RETRY_DELAY_MS = 500; String.prototype.replaceAll = function (search, replacement) { let target = this; return target .split(search) .join(replacement); }; const escapeJsonPointer = path => { return path .replaceAll('~', '~0') .replaceAll('/', '~1') .replaceAll('{', '\{') .replaceAll('}', '\}'); } const createPatchForStage = (settings) => { if (settings.apiGatewayIsShared) { return []; } let patch = [{ op: 'replace', path: '/cacheClusterEnabled', value: `${settings.cachingEnabled}` }]; if (settings.cachingEnabled) { patch.push({ op: 'replace', path: '/cacheClusterSize', value: `${settings.cacheClusterSize}` }); patch.push({ op: 'replace', path: '/*/*/caching/dataEncrypted', value: `${settings.dataEncrypted}` }); patch.push({ op: 'replace', path: '/*/*/caching/ttlInSeconds', value: `${settings.cacheTtlInSeconds}` }); } return patch; } const createPatchForMethod = (path, method, endpointSettings, stageState) => { let patchPath = patchPathFor(path, method); let patch = [{ op: 'replace', path: `/${patchPath}/caching/enabled`, value: `${endpointSettings.cachingEnabled}` }]; if (endpointSettings.cachingEnabled) { patch.push({ op: 'replace', path: `/${patchPath}/caching/ttlInSeconds`, value: `${endpointSettings.cacheTtlInSeconds}` }); patch.push({ op: 'replace', path: `/${patchPath}/caching/dataEncrypted`, value: `${endpointSettings.dataEncrypted}` }); } if (endpointSettings.perKeyInvalidation) { patch.push({ op: 'replace', path: `/${patchPath}/caching/requireAuthorizationForCacheControl`, value: `${endpointSettings.perKeyInvalidation.requireAuthorization}` }); if (endpointSettings.perKeyInvalidation.requireAuthorization) { patch.push({ op: 'replace', path: `/${patchPath}/caching/unauthorizedCacheControlHeaderStrategy`, value: `${endpointSettings.perKeyInvalidation.handleUnauthorizedRequests}` }); } } if (endpointSettings.inheritCloudWatchSettingsFromStage && stageState.methodSettings['*/*']) { if (stageState.methodSettings['*/*'].loggingLevel) { patch.push({ op: 'replace', path: `/${patchPath}/logging/loglevel`, value: stageState.methodSettings['*/*'].loggingLevel, }); } patch.push({ op: 'replace', path: `/${patchPath}/logging/dataTrace`, value: stageState.methodSettings['*/*'].dataTraceEnabled ? 'true' : 'false', }); patch.push({ op: 'replace', path: `/${patchPath}/metrics/enabled`, value: stageState.methodSettings['*/*'].metricsEnabled ? 'true' : 'false', }); } return patch; } const httpEventOf = (lambda, endpointSettings) => { let httpEvents = lambda.events.filter(e => e.http != undefined) .map(e => { if (typeof (e.http) === 'string') { let parts = e.http.split(' '); return { method: parts[0], path: parts[1] } } else { return { method: e.http.method, path: e.http.path } } }); const event = httpEvents.filter(e => (e.path === endpointSettings.path) || (`/${e.path}` === endpointSettings.path) || (e.path == endpointSettings.pathWithoutGlobalBasePath) || (`/${e.path}` == endpointSettings.pathWithoutGlobalBasePath)) .filter(e => e.method.toUpperCase() == endpointSettings.method.toUpperCase()); return event; } const createPatchForEndpoint = (endpointSettings, serverless, stageState) => { let lambda = serverless.service.getFunction(endpointSettings.functionName); if (isEmpty(lambda.events)) { serverless.cli.log(`[serverless-api-gateway-caching] Lambda ${endpointSettings.functionName} has not defined events.`); return; } const httpEvents = httpEventOf(lambda, endpointSettings); if (isEmpty(httpEvents)) { serverless.cli.log(`[serverless-api-gateway-caching] Lambda ${endpointSettings.functionName} has not defined any HTTP events.`); return; } const { path, method } = endpointSettings; let patch = []; if (method.toUpperCase() == 'ANY') { let httpMethodsToDisableCacheFor = ['DELETE', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT']; // TODO could come from settings, vNext for (let methodWithCacheDisabled of httpMethodsToDisableCacheFor) { patch = patch.concat(createPatchForMethod(path, methodWithCacheDisabled, { cachingEnabled: false })); }; patch = patch.concat(createPatchForMethod(path, 'GET', endpointSettings, stageState)); } else { patch = patch.concat(createPatchForMethod(path, method, endpointSettings, stageState)); } return patch; } const patchPathFor = (path, method) => { let escapedPath = escapeJsonPointer(path); if (!escapedPath.startsWith('~1')) { escapedPath = `~1${escapedPath}`; } let patchPath = `${escapedPath}/${method.toUpperCase()}`; return patchPath; } const updateStageFor = async (serverless, params, stage, region) => { if (params.patchOperations.length == 0) { serverless.cli.log(`[serverless-api-gateway-caching] Will not update API Gateway cache settings because apiGatewayIsShared is set to true.`); return; } const chunkSize = MAX_PATCH_OPERATIONS_PER_STAGE_UPDATE; const { patchOperations } = params; const paramsInChunks = []; if (patchOperations.length > chunkSize) { for (let i = 0; i < patchOperations.length; i += chunkSize) { paramsInChunks.push({ restApiId: params.restApiId, stageName: params.stageName, patchOperations: patchOperations.slice(i, i + chunkSize) }); } } else { paramsInChunks.push(params); } for (const [index, chunk] of paramsInChunks.entries()) { serverless.cli.log(`[serverless-api-gateway-caching] Updating API Gateway cache settings (${index + 1} of ${paramsInChunks.length}).`); await applyUpdateStageForChunk(chunk, serverless, stage, region); } serverless.cli.log(`[serverless-api-gateway-caching] Done updating API Gateway cache settings.`); } const applyUpdateStageForChunk = async (chunk, serverless, stage, region) => { const maxRetries = 10; const baseDelay = BASE_RETRY_DELAY_MS; let attempt = 0; while (attempt < maxRetries) { try { serverless.cli.log(`[serverless-api-gateway-caching] Updating API Gateway cache settings. Attempt ${attempt + 1}.`); await serverless.providers.aws.request('APIGateway', 'updateStage', chunk, stage, region); return; } catch (error) { // Check for specific error code first, fallback to message check if ( (error.code === 'ConflictException' || error.message.includes('A previous change is still in progress')) ) { attempt++; if (attempt >= maxRetries) { serverless.cli.log(`[serverless-api-gateway-caching] Maximum retries (${maxRetries}) reached. Failed to update API Gateway cache settings.`); // Log the full error for better debugging before throwing console.error('[serverless-api-gateway-caching] Final error details:', error); throw new Error(`Failed to update API Gateway cache settings after ${maxRetries} retries: ${error.message}`); } const delay = baseDelay * 2 ** attempt; serverless.cli.log(`[serverless-api-gateway-caching] Retrying (${attempt}/${maxRetries}) after ${delay}ms due to error: ${error.message}`); await new Promise((resolve) => setTimeout(resolve, delay)); } else { console.error('[serverless-api-gateway-caching] Non-retryable error during update:', error); // Throw immediately for non-retryable errors or if string/code doesn't match throw new Error(`Failed to update API Gateway cache settings: ${error.message}`); } } } } const updateStageCacheSettings = async (settings, serverless) => { // do nothing if caching settings are not defined if (settings.cachingEnabled == undefined) { return; } let restApiId = await retrieveRestApiId(serverless, settings); let stageState = await serverless.providers.aws.request('APIGateway', 'getStage', { restApiId, stageName: settings.stage }, { region: settings.region }); let patchOps = createPatchForStage(settings); let endpointsWithCachingEnabled = settings.endpointSettings.filter(e => e.cachingEnabled) .concat(settings.additionalEndpointSettings.filter(e => e.cachingEnabled)); if (settings.cachingEnabled && isEmpty(endpointsWithCachingEnabled)) { serverless.cli.log(`[serverless-api-gateway-caching] [WARNING] API Gateway caching is enabled but none of the endpoints have caching enabled`); } for (let endpointSettings of settings.endpointSettings) { let endpointPatch = createPatchForEndpoint(endpointSettings, serverless, stageState); patchOps = patchOps.concat(endpointPatch); } // TODO handle 'ANY' method, if possible for (let additionalEndpointSettings of settings.additionalEndpointSettings) { let endpointPatch = createPatchForMethod(additionalEndpointSettings.path, additionalEndpointSettings.method, additionalEndpointSettings, stageState); patchOps = patchOps.concat(endpointPatch); } let params = { restApiId, stageName: settings.stage, patchOperations: patchOps } await updateStageFor(serverless, params, settings.stage, settings.region); } module.exports = { updateStageCacheSettings, applyUpdateStageForChunk }