├── .circleci └── config.yml ├── .gitignore ├── .npmignore ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── ApiGatewayCachingSettings.js ├── UnauthorizedCacheControlHeaderStrategy.js ├── apiGatewayCachingPlugin.js ├── cacheKeyParameters.js ├── restApiId.js └── stageCache.js └── test ├── configuring-a-default-base-path.js ├── configuring-cache-key-parameters-for-additional-endpoints.js ├── configuring-cache-key-parameters.js ├── configuring-rest-api-id.js ├── creating-plugin.js ├── creating-settings.js ├── determining-api-gateway-resource-name.js ├── inheriting-cloudwatch-settings-from-stage.js ├── model ├── Serverless.js ├── ServerlessFunction.js └── templates │ ├── aws-api-gateway-method.json │ └── aws-lambda-function.json ├── steps ├── given.js └── when.js ├── updating-stage-cache-settings-for-additional-endpoints.js └── updating-stage-cache-settings.js /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | references: 4 | container_config: &container_config 5 | docker: 6 | - image: cimg/node:22.1.0 7 | poke_npmrc: &poke_npmrc 8 | run: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc 9 | 10 | restore_npm_cache: &restore_npm_cache 11 | restore_cache: 12 | keys: 13 | - v1-dependencies-{{ checksum "package.json" }} 14 | - v1-dependencies- 15 | 16 | save_npm_cache: &save_npm_cache 17 | save_cache: 18 | paths: 19 | - node_modules 20 | key: v1-dependencies-{{ checksum "package.json" }} 21 | 22 | create_test_results_dir: &create_test_results_dir 23 | run: 24 | command: | 25 | mkdir test-results 26 | mkdir test-results/mocha 27 | 28 | store_test_results: &store_test_results 29 | store_test_results: 30 | path: test-results 31 | 32 | jobs: 33 | dev: 34 | <<: *container_config 35 | steps: 36 | - *poke_npmrc 37 | - checkout 38 | - *restore_npm_cache 39 | - run: npm install 40 | - *save_npm_cache 41 | - *create_test_results_dir 42 | - run: 43 | environment: 44 | MOCHA_FILE: ./test-results/mocha/results.xml 45 | command: npm run test -- --reporter mocha-junit-reporter 46 | - *store_test_results 47 | 48 | live: 49 | <<: *container_config 50 | steps: 51 | - *poke_npmrc 52 | - checkout 53 | - *restore_npm_cache 54 | - run: npm install 55 | - *save_npm_cache 56 | - run: npm publish 57 | 58 | workflows: 59 | version: 2 60 | build: 61 | jobs: 62 | - dev 63 | - live: 64 | requires: 65 | - dev 66 | filters: 67 | branches: 68 | only: master 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # package directories 2 | node_modules 3 | 4 | # Serverless directories 5 | .serverless 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # package directories 2 | node_modules 3 | 4 | test 5 | .gitignore 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Tests", 9 | "type": "node", 10 | "request": "launch", 11 | "program": "${workspaceRoot}/node_modules/mocha/bin/_mocha", 12 | "args": [ 13 | "test/**/*.js", 14 | "-t", 15 | "10000", 16 | "--color" 17 | ] 18 | }, 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2022 Diana Ionita 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 11 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 13 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 14 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 15 | PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # serverless-api-gateway-caching 2 | 3 | [![CircleCI](https://circleci.com/gh/DianaIonita/serverless-api-gateway-caching.svg?style=svg)](https://circleci.com/gh/DianaIonita/serverless-api-gateway-caching) 4 | ![npm](https://img.shields.io/npm/v/serverless-api-gateway-caching.svg) 5 | [![npm downloads](https://img.shields.io/npm/dt/serverless-api-gateway-caching.svg?style=svg)](https://www.npmjs.com/package/serverless-api-gateway-caching) 6 | 7 | ## Intro 8 | A plugin for the serverless framework which helps with configuring caching for API Gateway endpoints. 9 | 10 | ## Quick Start 11 | * If you enable caching globally, it does *NOT* automatically enable caching for your endpoints - you have to be explicit about which endpoints should have caching enabled. 12 | However, disabling caching globally disables it across endpoints. 13 | 14 | ```yml 15 | plugins: 16 | - serverless-api-gateway-caching 17 | 18 | custom: 19 | # Enable or disable caching globally 20 | apiGatewayCaching: 21 | enabled: true 22 | 23 | functions: 24 | # Responses are cached 25 | list-all-cats: 26 | handler: rest_api/cats/get/handler.handle 27 | events: 28 | - http: 29 | path: /cats 30 | method: get 31 | caching: 32 | enabled: true 33 | 34 | # Responses are *not* cached 35 | update-cat: 36 | handler: rest_api/cat/post/handler.handle 37 | events: 38 | - http: 39 | path: /cat 40 | method: post 41 | 42 | # Responses are cached based on the 'pawId' path parameter and the 'Accept-Language' header 43 | get-cat-by-paw-id: 44 | handler: rest_api/cat/get/handler.handle 45 | events: 46 | - http: 47 | path: /cats/{pawId} 48 | method: get 49 | caching: 50 | enabled: true 51 | cacheKeyParameters: 52 | - name: request.path.pawId 53 | - name: request.header.Accept-Language 54 | ``` 55 | 56 | ## Only supports REST API 57 | 58 | This plugin only supports REST API, because HTTP API does not support API Gateway Caching at the time of this writing. See [docs](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-vs-rest.html). 59 | 60 | ## Time-to-live, encryption, cache invalidation settings 61 | 62 | You can use the `apiGatewayCaching` section ("global settings") to quickly configure cache time-to-live, data encryption and per-key cache invalidation for all endpoints. The settings are inherited by each endpoint for which caching is enabled. 63 | 64 | Cache `clusterSize` can only be specified under global settings, because there's only one cluster per API Gateway stage. 65 | 66 | The setting `endpointsInheritCloudWatchSettingsFromStage` makes sure that settings like `Log Level` and whether to `Enable detailed CloudWatch metrics` are copied over from the stage to each endpoint's settings. It is `true` by default. 67 | 68 | ```yml 69 | plugins: 70 | - serverless-api-gateway-caching 71 | 72 | custom: 73 | apiGatewayCaching: 74 | enabled: true 75 | clusterSize: '0.5' # defaults to '0.5' 76 | ttlInSeconds: 300 # defaults to the maximum allowed: 3600 77 | dataEncrypted: true # defaults to false 78 | perKeyInvalidation: 79 | requireAuthorization: true # default is true 80 | handleUnauthorizedRequests: Ignore # default is "IgnoreWithWarning". 81 | endpointsInheritCloudWatchSettingsFromStage: true # default is true 82 | ``` 83 | 84 | ### Configuring per-endpoint settings 85 | 86 | If you need a specific endpoint to override any of the global settings, you can add them like this: 87 | 88 | ```yml 89 | plugins: 90 | - serverless-api-gateway-caching 91 | 92 | custom: 93 | apiGatewayCaching: 94 | enabled: true 95 | ttlInSeconds: 300 96 | 97 | functions: 98 | get-cat-by-paw-id: 99 | handler: rest_api/cat/get/handler.handle 100 | events: 101 | - http: 102 | path: /cats/{pawId} 103 | method: get 104 | caching: 105 | enabled: true 106 | ttlInSeconds: 3600 # overrides the global setting for ttlInSeconds 107 | dataEncrypted: true # default is false 108 | inheritCloudWatchSettingsFromStage: false # default is true 109 | perKeyInvalidation: 110 | requireAuthorization: true # default is true 111 | handleUnauthorizedRequests: Fail # default is "IgnoreWithWarning" 112 | cacheKeyParameters: 113 | - name: request.path.pawId 114 | - name: request.header.Accept-Language 115 | ``` 116 | 117 | ## Good to know 118 | * For HTTP method `ANY`, caching will be enabled only for the `GET` method and disabled for the other methods. 119 | 120 | ## Per-key cache invalidation 121 | If you don't configure per-key cache invalidation authorization, by default it is *required*. 122 | You can configure how to handle unauthorized requests to invalidate a cache key using the options: 123 | * `Ignore` - ignores the request to invalidate the cache key. 124 | * `IgnoreWithWarning` - ignores the request to invalidate and adds a `warning` header in the response. 125 | * `Fail` - fails the request to invalidate the cache key with a 403 response status code. 126 | 127 | ## Cache key parameters 128 | You would define these for endpoints where the response varies according to one or more request parameters. API Gateway creates entries in the cache keyed based on them. 129 | Please note that cache key parameters are *case sensitive*. 130 | 131 | ### Quick overview of how cache entries are created 132 | Suppose the configuration looks like this: 133 | 134 | ```yml 135 | plugins: 136 | - serverless-api-gateway-caching 137 | 138 | custom: 139 | apiGatewayCaching: 140 | enabled: true 141 | 142 | functions: 143 | get-cat-by-paw-id: 144 | handler: rest_api/cat/get/handler.handle 145 | events: 146 | - http: 147 | path: /cats/{pawId} 148 | method: get 149 | caching: 150 | enabled: true 151 | cacheKeyParameters: 152 | - name: request.path.pawId 153 | - name: request.querystring.catName 154 | ``` 155 | 156 | When the endpoint is hit, API Gateway will create cache entries based on the `pawId` path parameter and the `catName` query string parameter. For instance: 157 | - `GET /cats/4` will create a cache entry for `pawId=4` and `catName` as `undefined`. 158 | - `GET /cats/34?catName=Dixon` will create a cache entry for `pawId=34` and `catName=Dixon`. 159 | - `GET /cats/72?catName=Tsunami&furColour=white` will create a cache entry for `pawId=72` and `catName=Tsunami`, but will ignore the `furColour` query string parameter. That means that a subsequent request to `GET /cats/72?catName=Tsunami&furColour=black` will return the cached response for `pawId=72` and `catName=Tsunami`. 160 | 161 | ### Cache key parameters from the path, query string and header 162 | When an endpoint varies its responses based on values found in the `path`, `query string` or `header`, you can specify all the parameter names as cache key parameters: 163 | 164 | ```yml 165 | plugins: 166 | - serverless-api-gateway-caching 167 | 168 | custom: 169 | apiGatewayCaching: 170 | enabled: true 171 | 172 | functions: 173 | get-cats: 174 | handler: rest_api/cat/get/handler.handle 175 | events: 176 | - http: 177 | path: /cats/{city}/{shelterId}/ 178 | method: get 179 | caching: 180 | enabled: true 181 | cacheKeyParameters: 182 | - name: request.path.city 183 | - name: request.path.shelterId 184 | - name: request.querystring.breed 185 | - name: request.querystring.furColour 186 | - name: request.header.Accept-Language 187 | ``` 188 | 189 | ### Caching catch-all path parameters 190 | When you specify a catch-all route that intercepts all requests to the path and routes them to the same function, you can also configure the path as a cache key parameter. 191 | In this example: 192 | 193 | ```yml 194 | plugins: 195 | - serverless-api-gateway-caching 196 | 197 | custom: 198 | apiGatewayCaching: 199 | enabled: true 200 | 201 | functions: 202 | get-cats: 203 | handler: rest_api/cat/get/handler.handle 204 | events: 205 | - http: 206 | path: /cats/{proxy+} 207 | method: get 208 | caching: 209 | enabled: true 210 | cacheKeyParameters: 211 | - name: request.path.proxy 212 | ``` 213 | API Gateway will create cache entries like this: 214 | - `GET /cats/toby/` will create a cache entry for `proxy=toby` 215 | - `GET /cats/in/london` will create an entry for `proxy=in/london` 216 | - `GET /cats/in/london?named=toby` will only create an entry for `proxy=in/london`, ignoring the query string. Note, however, that you can also add the `named` query string parameter as a cache key parameter and it will cache based on that value as well. 217 | 218 | 219 | ### Cache key parameters from the body 220 | When the cache key parameter is the entire request body, you must set up a mapping from the client method request to the integration request. 221 | 222 | ```yml 223 | plugins: 224 | - serverless-api-gateway-caching 225 | 226 | custom: 227 | apiGatewayCaching: 228 | enabled: true 229 | 230 | functions: 231 | # Cache responses for POST requests based on the whole request body 232 | cats-graphql: 233 | handler: graphql/handler.handle 234 | events: 235 | - http: 236 | path: /graphql 237 | method: post 238 | caching: 239 | enabled: true 240 | cacheKeyParameters: 241 | - name: integration.request.header.bodyValue 242 | mappedFrom: method.request.body 243 | ``` 244 | 245 | When the cache key parameter is part of the request body, you can define a JSONPath expression. The following example uses as cache key parameter the `cities[0].petCount` value from the request body: 246 | 247 | ```yml 248 | plugins: 249 | - serverless-api-gateway-caching 250 | 251 | custom: 252 | apiGatewayCaching: 253 | enabled: true 254 | 255 | functions: 256 | # Cache responses for POST requests based on a part of the request body 257 | cats-graphql: 258 | handler: graphql/handler.handle 259 | events: 260 | - http: 261 | path: /graphql 262 | method: post 263 | caching: 264 | enabled: true 265 | cacheKeyParameters: 266 | - name: integration.request.header.petCount 267 | mappedFrom: method.request.body.cities[0].petCount 268 | ``` 269 | 270 | ### Limitations 271 | Cache key parameters coming from multi-value query strings and multi-value headers are currently not supported. 272 | 273 | ## Configuring a shared API Gateway 274 | Setting `apiGatewayIsShared` to `true` means that no changes are applied to the root caching configuration of the API Gateway. However, `ttlInSeconds`, `dataEncryption` and `perKeyInvalidation` are still applied to all functions, unless specifically overridden. 275 | 276 | If the shared API Gateway is in a different CloudFormation stack, you'll need to export its `RestApiId` and pass it to the plugin via the optional `restApiId` setting. If the gateway is part of the stack you are deploying, you don't need to do this; the plugin will find the `RestApiId` automatically. 277 | 278 | If the shared gateway has a default base path that is not part of your endpoint configuration, you can specify it using the optional `basePath` setting. 279 | 280 | ```yml 281 | plugins: 282 | - serverless-api-gateway-caching 283 | 284 | custom: 285 | apiGatewayCaching: 286 | enabled: true 287 | apiGatewayIsShared: true 288 | restApiId: ${cf:api-gateway-${self:provider.stage}.RestApiId} 289 | basePath: /animals 290 | clusterSize: '0.5' 291 | ttlInSeconds: 300 292 | dataEncrypted: true 293 | perKeyInvalidation: 294 | requireAuthorization: true 295 | handleUnauthorizedRequests: Ignore 296 | ``` 297 | 298 | ### Example of configuring an API Gateway with endpoints scattered across multiple serverless projects 299 | 300 | In the project hosting the main API Gateway deployment: 301 | 302 | ```yml 303 | plugins: 304 | - serverless-api-gateway-caching 305 | 306 | custom: 307 | apiGatewayCaching: 308 | enabled: true # create an API cache 309 | ttlInSeconds: 0 # but don't cache responses by default (individual endpoints can override this) 310 | ``` 311 | 312 | In a project that references that API Gateway: 313 | 314 | ```yml 315 | plugins: 316 | - serverless-api-gateway-caching 317 | 318 | custom: 319 | apiGatewayCaching: 320 | enabled: true # enables caching for endpoints in this project (each endpoint must also set caching: enabled to true) 321 | apiGatewayIsShared: true # makes sure the settings on the Main API Gateway are not changed 322 | restApiId: ${cf:api-gateway-${self:provider.stage}.RestApiId} 323 | basePath: /animals 324 | 325 | functions: 326 | # caching disabled, it must be explicitly enabled 327 | adoptCat: 328 | handler: adoptCat.handle 329 | events: 330 | - http: 331 | path: /cat/adoption 332 | method: post 333 | 334 | getCats: 335 | handler: getCats.handle 336 | events: 337 | - http: 338 | path: /cats 339 | method: get 340 | caching: 341 | enabled: true # enables caching for this endpoint 342 | ``` 343 | 344 | ## Configuring caching settings for endpoints defined in CloudFormation 345 | You can use this feature to configure caching for endpoints which are defined in CloudFormation and not as serverless functions. 346 | If your `serverless.yml` contains, for example, a [HTTP Proxy](https://www.serverless.com/framework/docs/providers/aws/events/apigateway/#setting-an-http-proxy-on-api-gateway) like this: 347 | 348 | ```yml 349 | resources: 350 | Resources: 351 | ProxyResource: 352 | Type: AWS::ApiGateway::Resource 353 | Properties: 354 | ParentId: 355 | Fn::GetAtt: 356 | - ApiGatewayRestApi # the default REST API logical ID 357 | - RootResourceId 358 | PathPart: serverless # the endpoint in your API that is set as proxy 359 | RestApiId: 360 | Ref: ApiGatewayRestApi 361 | ProxyMethod: 362 | Type: AWS::ApiGateway::Method 363 | Properties: 364 | ResourceId: 365 | Ref: ProxyResource 366 | RestApiId: 367 | Ref: ApiGatewayRestApi 368 | HttpMethod: GET 369 | AuthorizationType: NONE 370 | MethodResponses: 371 | - StatusCode: 200 372 | Integration: 373 | IntegrationHttpMethod: POST 374 | Type: HTTP 375 | Uri: http://serverless.com # the URL you want to set a proxy to 376 | IntegrationResponses: 377 | - StatusCode: 200 378 | ``` 379 | 380 | Then you can configure caching for it like this: 381 | 382 | ```yml 383 | plugins: 384 | - serverless-api-gateway-caching 385 | 386 | custom: 387 | apiGatewayCaching: 388 | enabled: true 389 | additionalEndpoints: 390 | - method: GET 391 | path: /serverless 392 | caching: 393 | enabled: true # it must be specifically enabled 394 | ttlInSeconds: 1200 # if not set, inherited from global settings 395 | dataEncrypted: true # if not set, inherited from global settings 396 | ``` 397 | 398 | ## Configuring caching when the endpoint bypasses lambda and talks to a service like DynamoDb 399 | 400 | This example uses the `serverless-apigateway-service-proxy` plugin which creates the path `/dynamodb?id=cat_id`. 401 | Caching can be configured using the `additionalEndpoints` feature. The method and path must match the ones defined as a service proxy. It also supports cache key parameters. 402 | 403 | ```yml 404 | plugins: 405 | - serverless-api-gateway-caching 406 | - serverless-apigateway-service-proxy 407 | 408 | custom: 409 | apiGatewayCaching: 410 | enabled: true 411 | additionalEndpoints: 412 | - method: GET 413 | path: /dynamodb 414 | caching: 415 | enabled: true 416 | ttlInSeconds: 120 417 | cacheKeyParameters: 418 | - name: request.querystring.id 419 | 420 | apiGatewayServiceProxies: 421 | - dynamodb: 422 | path: /dynamodb 423 | method: get 424 | tableName: { Ref: 'MyDynamoCatsTable' } 425 | hashKey: 426 | queryStringParam: id # use query string parameter 427 | attributeType: S 428 | action: GetItem 429 | cors: true 430 | 431 | resources: 432 | Resources: 433 | MyDynamoCatsTable: 434 | Type: AWS::DynamoDB::Table 435 | Properties: 436 | TableName: my-dynamo-cats 437 | AttributeDefinitions: 438 | - AttributeName: id 439 | AttributeType: S 440 | KeySchema: 441 | - AttributeName: id 442 | KeyType: HASH 443 | ``` 444 | 445 | ## More Examples 446 | 447 | A function with several endpoints: 448 | 449 | ```yml 450 | plugins: 451 | - serverless-api-gateway-caching 452 | 453 | custom: 454 | apiGatewayCaching: 455 | enabled: true 456 | 457 | functions: 458 | get-cat-by-pawId: 459 | handler: rest_api/cat/get/handler.handle 460 | events: 461 | - http: 462 | path: /cats/{pawId} 463 | method: get 464 | caching: 465 | enabled: true 466 | cacheKeyParameters: 467 | - name: request.path.pawId 468 | - name: request.querystring.includeAdopted 469 | - name: request.header.Accept-Language 470 | - http: 471 | path: /cats 472 | method: get 473 | caching: 474 | enabled: true 475 | cacheKeyParameters: 476 | - name: request.querystring.pawId 477 | - name: request.querystring.includeAdopted 478 | - name: request.header.Accept-Language 479 | ``` 480 | 481 | Cache key parameters found in the `body` and as `querystring`: 482 | 483 | ```yml 484 | plugins: 485 | - serverless-api-gateway-caching 486 | 487 | custom: 488 | apiGatewayCaching: 489 | enabled: true 490 | 491 | functions: 492 | list-cats: 493 | handler: rest_api/cat/get/handler.handle 494 | events: 495 | - http: 496 | path: /cats 497 | method: post 498 | caching: 499 | enabled: true 500 | cacheKeyParameters: 501 | - name: request.querystring.catName 502 | - name: integration.request.header.furColour 503 | mappedFrom: method.request.body.furColour 504 | ``` 505 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-api-gateway-caching", 3 | "version": "1.11.0", 4 | "description": "A plugin for the serverless framework which helps with configuring caching for API Gateway endpoints.", 5 | "main": "src/apiGatewayCachingPlugin.js", 6 | "scripts": { 7 | "test": "mocha --recursive 'test/**/*.js' -t 5000" 8 | }, 9 | "keywords": [ 10 | "serverless", 11 | "aws", 12 | "api", 13 | "gateway", 14 | "rest", 15 | "response", 16 | "caching" 17 | ], 18 | "author": "Diana Ionita", 19 | "license": "ISC", 20 | "dependencies": { 21 | "lodash.get": "^4.4.2", 22 | "lodash.isempty": "^4.4.0" 23 | }, 24 | "bugs": { 25 | "url": "https://github.com/DianaIonita/serverless-api-gateway-caching/issues" 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "https://github.com/DianaIonita/serverless-api-gateway-caching" 30 | }, 31 | "devDependencies": { 32 | "chai": "^4.1.2", 33 | "chai-as-promised": "^8.0.1", 34 | "chance": "^1.0.16", 35 | "lodash.split": "^4.4.0", 36 | "mocha": "^10.0.0", 37 | "mocha-junit-reporter": "^2.0.2", 38 | "proxyquire": "^2.1.0", 39 | "sinon": "^20.0.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/ApiGatewayCachingSettings.js: -------------------------------------------------------------------------------- 1 | const get = require('lodash.get'); 2 | const { Ignore, IgnoreWithWarning, Fail } = require('./UnauthorizedCacheControlHeaderStrategy'); 3 | 4 | const DEFAULT_CACHE_CLUSTER_SIZE = '0.5'; 5 | const DEFAULT_DATA_ENCRYPTED = false; 6 | const DEFAULT_TTL = 3600; 7 | const DEFAULT_UNAUTHORIZED_INVALIDATION_REQUEST_STRATEGY = IgnoreWithWarning; 8 | const DEFAULT_INHERIT_CLOUDWATCH_SETTINGS_FROM_STAGE = true; 9 | 10 | const mapUnauthorizedRequestStrategy = strategy => { 11 | if (!strategy) { 12 | return DEFAULT_UNAUTHORIZED_INVALIDATION_REQUEST_STRATEGY; 13 | } 14 | switch (strategy.toLowerCase()) { 15 | case 'ignore': return Ignore; 16 | case 'ignorewithwarning': return IgnoreWithWarning; 17 | case 'fail': return Fail; 18 | default: return DEFAULT_UNAUTHORIZED_INVALIDATION_REQUEST_STRATEGY; 19 | } 20 | } 21 | 22 | const isApiGatewayEndpoint = event => { 23 | return event.http ? true : false; 24 | } 25 | 26 | const getApiGatewayResourceNameFor = (path, httpMethod) => { 27 | const pathElements = path.split('/'); 28 | pathElements.push(httpMethod.toLowerCase()); 29 | let gatewayResourceName = pathElements 30 | .map(element => { 31 | element = element.toLowerCase(); 32 | element = element.replaceAll('+', ''); 33 | element = element.replaceAll('_', ''); 34 | element = element.replaceAll('.', ''); 35 | element = element.replaceAll('-', 'Dash'); 36 | if (element.startsWith('{')) { 37 | element = element.substring(element.indexOf('{') + 1, element.indexOf('}')) + "Var"; 38 | } 39 | //capitalize first letter 40 | return element.charAt(0).toUpperCase() + element.slice(1); 41 | }).reduce((a, b) => a + b); 42 | 43 | gatewayResourceName = "ApiGatewayMethod" + gatewayResourceName; 44 | return gatewayResourceName; 45 | } 46 | 47 | class PerKeyInvalidationSettings { 48 | constructor(cachingSettings) { 49 | let { perKeyInvalidation } = cachingSettings; 50 | if (!perKeyInvalidation) { 51 | this.requireAuthorization = true; 52 | this.handleUnauthorizedRequests = DEFAULT_UNAUTHORIZED_INVALIDATION_REQUEST_STRATEGY; 53 | } 54 | else { 55 | this.requireAuthorization = perKeyInvalidation.requireAuthorization 56 | if (perKeyInvalidation.requireAuthorization) { 57 | this.handleUnauthorizedRequests = 58 | mapUnauthorizedRequestStrategy(perKeyInvalidation.handleUnauthorizedRequests); 59 | } 60 | } 61 | } 62 | } 63 | 64 | class ApiGatewayEndpointCachingSettings { 65 | constructor(functionName, event, globalSettings) { 66 | this.functionName = functionName; 67 | 68 | if (typeof (event.http) === 'string') { 69 | let parts = event.http.split(' '); 70 | this.method = parts[0]; 71 | this.path = parts[1]; 72 | } 73 | else { 74 | this.path = event.http.path; 75 | this.method = event.http.method; 76 | } 77 | 78 | if (this.path.endsWith('/') && this.path.length > 1) { 79 | this.path = this.path.slice(0, -1); 80 | } 81 | 82 | this.gatewayResourceName = getApiGatewayResourceNameFor(this.path, this.method); 83 | 84 | let { basePath } = globalSettings; 85 | if (basePath) { 86 | if (!basePath.startsWith('/')) { 87 | basePath = '/'.concat(basePath); 88 | } 89 | if (basePath.endsWith('/')) { 90 | basePath = basePath.slice(0, -1); 91 | } 92 | this.pathWithoutGlobalBasePath = this.path; 93 | this.path = basePath.concat(this.path); 94 | } 95 | 96 | if (event.http.caching && event.http.caching.inheritCloudWatchSettingsFromStage != undefined) { 97 | this.inheritCloudWatchSettingsFromStage = event.http.caching.inheritCloudWatchSettingsFromStage; 98 | } 99 | else { 100 | this.inheritCloudWatchSettingsFromStage = globalSettings.endpointsInheritCloudWatchSettingsFromStage; 101 | } 102 | 103 | if (!event.http.caching) { 104 | this.cachingEnabled = false; 105 | return; 106 | } 107 | let cachingConfig = event.http.caching; 108 | this.cachingEnabled = globalSettings.cachingEnabled ? cachingConfig.enabled : false; 109 | this.dataEncrypted = cachingConfig.dataEncrypted || globalSettings.dataEncrypted; 110 | this.cacheTtlInSeconds = cachingConfig.ttlInSeconds >= 0 ? cachingConfig.ttlInSeconds : globalSettings.cacheTtlInSeconds; 111 | this.cacheKeyParameters = cachingConfig.cacheKeyParameters; 112 | 113 | if (!cachingConfig.perKeyInvalidation) { 114 | this.perKeyInvalidation = globalSettings.perKeyInvalidation; 115 | } else { 116 | this.perKeyInvalidation = new PerKeyInvalidationSettings(cachingConfig); 117 | } 118 | } 119 | } 120 | 121 | class ApiGatewayAdditionalEndpointCachingSettings { 122 | constructor(method, path, caching, globalSettings) { 123 | this.method = method; 124 | this.path = path; 125 | 126 | this.gatewayResourceName = getApiGatewayResourceNameFor(this.path, this.method); 127 | 128 | if (!caching) { 129 | this.cachingEnabled = false; 130 | return; 131 | } 132 | const cachingConfig = caching; 133 | this.cachingEnabled = globalSettings.cachingEnabled ? cachingConfig.enabled : false; 134 | this.dataEncrypted = cachingConfig.dataEncrypted || globalSettings.dataEncrypted; 135 | this.cacheTtlInSeconds = caching.ttlInSeconds >= 0 ? caching.ttlInSeconds : globalSettings.cacheTtlInSeconds; 136 | this.cacheKeyParameters = cachingConfig.cacheKeyParameters; 137 | 138 | if (!cachingConfig.perKeyInvalidation) { 139 | this.perKeyInvalidation = globalSettings.perKeyInvalidation; 140 | } else { 141 | this.perKeyInvalidation = new PerKeyInvalidationSettings(cachingConfig); 142 | } 143 | } 144 | } 145 | 146 | class ApiGatewayCachingSettings { 147 | constructor(serverless, options) { 148 | if (!get(serverless, 'service.custom.apiGatewayCaching')) { 149 | return; 150 | } 151 | const cachingSettings = serverless.service.custom.apiGatewayCaching; 152 | this.cachingEnabled = cachingSettings.enabled; 153 | this.apiGatewayIsShared = cachingSettings.apiGatewayIsShared; 154 | this.restApiId = cachingSettings.restApiId; 155 | this.basePath = cachingSettings.basePath; 156 | 157 | if (options) { 158 | this.stage = options.stage || serverless.service.provider.stage; 159 | this.region = options.region || serverless.service.provider.region; 160 | } else { 161 | this.stage = serverless.service.provider.stage; 162 | this.region = serverless.service.provider.region; 163 | } 164 | 165 | this.endpointSettings = []; 166 | this.additionalEndpointSettings = []; 167 | 168 | this.cacheClusterSize = cachingSettings.clusterSize || DEFAULT_CACHE_CLUSTER_SIZE; 169 | this.cacheTtlInSeconds = cachingSettings.ttlInSeconds >= 0 ? cachingSettings.ttlInSeconds : DEFAULT_TTL; 170 | this.dataEncrypted = cachingSettings.dataEncrypted || DEFAULT_DATA_ENCRYPTED; 171 | 172 | this.perKeyInvalidation = new PerKeyInvalidationSettings(cachingSettings); 173 | 174 | this.endpointsInheritCloudWatchSettingsFromStage = cachingSettings.endpointsInheritCloudWatchSettingsFromStage == undefined ? 175 | DEFAULT_INHERIT_CLOUDWATCH_SETTINGS_FROM_STAGE : cachingSettings.endpointsInheritCloudWatchSettingsFromStage; 176 | 177 | for (let functionName in serverless.service.functions) { 178 | let functionSettings = serverless.service.functions[functionName]; 179 | for (let event in functionSettings.events) { 180 | if (isApiGatewayEndpoint(functionSettings.events[event])) { 181 | this.endpointSettings.push(new ApiGatewayEndpointCachingSettings(functionName, functionSettings.events[event], this)) 182 | } 183 | } 184 | } 185 | 186 | const additionalEndpoints = cachingSettings.additionalEndpoints || []; 187 | for (let additionalEndpoint of additionalEndpoints) { 188 | const { method, path, caching } = additionalEndpoint; 189 | this.additionalEndpointSettings.push(new ApiGatewayAdditionalEndpointCachingSettings(method, path, caching, this)) 190 | } 191 | } 192 | } 193 | 194 | module.exports = ApiGatewayCachingSettings 195 | -------------------------------------------------------------------------------- /src/UnauthorizedCacheControlHeaderStrategy.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | Fail: 'FAIL_WITH_403', 3 | IgnoreWithWarning: 'SUCCEED_WITH_RESPONSE_HEADER', 4 | Ignore: 'SUCCEED_WITHOUT_RESPONSE_HEADER' 5 | } 6 | -------------------------------------------------------------------------------- /src/apiGatewayCachingPlugin.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const ApiGatewayCachingSettings = require('./ApiGatewayCachingSettings'); 4 | const cacheKeyParameters = require('./cacheKeyParameters'); 5 | const { updateStageCacheSettings } = require('./stageCache'); 6 | const { restApiExists, outputRestApiIdTo } = require('./restApiId'); 7 | 8 | class ApiGatewayCachingPlugin { 9 | constructor(serverless, options) { 10 | this.serverless = serverless; 11 | this.options = options; 12 | 13 | this.hooks = { 14 | 'before:package:initialize': this.createSettings.bind(this), 15 | 'before:package:finalize': this.updateCloudFormationTemplate.bind(this), 16 | 'after:deploy:deploy': this.updateStage.bind(this), 17 | }; 18 | 19 | this.defineValidationSchema(serverless); 20 | } 21 | 22 | createSettings() { 23 | this.settings = new ApiGatewayCachingSettings(this.serverless, this.options); 24 | } 25 | 26 | async updateCloudFormationTemplate() { 27 | this.thereIsARestApi = await restApiExists(this.serverless, this.settings); 28 | if (!this.thereIsARestApi) { 29 | this.serverless.cli.log(`[serverless-api-gateway-caching] No REST API found. Caching settings will not be updated.`); 30 | return; 31 | } 32 | 33 | outputRestApiIdTo(this.serverless); 34 | 35 | // if caching is not defined or disabled 36 | if (!this.settings.cachingEnabled) { 37 | return; 38 | } 39 | 40 | return cacheKeyParameters.addCacheKeyParametersConfig(this.settings, this.serverless); 41 | } 42 | 43 | async updateStage() { 44 | if (!this.settings) { 45 | this.createSettings() 46 | } 47 | 48 | this.thereIsARestApi = await restApiExists(this.serverless, this.settings); 49 | if (!this.thereIsARestApi) { 50 | this.serverless.cli.log(`[serverless-api-gateway-caching] No REST API found. Caching settings will not be updated.`); 51 | return; 52 | } 53 | 54 | return updateStageCacheSettings(this.settings, this.serverless); 55 | } 56 | 57 | defineValidationSchema() { 58 | if (!this.serverless.configSchemaHandler 59 | || !this.serverless.configSchemaHandler.defineCustomProperties 60 | || !this.serverless.configSchemaHandler.defineFunctionEventProperties) { 61 | return; 62 | } 63 | 64 | const customSchema = this.customCachingSchema(); 65 | this.serverless.configSchemaHandler.defineCustomProperties(customSchema); 66 | 67 | const httpSchema = this.httpEventCachingSchema(); 68 | this.serverless.configSchemaHandler.defineFunctionEventProperties('aws', 'http', httpSchema); 69 | } 70 | 71 | httpEventCachingSchema() { 72 | return { 73 | type: 'object', 74 | properties: { 75 | caching: { 76 | type: 'object', 77 | properties: { 78 | enabled: { type: 'boolean' }, 79 | ttlInSeconds: { type: 'number' }, 80 | dataEncrypted: { type: 'boolean' }, 81 | perKeyInvalidation: { 82 | type: 'object', 83 | properties: { 84 | requireAuthorization: { type: 'boolean' }, 85 | handleUnauthorizedRequests: { 86 | type: 'string', 87 | enum: ['Ignore', 'IgnoreWithWarning', 'Fail'] 88 | } 89 | } 90 | }, 91 | cacheKeyParameters: { 92 | type: 'array', 93 | items: { 94 | type: 'object', 95 | properties: { 96 | name: { type: 'string' }, 97 | value: { type: 'string' } 98 | } 99 | } 100 | } 101 | } 102 | } 103 | } 104 | } 105 | } 106 | 107 | customCachingSchema() { 108 | return { 109 | type: 'object', 110 | properties: { 111 | apiGatewayCaching: { 112 | type: 'object', 113 | properties: { 114 | enabled: { type: 'boolean' }, 115 | apiGatewayIsShared: { type: 'boolean' }, 116 | basePath: { type: 'string' }, 117 | restApiId: { type: 'string' }, 118 | clusterSize: { type: 'string' }, 119 | ttlInSeconds: { type: 'number' }, 120 | dataEncrypted: { type: 'boolean' }, 121 | perKeyInvalidation: { 122 | type: 'object', 123 | properties: { 124 | requireAuthorization: { type: 'boolean' }, 125 | handleUnauthorizedRequests: { 126 | type: 'string', 127 | enum: ['Ignore', 'IgnoreWithWarning', 'Fail'] 128 | } 129 | } 130 | }, 131 | additionalEndpoints: { 132 | type: 'array', 133 | items: { 134 | type: 'object', 135 | properties: { 136 | method: { type: 'string' }, 137 | path: { type: 'string' }, 138 | caching: { 139 | type: 'object', 140 | properties: { 141 | enabled: { type: 'boolean' }, 142 | ttlInSeconds: { type: 'number' }, 143 | dataEncrypted: { type: 'boolean' }, 144 | perKeyInvalidation: { 145 | type: 'object', 146 | properties: { 147 | requireAuthorization: { type: 'boolean' }, 148 | handleUnauthorizedRequests: { 149 | type: 'string', 150 | enum: ['Ignore', 'IgnoreWithWarning', 'Fail'] 151 | } 152 | } 153 | }, 154 | cacheKeyParameters: { 155 | type: 'array', 156 | items: { 157 | type: 'object', 158 | properties: { 159 | name: { type: 'string' }, 160 | value: { type: 'string' } 161 | } 162 | } 163 | } 164 | } 165 | } 166 | } 167 | } 168 | } 169 | } 170 | } 171 | } 172 | } 173 | } 174 | } 175 | 176 | module.exports = ApiGatewayCachingPlugin; 177 | -------------------------------------------------------------------------------- /src/cacheKeyParameters.js: -------------------------------------------------------------------------------- 1 | const getResourcesByName = (name, serverless) => { 2 | let resourceKeys = Object.keys(serverless.service.provider.compiledCloudFormationTemplate.Resources); 3 | for (let resourceName of resourceKeys) { 4 | if (resourceName == name) { 5 | return serverless.service.provider.compiledCloudFormationTemplate.Resources[resourceName]; 6 | } 7 | } 8 | } 9 | 10 | const applyCacheKeyParameterSettings = (settings, serverless) => { 11 | for (let endpointSettings of settings) { 12 | if (!endpointSettings.cacheKeyParameters) { 13 | continue; 14 | } 15 | const method = getResourcesByName(endpointSettings.gatewayResourceName, serverless); 16 | if (!method) { 17 | serverless.cli.log(`[serverless-api-gateway-caching] The method ${endpointSettings.gatewayResourceName} couldn't be found in the 18 | compiled CloudFormation template. Caching settings will not be updated for this endpoint.`); 19 | continue; 20 | } 21 | if (!method.Properties.Integration.CacheKeyParameters) { 22 | method.Properties.Integration.CacheKeyParameters = []; 23 | } 24 | if (!method.Properties.Integration.RequestParameters) { 25 | method.Properties.Integration.RequestParameters = {} 26 | } 27 | 28 | for (let cacheKeyParameter of endpointSettings.cacheKeyParameters) { 29 | if (!cacheKeyParameter.mappedFrom) { 30 | let existingValue = method.Properties.RequestParameters[`method.${cacheKeyParameter.name}`]; 31 | method.Properties.RequestParameters[`method.${cacheKeyParameter.name}`] = (existingValue == null || existingValue == undefined) ? false : existingValue; 32 | 33 | // without this check, endpoints 500 when using cache key parameters like "Authorization" or headers with the same characters in different casing (e.g. "origin" and "Origin") 34 | if (method.Properties.Integration.Type !== 'AWS_PROXY') { 35 | method.Properties.Integration.RequestParameters[`integration.${cacheKeyParameter.name}`] = `method.${cacheKeyParameter.name}`; 36 | } 37 | 38 | method.Properties.Integration.CacheKeyParameters.push(`method.${cacheKeyParameter.name}`); 39 | } else { 40 | let existingValue = method.Properties.RequestParameters[cacheKeyParameter.mappedFrom]; 41 | if ( 42 | cacheKeyParameter.mappedFrom.includes('method.request.querystring') || 43 | cacheKeyParameter.mappedFrom.includes('method.request.header') || 44 | cacheKeyParameter.mappedFrom.includes('method.request.path') 45 | ) { 46 | method.Properties.RequestParameters[cacheKeyParameter.mappedFrom] = (existingValue == null || existingValue == undefined) ? false : existingValue; 47 | } 48 | 49 | // in v1.8.0 "lambda" integration check was removed because setting cache key parameters seemed to work for both AWS_PROXY and AWS (lambda) integration 50 | // reconsider if this becomes an issue 51 | 52 | // if (method.Properties.Integration.Type !== 'AWS_PROXY') { 53 | method.Properties.Integration.RequestParameters[cacheKeyParameter.name] = cacheKeyParameter.mappedFrom; 54 | // } 55 | method.Properties.Integration.CacheKeyParameters.push(cacheKeyParameter.name) 56 | } 57 | } 58 | method.Properties.Integration.CacheNamespace = `${endpointSettings.gatewayResourceName}CacheNS`; 59 | } 60 | } 61 | const addCacheKeyParametersConfig = (settings, serverless) => { 62 | applyCacheKeyParameterSettings(settings.endpointSettings, serverless); 63 | applyCacheKeyParameterSettings(settings.additionalEndpointSettings, serverless); 64 | } 65 | 66 | module.exports = { 67 | addCacheKeyParametersConfig: addCacheKeyParametersConfig 68 | } 69 | -------------------------------------------------------------------------------- /src/restApiId.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const get = require('lodash.get'); 4 | const REST_API_ID_KEY = 'RestApiIdForApigCaching'; 5 | 6 | const getConfiguredRestApiId = (serverless) => { 7 | return get(serverless, 'service.provider.apiGateway.restApiId') 8 | } 9 | 10 | const restApiExists = async (serverless, settings) => { 11 | if (get(settings, 'restApiId')) { 12 | return true; 13 | } 14 | const configuredRestApiId = getConfiguredRestApiId(serverless); 15 | if (configuredRestApiId) { 16 | return true; 17 | } 18 | const resource = serverless.service.provider.compiledCloudFormationTemplate.Resources['ApiGatewayRestApi']; 19 | if (resource) { 20 | return true; 21 | } 22 | 23 | const stack = await getAlreadyDeployedStack(serverless, settings); 24 | if (stack) { 25 | const restApiIdFromAlreadyDeployedStack = await retrieveRestApiId(serverless, settings); 26 | if (restApiIdFromAlreadyDeployedStack) { 27 | return true; 28 | } 29 | } 30 | return false; 31 | } 32 | 33 | const outputRestApiIdTo = (serverless) => { 34 | const configuredRestApiId = getConfiguredRestApiId(serverless); 35 | const autoGeneratedRestApiId = { Ref: 'ApiGatewayRestApi' }; 36 | 37 | serverless.service.provider.compiledCloudFormationTemplate.Outputs[REST_API_ID_KEY] = { 38 | Description: 'REST API ID', 39 | Value: configuredRestApiId || autoGeneratedRestApiId, 40 | }; 41 | }; 42 | 43 | const getAlreadyDeployedStack = async (serverless, settings) => { 44 | try { 45 | const stackName = serverless.providers.aws.naming.getStackName(settings.stage); 46 | const stack = await serverless.providers.aws.request('CloudFormation', 'describeStacks', { StackName: stackName }, 47 | settings.stage, 48 | settings.region 49 | ); 50 | return stack; 51 | } 52 | catch (error) { 53 | serverless.cli.log(`[serverless-api-gateway-caching] Could not retrieve stack because: ${error.message}.`); 54 | return; 55 | } 56 | } 57 | 58 | const retrieveRestApiId = async (serverless, settings) => { 59 | if (settings.restApiId) { 60 | return settings.restApiId; 61 | } 62 | 63 | const stack = await getAlreadyDeployedStack(serverless, settings); 64 | const outputs = stack.Stacks[0].Outputs; 65 | const restApiKey = outputs.find(({ OutputKey }) => OutputKey === REST_API_ID_KEY) 66 | if (restApiKey) { 67 | return restApiKey.OutputValue; 68 | } 69 | else { 70 | serverless.cli.log(`[serverless-api-gateway-caching] Could not find stack output variable named ${REST_API_ID_KEY}.`); 71 | } 72 | }; 73 | 74 | module.exports = { 75 | restApiExists, 76 | outputRestApiIdTo, 77 | retrieveRestApiId 78 | }; 79 | -------------------------------------------------------------------------------- /src/stageCache.js: -------------------------------------------------------------------------------- 1 | const isEmpty = require('lodash.isempty'); 2 | const { retrieveRestApiId } = require('./restApiId'); 3 | const MAX_PATCH_OPERATIONS_PER_STAGE_UPDATE = 80; 4 | const BASE_RETRY_DELAY_MS = 500; 5 | 6 | String.prototype.replaceAll = function (search, replacement) { 7 | let target = this; 8 | 9 | return target 10 | .split(search) 11 | .join(replacement); 12 | }; 13 | 14 | const escapeJsonPointer = path => { 15 | return path 16 | .replaceAll('~', '~0') 17 | .replaceAll('/', '~1') 18 | .replaceAll('{', '\{') 19 | .replaceAll('}', '\}'); 20 | } 21 | 22 | const createPatchForStage = (settings) => { 23 | 24 | if (settings.apiGatewayIsShared) { 25 | return []; 26 | } 27 | 28 | let patch = [{ 29 | op: 'replace', 30 | path: '/cacheClusterEnabled', 31 | value: `${settings.cachingEnabled}` 32 | }]; 33 | 34 | if (settings.cachingEnabled) { 35 | patch.push({ 36 | op: 'replace', 37 | path: '/cacheClusterSize', 38 | value: `${settings.cacheClusterSize}` 39 | }); 40 | 41 | patch.push({ 42 | op: 'replace', 43 | path: '/*/*/caching/dataEncrypted', 44 | value: `${settings.dataEncrypted}` 45 | }); 46 | 47 | patch.push({ 48 | op: 'replace', 49 | path: '/*/*/caching/ttlInSeconds', 50 | value: `${settings.cacheTtlInSeconds}` 51 | }); 52 | } 53 | 54 | return patch; 55 | } 56 | 57 | const createPatchForMethod = (path, method, endpointSettings, stageState) => { 58 | let patchPath = patchPathFor(path, method); 59 | let patch = [{ 60 | op: 'replace', 61 | path: `/${patchPath}/caching/enabled`, 62 | value: `${endpointSettings.cachingEnabled}` 63 | }]; 64 | if (endpointSettings.cachingEnabled) { 65 | patch.push({ 66 | op: 'replace', 67 | path: `/${patchPath}/caching/ttlInSeconds`, 68 | value: `${endpointSettings.cacheTtlInSeconds}` 69 | }); 70 | patch.push({ 71 | op: 'replace', 72 | path: `/${patchPath}/caching/dataEncrypted`, 73 | value: `${endpointSettings.dataEncrypted}` 74 | }); 75 | } 76 | if (endpointSettings.perKeyInvalidation) { 77 | patch.push({ 78 | op: 'replace', 79 | path: `/${patchPath}/caching/requireAuthorizationForCacheControl`, 80 | value: `${endpointSettings.perKeyInvalidation.requireAuthorization}` 81 | }); 82 | if (endpointSettings.perKeyInvalidation.requireAuthorization) { 83 | patch.push({ 84 | op: 'replace', 85 | path: `/${patchPath}/caching/unauthorizedCacheControlHeaderStrategy`, 86 | value: `${endpointSettings.perKeyInvalidation.handleUnauthorizedRequests}` 87 | }); 88 | } 89 | } 90 | 91 | if (endpointSettings.inheritCloudWatchSettingsFromStage && stageState.methodSettings['*/*']) { 92 | if (stageState.methodSettings['*/*'].loggingLevel) { 93 | patch.push({ 94 | op: 'replace', 95 | path: `/${patchPath}/logging/loglevel`, 96 | value: stageState.methodSettings['*/*'].loggingLevel, 97 | }); 98 | } 99 | patch.push({ 100 | op: 'replace', 101 | path: `/${patchPath}/logging/dataTrace`, 102 | value: stageState.methodSettings['*/*'].dataTraceEnabled ? 'true' : 'false', 103 | }); 104 | patch.push({ 105 | op: 'replace', 106 | path: `/${patchPath}/metrics/enabled`, 107 | value: stageState.methodSettings['*/*'].metricsEnabled ? 'true' : 'false', 108 | }); 109 | } 110 | return patch; 111 | } 112 | 113 | const httpEventOf = (lambda, endpointSettings) => { 114 | let httpEvents = lambda.events.filter(e => e.http != undefined) 115 | .map(e => { 116 | if (typeof (e.http) === 'string') { 117 | let parts = e.http.split(' '); 118 | return { 119 | method: parts[0], 120 | path: parts[1] 121 | } 122 | } else { 123 | return { 124 | method: e.http.method, 125 | path: e.http.path 126 | } 127 | } 128 | }); 129 | 130 | const event = httpEvents.filter(e => 131 | (e.path === endpointSettings.path) || 132 | (`/${e.path}` === endpointSettings.path) || 133 | (e.path == endpointSettings.pathWithoutGlobalBasePath) || 134 | (`/${e.path}` == endpointSettings.pathWithoutGlobalBasePath)) 135 | .filter(e => e.method.toUpperCase() == endpointSettings.method.toUpperCase()); 136 | return event; 137 | } 138 | 139 | const createPatchForEndpoint = (endpointSettings, serverless, stageState) => { 140 | let lambda = serverless.service.getFunction(endpointSettings.functionName); 141 | if (isEmpty(lambda.events)) { 142 | serverless.cli.log(`[serverless-api-gateway-caching] Lambda ${endpointSettings.functionName} has not defined events.`); 143 | return; 144 | } 145 | const httpEvents = httpEventOf(lambda, endpointSettings); 146 | if (isEmpty(httpEvents)) { 147 | serverless.cli.log(`[serverless-api-gateway-caching] Lambda ${endpointSettings.functionName} has not defined any HTTP events.`); 148 | return; 149 | } 150 | const { path, method } = endpointSettings; 151 | 152 | let patch = []; 153 | if (method.toUpperCase() == 'ANY') { 154 | let httpMethodsToDisableCacheFor = ['DELETE', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT']; // TODO could come from settings, vNext 155 | for (let methodWithCacheDisabled of httpMethodsToDisableCacheFor) { 156 | patch = patch.concat(createPatchForMethod(path, methodWithCacheDisabled, 157 | { cachingEnabled: false })); 158 | }; 159 | 160 | patch = patch.concat(createPatchForMethod(path, 'GET', endpointSettings, stageState)); 161 | } 162 | else { 163 | patch = patch.concat(createPatchForMethod(path, method, endpointSettings, stageState)); 164 | } 165 | return patch; 166 | } 167 | 168 | const patchPathFor = (path, method) => { 169 | let escapedPath = escapeJsonPointer(path); 170 | if (!escapedPath.startsWith('~1')) { 171 | escapedPath = `~1${escapedPath}`; 172 | } 173 | let patchPath = `${escapedPath}/${method.toUpperCase()}`; 174 | return patchPath; 175 | } 176 | 177 | const updateStageFor = async (serverless, params, stage, region) => { 178 | if (params.patchOperations.length == 0) { 179 | serverless.cli.log(`[serverless-api-gateway-caching] Will not update API Gateway cache settings because apiGatewayIsShared is set to true.`); 180 | return; 181 | } 182 | const chunkSize = MAX_PATCH_OPERATIONS_PER_STAGE_UPDATE; 183 | const { patchOperations } = params; 184 | const paramsInChunks = []; 185 | if (patchOperations.length > chunkSize) { 186 | for (let i = 0; i < patchOperations.length; i += chunkSize) { 187 | paramsInChunks.push({ 188 | restApiId: params.restApiId, 189 | stageName: params.stageName, 190 | patchOperations: patchOperations.slice(i, i + chunkSize) 191 | }); 192 | } 193 | } 194 | else { 195 | paramsInChunks.push(params); 196 | } 197 | 198 | for (const [index, chunk] of paramsInChunks.entries()) { 199 | serverless.cli.log(`[serverless-api-gateway-caching] Updating API Gateway cache settings (${index + 1} of ${paramsInChunks.length}).`); 200 | await applyUpdateStageForChunk(chunk, serverless, stage, region); 201 | } 202 | 203 | serverless.cli.log(`[serverless-api-gateway-caching] Done updating API Gateway cache settings.`); 204 | } 205 | 206 | const applyUpdateStageForChunk = async (chunk, serverless, stage, region) => { 207 | const maxRetries = 10; 208 | const baseDelay = BASE_RETRY_DELAY_MS; 209 | let attempt = 0; 210 | 211 | while (attempt < maxRetries) { 212 | try { 213 | serverless.cli.log(`[serverless-api-gateway-caching] Updating API Gateway cache settings. Attempt ${attempt + 1}.`); 214 | await serverless.providers.aws.request('APIGateway', 'updateStage', chunk, stage, region); 215 | return; 216 | } catch (error) { 217 | // Check for specific error code first, fallback to message check 218 | if ( 219 | (error.code === 'ConflictException' || error.message.includes('A previous change is still in progress')) 220 | ) { 221 | attempt++; 222 | if (attempt >= maxRetries) { 223 | serverless.cli.log(`[serverless-api-gateway-caching] Maximum retries (${maxRetries}) reached. Failed to update API Gateway cache settings.`); 224 | // Log the full error for better debugging before throwing 225 | console.error('[serverless-api-gateway-caching] Final error details:', error); 226 | throw new Error(`Failed to update API Gateway cache settings after ${maxRetries} retries: ${error.message}`); 227 | } 228 | const delay = baseDelay * 2 ** attempt; 229 | serverless.cli.log(`[serverless-api-gateway-caching] Retrying (${attempt}/${maxRetries}) after ${delay}ms due to error: ${error.message}`); 230 | await new Promise((resolve) => setTimeout(resolve, delay)); 231 | } else { 232 | console.error('[serverless-api-gateway-caching] Non-retryable error during update:', error); 233 | // Throw immediately for non-retryable errors or if string/code doesn't match 234 | throw new Error(`Failed to update API Gateway cache settings: ${error.message}`); 235 | } 236 | } 237 | } 238 | } 239 | 240 | const updateStageCacheSettings = async (settings, serverless) => { 241 | // do nothing if caching settings are not defined 242 | if (settings.cachingEnabled == undefined) { 243 | return; 244 | } 245 | 246 | let restApiId = await retrieveRestApiId(serverless, settings); 247 | 248 | let stageState = await serverless.providers.aws.request('APIGateway', 'getStage', { restApiId, stageName: settings.stage }, { region: settings.region }); 249 | 250 | let patchOps = createPatchForStage(settings); 251 | 252 | let endpointsWithCachingEnabled = settings.endpointSettings.filter(e => e.cachingEnabled) 253 | .concat(settings.additionalEndpointSettings.filter(e => e.cachingEnabled)); 254 | if (settings.cachingEnabled && isEmpty(endpointsWithCachingEnabled)) { 255 | serverless.cli.log(`[serverless-api-gateway-caching] [WARNING] API Gateway caching is enabled but none of the endpoints have caching enabled`); 256 | } 257 | 258 | for (let endpointSettings of settings.endpointSettings) { 259 | let endpointPatch = createPatchForEndpoint(endpointSettings, serverless, stageState); 260 | patchOps = patchOps.concat(endpointPatch); 261 | } 262 | 263 | // TODO handle 'ANY' method, if possible 264 | for (let additionalEndpointSettings of settings.additionalEndpointSettings) { 265 | let endpointPatch = createPatchForMethod(additionalEndpointSettings.path, additionalEndpointSettings.method, additionalEndpointSettings, stageState); 266 | patchOps = patchOps.concat(endpointPatch); 267 | } 268 | 269 | let params = { 270 | restApiId, 271 | stageName: settings.stage, 272 | patchOperations: patchOps 273 | } 274 | 275 | await updateStageFor(serverless, params, settings.stage, settings.region); 276 | } 277 | 278 | module.exports = { 279 | updateStageCacheSettings, 280 | applyUpdateStageForChunk 281 | } 282 | -------------------------------------------------------------------------------- /test/configuring-a-default-base-path.js: -------------------------------------------------------------------------------- 1 | const APP_ROOT = '..'; 2 | const given = require(`${APP_ROOT}/test/steps/given`); 3 | const expect = require('chai').expect; 4 | const ApiGatewayCachingSettings = require(`${APP_ROOT}/src/ApiGatewayCachingSettings`); 5 | 6 | describe('Configuring a default base path', () => { 7 | const serviceName = 'cat-api'; 8 | const endpointPath = '/cat/{pawId}'; 9 | const functionName = 'get-cat-by-paw-id'; 10 | 11 | describe('when a base path is specified', () => { 12 | const scenarios = [ 13 | { 14 | description: 'does not start with a forward slash', 15 | basePath: 'animals' 16 | }, 17 | { 18 | description: 'starts with a forward slash', 19 | basePath: '/animals' 20 | }, 21 | { 22 | description: 'has a trailing slash', 23 | basePath: 'animals/' 24 | } 25 | ]; 26 | let settings; 27 | 28 | for (scenario of scenarios) { 29 | describe(`and ${scenario.description}`, () => { 30 | before(() => { 31 | const endpoint = given 32 | .a_serverless_function(functionName) 33 | .withHttpEndpoint('get', endpointPath, { enabled: true }); 34 | 35 | const serverless = given 36 | .a_serverless_instance(serviceName) 37 | .withApiGatewayCachingConfig({ basePath: scenario.basePath }) 38 | .withFunction(endpoint); 39 | 40 | settings = new ApiGatewayCachingSettings(serverless); 41 | }); 42 | 43 | it('should be prepended to each endpoint and form a valid path', () => { 44 | expect(path_of(functionName, settings)).to.equal(`/animals${endpointPath}`); 45 | }); 46 | }); 47 | } 48 | }); 49 | 50 | describe('when no base path is specified', () => { 51 | before(() => { 52 | const endpoint = given 53 | .a_serverless_function(functionName) 54 | .withHttpEndpoint('get', endpointPath, { enabled: true }); 55 | 56 | const serverless = given 57 | .a_serverless_instance(serviceName) 58 | .withApiGatewayCachingConfig() 59 | .withFunction(endpoint); 60 | 61 | settings = new ApiGatewayCachingSettings(serverless); 62 | }); 63 | 64 | it('should just use the endpoint path', () => { 65 | expect(path_of(functionName, settings)).to.equal(endpointPath); 66 | }); 67 | }); 68 | }); 69 | 70 | const path_of = (functionName, settings) => { 71 | return settings 72 | .endpointSettings 73 | .find(x => x.functionName === functionName) 74 | .path; 75 | } 76 | -------------------------------------------------------------------------------- /test/configuring-cache-key-parameters-for-additional-endpoints.js: -------------------------------------------------------------------------------- 1 | const given = require('../test/steps/given'); 2 | const ApiGatewayCachingSettings = require('../src/ApiGatewayCachingSettings'); 3 | const cacheKeyParams = require('../src/cacheKeyParameters'); 4 | const expect = require('chai').expect; 5 | 6 | describe('Configuring path parameters for additional endpoints defined as CloudFormation', () => { 7 | let serverless; 8 | let serviceName = 'cat-api', stage = 'dev'; 9 | 10 | describe('when there are no additional endpoints', () => { 11 | before(() => { 12 | serverless = given.a_serverless_instance(serviceName) 13 | .withApiGatewayCachingConfig() 14 | .forStage(stage); 15 | }); 16 | 17 | it('should do nothing to the serverless instance', () => { 18 | let stringified = JSON.stringify(serverless); 19 | when_configuring_cache_key_parameters(serverless); 20 | let stringifiedAfter = JSON.stringify(serverless); 21 | expect(stringified).to.equal(stringifiedAfter); 22 | }); 23 | }); 24 | 25 | describe('when one of the additional endpoints has cache key parameters', () => { 26 | let cacheKeyParameters, apiGatewayMethod; 27 | before(() => { 28 | cacheKeyParameters = [ 29 | { name: 'request.path.pawId' }, 30 | { name: 'request.header.Accept-Language' }] 31 | const additionalEndpointWithCaching = given.an_additional_endpoint({ 32 | method: 'GET', path: '/items', 33 | caching: { 34 | enabled: true, ttlInSeconds: 120, 35 | cacheKeyParameters 36 | } 37 | }) 38 | const additionalEndpointWithoutCaching = given.an_additional_endpoint({ 39 | method: 'POST', path: '/blue-items', 40 | caching: { enabled: true } 41 | }); 42 | 43 | serverless = given.a_serverless_instance(serviceName) 44 | .withApiGatewayCachingConfig() 45 | .withAdditionalEndpoints([additionalEndpointWithCaching, additionalEndpointWithoutCaching]) 46 | .forStage('somestage'); 47 | 48 | when_configuring_cache_key_parameters(serverless); 49 | 50 | apiGatewayMethod = serverless.getMethodResourceForAdditionalEndpoint(additionalEndpointWithCaching); 51 | }); 52 | 53 | it('should configure the method\'s request parameters', () => { 54 | for (let parameter of cacheKeyParameters) { 55 | expect(apiGatewayMethod.Properties.RequestParameters) 56 | .to.deep.include({ 57 | [`method.${parameter.name}`]: false 58 | }); 59 | } 60 | }); 61 | 62 | it('should not set integration request parameters', () => { 63 | for (let parameter of cacheKeyParameters) { 64 | expect(apiGatewayMethod.Properties.Integration.RequestParameters) 65 | .to.not.include({ 66 | [`integration.${parameter.name}`]: `method.${parameter.name}` 67 | }); 68 | } 69 | }); 70 | 71 | it('should set the method\'s integration cache key parameters', () => { 72 | for (let parameter of cacheKeyParameters) { 73 | expect(apiGatewayMethod.Properties.Integration.CacheKeyParameters) 74 | .to.include(`method.${parameter.name}`); 75 | } 76 | }); 77 | 78 | it('should set a cache namespace', () => { 79 | expect(apiGatewayMethod.Properties.Integration.CacheNamespace).to.exist; 80 | }); 81 | }); 82 | }); 83 | 84 | const when_configuring_cache_key_parameters = (serverless) => { 85 | let cacheSettings = new ApiGatewayCachingSettings(serverless); 86 | return cacheKeyParams.addCacheKeyParametersConfig(cacheSettings, serverless); 87 | } 88 | -------------------------------------------------------------------------------- /test/configuring-cache-key-parameters.js: -------------------------------------------------------------------------------- 1 | const APP_ROOT = '..'; 2 | const given = require(`${APP_ROOT}/test/steps/given`); 3 | const ApiGatewayCachingSettings = require(`${APP_ROOT}/src/ApiGatewayCachingSettings`); 4 | const cacheKeyParams = require(`${APP_ROOT}/src/cacheKeyParameters`); 5 | const expect = require('chai').expect; 6 | 7 | describe('Configuring cache key parameters', () => { 8 | let serverless; 9 | let serviceName = 'cat-api', stage = 'dev'; 10 | 11 | describe('when there are no endpoints', () => { 12 | before(() => { 13 | serverless = given.a_serverless_instance(serviceName) 14 | .withApiGatewayCachingConfig() 15 | .forStage(stage); 16 | }); 17 | 18 | it('should do nothing to the serverless instance', () => { 19 | let stringified = JSON.stringify(serverless); 20 | when_configuring_cache_key_parameters(serverless); 21 | let stringifiedAfter = JSON.stringify(serverless); 22 | expect(stringified).to.equal(stringifiedAfter); 23 | }); 24 | }); 25 | 26 | describe('when there are no endpoints with cache key parameters', () => { 27 | before(() => { 28 | let endpoint = given.a_serverless_function('get-cat-by-paw-id') 29 | .withHttpEndpoint('get', '/cat/{pawId}', { enabled: true }); 30 | serverless = given.a_serverless_instance(serviceName) 31 | .withApiGatewayCachingConfig() 32 | .forStage(stage) 33 | .withFunction(endpoint); 34 | }); 35 | 36 | it('should do nothing to the serverless instance', () => { 37 | let stringified = JSON.stringify(serverless); 38 | when_configuring_cache_key_parameters(serverless); 39 | let stringifiedAfter = JSON.stringify(serverless); 40 | expect(stringified).to.equal(stringifiedAfter); 41 | }); 42 | }); 43 | 44 | describe('when one endpoint with lambda integration has cache key parameters', () => { 45 | let cacheKeyParameters, method, functionWithCachingName; 46 | before(() => { 47 | functionWithCachingName = 'get-cat-by-paw-id'; 48 | cacheKeyParameters = [{ name: 'request.path.pawId' }, { name: 'request.header.Accept-Language' }]; 49 | 50 | const withLambdaIntegration = true; 51 | let functionWithCaching = given.a_serverless_function(functionWithCachingName) 52 | .withHttpEndpoint('get', '/cat/{pawId}', { enabled: true, cacheKeyParameters }, withLambdaIntegration); 53 | 54 | serverless = given.a_serverless_instance(serviceName) 55 | .withApiGatewayCachingConfig() 56 | .forStage(stage) 57 | .withFunction(functionWithCaching); 58 | 59 | when_configuring_cache_key_parameters(serverless); 60 | 61 | method = serverless.getMethodResourceForFunction(functionWithCachingName); 62 | }); 63 | 64 | it('should configure the method\'s request parameters', () => { 65 | for (let parameter of cacheKeyParameters) { 66 | expect(method.Properties.RequestParameters) 67 | .to.deep.include({ 68 | [`method.${parameter.name}`]: false 69 | }); 70 | } 71 | }); 72 | 73 | it('should not set integration request parameters', () => { 74 | for (let parameter of cacheKeyParameters) { 75 | expect(method.Properties.Integration.RequestParameters) 76 | .to.not.include({ 77 | [`integration.${parameter.name}`]: `method.${parameter.name}` 78 | }); 79 | } 80 | }); 81 | 82 | it('should set the method\'s integration cache key parameters', () => { 83 | for (let parameter of cacheKeyParameters) { 84 | expect(method.Properties.Integration.CacheKeyParameters) 85 | .to.include(`method.${parameter.name}`); 86 | } 87 | }); 88 | 89 | it('should set a cache namespace', () => { 90 | expect(method.Properties.Integration.CacheNamespace).to.exist; 91 | }); 92 | }); 93 | 94 | describe('when one of the endpoints has cache key parameters', () => { 95 | let cacheKeyParameters, method; 96 | let functionWithoutCachingName, functionWithCachingName; 97 | before(() => { 98 | functionWithoutCachingName = 'list-all-cats'; 99 | let functionWithoutCaching = given.a_serverless_function(functionWithoutCachingName) 100 | .withHttpEndpoint('get', '/cats'); 101 | 102 | functionWithCachingName = 'get-cat-by-paw-id'; 103 | cacheKeyParameters = [{ name: 'request.path.pawId' }, { name: 'request.header.Accept-Language' }]; 104 | let functionWithCaching = given.a_serverless_function(functionWithCachingName) 105 | .withHttpEndpoint('get', '/cat/{pawId}', { enabled: true, cacheKeyParameters }); 106 | 107 | serverless = given.a_serverless_instance(serviceName) 108 | .withApiGatewayCachingConfig() 109 | .forStage(stage) 110 | .withFunction(functionWithCaching) 111 | .withFunction(functionWithoutCaching); 112 | 113 | when_configuring_cache_key_parameters(serverless); 114 | }); 115 | 116 | 117 | describe('on the method corresponding with the endpoint with cache key parameters', () => { 118 | before(() => { 119 | method = serverless.getMethodResourceForFunction(functionWithCachingName); 120 | }); 121 | 122 | it('should configure them as request parameters', () => { 123 | for (let parameter of cacheKeyParameters) { 124 | expect(method.Properties.RequestParameters) 125 | .to.deep.include({ 126 | [`method.${parameter.name}`]: false 127 | }); 128 | } 129 | }); 130 | 131 | it('should set integration request parameters', () => { 132 | for (let parameter of cacheKeyParameters) { 133 | expect(method.Properties.Integration.RequestParameters) 134 | .to.deep.include({ 135 | [`integration.${parameter.name}`]: `method.${parameter.name}` 136 | }); 137 | } 138 | }); 139 | 140 | it('should set integration cache key parameters', () => { 141 | for (let parameter of cacheKeyParameters) { 142 | expect(method.Properties.Integration.CacheKeyParameters) 143 | .to.include(`method.${parameter.name}`); 144 | } 145 | }); 146 | 147 | it('should set a cache namespace', () => { 148 | expect(method.Properties.Integration.CacheNamespace).to.exist; 149 | }); 150 | }); 151 | 152 | describe('on the method resource corresponding with the endpoint without cache key parameters', () => { 153 | before(() => { 154 | method = serverless.getMethodResourceForFunction(functionWithoutCachingName); 155 | }); 156 | 157 | it('should not set whether request parameters are required', () => { 158 | expect(method.Properties.RequestParameters).to.deep.equal({}); 159 | }); 160 | 161 | it('should not set integration request parameters', () => { 162 | expect(method.Properties.Integration.RequestParameters).to.not.exist; 163 | }); 164 | 165 | it('should not set integration cache key parameters', () => { 166 | expect(method.Properties.Integration.CacheKeyParameters).to.not.exist; 167 | }); 168 | 169 | it('should not set a cache namespace', () => { 170 | expect(method.Properties.Integration.CacheNamespace).to.not.exist; 171 | }); 172 | }); 173 | }); 174 | 175 | describe('when one endpoint has cache key parameters and a path parameter containing underscore', () => { 176 | let cacheKeyParameters, method; 177 | let functionWithoutCachingName, functionWithCachingName; 178 | before(() => { 179 | functionWithoutCachingName = 'list-all-cats'; 180 | let functionWithoutCaching = given.a_serverless_function(functionWithoutCachingName) 181 | .withHttpEndpoint('get', '/cats'); 182 | 183 | functionWithCachingName = 'get-cat-by-paw-id'; 184 | cacheKeyParameters = [{ name: 'request.path.paw_id' }, { name: 'request.header.Accept-Language' }]; 185 | let functionWithCaching = given.a_serverless_function(functionWithCachingName) 186 | .withHttpEndpoint('get', '/cat/{paw_id}', { enabled: true, cacheKeyParameters }); 187 | 188 | serverless = given.a_serverless_instance(serviceName) 189 | .withApiGatewayCachingConfig() 190 | .forStage(stage) 191 | .withFunction(functionWithCaching) 192 | .withFunction(functionWithoutCaching); 193 | 194 | when_configuring_cache_key_parameters(serverless); 195 | }); 196 | 197 | describe('on the method corresponding with the endpoint with cache key parameters', () => { 198 | before(() => { 199 | method = serverless.getMethodResourceForFunction(functionWithCachingName); 200 | }); 201 | 202 | it('should configure them as request parameters', () => { 203 | for (let parameter of cacheKeyParameters) { 204 | expect(method.Properties.RequestParameters) 205 | .to.deep.include({ 206 | [`method.${parameter.name}`]: false 207 | }); 208 | } 209 | }); 210 | 211 | it('should set integration request parameters', () => { 212 | for (let parameter of cacheKeyParameters) { 213 | expect(method.Properties.Integration.RequestParameters) 214 | .to.deep.include({ 215 | [`integration.${parameter.name}`]: `method.${parameter.name}` 216 | }); 217 | } 218 | }); 219 | 220 | it('should set integration cache key parameters', () => { 221 | for (let parameter of cacheKeyParameters) { 222 | expect(method.Properties.Integration.CacheKeyParameters) 223 | .to.include(`method.${parameter.name}`); 224 | } 225 | }); 226 | 227 | it('should set a cache namespace', () => { 228 | expect(method.Properties.Integration.CacheNamespace).to.exist; 229 | }); 230 | }); 231 | }); 232 | 233 | describe('when one endpoint has cache key parameters', () => { 234 | let cacheKeyParameters, functionWithCachingName; 235 | before(() => { 236 | functionWithCachingName = 'get-cat-by-paw-id'; 237 | cacheKeyParameters = [{ name: 'request.path.pawId' }, { name: 'request.header.Accept-Language' }]; 238 | 239 | let functionWithCaching = given.a_serverless_function(functionWithCachingName) 240 | .withHttpEndpoint('get', '/cat/{pawId}', { enabled: true, cacheKeyParameters }); 241 | 242 | serverless = given.a_serverless_instance(serviceName) 243 | .withApiGatewayCachingConfig() 244 | .forStage(stage) 245 | .withFunction(functionWithCaching); 246 | }); 247 | 248 | let alreadyConfiguredParamsScenarios = [ 249 | { 250 | description: "required", 251 | isRequired: true 252 | }, 253 | { 254 | description: "not required", 255 | isRequired: false 256 | } 257 | ]; 258 | for (let { description, isRequired } of alreadyConfiguredParamsScenarios) { 259 | describe(`and one of them has been already configured as ${description} for http request validation by another plugin`, () => { 260 | let method; 261 | before(() => { 262 | method = serverless.getMethodResourceForFunction(functionWithCachingName); 263 | method.Properties.RequestParameters[`method.${cacheKeyParameters[0].name}`] = isRequired; 264 | 265 | when_configuring_cache_key_parameters(serverless) 266 | }); 267 | 268 | it('should keep configuration', () => { 269 | expect(method.Properties.RequestParameters) 270 | .to.deep.include({ 271 | [`method.${cacheKeyParameters[0].name}`]: isRequired 272 | }); 273 | }); 274 | }); 275 | } 276 | }); 277 | 278 | describe('when there are two endpoints with a cache key parameter', () => { 279 | describe(`and the second endpoint's name is a substring of the first endpoint's name`, () => { 280 | let method, firstEndpointName, firstEndpointCacheKeyParameters, secondEndpointName, secondEndpointCacheKeyParameters; 281 | before(() => { 282 | firstEndpointName = 'catpaw'; 283 | secondEndpointName = 'paw'; 284 | firstEndpointCacheKeyParameters = [{ name: 'request.path.catPawId' }]; 285 | secondEndpointCacheKeyParameters = [{ name: 'request.path.pawId' }]; 286 | 287 | let firstFunctionWithCaching = given.a_serverless_function(firstEndpointName) 288 | .withHttpEndpoint('get', '/cat/paw/{pawId}', { enabled: true, cacheKeyParameters: firstEndpointCacheKeyParameters }); 289 | 290 | let secondFunctionWithCaching = given.a_serverless_function(secondEndpointName) 291 | .withHttpEndpoint('get', '/paw/{catPawId}', { enabled: true, cacheKeyParameters: secondEndpointCacheKeyParameters }); 292 | 293 | serverless = given.a_serverless_instance(serviceName) 294 | .withApiGatewayCachingConfig() 295 | .forStage(stage) 296 | .withFunction(firstFunctionWithCaching) 297 | .withFunction(secondFunctionWithCaching); 298 | 299 | when_configuring_cache_key_parameters(serverless); 300 | }); 301 | 302 | describe('on the method corresponding with the first endpoint with cache key parameters', () => { 303 | before(() => { 304 | method = serverless.getMethodResourceForFunction(firstEndpointName); 305 | }); 306 | 307 | it('should configure them as request parameters', () => { 308 | for (let parameter of firstEndpointCacheKeyParameters) { 309 | expect(method.Properties.RequestParameters) 310 | .to.deep.include({ 311 | [`method.${parameter.name}`]: false 312 | }); 313 | } 314 | }); 315 | 316 | it('should set integration request parameters', () => { 317 | for (let parameter of firstEndpointCacheKeyParameters) { 318 | expect(method.Properties.Integration.RequestParameters) 319 | .to.deep.include({ 320 | [`integration.${parameter.name}`]: `method.${parameter.name}` 321 | }); 322 | } 323 | }); 324 | 325 | it('should set integration cache key parameters', () => { 326 | for (let parameter of firstEndpointCacheKeyParameters) { 327 | expect(method.Properties.Integration.CacheKeyParameters) 328 | .to.include(`method.${parameter.name}`); 329 | } 330 | }); 331 | 332 | it('should set a cache namespace', () => { 333 | expect(method.Properties.Integration.CacheNamespace).to.exist; 334 | }); 335 | }); 336 | 337 | describe('on the method corresponding with the second endpoint with cache key parameters', () => { 338 | before(() => { 339 | method = serverless.getMethodResourceForFunction(secondEndpointName); 340 | }); 341 | 342 | it('should configure them as request parameters', () => { 343 | for (let parameter of secondEndpointCacheKeyParameters) { 344 | expect(method.Properties.RequestParameters) 345 | .to.deep.include({ 346 | [`method.${parameter.name}`]: false 347 | }); 348 | } 349 | }); 350 | 351 | it('should set integration request parameters', () => { 352 | for (let parameter of secondEndpointCacheKeyParameters) { 353 | expect(method.Properties.Integration.RequestParameters) 354 | .to.deep.include({ 355 | [`integration.${parameter.name}`]: `method.${parameter.name}` 356 | }); 357 | } 358 | }); 359 | 360 | it('should set integration cache key parameters', () => { 361 | for (let parameter of secondEndpointCacheKeyParameters) { 362 | expect(method.Properties.Integration.CacheKeyParameters) 363 | .to.include(`method.${parameter.name}`); 364 | } 365 | }); 366 | 367 | it('should set a cache namespace', () => { 368 | expect(method.Properties.Integration.CacheNamespace).to.exist; 369 | }); 370 | }); 371 | }); 372 | }); 373 | 374 | describe('when there are two endpoints with a cache key parameter on the same function', () => { 375 | let method, functionName, firstEndpointCacheKeyParameters, secondEndpointCacheKeyParameters; 376 | before(() => { 377 | functionName = 'catpaw'; 378 | 379 | firstEndpointCacheKeyParameters = [{ name: 'request.path.pawId' }]; 380 | secondEndpointCacheKeyParameters = [{ name: 'request.path.pawId' }]; 381 | 382 | let firstFunctionWithCaching = given.a_serverless_function(functionName) 383 | .withHttpEndpoint('get', '/cat/paw/{pawId}', { enabled: true, cacheKeyParameters: firstEndpointCacheKeyParameters }) 384 | .withHttpEndpoint('delete', '/cat/paw/{pawId}', { enabled: true, cacheKeyParameters: secondEndpointCacheKeyParameters }); 385 | serverless = given.a_serverless_instance(serviceName) 386 | .withApiGatewayCachingConfig() 387 | .forStage(stage) 388 | .withFunction(firstFunctionWithCaching) 389 | 390 | when_configuring_cache_key_parameters(serverless); 391 | }); 392 | 393 | describe('on the method corresponding with the first endpoint with cache key parameters', () => { 394 | before(() => { 395 | method = serverless.getMethodResourceForMethodName("ApiGatewayMethodCatPawPawidVarGet"); 396 | }); 397 | 398 | it('should configure them as request parameters', () => { 399 | for (let parameter of firstEndpointCacheKeyParameters) { 400 | expect(method.Properties.RequestParameters) 401 | .to.deep.include({ 402 | [`method.${parameter.name}`]: false 403 | }); 404 | } 405 | }); 406 | 407 | it('should set integration request parameters', () => { 408 | for (let parameter of firstEndpointCacheKeyParameters) { 409 | expect(method.Properties.Integration.RequestParameters) 410 | .to.deep.include({ 411 | [`integration.${parameter.name}`]: `method.${parameter.name}` 412 | }); 413 | } 414 | }); 415 | 416 | it('should set integration cache key parameters', () => { 417 | for (let parameter of firstEndpointCacheKeyParameters) { 418 | expect(method.Properties.Integration.CacheKeyParameters) 419 | .to.include(`method.${parameter.name}`); 420 | } 421 | }); 422 | 423 | it('should set a cache namespace', () => { 424 | expect(method.Properties.Integration.CacheNamespace).to.exist; 425 | }); 426 | }); 427 | 428 | describe('on the method corresponding with the second endpoint with cache key parameters', () => { 429 | before(() => { 430 | method = serverless.getMethodResourceForMethodName("ApiGatewayMethodCatPawPawidVarDelete"); 431 | }); 432 | 433 | it('should configure them as request parameters', () => { 434 | for (let parameter of secondEndpointCacheKeyParameters) { 435 | expect(method.Properties.RequestParameters) 436 | .to.deep.include({ 437 | [`method.${parameter.name}`]: false 438 | }); 439 | } 440 | }); 441 | 442 | it('should set integration request parameters', () => { 443 | for (let parameter of secondEndpointCacheKeyParameters) { 444 | expect(method.Properties.Integration.RequestParameters) 445 | .to.deep.include({ 446 | [`integration.${parameter.name}`]: `method.${parameter.name}` 447 | }); 448 | } 449 | }); 450 | 451 | it('should set integration cache key parameters', () => { 452 | for (let parameter of secondEndpointCacheKeyParameters) { 453 | expect(method.Properties.Integration.CacheKeyParameters) 454 | .to.include(`method.${parameter.name}`); 455 | } 456 | }); 457 | 458 | it('should set a cache namespace', () => { 459 | expect(method.Properties.Integration.CacheNamespace).to.exist; 460 | }); 461 | }); 462 | }); 463 | 464 | let specialCharacterScenarios = [ 465 | { 466 | description: 'contains the \'+\' special character', 467 | httpEndpointPath: '/cat/{pawId+}' 468 | }, 469 | { 470 | description: 'contains the \'.\' special character', 471 | httpEndpointPath: 'cat.plaything.js' 472 | }, 473 | { 474 | description: 'contains the \'_\' special character', 475 | httpEndpointPath: '/cat/{paw_id}' 476 | }, 477 | { 478 | description: 'contains the \'-\' special character', 479 | httpEndpointPath: 'cat-list' 480 | } 481 | ]; 482 | for (let { description, httpEndpointPath } of specialCharacterScenarios) { 483 | describe(`when an http event path ${description}`, () => { 484 | let cacheKeyParameters, functionWithCachingName; 485 | before(() => { 486 | functionWithCachingName = 'get-cat-by-paw-id'; 487 | cacheKeyParameters = [{ name: 'request.path.pawId' }, { name: 'request.header.Accept-Language' }]; 488 | 489 | let functionWithCaching = given.a_serverless_function(functionWithCachingName) 490 | .withHttpEndpoint('get', httpEndpointPath, { enabled: true, cacheKeyParameters }); 491 | 492 | serverless = given.a_serverless_instance(serviceName) 493 | .withApiGatewayCachingConfig() 494 | .forStage(stage) 495 | .withFunction(functionWithCaching); 496 | 497 | when_configuring_cache_key_parameters(serverless) 498 | }); 499 | 500 | describe('on the corresponding method', () => { 501 | before(() => { 502 | method = serverless.getMethodResourceForFunction(functionWithCachingName); 503 | }); 504 | 505 | it('should configure cache key parameters as request parameters', () => { 506 | for (let parameter of cacheKeyParameters) { 507 | expect(method.Properties.RequestParameters) 508 | .to.deep.include({ 509 | [`method.${parameter.name}`]: false 510 | }); 511 | } 512 | }); 513 | 514 | it('should set integration request parameters', () => { 515 | for (let parameter of cacheKeyParameters) { 516 | expect(method.Properties.Integration.RequestParameters) 517 | .to.deep.include({ 518 | [`integration.${parameter.name}`]: `method.${parameter.name}` 519 | }); 520 | } 521 | }); 522 | 523 | it('should set integration cache key parameters', () => { 524 | for (let parameter of cacheKeyParameters) { 525 | expect(method.Properties.Integration.CacheKeyParameters) 526 | .to.include(`method.${parameter.name}`); 527 | } 528 | }); 529 | 530 | it('should set a cache namespace', () => { 531 | expect(method.Properties.Integration.CacheNamespace).to.exist; 532 | }); 533 | }); 534 | }); 535 | } 536 | 537 | // in v1.8.0 "lambda" integration check was removed because setting cache key parameters seemed to work for both AWS_PROXY and AWS (lambda) integration 538 | describe('when there are methods with mapped cache key parameters', () => { 539 | let method, functionWithCachingName, getMethodCacheKeyParameters, postMethodCacheKeyParameters; 540 | before(() => { 541 | functionWithCachingName = 'cats-graphql'; 542 | getMethodCacheKeyParameters = [{ name: 'integration.request.header.querystringCacheHeader', mappedFrom: 'method.request.querystring.query' }]; 543 | postMethodCacheKeyParameters = [{ name: 'integration.request.header.bodyCacheHeader', mappedFrom: 'method.request.body' }]; 544 | 545 | let functionWithCaching = given.a_serverless_function(functionWithCachingName) 546 | .withHttpEndpoint('get', '/graphql', { enabled: true, cacheKeyParameters: getMethodCacheKeyParameters }) 547 | .withHttpEndpoint('post', '/graphql', { enabled: true, cacheKeyParameters: postMethodCacheKeyParameters }); 548 | 549 | serverless = given.a_serverless_instance(serviceName) 550 | .withApiGatewayCachingConfig() 551 | .forStage(stage) 552 | .withFunction(functionWithCaching); 553 | 554 | when_configuring_cache_key_parameters(serverless) 555 | }); 556 | 557 | describe('on the GET method', () => { 558 | before(() => { 559 | method = serverless.getMethodResourceForMethodName("ApiGatewayMethodGraphqlGet"); 560 | }); 561 | 562 | it('should configure appropriate cache key parameters as request parameters', () => { 563 | for (let parameter of getMethodCacheKeyParameters) { 564 | if ( 565 | parameter.mappedFrom.includes('method.request.querystring') || 566 | parameter.mappedFrom.includes('method.request.header') || 567 | parameter.mappedFrom.includes('method.request.path') 568 | ) { 569 | expect(method.Properties.RequestParameters) 570 | .to.deep.include({ 571 | [parameter.mappedFrom]: false 572 | }); 573 | } 574 | } 575 | }); 576 | 577 | it('should set integration request parameters', () => { 578 | for (let parameter of getMethodCacheKeyParameters) { 579 | expect(method.Properties.Integration.RequestParameters) 580 | .to.deep.include({ 581 | [parameter.name]: parameter.mappedFrom 582 | }); 583 | } 584 | }); 585 | 586 | it('should set integration cache key parameters', () => { 587 | for (let parameter of getMethodCacheKeyParameters) { 588 | expect(method.Properties.Integration.CacheKeyParameters) 589 | .to.include(parameter.name); 590 | } 591 | }); 592 | 593 | it('should set a cache namespace', () => { 594 | expect(method.Properties.Integration.CacheNamespace).to.exist; 595 | }); 596 | }); 597 | 598 | describe('on the POST method', () => { 599 | before(() => { 600 | method = serverless.getMethodResourceForMethodName("ApiGatewayMethodGraphqlPost"); 601 | }); 602 | 603 | it('should configure appropriate cache key parameters as request parameters', () => { 604 | for (let parameter of postMethodCacheKeyParameters) { 605 | if ( 606 | parameter.mappedFrom.includes('method.request.querystring') || 607 | parameter.mappedFrom.includes('method.request.header') || 608 | parameter.mappedFrom.includes('method.request.path') 609 | ) { 610 | expect(method.Properties.RequestParameters) 611 | .to.deep.include({ 612 | [parameter.mappedFrom]: false 613 | }); 614 | } 615 | } 616 | }); 617 | 618 | it('should set integration request parameters', () => { 619 | for (let parameter of postMethodCacheKeyParameters) { 620 | expect(method.Properties.Integration.RequestParameters) 621 | .to.deep.include({ 622 | [parameter.name]: parameter.mappedFrom 623 | }); 624 | } 625 | }); 626 | 627 | it('should set integration cache key parameters', () => { 628 | for (let parameter of postMethodCacheKeyParameters) { 629 | expect(method.Properties.Integration.CacheKeyParameters) 630 | .to.include(parameter.name); 631 | } 632 | }); 633 | 634 | it('should set a cache namespace', () => { 635 | expect(method.Properties.Integration.CacheNamespace).to.exist; 636 | }); 637 | }); 638 | }); 639 | }); 640 | 641 | const when_configuring_cache_key_parameters = (serverless) => { 642 | let cacheSettings = new ApiGatewayCachingSettings(serverless); 643 | return cacheKeyParams.addCacheKeyParametersConfig(cacheSettings, serverless); 644 | } 645 | -------------------------------------------------------------------------------- /test/configuring-rest-api-id.js: -------------------------------------------------------------------------------- 1 | const APP_ROOT = '..'; 2 | const given = require(`${APP_ROOT}/test/steps/given`); 3 | const expect = require('chai').expect; 4 | const { restApiExists, retrieveRestApiId } = require(`${APP_ROOT}/src/restApiId`); 5 | const ApiGatewayCachingSettings = require(`${APP_ROOT}/src/ApiGatewayCachingSettings`); 6 | 7 | describe('Finding the REST API', () => { 8 | let result; 9 | 10 | describe('when the REST API ID has been specified in the settings', () => { 11 | before(async () => { 12 | let serverless = given 13 | .a_serverless_instance() 14 | .withApiGatewayCachingConfig({ restApiId: given.a_rest_api_id() }); 15 | 16 | let settings = new ApiGatewayCachingSettings(serverless); 17 | 18 | result = await restApiExists(serverless, settings); 19 | }); 20 | 21 | it('should return that the REST API exists', () => { 22 | expect(result).to.be.true; 23 | }); 24 | }); 25 | 26 | describe('when the REST API ID has already been defined in serverless configuration', () => { 27 | before(async () => { 28 | let serverless = given 29 | .a_serverless_instance() 30 | .withProviderRestApiId(given.a_rest_api_id()); 31 | settings = new ApiGatewayCachingSettings(serverless); 32 | 33 | result = await restApiExists(serverless, settings); 34 | }); 35 | 36 | it('should return that the REST API exists', () => { 37 | expect(result).to.be.true; 38 | }); 39 | }); 40 | 41 | describe('when the CloudFormation stack has already been deployed and it output a RestApiIdForApigCaching', () => { 42 | let restApiId, serverless, settings; 43 | before(async () => { 44 | serverless = given 45 | .a_serverless_instance(); 46 | 47 | settings = new ApiGatewayCachingSettings(serverless); 48 | restApiId = given.a_rest_api_id_for_deployment(serverless, settings); 49 | 50 | result = await restApiExists(serverless, settings); 51 | }); 52 | 53 | it('should return that the REST API exists', () => { 54 | expect(result).to.be.true; 55 | }); 56 | 57 | it('should return the value of the REST API id', async () => { 58 | const retrievedRestApiId = await retrieveRestApiId(serverless, settings); 59 | expect(retrievedRestApiId).to.equal(restApiId); 60 | }); 61 | }); 62 | 63 | describe('when the REST API has not been defined in serverless configuration', () => { 64 | describe('and there are HTTP handler functions', () => { 65 | before(async () => { 66 | let functionWithHttpEndpoint = given 67 | .a_serverless_function('get-cat-by-paw-id') 68 | .withHttpEndpoint('get', '/cat/{pawId}'); 69 | serverless = given 70 | .a_serverless_instance() 71 | .withFunction(functionWithHttpEndpoint); 72 | settings = new ApiGatewayCachingSettings(serverless); 73 | 74 | result = await restApiExists(serverless, settings); 75 | }); 76 | 77 | it('should return that the REST API does exist', () => { 78 | expect(result).to.be.true; 79 | }); 80 | }); 81 | 82 | describe('and there are no HTTP handler functions', () => { 83 | before(async () => { 84 | serverless = given.a_serverless_instance(); 85 | settings = new ApiGatewayCachingSettings(serverless); 86 | given.the_rest_api_id_is_not_set_for_deployment(serverless, settings); 87 | 88 | result = await restApiExists(serverless, settings); 89 | }); 90 | 91 | it('should return that the REST API does not exist', () => { 92 | expect(result).to.be.false; 93 | }); 94 | }); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /test/creating-plugin.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chai = require('chai'); 4 | const proxyquire = require('proxyquire'); 5 | const expect = chai.expect; 6 | 7 | describe('Creating plugin', () => { 8 | describe('When updating the CloudFormation template', () => { 9 | let scenarios = [ 10 | { 11 | description: 'there is no REST API', 12 | thereIsARestApi: false, 13 | expectedLogMessage: '[serverless-api-gateway-caching] No REST API found. Caching settings will not be updated.', 14 | expectedToOutputRestApiId: false, 15 | expectedToAddCacheKeyParametersConfig: false 16 | }, 17 | { 18 | description: 'there is a REST API and caching is enabled', 19 | cachingEnabled: true, 20 | thereIsARestApi: true, 21 | expectedLogMessage: undefined, 22 | expectedToOutputRestApiId: true, 23 | expectedToAddCacheKeyParametersConfig: true, 24 | }, 25 | { 26 | description: 'there is a REST API and caching is disabled', 27 | cachingEnabled: false, 28 | thereIsARestApi: true, 29 | expectedLogMessage: undefined, 30 | expectedToOutputRestApiId: true, 31 | expectedToAddCacheKeyParametersConfig: false, 32 | } 33 | ]; 34 | 35 | for (let scenario of scenarios) { 36 | describe(`and ${scenario.description}`, () => { 37 | let logCalledWith, outputRestApiIdCalled = false, addCacheKeyParametersConfigCalled = false; 38 | const serverless = { cli: { log: (message) => { logCalledWith = message } } }; 39 | const restApiIdStub = { 40 | restApiExists: () => scenario.thereIsARestApi, 41 | outputRestApiIdTo: () => outputRestApiIdCalled = true 42 | }; 43 | const cacheKeyParametersStub = { 44 | addCacheKeyParametersConfig: () => addCacheKeyParametersConfigCalled = true 45 | } 46 | const ApiGatewayCachingPlugin = proxyquire('../src/apiGatewayCachingPlugin', { './restApiId': restApiIdStub, './cacheKeyParameters': cacheKeyParametersStub }); 47 | 48 | before(() => { 49 | const plugin = new ApiGatewayCachingPlugin(serverless, {}); 50 | plugin.settings = { cachingEnabled: scenario.cachingEnabled } 51 | plugin.updateCloudFormationTemplate(); 52 | }); 53 | 54 | it('should log a message', () => { 55 | expect(logCalledWith).to.equal(scenario.expectedLogMessage); 56 | }); 57 | 58 | it(`is expected to output REST API ID: ${scenario.expectedToOutputRestApiId}`, () => { 59 | expect(outputRestApiIdCalled).to.equal(scenario.expectedToOutputRestApiId); 60 | }); 61 | 62 | it(`is expected to add path parameters to cache config: ${scenario.expectedToAddCacheKeyParametersConfig}`, () => { 63 | expect(addCacheKeyParametersConfigCalled).to.equal(scenario.expectedToAddCacheKeyParametersConfig); 64 | }); 65 | }); 66 | } 67 | }); 68 | 69 | describe('When updating the stage', () => { 70 | let scenarios = [ 71 | { 72 | description: 'there is no REST API', 73 | thereIsARestApi: false, 74 | expectedLogMessage: '[serverless-api-gateway-caching] No REST API found. Caching settings will not be updated.', 75 | expectedToUpdateStageCache: false, 76 | expectedToHaveSettings: true 77 | }, 78 | { 79 | description: 'there is a REST API', 80 | thereIsARestApi: true, 81 | expectedLogMessage: undefined, 82 | expectedToUpdateStageCache: true, 83 | expectedToHaveSettings: true 84 | } 85 | ]; 86 | 87 | for (let scenario of scenarios) { 88 | describe(`and ${scenario.description}`, () => { 89 | let logCalledWith, updateStageCacheSettingsCalled = false; 90 | const serverless = { cli: { log: (message) => { logCalledWith = message } } }; 91 | const restApiIdStub = { 92 | restApiExists: () => scenario.thereIsARestApi, 93 | outputRestApiIdTo: () => {} 94 | }; 95 | const stageCacheStub = { 96 | updateStageCacheSettings: () => updateStageCacheSettingsCalled = true 97 | }; 98 | const ApiGatewayCachingPlugin = proxyquire('../src/apiGatewayCachingPlugin', { './restApiId': restApiIdStub, './stageCache': stageCacheStub }); 99 | const plugin = new ApiGatewayCachingPlugin(serverless, {}); 100 | 101 | before(async () => { 102 | await plugin.updateStage(); 103 | }); 104 | 105 | it('should log a message', () => { 106 | expect(logCalledWith).to.equal(scenario.expectedLogMessage); 107 | }); 108 | 109 | it(`is expected to have settings: ${scenario.expectedToHaveSettings}`, () => { 110 | if (scenario.expectedToHaveSettings) { 111 | expect(plugin.settings).to.exist; 112 | } else { 113 | expect(plugin.settings).to.not.exist; 114 | } 115 | }); 116 | 117 | it(`is expected to update stage cache: ${scenario.expectedToUpdateStageCache}`, () => { 118 | expect(updateStageCacheSettingsCalled).to.equal(scenario.expectedToUpdateStageCache); 119 | }); 120 | }); 121 | } 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /test/creating-settings.js: -------------------------------------------------------------------------------- 1 | const APP_ROOT = '..'; 2 | const given = require(`${APP_ROOT}/test/steps/given`); 3 | const ApiGatewayCachingSettings = require(`${APP_ROOT}/src/ApiGatewayCachingSettings`); 4 | const UnauthorizedCacheControlHeaderStrategy = require(`${APP_ROOT}/src/UnauthorizedCacheControlHeaderStrategy`); 5 | const expect = require('chai').expect; 6 | 7 | describe('Creating settings', () => { 8 | let cacheSettings, serverless; 9 | 10 | let getCatByPawIdFunctionName = 'get-cat-by-paw-id'; 11 | let listAllCatsFunctionName = 'list-all-cats'; 12 | let getMyCatFunctionName = 'get-my-cat'; 13 | 14 | describe('when the input is invalid', () => { 15 | it('should set caching to undefined', () => { 16 | cacheSettings = createSettingsFor(); 17 | expect(cacheSettings.cachingEnabled).to.be.undefined; 18 | }); 19 | }); 20 | 21 | describe('when there are no settings for Api Gateway caching', () => { 22 | it('should set caching to undefined', () => { 23 | cacheSettings = createSettingsFor(given.a_serverless_instance()); 24 | expect(cacheSettings.cachingEnabled).to.be.undefined; 25 | }); 26 | }); 27 | 28 | describe('when the cluster size is omitted from Api Gateway caching settings', () => { 29 | before(() => { 30 | serverless = given.a_serverless_instance() 31 | .withApiGatewayCachingConfig({ clusterSize: null }); 32 | 33 | cacheSettings = createSettingsFor(serverless); 34 | }); 35 | 36 | it('should set the cache cluster size to the default', () => { 37 | expect(cacheSettings.cacheClusterSize).to.equal('0.5'); 38 | }); 39 | }); 40 | 41 | describe('when the time to live is omitted from Api Gateway caching settings', () => { 42 | before(() => { 43 | serverless = given.a_serverless_instance() 44 | .withApiGatewayCachingConfig(); 45 | serverless.service.custom.apiGatewayCaching.ttlInSeconds = undefined; 46 | 47 | cacheSettings = createSettingsFor(serverless); 48 | }); 49 | 50 | it('should set the cache time to live to the default', () => { 51 | expect(cacheSettings.cacheTtlInSeconds).to.equal(3600); 52 | }); 53 | }); 54 | 55 | describe('when per-key invalidation settings are omitted from Api Gateway caching settings', () => { 56 | before(() => { 57 | serverless = given.a_serverless_instance() 58 | .withApiGatewayCachingConfig(); 59 | 60 | cacheSettings = createSettingsFor(serverless); 61 | }); 62 | 63 | it('should set that cache invalidation requires authorization', () => { 64 | expect(cacheSettings.perKeyInvalidation.requireAuthorization).to.be.true; 65 | }); 66 | 67 | it('should set the strategy to ignore unauthorized invalidation requests with a warning', () => { 68 | expect(cacheSettings.perKeyInvalidation.handleUnauthorizedRequests) 69 | .to.equal(UnauthorizedCacheControlHeaderStrategy.IgnoreWithWarning); 70 | }); 71 | }); 72 | 73 | describe('when the endpointsInheritCloudWatchSettingsFromStage toggle is omitted from Api Gateway caching settings', () => { 74 | before(() => { 75 | serverless = given.a_serverless_instance() 76 | .withApiGatewayCachingConfig(); 77 | serverless.service.custom.apiGatewayCaching.endpointsInheritCloudWatchSettingsFromStage = undefined; 78 | 79 | cacheSettings = createSettingsFor(serverless); 80 | }); 81 | 82 | it('should set endpointsInheritCloudWatchSettingsFromStage to true by default', () => { 83 | expect(cacheSettings.endpointsInheritCloudWatchSettingsFromStage).to.equal(true); 84 | }); 85 | }); 86 | 87 | describe('when settings are defined for Api Gateway caching', () => { 88 | let scenarios = [ 89 | { 90 | description: 'and per key cache invalidation does not require authorization', 91 | serverless: given.a_serverless_instance() 92 | .withApiGatewayCachingConfig({ perKeyInvalidation: { requireAuthorization: false } }), 93 | expectedCacheSettings: { 94 | cachingEnabled: true, 95 | cacheClusterSize: '0.5', 96 | cacheTtlInSeconds: 45, 97 | perKeyInvalidation: { 98 | requireAuthorization: false 99 | } 100 | } 101 | }, 102 | { 103 | description: 'and the strategy to handle unauthorized invalidation requests is to ignore', 104 | serverless: given.a_serverless_instance() 105 | .withApiGatewayCachingConfig({ perKeyInvalidation: { requireAuthorization: true, handleUnauthorizedRequests: 'Ignore' } }), 106 | expectedCacheSettings: { 107 | cachingEnabled: true, 108 | cacheClusterSize: '0.5', 109 | cacheTtlInSeconds: 45, 110 | perKeyInvalidation: { 111 | requireAuthorization: true, 112 | handleUnauthorizedRequests: UnauthorizedCacheControlHeaderStrategy.Ignore 113 | } 114 | } 115 | }, 116 | { 117 | description: 'and the strategy to handle unauthorized invalidation requests is to ignore with a warning', 118 | serverless: given.a_serverless_instance() 119 | .withApiGatewayCachingConfig({ perKeyInvalidation: { requireAuthorization: true, handleUnauthorizedRequests: 'IgnoreWithWarning' } }), 120 | expectedCacheSettings: { 121 | cachingEnabled: true, 122 | cacheClusterSize: '0.5', 123 | cacheTtlInSeconds: 45, 124 | perKeyInvalidation: { 125 | requireAuthorization: true, 126 | handleUnauthorizedRequests: UnauthorizedCacheControlHeaderStrategy.IgnoreWithWarning 127 | } 128 | } 129 | }, 130 | { 131 | description: 'and the strategy to handle unauthorized invalidation requests is to ignore with a warning', 132 | serverless: given.a_serverless_instance() 133 | .withApiGatewayCachingConfig({ perKeyInvalidation: { requireAuthorization: true, handleUnauthorizedRequests: 'IgnoreWithWarning' } }), 134 | expectedCacheSettings: { 135 | cachingEnabled: true, 136 | cacheClusterSize: '0.5', 137 | cacheTtlInSeconds: 45, 138 | perKeyInvalidation: { 139 | requireAuthorization: true, 140 | handleUnauthorizedRequests: UnauthorizedCacheControlHeaderStrategy.IgnoreWithWarning 141 | } 142 | } 143 | }, 144 | { 145 | description: 'and the strategy to handle unauthorized invalidation requests is to fail the request', 146 | serverless: given.a_serverless_instance() 147 | .withApiGatewayCachingConfig({ perKeyInvalidation: { requireAuthorization: true, handleUnauthorizedRequests: 'Fail' } }), 148 | expectedCacheSettings: { 149 | cachingEnabled: true, 150 | cacheClusterSize: '0.5', 151 | cacheTtlInSeconds: 45, 152 | perKeyInvalidation: { 153 | requireAuthorization: true, 154 | handleUnauthorizedRequests: UnauthorizedCacheControlHeaderStrategy.Fail 155 | } 156 | } 157 | }, 158 | { 159 | description: 'and the strategy to handle unauthorized invalidation requests is not set', 160 | serverless: given.a_serverless_instance() 161 | .withApiGatewayCachingConfig({ perKeyInvalidation: { requireAuthorization: true } }), 162 | expectedCacheSettings: { 163 | cachingEnabled: true, 164 | cacheClusterSize: '0.5', 165 | cacheTtlInSeconds: 45, 166 | perKeyInvalidation: { 167 | requireAuthorization: true, 168 | handleUnauthorizedRequests: UnauthorizedCacheControlHeaderStrategy.IgnoreWithWarning 169 | } 170 | } 171 | } 172 | ]; 173 | 174 | for (let scenario of scenarios) { 175 | describe(scenario.description, () => { 176 | before(() => { 177 | serverless = given.a_serverless_instance() 178 | .withApiGatewayCachingConfig({ perKeyInvalidation: { requireAuthorization: true, handleUnauthorizedRequests: 'Ignore' } }); 179 | 180 | cacheSettings = createSettingsFor(scenario.serverless); 181 | }); 182 | 183 | it('should set whether caching is enabled', () => { 184 | expect(cacheSettings.cachingEnabled).to.deep.equal(scenario.expectedCacheSettings.cachingEnabled); 185 | }); 186 | 187 | it('should set cache cluster size', () => { 188 | expect(cacheSettings.cacheClusterSize).to.deep.equal(scenario.expectedCacheSettings.cacheClusterSize); 189 | }); 190 | 191 | it('should set cache time to live', () => { 192 | expect(cacheSettings.cacheTtlInSeconds).to.deep.equal(scenario.expectedCacheSettings.cacheTtlInSeconds); 193 | }); 194 | 195 | it('should set per key invalidation settings correctly', () => { 196 | expect(cacheSettings.perKeyInvalidation).to.deep.equal(scenario.expectedCacheSettings.perKeyInvalidation); 197 | }); 198 | }); 199 | } 200 | }); 201 | 202 | describe('when there are settings defined for Api Gateway caching', () => { 203 | describe('and there are functions', () => { 204 | describe('and none of them are http endpoints', () => { 205 | before(() => { 206 | serverless = given.a_serverless_instance() 207 | .withApiGatewayCachingConfig() 208 | .withFunction(given.a_serverless_function('schedule-cat-nap')) 209 | .withFunction(given.a_serverless_function('take-cat-to-vet')); 210 | 211 | cacheSettings = createSettingsFor(serverless); 212 | }); 213 | 214 | it('should not have caching settings for non-http endpoints', () => { 215 | expect(cacheSettings.endpointSettings).to.be.empty; 216 | }); 217 | }); 218 | 219 | describe('and there are some http endpoints', () => { 220 | before(() => { 221 | let listCats = given.a_serverless_function(listAllCatsFunctionName) 222 | .withHttpEndpoint('get', '/cats'); 223 | 224 | let getCatByPawIdCaching = { enabled: true, ttlInSeconds: 30 }; 225 | let getCatByPawId = given.a_serverless_function(getCatByPawIdFunctionName) 226 | .withHttpEndpoint('get', '/cat/{pawId}', getCatByPawIdCaching) 227 | .withHttpEndpoint('delete', '/cat/{pawId}', getCatByPawIdCaching); 228 | 229 | let getMyCatCaching = { enabled: false }; 230 | let getMyCat = given.a_serverless_function(getMyCatFunctionName) 231 | .withHttpEndpoint('get', '/cat/{pawId}', getMyCatCaching); 232 | 233 | serverless = given.a_serverless_instance() 234 | .withApiGatewayCachingConfig() 235 | .withFunction(given.a_serverless_function('schedule-cat-nap')) 236 | .withFunction(listCats) 237 | .withFunction(getCatByPawId) 238 | .withFunction(getMyCat); 239 | 240 | cacheSettings = createSettingsFor(serverless); 241 | }); 242 | 243 | it('should create cache settings for all http endpoints', () => { 244 | expect(cacheSettings.endpointSettings).to.have.lengthOf(4); 245 | }); 246 | 247 | describe('caching for http endpoint without cache settings defined', () => { 248 | let endpointSettings; 249 | before(() => { 250 | endpointSettings = cacheSettings.endpointSettings.find(e => e.functionName == listAllCatsFunctionName); 251 | }); 252 | 253 | it('should default to false', () => { 254 | expect(endpointSettings.cachingEnabled).to.be.false; 255 | }); 256 | }); 257 | 258 | describe('caching for the http endpoint with cache settings disabled', () => { 259 | let endpointSettings; 260 | before(() => { 261 | endpointSettings = cacheSettings.endpointSettings.find(e => e.functionName == getMyCatFunctionName); 262 | }); 263 | 264 | it('should be set to false', () => { 265 | expect(endpointSettings.cachingEnabled).to.be.false; 266 | }); 267 | }); 268 | 269 | describe('caching for the http endpoint with cache settings enabled', () => { 270 | let endpointSettings; 271 | before(() => { 272 | endpointSettings = cacheSettings.endpointSettings.find(e => e.functionName == getCatByPawIdFunctionName); 273 | }); 274 | 275 | it('should be enabled', () => { 276 | expect(endpointSettings.cachingEnabled).to.be.true; 277 | }); 278 | }); 279 | }); 280 | }); 281 | }); 282 | 283 | describe('when there are caching settings for an http endpoint', () => { 284 | let endpoint; 285 | describe('and caching is turned off globally', () => { 286 | before(() => { 287 | let caching = { enabled: true } 288 | endpoint = given.a_serverless_function(getCatByPawIdFunctionName) 289 | .withHttpEndpoint('get', '/cat/{pawId}', caching); 290 | serverless = given.a_serverless_instance() 291 | .withApiGatewayCachingConfig({ cachingEnabled: false }) 292 | .withFunction(endpoint); 293 | 294 | cacheSettings = createSettingsFor(serverless); 295 | }); 296 | 297 | it('caching should be disabled for the endpoint', () => { 298 | expect(cacheSettings.endpointSettings[0].cachingEnabled).to.be.false; 299 | }); 300 | }); 301 | 302 | describe('and caching is turned on globally', () => { 303 | before(() => { 304 | serverless = given.a_serverless_instance() 305 | .withApiGatewayCachingConfig({ clusterSize: '1', ttlInSeconds: 20, perKeyInvalidation: { requireAuthorization: true, handleUnauthorizedRequests: 'Ignore' } }); 306 | }); 307 | 308 | describe('and only the fact that caching is enabled is specified', () => { 309 | before(() => { 310 | let caching = { enabled: true } 311 | endpoint = given.a_serverless_function(getCatByPawIdFunctionName) 312 | .withHttpEndpoint('get', '/cat/{pawId}', caching); 313 | serverless = serverless.withFunction(endpoint); 314 | 315 | cacheSettings = createSettingsFor(serverless); 316 | }); 317 | 318 | it('should inherit time to live settings from global settings', () => { 319 | expect(cacheSettings.endpointSettings[0].cacheTtlInSeconds).to.equal(20); 320 | }); 321 | 322 | it('should not set cache key parameter settings', () => { 323 | expect(cacheSettings.endpointSettings[0].cacheKeyParameters).to.not.exist; 324 | }); 325 | }); 326 | 327 | describe('and the time to live is specified', () => { 328 | before(() => { 329 | let caching = { enabled: true, ttlInSeconds: 67 } 330 | endpoint = given.a_serverless_function(getCatByPawIdFunctionName) 331 | .withHttpEndpoint('get', '/cat/{pawId}', caching); 332 | serverless = serverless.withFunction(endpoint); 333 | 334 | cacheSettings = createSettingsFor(serverless); 335 | }); 336 | 337 | it('should set the correct time to live', () => { 338 | expect(cacheSettings.endpointSettings[0].cacheTtlInSeconds).to.equal(67); 339 | }); 340 | }); 341 | 342 | describe('and there are cache key parameters', () => { 343 | let caching; 344 | before(() => { 345 | caching = { 346 | enabled: true, 347 | cacheKeyParameters: [{ name: 'request.path.pawId' }, { name: 'request.header.Accept-Language' }] 348 | }; 349 | endpoint = given.a_serverless_function(getCatByPawIdFunctionName) 350 | .withHttpEndpoint('get', '/cat/{pawId}', caching); 351 | serverless = serverless.withFunction(endpoint); 352 | 353 | cacheSettings = createSettingsFor(serverless); 354 | }); 355 | 356 | it('should set cache key parameters', () => { 357 | expect(cacheSettings.endpointSettings[0].cacheKeyParameters) 358 | .to.deep.equal([{ name: 'request.path.pawId' }, { name: 'request.header.Accept-Language' }]); 359 | }); 360 | }); 361 | 362 | let scenarios = [ 363 | { 364 | description: 'and it is configured to handle unauthorized invalidation requests by ignoring them with a warning', 365 | caching: { 366 | enabled: true, 367 | perKeyInvalidation: { 368 | requireAuthorization: true, 369 | handleUnauthorizedRequests: 'IgnoreWithWarning' 370 | } 371 | }, 372 | expectedCacheInvalidationRequiresAuthorization: true, 373 | expectedCacheInvalidationStrategy: UnauthorizedCacheControlHeaderStrategy.IgnoreWithWarning 374 | }, 375 | { 376 | description: 'and it is configured to handle unauthorized invalidation requests by ignoring them', 377 | caching: { 378 | enabled: true, 379 | perKeyInvalidation: { 380 | requireAuthorization: true, 381 | handleUnauthorizedRequests: 'Ignore' 382 | } 383 | }, 384 | expectedCacheInvalidationRequiresAuthorization: true, 385 | expectedCacheInvalidationStrategy: UnauthorizedCacheControlHeaderStrategy.Ignore 386 | }, 387 | { 388 | description: 'and it is configured to handle unauthorized invalidation requests by failing the request', 389 | caching: { 390 | enabled: true, 391 | perKeyInvalidation: { 392 | requireAuthorization: true, 393 | handleUnauthorizedRequests: 'Fail' 394 | } 395 | }, 396 | expectedCacheInvalidationRequiresAuthorization: true, 397 | expectedCacheInvalidationStrategy: UnauthorizedCacheControlHeaderStrategy.Fail 398 | }, 399 | { 400 | description: 'and the strategy for handling unauthorized invalidation requests is not defined', 401 | caching: { 402 | enabled: true, 403 | perKeyInvalidation: { 404 | requireAuthorization: true 405 | } 406 | }, 407 | expectedCacheInvalidationRequiresAuthorization: true, 408 | expectedCacheInvalidationStrategy: UnauthorizedCacheControlHeaderStrategy.IgnoreWithWarning 409 | }, 410 | { 411 | description: 'and it is configured to not require cache control authorization', 412 | caching: { 413 | enabled: true, 414 | perKeyInvalidation: { 415 | requireAuthorization: false 416 | } 417 | }, 418 | expectedCacheInvalidationRequiresAuthorization: false, 419 | expectedCacheInvalidationStrategy: undefined 420 | }, 421 | { 422 | description: 'and cache control authorization is not configured', 423 | caching: { 424 | enabled: true 425 | }, 426 | // defaults to global settings 427 | expectedCacheInvalidationRequiresAuthorization: true, 428 | expectedCacheInvalidationStrategy: UnauthorizedCacheControlHeaderStrategy.Ignore 429 | } 430 | ]; 431 | 432 | for (let scenario of scenarios) { 433 | describe(scenario.description, () => { 434 | before(() => { 435 | endpoint = given.a_serverless_function(getCatByPawIdFunctionName) 436 | .withHttpEndpoint('get', '/cat/{pawId}', scenario.caching); 437 | serverless = serverless.withFunction(endpoint); 438 | 439 | cacheSettings = createSettingsFor(serverless); 440 | }); 441 | 442 | it('should set per-key cache invalidation authorization', () => { 443 | expect(cacheSettings.endpointSettings[0].perKeyInvalidation.requireAuthorization) 444 | .to.equal(scenario.expectedCacheInvalidationRequiresAuthorization) 445 | }); 446 | 447 | it('should set the strategy to handle unauthorized cache invalidation requests', () => { 448 | expect(cacheSettings.endpointSettings[0].perKeyInvalidation.handleUnauthorizedRequests) 449 | .to.equal(scenario.expectedCacheInvalidationStrategy); 450 | }); 451 | }); 452 | } 453 | }); 454 | }); 455 | 456 | describe('when there are additional endpoints defined', () => { 457 | describe('and caching is turned off globally', () => { 458 | before(() => { 459 | serverless = given.a_serverless_instance() 460 | .withApiGatewayCachingConfig({ cachingEnabled: false }) 461 | .withAdditionalEndpoints([{ method: 'GET', path: '/shelter', caching: { enabled: true } }]); 462 | 463 | cacheSettings = createSettingsFor(serverless); 464 | }); 465 | 466 | it('caching should be disabled for the additional endpoints', () => { 467 | expect(cacheSettings.additionalEndpointSettings[0].cachingEnabled).to.be.false; 468 | }); 469 | }); 470 | 471 | describe('and caching is turned on globally', () => { 472 | before(() => { 473 | serverless = given.a_serverless_instance() 474 | .withApiGatewayCachingConfig({ clusterSize: '1', ttlInSeconds: 20 }); 475 | }); 476 | 477 | describe('and no caching settings are defined for the additional endpoint', () => { 478 | before(() => { 479 | serverless = serverless 480 | .withAdditionalEndpoints([{ method: 'GET', path: '/shelter' }]); 481 | 482 | cacheSettings = createSettingsFor(serverless); 483 | }); 484 | 485 | it('should disable caching for the endpoint', () => { 486 | expect(cacheSettings.additionalEndpointSettings[0].cachingEnabled).to.equal(false); 487 | }); 488 | }); 489 | 490 | describe('and only the fact that caching is enabled is specified on the additional endpoint', () => { 491 | before(() => { 492 | let caching = { enabled: true }; 493 | serverless = serverless 494 | .withAdditionalEndpoints([{ method: 'GET', path: '/shelter', caching }]); 495 | 496 | cacheSettings = createSettingsFor(serverless); 497 | }); 498 | 499 | it('should inherit time to live settings from global settings', () => { 500 | expect(cacheSettings.additionalEndpointSettings[0].cacheTtlInSeconds).to.equal(20); 501 | }); 502 | 503 | it('should inherit data encryption settings from global settings', () => { 504 | expect(cacheSettings.additionalEndpointSettings[0].dataEncrypted).to.equal(false); 505 | }); 506 | }); 507 | 508 | describe('and the time to live is specified', () => { 509 | before(() => { 510 | let caching = { enabled: true, ttlInSeconds: 67 } 511 | serverless = serverless 512 | .withAdditionalEndpoints([{ method: 'GET', path: '/shelter', caching }]); 513 | 514 | cacheSettings = createSettingsFor(serverless); 515 | }); 516 | 517 | it('should set the correct time to live', () => { 518 | expect(cacheSettings.additionalEndpointSettings[0].cacheTtlInSeconds).to.equal(67); 519 | }); 520 | 521 | it('should inherit data encryption settings from global settings', () => { 522 | expect(cacheSettings.additionalEndpointSettings[0].dataEncrypted).to.equal(false); 523 | }); 524 | }); 525 | 526 | describe('and data encryption is specified', () => { 527 | before(() => { 528 | let caching = { enabled: true, dataEncrypted: true } 529 | serverless = serverless 530 | .withAdditionalEndpoints([{ method: 'GET', path: '/shelter', caching }]); 531 | 532 | cacheSettings = createSettingsFor(serverless); 533 | }); 534 | 535 | it('should set the correct data encryption', () => { 536 | expect(cacheSettings.additionalEndpointSettings[0].dataEncrypted).to.equal(true); 537 | }); 538 | 539 | it('should inherit time to live settings from global settings', () => { 540 | expect(cacheSettings.additionalEndpointSettings[0].cacheTtlInSeconds).to.equal(20); 541 | }); 542 | }); 543 | }); 544 | }); 545 | 546 | describe('when there are command line options for the deployment', () => { 547 | let options; 548 | before(() => { 549 | serverless = given.a_serverless_instance() 550 | .forStage('devstage') 551 | .forRegion('eu-west-1') 552 | .withApiGatewayCachingConfig(); 553 | }); 554 | 555 | describe('and they do not specify the stage', () => { 556 | before(() => { 557 | options = {} 558 | 559 | cacheSettings = createSettingsFor(serverless, options); 560 | }); 561 | 562 | it('should use the provider stage', () => { 563 | expect(cacheSettings.stage).to.equal('devstage'); 564 | }); 565 | }); 566 | 567 | describe('and they specify the stage', () => { 568 | before(() => { 569 | options = { stage: 'anotherstage' } 570 | 571 | cacheSettings = createSettingsFor(serverless, options); 572 | }); 573 | 574 | it('should use the stage from command line', () => { 575 | expect(cacheSettings.stage).to.equal('anotherstage'); 576 | }); 577 | }); 578 | 579 | describe('and they do not specify the region', () => { 580 | before(() => { 581 | options = {} 582 | 583 | cacheSettings = createSettingsFor(serverless, options); 584 | }); 585 | 586 | it('should use the provider region', () => { 587 | expect(cacheSettings.region).to.equal('eu-west-1'); 588 | }); 589 | }); 590 | 591 | describe('and they specify the region', () => { 592 | before(() => { 593 | options = { region: 'someotherregion' } 594 | 595 | cacheSettings = createSettingsFor(serverless, options); 596 | }); 597 | 598 | it('should use the region from command line', () => { 599 | expect(cacheSettings.region).to.equal('someotherregion'); 600 | }); 601 | }); 602 | }); 603 | 604 | describe('when a http endpoint is defined in shorthand', () => { 605 | describe(`and caching is turned on globally`, () => { 606 | before(() => { 607 | endpoint = given.a_serverless_function('list-cats') 608 | .withHttpEndpointInShorthand('get /cats'); 609 | serverless = given.a_serverless_instance() 610 | .withApiGatewayCachingConfig() 611 | .withFunction(endpoint); 612 | 613 | cacheSettings = createSettingsFor(serverless); 614 | }); 615 | 616 | it('settings should contain the endpoint method', () => { 617 | expect(cacheSettings.endpointSettings[0].method).to.equal('get'); 618 | }); 619 | 620 | it('settings should contain the endpoint path', () => { 621 | expect(cacheSettings.endpointSettings[0].path).to.equal('/cats'); 622 | }); 623 | 624 | it('caching should not be enabled for the http endpoint', () => { 625 | expect(cacheSettings.endpointSettings[0].cachingEnabled).to.be.false; 626 | }); 627 | }); 628 | }); 629 | 630 | describe('when the apiGateway is shared and a basePath is defined', () => { 631 | before(() => { 632 | endpoint = given.a_serverless_function('list-cats') 633 | .withHttpEndpoint('get', '/cat/'); 634 | serverless = given.a_serverless_instance() 635 | .withApiGatewayCachingConfig({ basePath: '/animals' }) 636 | .withFunction(endpoint); 637 | 638 | cacheSettings = createSettingsFor(serverless); 639 | }); 640 | 641 | it('settings should contain the endpoint path including the base path', () => { 642 | expect(cacheSettings.endpointSettings[0].path).to.equal('/animals/cat'); 643 | }); 644 | 645 | it('settings should contain the endpoint pathWithoutGlobalBasePath', () => { 646 | expect(cacheSettings.endpointSettings[0].pathWithoutGlobalBasePath).to.equal('/cat'); 647 | }); 648 | }); 649 | 650 | // API Gateway's updateStage doesn't like paths which end in forward slash 651 | describe('when a http endpoint path ends with a forward slash character and caching is turned on globally', () => { 652 | before(() => { 653 | endpoint = given.a_serverless_function('list-cats') 654 | .withHttpEndpoint('get', '/cat/'); 655 | serverless = given.a_serverless_instance() 656 | .withApiGatewayCachingConfig() 657 | .withFunction(endpoint); 658 | 659 | cacheSettings = createSettingsFor(serverless); 660 | }); 661 | 662 | it('settings should contain the endpoint path without the forward slash at the end', () => { 663 | expect(cacheSettings.endpointSettings[0].path).to.equal('/cat'); 664 | }); 665 | }); 666 | 667 | describe('when a http endpoint path is a forward slash character and caching is turned on globally', () => { 668 | before(() => { 669 | endpoint = given.a_serverless_function('list-cats') 670 | .withHttpEndpoint('get', '/'); 671 | serverless = given.a_serverless_instance() 672 | .withApiGatewayCachingConfig() 673 | .withFunction(endpoint); 674 | 675 | cacheSettings = createSettingsFor(serverless); 676 | }); 677 | 678 | it('settings should contain the endpoint path as is', () => { 679 | expect(cacheSettings.endpointSettings[0].path).to.equal('/'); 680 | }); 681 | }); 682 | 683 | describe('when the global cache ttl is zero', () => { 684 | before(() => { 685 | serverless = given.a_serverless_instance() 686 | .withApiGatewayCachingConfig({ ttlInSeconds: 0 }); 687 | 688 | cacheSettings = createSettingsFor(serverless); 689 | }); 690 | 691 | it('should be supported', () => { 692 | expect(cacheSettings.cacheTtlInSeconds).to.equal(0); 693 | }); 694 | }); 695 | 696 | describe('when global cache ttl is less than zero', () => { 697 | before(() => { 698 | serverless = given.a_serverless_instance() 699 | .withApiGatewayCachingConfig({ ttlInSeconds: -123 }); 700 | 701 | cacheSettings = createSettingsFor(serverless); 702 | }); 703 | 704 | it('should set the default global cache ttl', () => { 705 | expect(cacheSettings.cacheTtlInSeconds).to.equal(3600); 706 | }); 707 | }); 708 | 709 | describe('when a function cache ttl is zero', () => { 710 | before(() => { 711 | serverless = given.a_serverless_instance() 712 | .withApiGatewayCachingConfig({ ttlInSeconds: 60 }) 713 | .withFunction(given.a_serverless_function(getCatByPawIdFunctionName) 714 | .withHttpEndpoint('get', '/cat/{pawId}', { enabled: true, ttlInSeconds: 0 })); 715 | cacheSettings = createSettingsFor(serverless); 716 | }); 717 | 718 | it('should be supported', () => { 719 | expect(cacheSettings.endpointSettings[0].cacheTtlInSeconds).to.equal(0); 720 | }); 721 | }); 722 | 723 | describe('when a function cache ttl is less than zero', () => { 724 | before(() => { 725 | serverless = given.a_serverless_instance() 726 | .withApiGatewayCachingConfig({ ttlInSeconds: 60 }) 727 | .withFunction(given.a_serverless_function(getCatByPawIdFunctionName) 728 | .withHttpEndpoint('get', '/cat/{pawId}', { enabled: true, ttlInSeconds: -234 })); 729 | cacheSettings = createSettingsFor(serverless); 730 | }); 731 | 732 | it('should inherit cache ttl from global settings', () => { 733 | expect(cacheSettings.endpointSettings[0].cacheTtlInSeconds).to.equal(60); 734 | }); 735 | }); 736 | 737 | describe('when an additional endpoint cache ttl is zero', () => { 738 | before(() => { 739 | serverless = given.a_serverless_instance() 740 | .withApiGatewayCachingConfig({ ttlInSeconds: 60 }) 741 | .withAdditionalEndpoints([{ method: 'GET', path: '/shelter', caching: { enabled: true, ttlInSeconds: 0 } }]); 742 | cacheSettings = createSettingsFor(serverless); 743 | }); 744 | 745 | it('should be supported', () => { 746 | expect(cacheSettings.additionalEndpointSettings[0].cacheTtlInSeconds).to.equal(0); 747 | }); 748 | }); 749 | 750 | describe('when an additional endpoint cache ttl is less than zero', () => { 751 | before(() => { 752 | serverless = given.a_serverless_instance() 753 | .withApiGatewayCachingConfig({ ttlInSeconds: 60 }) 754 | .withAdditionalEndpoints([{ method: 'GET', path: '/shelter', caching: { enabled: true, ttlInSeconds: -135 } }]); 755 | cacheSettings = createSettingsFor(serverless); 756 | }); 757 | 758 | it('should inherit cache ttl from global settings', () => { 759 | expect(cacheSettings.additionalEndpointSettings[0].cacheTtlInSeconds).to.equal(60); 760 | }); 761 | }); 762 | 763 | describe('when caching config for a http endpoint sets whether to inherit CloudWatch settings from stage', () => { 764 | before(() => { 765 | listEndpoint = given.a_serverless_function('list-cats') 766 | .withHttpEndpoint('get', '/'); 767 | getEndpoint = given.a_serverless_function('get-cat') 768 | .withHttpEndpoint('get', '/cat/{id}', { inheritCloudWatchSettingsFromStage: false }); 769 | serverless = given.a_serverless_instance() 770 | .withApiGatewayCachingConfig() 771 | .withFunction(listEndpoint) 772 | .withFunction(getEndpoint) 773 | serverless.service.custom.apiGatewayCaching.endpointsInheritCloudWatchSettingsFromStage = true; 774 | 775 | cacheSettings = createSettingsFor(serverless); 776 | }); 777 | 778 | it('endpoint settings for whether to inherit CloudWatch settings from stage are correct', () => { 779 | expect(cacheSettings.endpointSettings[0].path).to.equal('/'); 780 | // propagates from global caching settings 781 | expect(cacheSettings.endpointSettings[0].inheritCloudWatchSettingsFromStage).to.equal(true); 782 | expect(cacheSettings.endpointSettings[1].path).to.equal('/cat/{id}'); 783 | expect(cacheSettings.endpointSettings[1].inheritCloudWatchSettingsFromStage).to.equal(false); 784 | }); 785 | }); 786 | }); 787 | 788 | const createSettingsFor = (serverless, options) => { 789 | return new ApiGatewayCachingSettings(serverless, options); 790 | } 791 | -------------------------------------------------------------------------------- /test/determining-api-gateway-resource-name.js: -------------------------------------------------------------------------------- 1 | const given = require('../test/steps/given'); 2 | const expect = require('chai').expect; 3 | const ApiGatewayCachingSettings = require('../src/ApiGatewayCachingSettings'); 4 | 5 | describe('Determining API Gateway resource names', () => { 6 | const serviceName = 'cat-api'; 7 | const functionName = 'get-cat-by-paw-id'; 8 | 9 | const scenarios = [ 10 | { 11 | path: '/', 12 | method: 'GET', 13 | expectedGatewayResourceName: 'ApiGatewayMethodGet' 14 | }, 15 | { 16 | path: '/', 17 | method: 'POST', 18 | expectedGatewayResourceName: 'ApiGatewayMethodPost' 19 | }, 20 | { 21 | path: '/cat/{pawId}', 22 | method: 'GET', 23 | expectedGatewayResourceName: 'ApiGatewayMethodCatPawidVarGet' 24 | }, 25 | { 26 | path: '/{id}', 27 | method: 'PATCH', 28 | expectedGatewayResourceName: 'ApiGatewayMethodIdVarPatch' 29 | } 30 | ]; 31 | for (const scenario of scenarios) { 32 | 33 | 34 | describe('when a base path is not specified', () => { 35 | before(() => { 36 | const endpoint = given 37 | .a_serverless_function(functionName) 38 | .withHttpEndpoint(scenario.method, scenario.path, { enabled: true }); 39 | 40 | const serverless = given 41 | .a_serverless_instance(serviceName) 42 | .withApiGatewayCachingConfig({ enabled: true }) 43 | .withFunction(endpoint); 44 | 45 | settings = new ApiGatewayCachingSettings(serverless); 46 | }); 47 | 48 | it('determines the resource name based on endpoint path and method', () => { 49 | expect(gatewayResourceNameOf(functionName, settings)).to.equal(scenario.expectedGatewayResourceName); 50 | }); 51 | }); 52 | 53 | describe('when a base path is specified', () => { 54 | before(() => { 55 | const endpoint = given 56 | .a_serverless_function(functionName) 57 | .withHttpEndpoint(scenario.method, scenario.path, { enabled: true }); 58 | 59 | const serverless = given 60 | .a_serverless_instance(serviceName) 61 | .withApiGatewayCachingConfig({ enabled: true, basePath: '/animals' }) 62 | .withFunction(endpoint); 63 | 64 | settings = new ApiGatewayCachingSettings(serverless); 65 | }); 66 | 67 | it('is not included in the API Gateway resource name', () => { 68 | expect(gatewayResourceNameOf(functionName, settings)).to.equal(scenario.expectedGatewayResourceName); 69 | }); 70 | }); 71 | } 72 | }); 73 | 74 | const gatewayResourceNameOf = (functionName, settings) => { 75 | return settings 76 | .endpointSettings 77 | .find(x => x.functionName === functionName) 78 | .gatewayResourceName; 79 | } 80 | -------------------------------------------------------------------------------- /test/inheriting-cloudwatch-settings-from-stage.js: -------------------------------------------------------------------------------- 1 | const given = require('../test/steps/given'); 2 | const when = require('../test/steps/when'); 3 | const ApiGatewayCachingSettings = require(`../src/ApiGatewayCachingSettings`); 4 | const expect = require('chai').expect; 5 | 6 | describe('Inheriting CloudWatch settings from stage', () => { 7 | const apiGatewayService = 'APIGateway', updateStageMethod = 'updateStage'; 8 | describe('when some endpoints are configured to inherit CloudWatch settings from stage and some are not', () => { 9 | before(async () => { 10 | let restApiId = given.a_rest_api_id(); 11 | let endpointWithInheritedCwSettings = given.a_serverless_function('get-my-cat') 12 | .withHttpEndpoint('get', '/', { inheritCloudWatchSettingsFromStage: true }) 13 | let endpointWithoutInheritedCwSettings = given.a_serverless_function('get-cat-by-paw-id') 14 | .withHttpEndpoint('get', '/cat/{pawId}', { inheritCloudWatchSettingsFromStage: false }) 15 | serverless = given.a_serverless_instance() 16 | .forStage('somestage') 17 | .withRestApiId(restApiId) 18 | .withApiGatewayCachingConfig({ endpointsInheritCloudWatchSettingsFromStage: true }) 19 | .withFunction(endpointWithInheritedCwSettings) 20 | .withFunction(endpointWithoutInheritedCwSettings) 21 | .withStageSettingsForCloudWatchMetrics({ loggingLevel: 'WARN', dataTraceEnabled: false, metricsEnabled: true }); 22 | settings = new ApiGatewayCachingSettings(serverless); 23 | 24 | await when.updating_stage_cache_settings(settings, serverless); 25 | 26 | requestsToAws = serverless.getRequestsToAws(); 27 | }); 28 | 29 | describe('the request sent to AWS SDK to update stage', () => { 30 | before(() => { 31 | apiGatewayRequest = requestsToAws.find(r => r.awsService == apiGatewayService && r.method == updateStageMethod); 32 | }); 33 | 34 | describe('for the endpoint which inherits CloudWatch settings from stage', () => { 35 | it('should set the value of logging/logLevel the same as the stage value of logging/logLevel', () => { 36 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 37 | op: 'replace', 38 | path: '/~1/GET/logging/loglevel', 39 | value: 'WARN' 40 | }); 41 | }); 42 | 43 | it('should set the value of logging/dataTrace the same as the stage value of logging/dataTraceEnabled', () => { 44 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 45 | op: 'replace', 46 | path: '/~1/GET/logging/dataTrace', 47 | value: 'false' 48 | }); 49 | }); 50 | 51 | it('should set the value of metrics/enabled the same as the stage value of metrics/enabled', () => { 52 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 53 | op: 'replace', 54 | path: '/~1/GET/metrics/enabled', 55 | value: 'true' 56 | }); 57 | }); 58 | }); 59 | 60 | describe('for the endpoint which does not inherit CloudWatch settings from stage', () => { 61 | it('should not set the value of logging/logLevel', () => { 62 | let operation = apiGatewayRequest.properties.patchOperations 63 | .find(o => o.path == '/~1cat~1{pawId}/GET/logging/logLevel'); 64 | expect(operation).to.not.exist; 65 | }); 66 | 67 | it('should not set the value of logging/dataTrace', () => { 68 | let operation = apiGatewayRequest.properties.patchOperations 69 | .find(o => o.path == '/~1cat~1{pawId}/GET/logging/dataTrace'); 70 | expect(operation).to.not.exist; 71 | }); 72 | 73 | it('should not set the value of metrics/enabled', () => { 74 | let operation = apiGatewayRequest.properties.patchOperations 75 | .find(o => o.path == '/~1cat~1{pawId}/GET/metrics/enabled'); 76 | expect(operation).to.not.exist; 77 | }); 78 | }); 79 | }); 80 | }); 81 | 82 | describe('when CloudWatch settings for the stage are not defined', () => { 83 | before(async () => { 84 | let restApiId = given.a_rest_api_id(); 85 | let endpointWithInheritedCwSettings = given.a_serverless_function('get-my-cat') 86 | .withHttpEndpoint('get', '/', { inheritCloudWatchSettingsFromStage: true }) 87 | serverless = given.a_serverless_instance() 88 | .forStage('somestage') 89 | .withRestApiId(restApiId) 90 | .withApiGatewayCachingConfig({ endpointsInheritCloudWatchSettingsFromStage: true }) 91 | .withFunction(endpointWithInheritedCwSettings) 92 | .withoutStageSettingsForCloudWatchMetrics(); 93 | settings = new ApiGatewayCachingSettings(serverless); 94 | 95 | await when.updating_stage_cache_settings(settings, serverless); 96 | 97 | requestsToAws = serverless.getRequestsToAws(); 98 | }); 99 | 100 | describe('the request sent to AWS SDK to update stage', () => { 101 | before(() => { 102 | apiGatewayRequest = requestsToAws.find(r => r.awsService == apiGatewayService && r.method == updateStageMethod); 103 | }); 104 | 105 | it('should not set the value of logging/logLevel', () => { 106 | let operation = apiGatewayRequest.properties.patchOperations 107 | .find(o => o.path == '/~1/GET/logging/loglevel'); 108 | expect(operation).to.not.exist; 109 | }); 110 | 111 | it('should not set the value of logging/dataTrace', () => { 112 | let operation = apiGatewayRequest.properties.patchOperations 113 | .find(o => o.path == '/~1/GET/logging/dataTrace'); 114 | expect(operation).to.not.exist; 115 | }); 116 | 117 | it('should not set the value of metrics/enabled', () => { 118 | let operation = apiGatewayRequest.properties.patchOperations 119 | .find(o => o.path == '/~1/GET/metrics/enabled'); 120 | expect(operation).to.not.exist; 121 | }); 122 | }); 123 | }); 124 | 125 | describe('when CloudWatch logging level for the stage is not defined', () => { 126 | before(async () => { 127 | let restApiId = given.a_rest_api_id(); 128 | let endpointWithInheritedCwSettings = given.a_serverless_function('get-my-cat') 129 | .withHttpEndpoint('get', '/', { inheritCloudWatchSettingsFromStage: true }) 130 | serverless = given.a_serverless_instance() 131 | .forStage('somestage') 132 | .withRestApiId(restApiId) 133 | .withApiGatewayCachingConfig({ endpointsInheritCloudWatchSettingsFromStage: true }) 134 | .withFunction(endpointWithInheritedCwSettings) 135 | .withStageSettingsForCloudWatchMetrics({ dataTraceEnabled: false, metricsEnabled: true }); 136 | settings = new ApiGatewayCachingSettings(serverless); 137 | 138 | await when.updating_stage_cache_settings(settings, serverless); 139 | 140 | requestsToAws = serverless.getRequestsToAws(); 141 | }); 142 | 143 | describe('the request sent to AWS SDK to update stage', () => { 144 | before(() => { 145 | apiGatewayRequest = requestsToAws.find(r => r.awsService == apiGatewayService && r.method == updateStageMethod); 146 | }); 147 | 148 | it('should not set the value of logging/logLevel', () => { 149 | let operation = apiGatewayRequest.properties.patchOperations 150 | .find(o => o.path == '/~1/GET/logging/loglevel'); 151 | expect(operation).to.not.exist; 152 | }); 153 | 154 | it('should set the value of logging/dataTrace the same as the stage value of logging/dataTraceEnabled', () => { 155 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 156 | op: 'replace', 157 | path: '/~1/GET/logging/dataTrace', 158 | value: 'false' 159 | }); 160 | }); 161 | 162 | it('should set the value of metrics/enabled the same as the stage value of metrics/enabled', () => { 163 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 164 | op: 'replace', 165 | path: '/~1/GET/metrics/enabled', 166 | value: 'true' 167 | }); 168 | }); 169 | }); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /test/model/Serverless.js: -------------------------------------------------------------------------------- 1 | class Serverless { 2 | constructor(serviceName) { 3 | this._logMessages = []; 4 | this._recordedAwsRequests = []; 5 | this._mockedRequestsToAws = []; 6 | this.cli = { 7 | log: (logMessage) => { 8 | this._logMessages.push(logMessage); 9 | } 10 | }; 11 | 12 | this.service = { 13 | service: serviceName, 14 | custom: {}, 15 | provider: { 16 | compiledCloudFormationTemplate: { 17 | Resources: [] 18 | } 19 | }, 20 | getFunction(functionName) { 21 | return this.functions[functionName]; 22 | } 23 | }; 24 | 25 | this.providers = { 26 | aws: { 27 | naming: { 28 | getStackName: (stage) => { 29 | if (stage != this.service.provider.stage) { 30 | throw new Error('[Serverless Test Model] Something went wrong getting the Stack Name'); 31 | } 32 | return 'serverless-stack-name'; 33 | } 34 | }, 35 | request: async (awsService, method, properties, stage, region) => { 36 | this._recordedAwsRequests.push({ awsService, method, properties, stage, region }); 37 | 38 | if (awsService == 'APIGateway' && method == 'updateStage') { 39 | return; 40 | } 41 | 42 | const params = { awsService, method, properties, stage, region }; 43 | const mockedFunction = this._mockedRequestsToAws[mockedRequestKeyFor(params)]; 44 | if (!mockedFunction) { 45 | throw new Error(`[Serverless Test Model] No mock found for request to AWS { awsService = ${awsService}, method = ${method}, properties = ${JSON.stringify(properties)}, stage = ${stage}, region = ${region} }`) 46 | } 47 | const mockedResponse = mockedFunction(params); 48 | if (!mockedFunction) { 49 | throw new Error(`[Serverless Test Model] No mock response found for request to AWS { awsService = ${awsService}, method = ${method}, properties = ${JSON.stringify(properties)}, stage = ${stage}, region = ${region} }`) 50 | } 51 | return mockedResponse; 52 | } 53 | } 54 | } 55 | 56 | // add default mock for getStage 57 | this._mockedRequestsToAws[mockedRequestKeyFor({ awsService: 'APIGateway', method: 'getStage' })] = defaultMockedRequestToAWS; 58 | } 59 | 60 | forStage(stage) { 61 | this.service.provider.stage = stage; 62 | return this; 63 | } 64 | 65 | forRegion(region) { 66 | this.service.provider.region = region; 67 | return this; 68 | } 69 | 70 | withApiGatewayCachingConfig({ cachingEnabled = true, clusterSize = '0.5', ttlInSeconds = 45, perKeyInvalidation, dataEncrypted, apiGatewayIsShared, 71 | restApiId, basePath, endpointsInheritCloudWatchSettingsFromStage } = {}) { 72 | this.service.custom.apiGatewayCaching = { 73 | enabled: cachingEnabled, 74 | apiGatewayIsShared, 75 | restApiId, 76 | basePath, 77 | clusterSize, 78 | ttlInSeconds, 79 | perKeyInvalidation, 80 | dataEncrypted, 81 | endpointsInheritCloudWatchSettingsFromStage 82 | }; 83 | return this; 84 | } 85 | 86 | withFunction(serverlessFunction) { 87 | if (!this.service.functions) { 88 | this.service.functions = {}; 89 | } 90 | let functionName = Object.keys(serverlessFunction)[0]; 91 | this.service.functions[functionName] = serverlessFunction[functionName]; 92 | 93 | let { functionResourceName, methodResourceName } = addFunctionToCompiledCloudFormationTemplate(serverlessFunction, this); 94 | if (!this._functionsToResourcesMapping) { 95 | this._functionsToResourcesMapping = {} 96 | } 97 | this._functionsToResourcesMapping[functionName] = { 98 | functionResourceName, 99 | methodResourceName 100 | } 101 | // when a function with an http endpoint is defined, serverless creates an ApiGatewayRestApi resource 102 | this.service.provider.compiledCloudFormationTemplate.Resources['ApiGatewayRestApi'] = {}; 103 | return this; 104 | } 105 | 106 | withAdditionalEndpoints(additionalEndpoints) { 107 | this.service.custom.apiGatewayCaching.additionalEndpoints = additionalEndpoints; 108 | // when a function with an http endpoint is defined, serverless creates an ApiGatewayRestApi resource 109 | this.service.provider.compiledCloudFormationTemplate.Resources['ApiGatewayRestApi'] = {}; 110 | 111 | for (const additionalEndpointToAdd of additionalEndpoints) { 112 | 113 | let { additionalEndpoint, methodResourceName } = addAdditionalEndpointToCompiledCloudFormationTemplate(additionalEndpointToAdd, this); 114 | if (!this._additionalEndpointsToResourcesMapping) { 115 | this._additionalEndpointsToResourcesMapping = {} 116 | } 117 | this._additionalEndpointsToResourcesMapping[JSON.stringify(additionalEndpoint)] = { 118 | additionalEndpoint, 119 | methodResourceName 120 | } 121 | } 122 | return this; 123 | } 124 | 125 | withoutStageSettingsForCloudWatchMetrics() { 126 | const expectedAwsService = 'APIGateway'; 127 | const expectedMethod = 'getStage'; 128 | const mockedRequestToAws = ({ awsService, method, properties, stage, region }) => { 129 | if (awsService == 'APIGateway' 130 | && method == 'getStage' 131 | && properties.restApiId == this._restApiId 132 | && properties.stageName == this.service.provider.stage 133 | && region == this.service.provider.region) { 134 | return { 135 | methodSettings: { 136 | } 137 | }; 138 | } 139 | }; 140 | this._mockedRequestsToAws[mockedRequestKeyFor({ awsService: expectedAwsService, method: expectedMethod })] = mockedRequestToAws; 141 | return this; 142 | } 143 | 144 | withStageSettingsForCloudWatchMetrics({ loggingLevel, dataTraceEnabled, metricsEnabled } = {}) { 145 | const expectedAwsService = 'APIGateway'; 146 | const expectedMethod = 'getStage'; 147 | const mockedRequestToAws = ({ awsService, method, properties, stage, region }) => { 148 | if (awsService == 'APIGateway' 149 | && method == 'getStage' 150 | && properties.restApiId == this._restApiId 151 | && properties.stageName == this.service.provider.stage 152 | && region == this.service.provider.region) { 153 | return { 154 | methodSettings: { 155 | ['*/*']: { 156 | loggingLevel, 157 | dataTraceEnabled, 158 | metricsEnabled 159 | } 160 | } 161 | }; 162 | } 163 | }; 164 | this._mockedRequestsToAws[mockedRequestKeyFor({ awsService: expectedAwsService, method: expectedMethod })] = mockedRequestToAws; 165 | return this; 166 | } 167 | 168 | withProviderRestApiId(restApiId) { 169 | if (!this.service.provider.apiGateway) { 170 | this.service.provider.apiGateway = {} 171 | } 172 | this.service.provider.apiGateway.restApiId = restApiId; 173 | return this; 174 | } 175 | 176 | getMethodResourceForFunction(functionName) { 177 | let { methodResourceName } = this._functionsToResourcesMapping[functionName]; 178 | return this.service.provider.compiledCloudFormationTemplate.Resources[methodResourceName]; 179 | } 180 | 181 | getMethodResourceForMethodName(methodResourceName) { 182 | return this.service.provider.compiledCloudFormationTemplate.Resources[methodResourceName]; 183 | } 184 | 185 | getMethodResourceForAdditionalEndpoint(additionalEndpoint) { 186 | let { methodResourceName } = this._additionalEndpointsToResourcesMapping[JSON.stringify(additionalEndpoint)]; 187 | return this.service.provider.compiledCloudFormationTemplate.Resources[methodResourceName]; 188 | } 189 | 190 | withRestApiId(restApiId) { 191 | this._restApiId = restApiId; 192 | const expectedAwsService = 'CloudFormation'; 193 | const expectedMethod = 'describeStacks'; 194 | const mockedRequestToAws = ({ awsService, method, properties, stage, region }) => { 195 | if (awsService == 'CloudFormation' 196 | && method == 'describeStacks' 197 | && properties.StackName == 'serverless-stack-name' 198 | && stage == this.service.provider.stage 199 | && region == this.service.provider.region) { 200 | const result = { 201 | Stacks: [{ 202 | Outputs: [{ 203 | OutputKey: 'RestApiIdForApigCaching', 204 | OutputValue: restApiId 205 | }] 206 | }] 207 | }; 208 | return result; 209 | } 210 | }; 211 | this._mockedRequestsToAws[mockedRequestKeyFor({ awsService: expectedAwsService, method: expectedMethod })] = mockedRequestToAws; 212 | return this; 213 | } 214 | 215 | getRequestsToAws() { 216 | return this._recordedAwsRequests; 217 | } 218 | } 219 | 220 | const clone = object => JSON.parse(JSON.stringify(object)); 221 | 222 | const createMethodResourceNameFor = (path, method) => { 223 | const pathElements = path.split('/'); 224 | pathElements.push(method.toLowerCase()); 225 | let gatewayResourceName = pathElements 226 | .map(element => { 227 | element = element.toLowerCase(); 228 | element = element.replaceAll('+', ''); 229 | element = element.replaceAll('_', ''); 230 | element = element.replaceAll('.', ''); 231 | element = element.replaceAll('-', 'Dash'); 232 | if (element.startsWith('{')) { 233 | element = element.substring(element.indexOf('{') + 1, element.indexOf('}')) + "Var"; 234 | } 235 | return element.charAt(0).toUpperCase() + element.slice(1); 236 | }).reduce((a, b) => a + b); 237 | 238 | gatewayResourceName = "ApiGatewayMethod" + gatewayResourceName; 239 | return gatewayResourceName; 240 | } 241 | 242 | const addFunctionToCompiledCloudFormationTemplate = (serverlessFunction, serverless) => { 243 | const functionName = Object.keys(serverlessFunction)[0]; 244 | const fullFunctionName = `${serverless.service.service}-${serverless.service.provider.stage}-${functionName}`; 245 | let { Resources } = serverless.service.provider.compiledCloudFormationTemplate; 246 | let functionTemplate = clone(require('./templates/aws-lambda-function')); 247 | functionTemplate.Properties.FunctionName = fullFunctionName; 248 | let functionResourceName = `${functionName}LambdaFunction`; 249 | Resources[functionResourceName] = functionTemplate; 250 | 251 | let methodTemplate = clone(require('./templates/aws-api-gateway-method')); 252 | let stringifiedMethodTemplate = JSON.stringify(methodTemplate); 253 | stringifiedMethodTemplate = stringifiedMethodTemplate.replace('#{LAMBDA_RESOURCE_DEPENDENCY}', functionResourceName); 254 | methodTemplate = JSON.parse(stringifiedMethodTemplate); 255 | 256 | const events = serverlessFunction[functionName].events; 257 | if (!Array.isArray(events) || !events.length) { 258 | methodResourceName = `ApiGatewayMethod${functionName}VarGet`; 259 | } else { 260 | for (event of events) { 261 | // if event is defined in shorthand 262 | let path, method; 263 | if (typeof (event.http) === 'string') { 264 | let parts = event.http.split(' '); 265 | method = parts[0]; 266 | path = parts[1]; 267 | } 268 | else { 269 | path = event.http.path; 270 | method = event.http.method; 271 | } 272 | methodResourceName = createMethodResourceNameFor(path, method); 273 | if (event.http.integration == 'lambda') { 274 | methodTemplate.Properties.Integration.Type = 'AWS_PROXY'; 275 | } else { 276 | methodTemplate.Properties.Integration.Type = 'AWS'; 277 | } 278 | Resources[methodResourceName] = methodTemplate; 279 | } 280 | } 281 | 282 | Resources[methodResourceName] = methodTemplate 283 | return { functionResourceName, methodResourceName } 284 | } 285 | 286 | const addAdditionalEndpointToCompiledCloudFormationTemplate = (additionalEndpoint, serverless) => { 287 | const { path, method } = additionalEndpoint; 288 | methodResourceName = createMethodResourceNameFor(path, method); 289 | 290 | let methodTemplate = clone(require('./templates/aws-api-gateway-method')); 291 | 292 | methodResourceName = createMethodResourceNameFor(path, method); 293 | 294 | let { Resources } = serverless.service.provider.compiledCloudFormationTemplate; 295 | Resources[methodResourceName] = methodTemplate 296 | return { additionalEndpoint, methodResourceName } 297 | } 298 | 299 | const mockedRequestKeyFor = ({ awsService, method }) => { 300 | return `${awsService}-${method}`; 301 | } 302 | 303 | const defaultMockedRequestToAWS = ({ awsService, method, properties, stage, region }) => { 304 | if (awsService == 'APIGateway' 305 | && method == 'getStage') { 306 | return { 307 | methodSettings: { 308 | ['*/*']: { 309 | loggingLevel: 'not set', 310 | dataTraceEnabled: 'not set', 311 | metricsEnabled: 'not set' 312 | } 313 | } 314 | }; 315 | } 316 | }; 317 | 318 | module.exports = Serverless; 319 | -------------------------------------------------------------------------------- /test/model/ServerlessFunction.js: -------------------------------------------------------------------------------- 1 | class ServerlessFunction { 2 | constructor(name) { 3 | this[name] = { 4 | } 5 | } 6 | 7 | getFunction() { 8 | return this[Object.keys(this)[0]]; 9 | } 10 | 11 | withHttpEndpoint(method, path, caching, withLambdaIntegration) { 12 | let f = this.getFunction(); 13 | if (!f.events) { f.events = []; } 14 | 15 | const http = { 16 | path, 17 | method, 18 | caching 19 | } 20 | if (withLambdaIntegration) { 21 | http.integration = 'lambda' 22 | } 23 | f.events.push({ 24 | http 25 | }); 26 | 27 | return this; 28 | } 29 | 30 | withHttpEndpointInShorthand(shorthand) { 31 | let f = this.getFunction(); 32 | if (!f.events) { f.events = []; } 33 | f.events.push({ 34 | http: shorthand 35 | }); 36 | 37 | return this; 38 | } 39 | } 40 | 41 | module.exports = ServerlessFunction; 42 | -------------------------------------------------------------------------------- /test/model/templates/aws-api-gateway-method.json: -------------------------------------------------------------------------------- 1 | { 2 | "Type": "AWS::ApiGateway::Method", 3 | "Properties": { 4 | "HttpMethod": "GET", 5 | "RequestParameters": {}, 6 | "ResourceId": { 7 | "Ref": "ApiGatewayResourceCats" 8 | }, 9 | "RestApiId": { 10 | "Ref": "ApiGatewayRestApi" 11 | }, 12 | "ApiKeyRequired": false, 13 | "AuthorizationType": "NONE", 14 | "Integration": { 15 | "IntegrationHttpMethod": "POST", 16 | "Type": "AWS_PROXY", 17 | "Uri": { 18 | "Fn::Join": [ 19 | "", 20 | [ 21 | "arn:", 22 | { 23 | "Ref": "AWS::Partition" 24 | }, 25 | ":apigateway:", 26 | { 27 | "Ref": "AWS::Region" 28 | }, 29 | ":lambda:path/2015-03-31/functions/", 30 | { 31 | "Fn::GetAtt": [ 32 | "#{LAMBDA_RESOURCE_DEPENDENCY}", 33 | "Arn" 34 | ] 35 | }, 36 | "/invocations" 37 | ] 38 | ] 39 | } 40 | }, 41 | "MethodResponses": [] 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /test/model/templates/aws-lambda-function.json: -------------------------------------------------------------------------------- 1 | { 2 | "Type": "AWS::Lambda::Function", 3 | "Properties": { 4 | "Code": { 5 | "S3Bucket": { 6 | "Ref": "ServerlessDeploymentBucket" 7 | }, 8 | "S3Key": "serverless/cat-api/somestage/1536510170443-2018-09-09T16:22:50.443Z/cat-api.zip" 9 | }, 10 | "FunctionName": "cat-api-somestage-list-all-cats", 11 | "Handler": "rest_api/handler.handle", 12 | "MemorySize": 1024, 13 | "Runtime": "nodejs8.10", 14 | "Timeout": 6, 15 | "Environment": { 16 | "Variables": { 17 | "STAGE": "somestage" 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /test/steps/given.js: -------------------------------------------------------------------------------- 1 | const APP_ROOT = '../..'; 2 | const Serverless = require(`${APP_ROOT}/test/model/Serverless`); 3 | const ServerlessFunction = require(`${APP_ROOT}/test/model/ServerlessFunction`); 4 | const chance = require('chance').Chance(); 5 | 6 | const a_serverless_instance = (serviceName) => { 7 | return new Serverless(serviceName); 8 | } 9 | 10 | const a_serverless_function = name => { 11 | return new ServerlessFunction(name); 12 | } 13 | 14 | const a_rest_api_id = () => { 15 | return chance.guid(); 16 | } 17 | 18 | const a_rest_api_id_for_deployment = (serverless) => { 19 | let restApiId = a_rest_api_id(); 20 | serverless = serverless.withRestApiId(restApiId); 21 | 22 | return restApiId; 23 | } 24 | 25 | const the_rest_api_id_is_not_set_for_deployment = (serverless, settings) => { 26 | serverless = serverless.withRestApiId(undefined); 27 | } 28 | 29 | const endpoints_with_caching_enabled = (endpointCount) => { 30 | let result = []; 31 | for (let i = 0; i < endpointCount; i++) { 32 | result.push( 33 | a_serverless_function(chance.word()) 34 | .withHttpEndpoint('GET', `/${chance.word()}`, { enabled: true })); 35 | } 36 | return result; 37 | } 38 | 39 | const an_additional_endpoint = ({ method, path, caching }) => { 40 | return { 41 | method, 42 | path, 43 | caching 44 | } 45 | } 46 | 47 | module.exports = { 48 | a_serverless_instance, 49 | a_serverless_function, 50 | a_rest_api_id, 51 | a_rest_api_id_for_deployment, 52 | the_rest_api_id_is_not_set_for_deployment, 53 | endpoints_with_caching_enabled, 54 | an_additional_endpoint 55 | } 56 | -------------------------------------------------------------------------------- /test/steps/when.js: -------------------------------------------------------------------------------- 1 | const { updateStageCacheSettings } = require('../../src/stageCache'); 2 | 3 | const updating_stage_cache_settings = async (settings, serverless) => { 4 | return await updateStageCacheSettings(settings, serverless); 5 | } 6 | 7 | module.exports = { 8 | updating_stage_cache_settings 9 | } 10 | -------------------------------------------------------------------------------- /test/updating-stage-cache-settings-for-additional-endpoints.js: -------------------------------------------------------------------------------- 1 | const given = require('../test/steps/given'); 2 | const when = require('../test/steps/when'); 3 | const ApiGatewayCachingSettings = require('../src/ApiGatewayCachingSettings'); 4 | const { updateStageCacheSettings } = require('../src/stageCache'); 5 | const expect = require('chai').expect; 6 | 7 | describe('Updating stage cache settings for additional endpoints defined as CloudFormation', () => { 8 | let serverless, settings, requestsToAws, apiGatewayRequest; 9 | const apiGatewayService = 'APIGateway', updateStageMethod = 'updateStage'; 10 | 11 | // Described in https://github.com/DianaIonita/serverless-api-gateway-caching/pull/68 12 | describe('When API Gateway caching is enabled', () => { 13 | 14 | describe('and there are additionalEndpoints configured for HTTP endpoints defined as CloudFormation', () => { 15 | before(async () => { 16 | const additionalEndpoints = [ 17 | given.an_additional_endpoint({ 18 | method: 'GET', path: '/items', 19 | caching: { enabled: true, ttlInSeconds: 120 } 20 | }), 21 | given.an_additional_endpoint({ 22 | method: 'POST', path: '/blue-items', 23 | caching: { enabled: false } 24 | })]; 25 | 26 | restApiId = given.a_rest_api_id(); 27 | serverless = given.a_serverless_instance() 28 | .withApiGatewayCachingConfig() 29 | .withAdditionalEndpoints(additionalEndpoints) 30 | .withRestApiId(restApiId) 31 | .forStage('somestage'); 32 | settings = new ApiGatewayCachingSettings(serverless); 33 | 34 | await when.updating_stage_cache_settings(settings, serverless); 35 | 36 | requestsToAws = serverless.getRequestsToAws(); 37 | }); 38 | 39 | describe('the request sent to AWS SDK to update stage', () => { 40 | before(() => { 41 | apiGatewayRequest = requestsToAws.find(r => r.awsService == apiGatewayService && r.method == updateStageMethod); 42 | }); 43 | 44 | it('should contain the REST API ID', () => { 45 | expect(apiGatewayRequest.properties.restApiId).to.equal(restApiId); 46 | }); 47 | 48 | it('should contain the stage name', () => { 49 | expect(apiGatewayRequest.properties.stageName).to.equal('somestage'); 50 | }); 51 | 52 | it('should specify exactly twelve patch operations', () => { 53 | expect(apiGatewayRequest.properties.patchOperations).to.have.lengthOf(12); 54 | }); 55 | 56 | it('should enable caching', () => { 57 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 58 | op: 'replace', 59 | path: '/cacheClusterEnabled', 60 | value: 'true' 61 | }); 62 | }); 63 | 64 | it('should set the cache cluster size', () => { 65 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 66 | op: 'replace', 67 | path: '/cacheClusterSize', 68 | value: '0.5' 69 | }); 70 | }); 71 | 72 | it('should set the cache encryption', () => { 73 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 74 | op: 'replace', 75 | path: '/*/*/caching/dataEncrypted', 76 | value: 'false' 77 | }); 78 | }); 79 | 80 | it('should set the cache ttlInSeconds', () => { 81 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 82 | op: 'replace', 83 | path: '/*/*/caching/ttlInSeconds', 84 | value: '45' 85 | }); 86 | }); 87 | }); 88 | 89 | describe('for the endpoint with caching enabled', () => { 90 | it('should enable caching', () => { 91 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 92 | op: 'replace', 93 | path: '/~1items/GET/caching/enabled', 94 | value: 'true' 95 | }); 96 | }); 97 | 98 | it('should set the correct cache time to live', () => { 99 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 100 | op: 'replace', 101 | path: '/~1items/GET/caching/ttlInSeconds', 102 | value: '120' 103 | }); 104 | }); 105 | 106 | it('should specify whether data is encrypted', () => { 107 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 108 | op: 'replace', 109 | path: '/~1items/GET/caching/dataEncrypted', 110 | value: 'false' 111 | }); 112 | }); 113 | }); 114 | 115 | describe('for each endpoint with caching disabled', () => { 116 | it('should disable caching', () => { 117 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 118 | op: 'replace', 119 | path: '/~1blue-items/POST/caching/enabled', 120 | value: 'false' 121 | }); 122 | }); 123 | }); 124 | }); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /test/updating-stage-cache-settings.js: -------------------------------------------------------------------------------- 1 | const given = require('../test/steps/given'); 2 | const when = require('../test/steps/when'); 3 | const ApiGatewayCachingSettings = require('../src/ApiGatewayCachingSettings'); 4 | const UnauthorizedCacheControlHeaderStrategy = require('../src/UnauthorizedCacheControlHeaderStrategy'); 5 | const chai = require('chai'); 6 | const sinon = require('sinon'); 7 | const expect = require('chai').expect; 8 | 9 | const { applyUpdateStageForChunk } = require('../src/stageCache'); 10 | 11 | // Use a before block to asynchronously load and configure chai-as-promised 12 | before(async () => { 13 | const chaiAsPromised = await import('chai-as-promised'); 14 | chai.use(chaiAsPromised.default); // Use .default when importing ESM dynamically 15 | }); 16 | 17 | describe('Updating stage cache settings', () => { 18 | let serverless, settings, requestsToAws, apiGatewayRequest; 19 | const apiGatewayService = 'APIGateway', updateStageMethod = 'updateStage'; 20 | 21 | describe('When api gateway caching is not specified as a setting', () => { 22 | before(async () => { 23 | serverless = given.a_serverless_instance(); 24 | settings = new ApiGatewayCachingSettings(serverless); 25 | await when.updating_stage_cache_settings(settings, serverless); 26 | 27 | requestsToAws = serverless.getRequestsToAws(); 28 | }); 29 | 30 | it('should not make calls to the AWS SDK', () => { 31 | expect(requestsToAws).to.be.empty; 32 | }); 33 | }); 34 | 35 | describe('When api gateway caching is disabled', () => { 36 | let restApiId; 37 | before(async () => { 38 | serverless = given.a_serverless_instance() 39 | .withApiGatewayCachingConfig({ cachingEnabled: false }) 40 | .forRegion('someregion') 41 | .forStage('somestage'); 42 | settings = new ApiGatewayCachingSettings(serverless); 43 | 44 | restApiId = await given.a_rest_api_id_for_deployment(serverless, settings); 45 | 46 | await when.updating_stage_cache_settings(settings, serverless); 47 | 48 | requestsToAws = serverless.getRequestsToAws(); 49 | }); 50 | 51 | it('should send a single request to AWS SDK to update stage', () => { 52 | request = requestsToAws.filter(r => r.awsService == apiGatewayService && r.method == updateStageMethod); 53 | expect(request).to.have.lengthOf(1); 54 | }); 55 | 56 | describe('the request sent to AWS SDK to update stage', () => { 57 | before(() => { 58 | apiGatewayRequest = requestsToAws.find(r => r.awsService == apiGatewayService && r.method == updateStageMethod); 59 | }); 60 | 61 | it('should contain the stage', () => { 62 | expect(apiGatewayRequest.stage).to.equal('somestage'); 63 | }); 64 | 65 | it('should contain the region', () => { 66 | expect(apiGatewayRequest.region).to.equal('someregion'); 67 | }); 68 | 69 | it('should contain the REST API ID', () => { 70 | expect(apiGatewayRequest.properties.restApiId).to.equal(restApiId); 71 | }); 72 | 73 | it('should contain the stage name', () => { 74 | expect(apiGatewayRequest.properties.stageName).to.equal('somestage'); 75 | }); 76 | 77 | it('should disable caching', () => { 78 | expect(apiGatewayRequest.properties.patchOperations).to.have.lengthOf(1); 79 | let patch = apiGatewayRequest.properties.patchOperations[0]; 80 | expect(patch).to.deep.equal({ 81 | op: 'replace', 82 | path: '/cacheClusterEnabled', 83 | value: 'false' 84 | }); 85 | }); 86 | }); 87 | }); 88 | 89 | describe('When api gateway caching is enabled and the api gateway is shared', () => { 90 | let restApiId; 91 | 92 | describe('and there are no endpoints for which to enable caching', () => { 93 | before(async () => { 94 | serverless = given.a_serverless_instance() 95 | .withApiGatewayCachingConfig({ apiGatewayIsShared: true }) 96 | .forStage('somestage'); 97 | settings = new ApiGatewayCachingSettings(serverless); 98 | 99 | restApiId = await given.a_rest_api_id_for_deployment(serverless, settings); 100 | 101 | await when.updating_stage_cache_settings(settings, serverless); 102 | 103 | requestsToAws = serverless.getRequestsToAws(); 104 | }); 105 | 106 | it('should not make calls the AWS SDK to update the stage', () => { 107 | expect(requestsToAws.filter(a => a.method == 'updateStage')).to.be.empty; 108 | }); 109 | }); 110 | 111 | describe('and there are some endpoints with caching enabled', () => { 112 | before(async () => { 113 | let endpointWithoutCaching = given.a_serverless_function('get-my-cat') 114 | .withHttpEndpoint('get', '/personal/cat', { enabled: false }) 115 | .withHttpEndpoint('get', '/personal/cat/{catId}', { enabled: false }); 116 | let endpointWithCaching = given.a_serverless_function('get-cat-by-paw-id') 117 | .withHttpEndpoint('get', '/cat/{pawId}', { enabled: true, ttlInSeconds: 45, dataEncrypted: true }) 118 | .withHttpEndpoint('delete', '/cat/{pawId}', { enabled: true, ttlInSeconds: 45 }); 119 | serverless = given.a_serverless_instance() 120 | .withApiGatewayCachingConfig({ ttlInSeconds: 60, apiGatewayIsShared: true }) 121 | .withFunction(endpointWithCaching) 122 | .withFunction(endpointWithoutCaching) 123 | .forStage('somestage'); 124 | 125 | settings = new ApiGatewayCachingSettings(serverless); 126 | 127 | restApiId = await given.a_rest_api_id_for_deployment(serverless, settings); 128 | 129 | await when.updating_stage_cache_settings(settings, serverless); 130 | 131 | requestsToAws = serverless.getRequestsToAws(); 132 | }); 133 | 134 | describe('the request sent to AWS SDK to update stage', () => { 135 | const noOperationsAreExpectedForPath = (path) => () => { 136 | const foundItems = apiGatewayRequest.properties.patchOperations.filter((item => item.path === path)) 137 | expect(foundItems.length).to.equal(0); 138 | } 139 | 140 | before(() => { 141 | apiGatewayRequest = requestsToAws.find(r => r.awsService == apiGatewayService && r.method == updateStageMethod); 142 | }); 143 | 144 | it('should contain the REST API ID', () => { 145 | expect(apiGatewayRequest.properties.restApiId).to.equal(restApiId); 146 | }); 147 | 148 | it('should contain the stage name', () => { 149 | expect(apiGatewayRequest.properties.stageName).to.equal('somestage'); 150 | }); 151 | 152 | it('should leave caching untouched', noOperationsAreExpectedForPath('/cacheClusterEnabled')); 153 | 154 | it('should leave the cache cluster size untouched', noOperationsAreExpectedForPath('/cacheClusterSize')); 155 | 156 | describe('for the endpoint with caching enabled', () => { 157 | it('should enable caching', () => { 158 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 159 | op: 'replace', 160 | path: '/~1cat~1{pawId}/GET/caching/enabled', 161 | value: 'true' 162 | }); 163 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 164 | op: 'replace', 165 | path: '/~1cat~1{pawId}/DELETE/caching/enabled', 166 | value: 'true' 167 | }); 168 | }); 169 | 170 | it('should set the correct cache time to live', () => { 171 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 172 | op: 'replace', 173 | path: '/~1cat~1{pawId}/GET/caching/ttlInSeconds', 174 | value: '45' 175 | }); 176 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 177 | op: 'replace', 178 | path: '/~1cat~1{pawId}/DELETE/caching/ttlInSeconds', 179 | value: '45' 180 | }); 181 | }); 182 | 183 | it('should configure data encryption where enabled', () => { 184 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 185 | op: 'replace', 186 | path: '/~1cat~1{pawId}/GET/caching/dataEncrypted', 187 | value: 'true' 188 | }); 189 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 190 | op: 'replace', 191 | path: '/~1cat~1{pawId}/DELETE/caching/dataEncrypted', 192 | value: 'false' 193 | }); 194 | }); 195 | }); 196 | 197 | describe('for each endpoint with caching disabled', () => { 198 | it('should disable caching', () => { 199 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 200 | op: 'replace', 201 | path: '/~1personal~1cat/GET/caching/enabled', 202 | value: 'false' 203 | }); 204 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 205 | op: 'replace', 206 | path: '/~1personal~1cat~1{catId}/GET/caching/enabled', 207 | value: 'false' 208 | }); 209 | }); 210 | 211 | it('should not set the cache time to live', () => { 212 | let ttlOperation = apiGatewayRequest.properties.patchOperations 213 | .find(o => o.path == '/~personal~1cat/GET/caching/ttlInSeconds' || 214 | o.path == '/~personal~1cat~1{catId}/GET/caching/ttlInSeconds'); 215 | expect(ttlOperation).to.not.exist; 216 | }); 217 | 218 | it('should not configure data encryption', () => { 219 | let dataEncryptionOperation = apiGatewayRequest.properties.patchOperations 220 | .find(o => o.path == '/~personal~1cat/GET/caching/dataEncryption' || 221 | o.path == '/~personal~1cat~1{catId}/GET/caching/dataEncryption'); 222 | expect(dataEncryptionOperation).to.not.exist; 223 | }); 224 | }); 225 | }); 226 | }); 227 | 228 | describe('and there is a basePath configured', () => { 229 | before(async () => { 230 | let endpointWithCaching = given.a_serverless_function('get-cat-by-paw-id') 231 | .withHttpEndpoint('delete', '/cat/{pawId}', { enabled: true, ttlInSeconds: 45 }); 232 | serverless = given.a_serverless_instance() 233 | .withApiGatewayCachingConfig({ ttlInSeconds: 60, apiGatewayIsShared: true, basePath: '/animals' }) 234 | .withFunction(endpointWithCaching) 235 | .forStage('somestage'); 236 | 237 | settings = new ApiGatewayCachingSettings(serverless); 238 | 239 | restApiId = await given.a_rest_api_id_for_deployment(serverless, settings); 240 | 241 | await when.updating_stage_cache_settings(settings, serverless); 242 | 243 | requestsToAws = serverless.getRequestsToAws(); 244 | }); 245 | 246 | describe('the request sent to AWS SDK to update stage', () => { 247 | before(() => { 248 | apiGatewayRequest = requestsToAws.find(r => r.awsService == apiGatewayService && r.method == updateStageMethod); 249 | }); 250 | 251 | it('should contain the REST API ID', () => { 252 | expect(apiGatewayRequest.properties.restApiId).to.equal(restApiId); 253 | }); 254 | 255 | it('should contain the stage name', () => { 256 | expect(apiGatewayRequest.properties.stageName).to.equal('somestage'); 257 | }); 258 | 259 | describe('for the endpoint with caching enabled', () => { 260 | it('includes the base path', () => { 261 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 262 | op: 'replace', 263 | path: '/~1animals~1cat~1{pawId}/DELETE/caching/enabled', 264 | value: 'true' 265 | }); 266 | }); 267 | }); 268 | }); 269 | }); 270 | }); 271 | 272 | describe('When api gateway caching is enabled', () => { 273 | let restApiId; 274 | 275 | describe('and there are no endpoints for which to enable caching', () => { 276 | before(async () => { 277 | serverless = given.a_serverless_instance() 278 | .withApiGatewayCachingConfig() 279 | .forStage('somestage'); 280 | settings = new ApiGatewayCachingSettings(serverless); 281 | 282 | restApiId = await given.a_rest_api_id_for_deployment(serverless, settings); 283 | 284 | await when.updating_stage_cache_settings(settings, serverless); 285 | 286 | requestsToAws = serverless.getRequestsToAws(); 287 | }); 288 | 289 | describe('the request sent to AWS SDK to update stage', () => { 290 | before(() => { 291 | apiGatewayRequest = requestsToAws.find(r => r.awsService == apiGatewayService && r.method == updateStageMethod); 292 | }); 293 | 294 | it('should contain the REST API ID', () => { 295 | expect(apiGatewayRequest.properties.restApiId).to.equal(restApiId); 296 | }); 297 | 298 | it('should contain the stage name', () => { 299 | expect(apiGatewayRequest.properties.stageName).to.equal('somestage'); 300 | }); 301 | 302 | it('should specify exactly four patch operations', () => { 303 | expect(apiGatewayRequest.properties.patchOperations).to.have.lengthOf(4); 304 | }) 305 | 306 | it('should enable caching', () => { 307 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 308 | op: 'replace', 309 | path: '/cacheClusterEnabled', 310 | value: 'true' 311 | }); 312 | }); 313 | 314 | it('should set the cache cluster size', () => { 315 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 316 | op: 'replace', 317 | path: '/cacheClusterSize', 318 | value: '0.5' 319 | }); 320 | }); 321 | 322 | it('should set the cache encryption', () => { 323 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 324 | op: 'replace', 325 | path: '/*/*/caching/dataEncrypted', 326 | value: 'false' 327 | }); 328 | }); 329 | 330 | it('should set the cache ttlInSeconds', () => { 331 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 332 | op: 'replace', 333 | path: '/*/*/caching/ttlInSeconds', 334 | value: '45' 335 | }); 336 | }); 337 | 338 | it('should log a warning message that no endpoints are being cached', () => { 339 | expect(serverless._logMessages).to 340 | .include('[serverless-api-gateway-caching] [WARNING] API Gateway caching is enabled but none of the endpoints have caching enabled'); 341 | }); 342 | }); 343 | }); 344 | 345 | describe('and there are some endpoints with caching enabled', () => { 346 | before(async () => { 347 | let endpointWithoutCaching = given.a_serverless_function('get-my-cat') 348 | .withHttpEndpoint('get', '/personal/cat', { enabled: false }) 349 | .withHttpEndpoint('get', '/personal/cat/{catId}', { enabled: false }); 350 | let endpointWithCaching = given.a_serverless_function('get-cat-by-paw-id') 351 | .withHttpEndpoint('get', '/cat/{pawId}', { enabled: true, ttlInSeconds: 45, dataEncrypted: true }) 352 | .withHttpEndpoint('delete', '/cat/{pawId}', { enabled: true, ttlInSeconds: 45 }); 353 | serverless = given.a_serverless_instance() 354 | .withApiGatewayCachingConfig({ ttlInSeconds: 60 }) 355 | .withFunction(endpointWithCaching) 356 | .withFunction(endpointWithoutCaching) 357 | .forStage('somestage'); 358 | settings = new ApiGatewayCachingSettings(serverless); 359 | 360 | restApiId = await given.a_rest_api_id_for_deployment(serverless, settings); 361 | 362 | await when.updating_stage_cache_settings(settings, serverless); 363 | 364 | requestsToAws = serverless.getRequestsToAws(); 365 | }); 366 | 367 | describe('the request sent to AWS SDK to update stage', () => { 368 | before(() => { 369 | apiGatewayRequest = requestsToAws.find(r => r.awsService == apiGatewayService && r.method == updateStageMethod); 370 | }); 371 | 372 | it('should contain the REST API ID', () => { 373 | expect(apiGatewayRequest.properties.restApiId).to.equal(restApiId); 374 | }); 375 | 376 | it('should contain the stage name', () => { 377 | expect(apiGatewayRequest.properties.stageName).to.equal('somestage'); 378 | }); 379 | 380 | it('should enable caching', () => { 381 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 382 | op: 'replace', 383 | path: '/cacheClusterEnabled', 384 | value: 'true' 385 | }); 386 | }); 387 | 388 | it('should set the cache cluster size', () => { 389 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 390 | op: 'replace', 391 | path: '/cacheClusterSize', 392 | value: '0.5' 393 | }); 394 | }); 395 | 396 | describe('for the endpoint with caching enabled', () => { 397 | it('should enable caching', () => { 398 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 399 | op: 'replace', 400 | path: '/~1cat~1{pawId}/GET/caching/enabled', 401 | value: 'true' 402 | }); 403 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 404 | op: 'replace', 405 | path: '/~1cat~1{pawId}/DELETE/caching/enabled', 406 | value: 'true' 407 | }); 408 | }); 409 | 410 | it('should set the correct cache time to live', () => { 411 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 412 | op: 'replace', 413 | path: '/~1cat~1{pawId}/GET/caching/ttlInSeconds', 414 | value: '45' 415 | }); 416 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 417 | op: 'replace', 418 | path: '/~1cat~1{pawId}/DELETE/caching/ttlInSeconds', 419 | value: '45' 420 | }); 421 | }); 422 | 423 | it('should configure data encryption where enabled', () => { 424 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 425 | op: 'replace', 426 | path: '/~1cat~1{pawId}/GET/caching/dataEncrypted', 427 | value: 'true' 428 | }); 429 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 430 | op: 'replace', 431 | path: '/~1cat~1{pawId}/DELETE/caching/dataEncrypted', 432 | value: 'false' 433 | }); 434 | }); 435 | }); 436 | 437 | describe('for each endpoint with caching disabled', () => { 438 | it('should disable caching', () => { 439 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 440 | op: 'replace', 441 | path: '/~1personal~1cat/GET/caching/enabled', 442 | value: 'false' 443 | }); 444 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 445 | op: 'replace', 446 | path: '/~1personal~1cat~1{catId}/GET/caching/enabled', 447 | value: 'false' 448 | }); 449 | }); 450 | 451 | it('should not set the cache time to live', () => { 452 | let ttlOperation = apiGatewayRequest.properties.patchOperations 453 | .find(o => o.path == '/~personal~1cat/GET/caching/ttlInSeconds' || 454 | o.path == '/~personal~1cat~1{catId}/GET/caching/ttlInSeconds'); 455 | expect(ttlOperation).to.not.exist; 456 | }); 457 | 458 | it('should not configure data encryption', () => { 459 | let dataEncryptionOperation = apiGatewayRequest.properties.patchOperations 460 | .find(o => o.path == '/~personal~1cat/GET/caching/dataEncryption' || 461 | o.path == '/~personal~1cat~1{catId}/GET/caching/dataEncryption'); 462 | expect(dataEncryptionOperation).to.not.exist; 463 | }); 464 | }); 465 | }); 466 | }); 467 | 468 | describe('and one endpoint has caching settings', () => { 469 | let scenarios = [ 470 | { 471 | description: 'with per-key cache invalidation authorization disabled', 472 | endpointCachingSettings: { 473 | enabled: true, 474 | perKeyInvalidation: { 475 | requireAuthorization: false 476 | } 477 | }, 478 | expectedPatchForAuth: { 479 | op: 'replace', 480 | path: '/~1personal~1cat/GET/caching/requireAuthorizationForCacheControl', 481 | value: 'false' 482 | } 483 | }, 484 | { 485 | description: 'with per-key cache invalidation authorization enabled', 486 | endpointCachingSettings: { 487 | enabled: true, 488 | perKeyInvalidation: { 489 | requireAuthorization: true 490 | } 491 | }, 492 | expectedPatchForAuth: { 493 | op: 'replace', 494 | path: '/~1personal~1cat/GET/caching/requireAuthorizationForCacheControl', 495 | value: 'true' 496 | }, 497 | expectedPatchForUnauthorizedStrategy: { 498 | op: 'replace', 499 | path: '/~1personal~1cat/GET/caching/unauthorizedCacheControlHeaderStrategy', 500 | value: UnauthorizedCacheControlHeaderStrategy.IgnoreWithWarning 501 | } 502 | }, 503 | { 504 | description: 'with the strategy to ignore unauthorized cache invalidation requests', 505 | endpointCachingSettings: { 506 | enabled: true, 507 | perKeyInvalidation: { 508 | requireAuthorization: true, 509 | handleUnauthorizedRequests: 'Ignore' 510 | } 511 | }, 512 | expectedPatchForAuth: { 513 | op: 'replace', 514 | path: '/~1personal~1cat/GET/caching/requireAuthorizationForCacheControl', 515 | value: 'true' 516 | }, 517 | expectedPatchForUnauthorizedStrategy: { 518 | op: 'replace', 519 | path: '/~1personal~1cat/GET/caching/unauthorizedCacheControlHeaderStrategy', 520 | value: UnauthorizedCacheControlHeaderStrategy.Ignore 521 | } 522 | }, 523 | { 524 | description: 'with the strategy to ignore unauthorized cache invalidation requests with a warning header', 525 | endpointCachingSettings: { 526 | enabled: true, 527 | perKeyInvalidation: { 528 | requireAuthorization: true, 529 | handleUnauthorizedRequests: 'IgnoreWithWarning' 530 | } 531 | }, 532 | expectedPatchForAuth: { 533 | op: 'replace', 534 | path: '/~1personal~1cat/GET/caching/requireAuthorizationForCacheControl', 535 | value: 'true' 536 | }, 537 | expectedPatchForUnauthorizedStrategy: { 538 | op: 'replace', 539 | path: '/~1personal~1cat/GET/caching/unauthorizedCacheControlHeaderStrategy', 540 | value: UnauthorizedCacheControlHeaderStrategy.IgnoreWithWarning 541 | } 542 | }, 543 | { 544 | description: 'with the strategy to fail unauthorized cache invalidation requests', 545 | endpointCachingSettings: { 546 | enabled: true, 547 | perKeyInvalidation: { 548 | requireAuthorization: true, 549 | handleUnauthorizedRequests: 'Fail' 550 | } 551 | }, 552 | expectedPatchForAuth: { 553 | op: 'replace', 554 | path: '/~1personal~1cat/GET/caching/requireAuthorizationForCacheControl', 555 | value: 'true' 556 | }, 557 | expectedPatchForUnauthorizedStrategy: { 558 | op: 'replace', 559 | path: '/~1personal~1cat/GET/caching/unauthorizedCacheControlHeaderStrategy', 560 | value: UnauthorizedCacheControlHeaderStrategy.Fail 561 | } 562 | }, 563 | { 564 | description: 'without per-key cache invalidation settings', 565 | endpointCachingSettings: { 566 | enabled: true 567 | }, 568 | // inherited from global settings 569 | expectedPatchForAuth: { 570 | op: 'replace', 571 | path: '/~1personal~1cat/GET/caching/requireAuthorizationForCacheControl', 572 | value: 'true' 573 | }, 574 | expectedPatchForUnauthorizedStrategy: { 575 | op: 'replace', 576 | path: '/~1personal~1cat/GET/caching/unauthorizedCacheControlHeaderStrategy', 577 | value: UnauthorizedCacheControlHeaderStrategy.Ignore 578 | } 579 | } 580 | ]; 581 | 582 | for (let scenario of scenarios) { 583 | describe(scenario.description, () => { 584 | before(async () => { 585 | let endpoint = given.a_serverless_function('get-my-cat') 586 | .withHttpEndpoint('get', '/personal/cat', scenario.endpointCachingSettings); 587 | serverless = given.a_serverless_instance() 588 | .withApiGatewayCachingConfig({ ttlInSeconds: 60, perKeyInvalidation: { requireAuthorization: true, handleUnauthorizedRequests: 'Ignore' } }) 589 | .withFunction(endpoint) 590 | .forStage('somestage'); 591 | settings = new ApiGatewayCachingSettings(serverless); 592 | 593 | restApiId = await given.a_rest_api_id_for_deployment(serverless, settings); 594 | 595 | await when.updating_stage_cache_settings(settings, serverless); 596 | 597 | requestsToAws = serverless.getRequestsToAws(); 598 | apiGatewayRequest = requestsToAws.find(r => r.awsService == apiGatewayService && r.method == updateStageMethod); 599 | }); 600 | 601 | it('should set whether the endpoint requires authorization for cache control', () => { 602 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include(scenario.expectedPatchForAuth); 603 | }); 604 | 605 | if (scenario.expectedPatchForUnauthorizedStrategy) { 606 | it('should set the strategy for unauthorized requests to invalidate cache', () => { 607 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include(scenario.expectedPatchForUnauthorizedStrategy); 608 | }); 609 | } 610 | }); 611 | } 612 | }); 613 | }); 614 | 615 | describe('When an endpoint with http method `any` has caching enabled', () => { 616 | before(async () => { 617 | let endpointWithCaching = given.a_serverless_function('do-anything-to-cat') 618 | .withHttpEndpoint('any', '/cat', { enabled: true, ttlInSeconds: 45 }); 619 | 620 | serverless = given.a_serverless_instance() 621 | .withApiGatewayCachingConfig({ ttlInSeconds: 60 }) 622 | .withFunction(endpointWithCaching) 623 | .forStage('somestage'); 624 | settings = new ApiGatewayCachingSettings(serverless); 625 | 626 | restApiId = await given.a_rest_api_id_for_deployment(serverless, settings); 627 | 628 | await when.updating_stage_cache_settings(settings, serverless); 629 | 630 | requestsToAws = serverless.getRequestsToAws(); 631 | apiGatewayRequest = requestsToAws.find(r => r.awsService == apiGatewayService && r.method == updateStageMethod); 632 | }); 633 | 634 | it('should enable caching for the GET method', () => { 635 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 636 | op: 'replace', 637 | path: '/~1cat/GET/caching/enabled', 638 | value: 'true' 639 | }); 640 | }); 641 | 642 | it('should set the correct time to live for the GET method cache', () => { 643 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 644 | op: 'replace', 645 | path: '/~1cat/GET/caching/ttlInSeconds', 646 | value: '45' 647 | }); 648 | }); 649 | 650 | let otherMethods = ['DELETE', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT']; 651 | for (let method of otherMethods) { 652 | it(`should disable caching for the ${method} method`, () => { 653 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 654 | op: 'replace', 655 | path: `/~1cat/${method}/caching/enabled`, 656 | value: 'false' 657 | }); 658 | }); 659 | } 660 | }); 661 | 662 | describe('When an endpoint with http method `any` has caching disabled', () => { 663 | before(async () => { 664 | let endpointWithoutCaching = given.a_serverless_function('do-anything-to-cat') 665 | .withHttpEndpoint('any', '/cat', { enabled: false }); 666 | 667 | serverless = given.a_serverless_instance() 668 | .withApiGatewayCachingConfig({ ttlInSeconds: 60 }) 669 | .withFunction(endpointWithoutCaching) 670 | .forStage('somestage'); 671 | settings = new ApiGatewayCachingSettings(serverless); 672 | 673 | restApiId = await given.a_rest_api_id_for_deployment(serverless, settings); 674 | 675 | await when.updating_stage_cache_settings(settings, serverless); 676 | 677 | requestsToAws = serverless.getRequestsToAws(); 678 | apiGatewayRequest = requestsToAws.find(r => r.awsService == apiGatewayService && r.method == updateStageMethod); 679 | }); 680 | 681 | let allMethods = ['GET', 'DELETE', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT']; 682 | for (let method of allMethods) { 683 | it(`should disable caching for the ${method} method`, () => { 684 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 685 | op: 'replace', 686 | path: `/~1cat/${method}/caching/enabled`, 687 | value: 'false' 688 | }); 689 | }); 690 | } 691 | }); 692 | 693 | describe('When an http endpoint is defined in shorthand', () => { 694 | before(async () => { 695 | let endpoint = given.a_serverless_function('list-cats') 696 | .withHttpEndpointInShorthand('get /cats'); 697 | 698 | serverless = given.a_serverless_instance() 699 | .withApiGatewayCachingConfig() 700 | .withFunction(endpoint) 701 | .forStage('somestage'); 702 | settings = new ApiGatewayCachingSettings(serverless); 703 | 704 | restApiId = await given.a_rest_api_id_for_deployment(serverless, settings); 705 | 706 | await when.updating_stage_cache_settings(settings, serverless); 707 | 708 | requestsToAws = serverless.getRequestsToAws(); 709 | apiGatewayRequest = requestsToAws.find(r => r.awsService == apiGatewayService && r.method == updateStageMethod); 710 | }); 711 | 712 | it(`should disable caching for the endpoint`, () => { 713 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 714 | op: 'replace', 715 | path: `/~1cats/GET/caching/enabled`, 716 | value: 'false' 717 | }); 718 | }); 719 | }); 720 | 721 | describe('when an http endpoint path is empty', () => { 722 | before(async () => { 723 | let endpoint = given.a_serverless_function('list-cats') 724 | .withHttpEndpoint('get', '', { enabled: true }); 725 | 726 | serverless = given.a_serverless_instance() 727 | .withApiGatewayCachingConfig() 728 | .withFunction(endpoint) 729 | .forStage('somestage'); 730 | settings = new ApiGatewayCachingSettings(serverless); 731 | 732 | restApiId = await given.a_rest_api_id_for_deployment(serverless, settings); 733 | 734 | await when.updating_stage_cache_settings(settings, serverless); 735 | 736 | requestsToAws = serverless.getRequestsToAws(); 737 | apiGatewayRequest = requestsToAws.find(r => r.awsService == apiGatewayService && r.method == updateStageMethod); 738 | }); 739 | 740 | it(`should enable caching for the endpoint`, () => { 741 | expect(apiGatewayRequest.properties.patchOperations).to.deep.include({ 742 | op: 'replace', 743 | path: `/~1/GET/caching/enabled`, 744 | value: 'true' 745 | }); 746 | }); 747 | }); 748 | 749 | // https://github.com/DianaIonita/serverless-api-gateway-caching/issues/46 750 | describe('When there are over twenty two http endpoints defined', () => { 751 | let requestsToAwsToUpdateStage, restApiId, expectedStageName; 752 | before(async () => { 753 | let endpoints = given.endpoints_with_caching_enabled(23); 754 | 755 | expectedStageName = 'somestage'; 756 | restApiId = given.a_rest_api_id(); 757 | serverless = given.a_serverless_instance() 758 | .withApiGatewayCachingConfig({ endpointsInheritCloudWatchSettingsFromStage: false }) 759 | .withRestApiId(restApiId) 760 | .forStage(expectedStageName); 761 | for (let endpoint of endpoints) { 762 | serverless = serverless.withFunction(endpoint) 763 | } 764 | 765 | settings = new ApiGatewayCachingSettings(serverless); 766 | 767 | await when.updating_stage_cache_settings(settings, serverless); 768 | 769 | requestsToAws = serverless.getRequestsToAws(); 770 | requestsToAwsToUpdateStage = requestsToAws.filter(r => r.method == 'updateStage'); 771 | }); 772 | 773 | it('should send two requests to update stage', () => { 774 | expect(requestsToAwsToUpdateStage).to.have.lengthOf(2); 775 | }); 776 | 777 | describe('each request to update stage', () => { 778 | let firstRequestToUpdateStage, secondRequestToUpdateStage; 779 | before(() => { 780 | firstRequestToUpdateStage = requestsToAwsToUpdateStage[0]; 781 | secondRequestToUpdateStage = requestsToAwsToUpdateStage[1]; 782 | }); 783 | 784 | it('should specify the REST API ID', () => { 785 | expect(firstRequestToUpdateStage.properties.restApiId).to.equal(restApiId); 786 | expect(secondRequestToUpdateStage.properties.restApiId).to.equal(restApiId); 787 | }); 788 | 789 | it('should specify the stage name', () => { 790 | expect(firstRequestToUpdateStage.properties.stageName).to.equal(expectedStageName); 791 | expect(secondRequestToUpdateStage.properties.stageName).to.equal(expectedStageName); 792 | }); 793 | 794 | it('should not contain more than 80 patch operations', () => { 795 | expect(firstRequestToUpdateStage.properties.patchOperations).to.have.length.at.most(80); 796 | expect(secondRequestToUpdateStage.properties.patchOperations).to.have.length.at.most(80); 797 | }); 798 | }); 799 | }); 800 | 801 | describe('applyUpdateStageForChunk function', () => { 802 | let serverless; 803 | let clock; 804 | const stage = 'test-stage'; 805 | const region = 'eu-west-1'; 806 | const chunk = { 807 | restApiId: 'test-api-id', 808 | stageName: stage, 809 | patchOperations: [{ op: 'replace', path: '/cacheClusterEnabled', value: 'true' }] 810 | }; 811 | 812 | beforeEach(() => { 813 | serverless = given.a_serverless_instance() 814 | .forStage(stage) 815 | .forRegion(region); 816 | clock = sinon.useFakeTimers(); 817 | }); 818 | 819 | afterEach(() => { 820 | clock.restore(); 821 | sinon.restore(); 822 | }); 823 | 824 | it('should call aws.request once on success', async () => { 825 | const requestStub = sinon.stub(serverless.providers.aws, 'request').resolves(); 826 | 827 | await applyUpdateStageForChunk(chunk, serverless, stage, region); 828 | 829 | expect(requestStub.calledOnce).to.be.true; 830 | expect(requestStub.getCall(0).args[0]).to.equal('APIGateway'); 831 | expect(requestStub.getCall(0).args[1]).to.equal('updateStage'); 832 | expect(requestStub.getCall(0).args[2]).to.deep.equal(chunk); 833 | expect(requestStub.getCall(0).args[3]).to.equal(stage); 834 | expect(requestStub.getCall(0).args[4]).to.equal(region); 835 | expect(serverless._logMessages).to.include('[serverless-api-gateway-caching] Updating API Gateway cache settings. Attempt 1.'); 836 | }); 837 | 838 | it('should retry on ConflictException and succeed on the second attempt', async () => { 839 | const conflictError = new Error('A previous change is still in progress'); 840 | conflictError.code = 'ConflictException'; 841 | 842 | // Mock AWS request: fail first, succeed second 843 | const requestStub = sinon.stub(serverless.providers.aws, 'request'); 844 | requestStub.onFirstCall().rejects(conflictError); 845 | requestStub.onSecondCall().resolves(); 846 | 847 | const promise = applyUpdateStageForChunk(chunk, serverless, stage, region); 848 | 849 | // Advance clock to trigger the retry timeout 850 | await clock.tickAsync(1000); // Advance past the first delay (500 * 2^1) 851 | 852 | await promise; // Wait for the function to complete 853 | 854 | expect(requestStub.calledTwice).to.be.true; 855 | expect(serverless._logMessages).to.include('[serverless-api-gateway-caching] Updating API Gateway cache settings. Attempt 1.'); 856 | expect(serverless._logMessages).to.include('[serverless-api-gateway-caching] Retrying (1/10) after 1000ms due to error: A previous change is still in progress'); 857 | expect(serverless._logMessages).to.include('[serverless-api-gateway-caching] Updating API Gateway cache settings. Attempt 2.'); 858 | }); 859 | 860 | it('should fail after max retries on persistent ConflictException', async () => { 861 | const conflictError = new Error('A previous change is still in progress'); 862 | conflictError.code = 'ConflictException'; 863 | const maxRetries = 10; // As defined in the function 864 | 865 | // Mock AWS request to always fail with ConflictException 866 | const requestStub = sinon.stub(serverless.providers.aws, 'request').rejects(conflictError); 867 | 868 | const promise = applyUpdateStageForChunk(chunk, serverless, stage, region); 869 | 870 | // Advance clock past all retry delays 871 | for (let i = 1; i <= maxRetries; i++) { 872 | await clock.tickAsync(500 * (2 ** i) + 10); // Ensure delay is passed 873 | } 874 | 875 | // Assert the promise rejects with the correct error 876 | await expect(promise).to.be.rejectedWith(`Failed to update API Gateway cache settings after ${maxRetries} retries: ${conflictError.message}`); 877 | expect(requestStub.callCount).to.equal(maxRetries); 878 | expect(serverless._logMessages).to.include(`[serverless-api-gateway-caching] Maximum retries (${maxRetries}) reached. Failed to update API Gateway cache settings.`); 879 | }); 880 | 881 | it('should fail immediately on non-retryable error', async () => { 882 | const otherError = new Error('Some other API Gateway error'); 883 | otherError.code = 'BadRequestException'; // Example non-retryable code 884 | 885 | // Mock AWS request to fail with a non-retryable error 886 | const requestStub = sinon.stub(serverless.providers.aws, 'request').rejects(otherError); 887 | const errorSpy = sinon.spy(console, 'error'); // Spy on console.error 888 | 889 | const promise = applyUpdateStageForChunk(chunk, serverless, stage, region); 890 | 891 | // Assert the promise rejects immediately 892 | await expect(promise).to.be.rejectedWith(`Failed to update API Gateway cache settings: ${otherError.message}`); 893 | expect(requestStub.calledOnce).to.be.true; // Should not retry 894 | expect(errorSpy.calledWith('[serverless-api-gateway-caching] Non-retryable error during update:', otherError)).to.be.true; 895 | errorSpy.restore(); // Restore the spy 896 | }); 897 | }); 898 | }); 899 | --------------------------------------------------------------------------------