├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .travis.yml ├── ISSUE_TEMPLATE.md ├── LICENSE ├── README.md ├── RELEASES.md ├── docs ├── api-gateway-proxy-request.md ├── api.md ├── authorization.md ├── binary-content.md ├── caching.md ├── cors.md ├── customise-responses.md ├── getting_started.md ├── intercepting.md ├── post-deploy.md ├── request-object.md └── variables.md ├── index.js ├── package.json ├── spec ├── api-builder-spec.js ├── ask-spec.js ├── convert-api-gw-proxy-request-spec.js ├── lowercase-keys-spec.js ├── merge-vars-spec.js ├── sequential-promise-map-spec.js ├── support │ └── jasmine-runner.js └── valid-http-code-spec.js └── src ├── api-builder.js ├── ask.js ├── convert-api-gw-proxy-request.js ├── lowercase-keys.js ├── merge-vars.js ├── sequential-promise-map.js └── valid-http-code.js /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "crockford", 3 | "env": { 4 | "node": true, 5 | "es6": true 6 | }, 7 | "parserOptions": { 8 | "ecmaVersion": 6 9 | }, 10 | "rules": { 11 | "semi": ["error", "always"], 12 | "strict": ["error", "function"], 13 | "no-unused-vars": "error", 14 | "indent": ["error", "tab" ], 15 | "no-const-assign": "error", 16 | "one-var": "error", 17 | "prefer-const": "error", 18 | "no-var": "warn", 19 | "no-plusplus": ["off"], 20 | "quotes": ["error", "single", {"avoidEscape": true, "allowTemplateLiterals": true}] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | SpecRunner.html 4 | .grunt 5 | *.log 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 4.3.2 4 | - 6.1.0 5 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Please use GitHub issues only to report bugs. To ask a general question or request assistance/support, please use the [Claudia.js Gitter Chat](https://gitter.im/claudiajs/claudia) instead. 2 | 3 | To report a bug or a problem, please fill in the sections below. The more you provide, the better we'll be able to help. 4 | 5 | --- 6 | 7 | * Expected behaviour: 8 | 9 | * What actually happens: 10 | 11 | * Link to a minimal, executable project that demonstrates the problem: 12 | 13 | * Steps to install the project: 14 | 15 | * Steps to reproduce the problem: 16 | 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016-2017 Gojko Adzic 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Claudia API Builder 2 | 3 | [![npm](https://img.shields.io/npm/v/claudia-api-builder.svg?maxAge=2592000?style=plastic)](https://www.npmjs.com/package/claudia-api-builder) 4 | [![npm](https://img.shields.io/npm/dt/claudia-api-builder.svg?maxAge=2592000?style=plastic)](https://www.npmjs.com/package/claudia-api-builder) 5 | [![npm](https://img.shields.io/npm/l/claudia-api-builder.svg?maxAge=2592000?style=plastic)](https://github.com/claudiajs/claudia-api-builder/blob/master/LICENSE) 6 | [![Build Status](https://travis-ci.org/claudiajs/claudia-api-builder.svg?branch=master)](https://travis-ci.org/claudiajs/claudia-api-builder) 7 | [![Join the chat at https://gitter.im/claudiajs/claudia](https://badges.gitter.im/claudiajs/claudia.svg)](https://gitter.im/claudiajs/claudia?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 8 | 9 | Claudia API Builder makes it possible to use AWS API Gateway as if it were a lightweight JavaScript web server, so it helps developers get started easily and reduces the learning curve required to launch web APIs in AWS. [Check out this video to see how to create and deploy an API in under 5 minutes](https://youtu.be/lsXHWy4b-I0). 10 | 11 | [![Claudia.js Introduction Video](https://claudiajs.com/assets/claudia-intro-video.png)](https://youtu.be/lsXHWy4b-I0) 12 | 13 | The API Builder helps you by: 14 | 15 | * setting up AWS API Gateway Web interfaces for Lambda projects easily, the way JavaScript developers expect out of the box 16 | * routing multiple AWS API Gateway end-points to a single Lambda function, so that you can develop and deploy an entire API simpler and avoid inconsistencies. 17 | * handling synchronous responses or asynchronous promises, so you can develop easier 18 | * configuring response content types and HTTP codes easily 19 | * enabling you to set-up post-install configuration steps, so that you can set up the deployments easier 20 | 21 | The API builder is designed to work with [Claudia](https://github.com/claudiajs), and add minimal overhead to client projects. 22 | 23 | ## Simple example 24 | 25 | ```javascript 26 | var ApiBuilder = require('claudia-api-builder'), 27 | api = new ApiBuilder(), 28 | superb = require('superb'); 29 | 30 | module.exports = api; 31 | 32 | api.get('/greet', function (request) { 33 | return request.queryString.name + ' is ' + superb.random(); 34 | }); 35 | ``` 36 | 37 | For a more examples, see the [Web API Example Projects](https://github.com/claudiajs/example-projects#web-api) 38 | 39 | ## Getting started 40 | 41 | * Check out the [Getting Started](https://claudiajs.com/tutorials/hello-world-api-gateway.html) guide for a basic Hello-World style example 42 | * Check out the [API Documentation](docs/api.md) for a detailed guide on handling requests, customising responses and configuring your API 43 | 44 | ## Questions, suggestions? 45 | [![Join the chat at https://gitter.im/claudiajs/claudia](https://badges.gitter.im/claudiajs/claudia.svg)](https://gitter.im/claudiajs/claudia?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 46 | 47 | 48 | ## License 49 | 50 | [MIT](LICENSE) 51 | -------------------------------------------------------------------------------- /RELEASES.md: -------------------------------------------------------------------------------- 1 | # Release history 2 | 3 | ## 4.1.0 15 June 2017 4 | 5 | - ApiResponse is now static, so it's easier to use in your own functions, thanks to [Aaron J. Lang](https://github.com/aaronjameslang) 6 | - bugfix for greedy path routing when the API_GW request type is used, thanks to [Aaron J. Lang](https://github.com/aaronjameslang) 7 | 8 | 9 | ## 4.0.0 9 April 2017 10 | 11 | - bumping version for sync with claudia 12 | 13 | ## 2.5.1, 7 June 2017 14 | 15 | - easier handling for Lambda environment and stage variables, using `{mergeVars: true}` 16 | 17 | ## 2.4.2, 3 May 2017 18 | 19 | - API builder will not explode, but provide a helpful error message when invalid or circular JSON structures passed as application/json requests or responses 20 | 21 | ## 2.4.0, 17 January 2017 22 | 23 | - support for API Gateway Binary content handling 24 | 25 | ## 2.3.0, 3 December 2016 26 | 27 | - expose CORS Max-Age in the config, so Claudia can set it even when default CORS settings are used on API Gateway 28 | 29 | ## 2.2.0, 28 November 2016 30 | 31 | - Allow ApiResponse to be returned from an interceptor, so it can send back a custom error code 32 | 33 | ## 2.1.0, 24 November 2016 34 | 35 | - enable max-age to be specified on CORS headers (thanks to [Philipp Holly](https://github.com/phips28)) \ 36 | - limit magic location header only to actual 3xx redirects (301 and 302), allowing other codes such as 304 to be handled differently, fixes [issue 20](https://github.com/claudiajs/claudia-api-builder/issues/20) 37 | 38 | ## 2.0.2, 25. October 2016 39 | 40 | - bugfix for setting the Access-Control-Allow-Credentials header, thanks to [StampStyle](https://github.com/StampStyle) 41 | 42 | ## 2.0.1, 16. October 2016 43 | 44 | - bugfix for intercepting non-web requests, where 2.0 introduced backwards incompatibility wrapping even non-API Gateway requests into proxyRequest. The behaviour is now compatible with 1.x, where non-web requests are sent to the intercepting function unmodified. 45 | 46 | ## 2.0.0, 27. September 2016 47 | 48 | - support for API Gateway [Lambda Proxy Integrations](docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-create-api-as-simple-proxy-for-lambda.html) 49 | - support for routing via .ANY method 50 | - support for selecting request type (either CLAUDIA_API_BUILDER or AWS_PROXY) 51 | - support for dynamic response codes 52 | - completed CORS support (all HTTP method request handlers can now also limit CORS allowed origins, instead of just the OPTIONS one) 53 | - support for asynchronous CORS origin filters 54 | - stopping support for Node 0.10 55 | - (will only work with claudia 2.x) 56 | 57 | ## 1.6.0, 26 August 2016 58 | 59 | - support for custom authorizers 60 | 61 | ## 1.5.1, 4 August 2016 62 | 63 | - by default, API processing stops the node VM using the [callbackWaitsForEmptyEventLoop](http://docs.aws.amazon.com/lambda/latest/dg/nodejs-prog-model-context.html) Lambda context parameter. This is to prevent runaway node.js events caused by third party libs causing the Lambda VM to become stuck after a request completes. If you really want the VM execution to continue until the event loop empties, even after your API process is done, then set `lambdaContext.callbackWaitsForEmptyEventLoop` to `true` in your request handler. 64 | 65 | ## 1.5.0, 12 July 2016 66 | 67 | - support for intercepting and modifying requests 68 | 69 | ## 1.4.1, 1.4.0, 11 July 2016 70 | 71 | - shortcut for configuring stage variables during deployment 72 | - you can now provide a handler for unsupported event formats, and invoke the same Lambda from a source that's not API Gateway 73 | -------------------------------------------------------------------------------- /docs/api-gateway-proxy-request.md: -------------------------------------------------------------------------------- 1 | ### Using the API Gateway Proxy Request Object 2 | 3 | As an alternative to the Claudia API Builder request, you can also use the [API Gateway Proxy Request](http://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-set-up-simple-proxy.html#api-gateway-simple-proxy-for-lambda-input-format) object directly. To do that, pass the `AWS_PROXY` format as the `requestFormat` option to the constructor when instantiating the API builder. 4 | 5 | ```javascript 6 | var ApiBuilder = require('claudia-api-builder'), 7 | api = new ApiBuilder({requestFormat: 'AWS_PROXY'}); 8 | 9 | api.get('/', request => { 10 | // request is now the raw API Gateway proxy object 11 | // not the API Builder replacement 12 | }); 13 | ``` 14 | 15 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API definition syntax 2 | 3 | > this is the API documentation for versions 2.x and later. If you are looking for older (1.x) versions, check out [version 1.x API documentation](https://github.com/claudiajs/claudia-api-builder/blob/4f5c30df0365812765806ae2f9fd97e7a1287ed9/docs/api.md) 4 | 5 | Claudia API builder makes it easy to configure and deploy API Gateway definitions together with a Lambda function, providing an abstraction that is similar to lightweight JavaScript web servers such as `express`. 6 | 7 | ```javascript 8 | var ApiBuilder = require('claudia-api-builder'), 9 | api = new ApiBuilder(), 10 | superb = require('superb'); 11 | 12 | module.exports = api; 13 | 14 | api.get('/greet', function (request) { 15 | return request.queryString.name + ' is ' + superb.random(); 16 | }); 17 | ``` 18 | 19 | For a more detailed example, see the [Web API Example project](https://github.com/claudiajs/example-projects/tree/master/web-api). 20 | 21 | ## Defining routes 22 | 23 | An instance of the Claudia API Builder should be used as the module export from your API module. You can create a new API simply 24 | by instantiating a new `ApiBuilder`, then defining HTTP handlers for paths by calling `.get`, `.put`, and `.post`. 25 | 26 | You can also create a generic handler for any method on a path, using `.any`. See the [Web API Generic Handlers Project](https://github.com/claudiajs/example-projects/tree/master/web-api-generic-handlers) for an example. 27 | 28 | ## Responding to requests 29 | 30 | Claudia API builder will try to automatically format the result according to the content type. If you use the 'application/json' content type, you can respond with a String or an Object, the response will be correctly encoded or serialised into JSON. 31 | 32 | You can either respond synchronously (just return a value), or respond with a `Promise`. In that case, the lambda function will wait until the 33 | `Promise` resolves or rejects before responding. API Builder just checks for the `.then` method, so it should work with any A+ Promise library. 34 | 35 | ```javascript 36 | api.get('/greet', function (request) { 37 | return request.queryString.name + ' is amazing'; 38 | }); 39 | api.post('/set-user', function (request) { 40 | return new Promise(function (resolve, reject) { 41 | // some asynchronous operation 42 | }).then(() => request.queryString.name + ' was saved'); 43 | }); 44 | 45 | ``` 46 | 47 | 48 | 49 | To implement a custom termination workflow, use the [`request.lambdaContext`](http://docs.aws.amazon.com/lambda/latest/dg/nodejs-prog-model-context.html) object, and return a `Promise` that never gets resolved. 50 | 51 | ## Detailed API documentation and examples 52 | 53 | * [The API Builder Request Object](request-object.md) 54 | * [How to use the API Gateway proxy object instead of the API Builder](api-gateway-proxy-request.md) 55 | * [How to customise responses (HTTP codes, headers, content types)](customise-responses.md) 56 | * [Controlling Cross-Origin Resource Sharing headers](cors.md) 57 | * [How to set configuration and environment variables](variables.md) 58 | * [How to set up authorization (API Keys, IAM and Cognito Authorizers)](authorization.md) 59 | * [How to control API result caching](caching.md) 60 | * [How to handle binary content](binary-content.md) 61 | * [Adding post-deploy configuration steps](post-deploy.md) 62 | * [How to filter, modify and intercept requests](intercepting.md) 63 | 64 | -------------------------------------------------------------------------------- /docs/authorization.md: -------------------------------------------------------------------------------- 1 | # Setting up Authorization for the API 2 | 3 | API Gateway supports several methods of authorization. 4 | 5 | * [API Keys](#require-api-keys), useful for authorising 3rd party client developers 6 | * [IAM Authorization](#iam-authorization), useful for a small number of managed client apps 7 | * [Cognito Authorization](#cognito-authorization), useful for a large number of self-service Internet users 8 | * [Custom Authorizers](#custom-authorizers), when you want to be fully in control 9 | 10 | ## Require API Keys 11 | 12 | You can force a method to require an API key by using an optional third argument to handler definition methods, and setting the `apiKeyRequired` property on it. For example: 13 | 14 | ```javascript 15 | api.get('/echo', function (request) { ... }, {apiKeyRequired: true}); 16 | ``` 17 | 18 | See [How to Use an API Key in API Gateway](http://docs.aws.amazon.com/apigateway/latest/developerguide/how-to-api-keys.html) for more information on creating and using API keys. 19 | 20 | ## IAM Authorization 21 | 22 | APIs by default do not require user level authorization, to enable browsers to call them. API Gateway also allows you to set fine-grained permissions based on IAM policies. To do that, configure the request processor by adding an `authorizationType` field, with the value of `AWS_IAM` – here's an example: 23 | 24 | ```javascript 25 | api.get('/hello', function (request) {...}, {authorizationType: 'AWS_IAM'} ); 26 | ``` 27 | 28 | See the [Permissions Documentation Page](http://docs.aws.amazon.com/apigateway/latest/developerguide/permissions.html) of the API Gateway developer guide for information on how to set up user policies for authorization. 29 | 30 | ### Overriding executing credentials 31 | 32 | By default, API Gateway requests will execute under the credentials of the user who created them. You can make the API execute under the credentials of a particular user/IAM role, or pass the caller credentials to the underlying Lambda function by setting the `invokeWithCredentials` flag. Set it to a IAM ARN to use a particular set of credentials, or to `true` to pass caller credentials. If you use this flag, the `authorizationType` is automatically set to `AWS_IAM`, so you don't need to specify it separately. 33 | 34 | ```javascript 35 | // use caller credentials 36 | api.get('/hello', function (request) {...}, {invokeWithCredentials: true} ); 37 | // use specific credentials 38 | api.get('/hello', function (request) {...}, {invokeWithCredentials: 'arn:aws:iam::123456789012:role/apigAwsProxyRole'} ); 39 | ``` 40 | 41 | ## Cognito authorization 42 | 43 | _since claudia 2.9.0_ 44 | 45 | You can also set up an API end-point to use a [Cognito Authorizer](http://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-integrate-with-cognito.html). Register the authorizer similar to custom authorizers, but specify `providerARNs` instead of a lambda name or lambda ARN, then provide `cognitoAuthorizer` in the endpoint options. You can use all the other options for custom authorizers (such as `validationExpression` and `headerName`). 46 | 47 | ```javascript 48 | api.registerAuthorizer('MyCognitoAuth', { 49 | providerARNs: [''] 50 | }); 51 | 52 | api.post('/lockedMessages', request => { 53 | return doSomethingUseful(request); 54 | }, { cognitoAuthorizer: 'MyCognitoAuth' }) 55 | ``` 56 | 57 | ### Adding authorization scopes to Cognito Authorizers 58 | _since claudia 5.7.0_ 59 | 60 | Add authorization scopes to your Cognito Authorizer by providing an array of strings in the `authorizationScopes` property: 61 | 62 | ```javascript 63 | api.get('/locked', () => { 64 | //... 65 | }, { 66 | cognitoAuthorizer: 'CognitoOauth2Auth', 67 | authorizationScopes: ['email', 'openid'] 68 | }); 69 | ``` 70 | 71 | ## Custom authorizers 72 | 73 | You can set up a [custom authorizer](http://docs.aws.amazon.com/apigateway/latest/developerguide/use-custom-authorizer.html) with your API by registering the authorizer using `api.registerAuthorizer`, and then referencing the authorizer by name in the `customAuthorizer` flag of the request handler options. 74 | 75 | Request Based authorizers are supported since `Claudia 3.1.0`. 76 | 77 | You can register the authorizer in several ways: 78 | 79 | ```javascript 80 | api.registerAuthorizer(name, options); 81 | ``` 82 | 83 | * `name`: `string` – the name for this authorizer 84 | * `options`: `object` – a key-value map containing the following properties 85 | * `lambdaName` – the name of a Lambda function for the authorizer. Mandatory unless `lambdaArn` is provided. 86 | * `lambdaArn` – full ARN of a Lambda function for the authorizer. Useful to wire up authorizers in third-party AWS accounts. If used, don't specify `lambdaName` or `lambdaVersion`. 87 | * `lambdaVersion` – _optional_. Additional qualifier for the Lambda authorizer execution. Can be a string version alias, a numerical version or `true`. if `true`, the API will pass the current stage name as the qualifier. This allows you to use different versions of the authorizer for different versions of the API, for example for testing and production. If not defined, the latest version of the Lambda authorizer will be used for all stages of the API. 88 | * `headerName`: `string` – _optional_ the header name that contains the authentication token. If not specified, Claudia will use the `Authorization` header 89 | * `identitySource`: `string` – _optional_ a list of identity sources for the authorizer. Useful if you want to specify the full identity source expression from the [Create Authorizer](https://docs.aws.amazon.com/cli/latest/reference/apigateway/create-authorizer.html) API. If not specified, the `headerName` argument is applied. 90 | * `validationExpression`: `string` – _optional_ a regular expression to validate authentication tokens 91 | * `credentials`: `string` – _optional_ an IAM role ARN for the credentials used to invoke the authorizer 92 | * `resultTtl`: `int` – _optional_ period (in seconds) API gateway is allowed to cache policies returned by the custom authorizer 93 | * `type`: `string` – _optional_ the API Gateway custom authorizer type. It can be `REQUEST`, `TOKEN` or `COGNITO_USER_POOLS`. By default, if `providerARNs` are specified, it sets the authorizer as Cognito user pools. Otherwise, the Token authorization is used. You have to specify this argument to use `REQUEST` authorizers. 94 | 95 | 96 | Here are a few examples: 97 | 98 | ```javascript 99 | // use always the latest version of a Lambda in the same AWS account, 100 | // authenticate with the Authorization header 101 | api.registerAuthorizer('companyAuth', { lambdaName: 'companyAuthLambda' }) 102 | 103 | // use always the latest version of a Lambda in the same AWS account, 104 | // authenticate with the UserToken header 105 | api.registerAuthorizer('companyAuth', { lambdaName: 'companyAuthLambda', headerName: 'UserToken' }) 106 | 107 | // use the authorizer version corresponding to the API stage 108 | api.registerAuthorizer('companyAuth', { lambdaName: 'companyAuthLambda', lambdaVersion: true }) 109 | 110 | // use a hard-coded lambda version for all stages 111 | api.registerAuthorizer('companyAuth', { lambdaName: 'companyAuthLambda', lambdaVersion: '12' }) 112 | 113 | // use a third-party authorizer with an ARN and a specific header 114 | api.registerAuthorizer('companyAuth', { lambdaArn: 'arn:aws:lambda:us-east-1:123456789012:function:MagicAuth', headerName: 'MagicAuth' }) 115 | 116 | // use a custom request-based authorizer 117 | api.registerAuthorizer('companyAuth', { 118 | lambdaName: 'companyAuthLambda', 119 | type: 'REQUEST', 120 | identitySource: 'method.request.header.Auth, method.request.querystring.Name' 121 | }) 122 | ``` 123 | 124 | After you register the authorizer, turn in on by providing a `customAuthorizer` field in the endpoint configuration. 125 | 126 | ```javascript 127 | api.get('/unlocked', function (request) { 128 | return 'OK for ' + request.context.authorizerPrincipalId; 129 | }, { customAuthorizer: 'companyAuth' }); 130 | ``` 131 | 132 | When the authorizer is specified using `lambdaName`, Claudia will automatically assign the correct access privileges so that your API can call the authorizer. When the authorizer is specified using `lambdaArn`, you need to ensure the right privileges exist between the API and the third-party authorizer Lambda function. 133 | 134 | Note that `request.context.authorizerPrincipalId` will contain the principal ID passed by the custom authorizer automatically. 135 | 136 | Check out the [Custom Authorizers Example](https://github.com/claudiajs/example-projects/tree/master/custom-authorizers) to see this in action. 137 | 138 | 139 | -------------------------------------------------------------------------------- /docs/binary-content.md: -------------------------------------------------------------------------------- 1 | # Binary content handling 2 | 3 | _since `claudia-api-builder 2.4.0`, `claudia 2.6.0`._ 4 | 5 | API Gateway has basic support for binary data handling, by converting incoming binary data into base64 strings, and decoding outgoing base64 strings into binary content. Claudia API Builder allows you to configure and manage those transformations: 6 | 7 | * use `api.setBinaryMediaTypes(array)` to configure MIME types your API wants to treat as binary. By default, common image types, application/pdf and application/octet-stream are treated as binary. 8 | * use `requestContentHandling` in the handler configuration to set the required incoming binary content handling behaviour (API Gateway Integration content handling). Valid values are `'CONVERT_TO_BINARY'` and `'CONVERT_TO_TEXT'` 9 | * use `success.contentHandling` in the handler configuration to set the required response content handling behaviour (API Gateway Integration Response content handling). Valid values are `'CONVERT_TO_BINARY'` and `'CONVERT_TO_TEXT'`. Remember to set the `success.contentType` to the appropriate binary content type as well. 10 | 11 | ```javascript 12 | api.setBinaryMediaTypes(['image/gif']); 13 | 14 | api.post('/thumb', (request) => { 15 | //... 16 | }, { 17 | requestContentHandling: 'CONVERT_TO_TEXT', 18 | success: { 19 | contentType: 'image/png', 20 | contentHandling: 'CONVERT_TO_BINARY' 21 | } 22 | }); 23 | ``` 24 | 25 | Claudia API Builder makes it easier to process binary content, by automatically encoding and decoding `Buffer` objects. Return a `Buffer` (eg the result of `fs.readFile`) from an endpoint handler, and Claudia will automatically convert it into a base64 string. If the incoming request is base64 encoded, Claudia API Builder will decode it for you, and set `request.body` to a `Buffer` with the decoded content. 26 | 27 | Check out the [Handling Binary Content Tutorial](https://claudiajs.com/tutorials/binary-content.html) and the [Binary Content Example Project](https://github.com/claudiajs/example-projects/tree/master/binary-content). 28 | -------------------------------------------------------------------------------- /docs/caching.md: -------------------------------------------------------------------------------- 1 | # Controlling API Gateway caching parameters 2 | 3 | To use API Gateway caching, API endpoints need to declare parameters. Since `claudia 2.1.2`, dynamic path parameters (eg from API endpoints `/person/{name}`) are set automatically. You can also use query string and header parameters to control caching, but you'll need to set them explicitly. 4 | 5 | Use the `requestParameters` configuration key, and provide a key-value hash object. For keys, either use the full API Gateway parameter location mapping (for example `method.request.querystring.name`) or create sub-objects `querystring` and `header` with just the parameter names inside. The values should be `true` or `false`, and indicate whether a parameter is required (`true`) or optional (`false`). For more information, check out [Request and Response Data Mappings](http://docs.aws.amazon.com/apigateway/latest/developerguide/request-response-data-mappings.html) from the API Gateway guide. 6 | 7 | ```javascript 8 | // set query string and header params using fully qualified names 9 | api.get('/test', function () { // handler }, 10 | { 11 | requestParameters: { 12 | 'method.request.querystring.name' : true, 13 | 'method.request.header.x-123': true 14 | } 15 | }); 16 | 17 | // set query string and header params using object notation 18 | api.get('/test', function () {}, 19 | { 20 | requestParameters: { 21 | querystring: { name : false }, 22 | header: {'x-123': true} 23 | } 24 | }) 25 | 26 | // no need to set path params, done automatically 27 | api.get('/some/{path}/param', function () { } ); 28 | 29 | // add specific header/query string to automatically created path params 30 | api.get('/some/{path}/param', function () { }, 31 | { 32 | requestParameters: { 33 | querystring: { name : false }, 34 | header: {'x-123': true} 35 | } 36 | }); 37 | ``` 38 | 39 | -------------------------------------------------------------------------------- /docs/cors.md: -------------------------------------------------------------------------------- 1 | # Controlling Cross-Origin Resource Sharing headers 2 | 3 | Claudia API builder automatically sets up the API to allow cross-origin resource sharing (CORS). The most common usage scenario for API Gateway projects is to provide dynamic functions to Web sites served on a different domain, so CORS is necessary to support that use case. To simplify things, by default, APIs allow calls from any domain. 4 | 5 | If you plan to proxy both the main web site and the APIs through a CDN and put them under a single domain, or if you want to restrict access to your APIs, you can override the default behaviour for CORS handling. 6 | 7 | To completely prevent CORS access, use: 8 | 9 | ```javascript 10 | api.corsOrigin(false) 11 | ``` 12 | 13 | To hard-code the CORS origin to a particular domain, call the `corsOrigin` function with a string, representing the target origin: 14 | 15 | ```javascript 16 | api.corsOrigin('https://www.claudiajs.com') 17 | ``` 18 | 19 | To dynamically choose an origin (for example to support different configurations for development and production use, or to allow multiple sub-domains to access your API), pass a JavaScript function into `corsOrigin`. Your function will receive the request object (filled with stage variables and the requesting headers) and should return a string with the contents of the origin header. This has to be a synchronous function (promises are not supported). 20 | 21 | ```javascript 22 | api.corsOrigin(function (request) { 23 | if (/claudiajs.com$/.test(request.normalizedHeaders.origin)) { 24 | return request.normalizedHeaders.origin; 25 | } 26 | return ''; 27 | }); 28 | ``` 29 | 30 | If your API endpoints use HTTP headers as parameters, you may need to allow additional headers in `Access-Control-Allow-Headers`. To do so, just call the `corsHeaders` method on the API, and pass a string with the `Allow-Header` value. 31 | 32 | ```javascript 33 | api.corsHeaders('Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Api-Version'); 34 | ``` 35 | 36 | The browser performs a pre-flight OPTIONS call before each _real_ call (GET, POST, ...), this takes a lot of time, if the browser has to do it every time. 37 | And you get also charged for AWS API Gateway and Lambda! 38 | To avoid this, you can define a `max-age` and the browser will cache the OPTIONS call for this duration. 39 | Default: disabled 40 | 41 | ```javascript 42 | api.corsMaxAge(60); // in seconds 43 | ``` 44 | 45 | To see this in action, see the [Custom CORS Example Project](https://github.com/claudiajs/example-projects/blob/master/web-api-custom-cors/web.js). For more information on CORS, see the [MDN CORS page](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS). 46 | -------------------------------------------------------------------------------- /docs/customise-responses.md: -------------------------------------------------------------------------------- 1 | # How to customise responses (HTTP codes, headers, content types) 2 | 3 | Claudia automatically configures your API for the most common use case, the way javascript developers expect. The HTTP response code 500 will be used for all runtime errors, and 200 for successful operations. The `application/json` content type is default for both successes and failures. 4 | 5 | Of course, you can easily change all those parameters, add your own custom headers and respond with different codes. There are several ways to configure the response: 6 | 7 | * [Static configuration](#static-configuration), when all responses on a route should always use the same code and headers 8 | * [Use an ApiResponse object](#apiresponse-object), when you want to decide at runtime which HTTP response code/headers to use 9 | * [Define Custom API Gateway responses](#api-gateway-responses), when you want to configure API Gateway errors (such as 'method not found') 10 | 11 | In addition, Claudia API builder has a few nice shortcuts for managing Cross-Origin Resource Sharing (CORS). Check out the [Configuring CORS](cors.md) page for more information. 12 | 13 | ## Static configuration 14 | 15 | You can provide static configuration to a handler, by setting the third argument of the method. All the keys are optional, and the structure is: 16 | 17 | * `error`: a number or a key-value map. If a number is specified, it will be used as the HTTP response code. If a key-value map is specified, it should have the following keys: 18 | * `code`: HTTP response code 19 | * `contentType`: the content type of the response 20 | * `headers`: a key-value map of hard-coded header values, or an array enumerating custom header names. See [Custom headers](#custom-headers) below for more information 21 | * `success`: a number or a key-value map. If a number is specified, it will be used as the HTTP response code. If a key-value map is specified, it should have the following keys: 22 | * `code`: HTTP response code 23 | * `contentType`: the content type of the response 24 | * `headers`: a key-value map of hard-coded header values, or an array enumerating custom header names. See [Custom headers](#custom-headers) below for more information 25 | * `apiKeyRequired`: boolean, determines if a valid API key is required to call this method. See [Requiring Api Keys](#requiring-api-keys) below for more information 26 | 27 | For example: 28 | 29 | ```javascript 30 | api.get('/greet', function (request) { 31 | return request.queryString.name + ' is ' + superb.random(); 32 | }, { 33 | success: { contentType: 'text/plain' }, 34 | error: {code: 403} 35 | }); 36 | 37 | ``` 38 | 39 | These special rules apply to content types and codes: 40 | 41 | * When the error content type is `text/plain` or `text/html`, only the error message is sent back in the body, not the entire error structure. 42 | * When the error content type is `application/json`, the entire error structure is sent back with the response. 43 | * When the response type is `application/json`, the response is JSON-encoded. So if you just send back a string, it will have quotes around it. 44 | * When the response type is `text/plain`, `text/xml`, `text/html` or `application/xml`, the response is sent back without JSON encoding (so no extra quotes). 45 | * In case of 3xx response codes for success, the response goes into the `Location` header, so you can easily create HTTP redirects. 46 | 47 | To see these options in action, see the [Serving HTML Example project](https://github.com/claudiajs/example-projects/tree/master/web-serving-html). 48 | 49 | You can also configure header values in the configuration (useful for ending sessions in case of errors, redirecting to a well-known location after log-outs etc), use the `success.headers` and `error.headers` keys. To do this, list headers as key-value pairs. For example: 50 | 51 | ```javascript 52 | api.get('/hard-coded-headers', function () { 53 | return 'OK'; 54 | }, {success: {headers: {'X-Version': '101', 'Content-Type': 'text/plain'}}}); 55 | ``` 56 | 57 | ## ApiResponse object 58 | 59 | To decide at runtime which HTTP response code/headers to use, instead of a string or JSON object, return or throw an instance of `ApiBuilder.ApiResponse`. This will allow you to dynamically set headers and the response code. 60 | 61 | ```javascript 62 | new ApiResponse(body, headers, httpCode) 63 | ``` 64 | 65 | * `body`: string – the body of the response 66 | * `header`: object – key-value map of header names to header values, all strings 67 | * `httpCode`: numeric response code. Defaults to 200 for successful responses and 500 for errors. 68 | 69 | Here's an example: 70 | ```javascript 71 | api.get('/programmatic-headers', function () { 72 | return new ApiBuilder.ApiResponse('OK', {'X-Version': '202', 'Content-Type': 'text/plain'}, 204); 73 | }); 74 | ``` 75 | 76 | You could throw: 77 | ```javascript 78 | api.get('/error', function () { 79 | throw new ApiBuilder.ApiResponse('NOT OK', {'Content-Type': 'text/xml'}, 500); 80 | }); 81 | ``` 82 | 83 | Or resolve/reject: 84 | ```javascript 85 | api.get('/heartbeat', () => 86 | gateway.heartbeat() 87 | .then(() => new ApiBuilder.ApiResponse({healthy:true}, 200)) 88 | .catch(() => new ApiBuilder.ApiResponse({healthy:false}, 503)) 89 | }); 90 | ``` 91 | 92 | To see custom headers in action, see the [Custom Headers Example Project](https://github.com/claudiajs/example-projects/blob/master/web-api-custom-headers/web.js). 93 | 94 | ## API Gateway Responses 95 | 96 | _since claudia-api-builder 3.0.0, claudia 3.0.0_ 97 | 98 | API Gateway supports customising error responses generated by the gateway itself, without any interaction with your Lambda. This is useful if you want to provide additional headers with an error response, or if you want to change the default behaviour for unmatched paths, for example. 99 | 100 | To define a custom API Response, use the `setGatewayResponse` method. The syntax is: 101 | 102 | ```javascript 103 | api.setGatewayResponse(responseType, responseConfig) 104 | ``` 105 | 106 | * `responseType`: `string` – one of the supported [API Gateway Response Types](http://docs.aws.amazon.com/apigateway/api-reference/resource/gateway-response/). 107 | * `config`: `object` – the API Gateway Response Configuration, containing optionally `statusCode`, `responseParameters` and `responseTemplates`. Check the [API Gateway Response Types](http://docs.aws.amazon.com/apigateway/api-reference/resource/gateway-response/) for more information on those parameters. 108 | 109 | 110 | Here's an example: 111 | 112 | ```javascript 113 | api.setGatewayResponse('DEFAULT_4XX', { 114 | responseParameters: { 115 | 'gatewayresponse.header.x-response-claudia': '\'yes\'', 116 | 'gatewayresponse.header.x-name': 'method.request.header.name', 117 | 'gatewayresponse.header.Access-Control-Allow-Origin': '\'a.b.c\'', 118 | 'gatewayresponse.header.Content-Type': '\'application/json\'' 119 | }, 120 | statusCode: 411, 121 | responseTemplates: { 122 | 'application/json': '{"custom": true, "message":$context.error.messageString}' 123 | } 124 | }); 125 | ``` 126 | 127 | In addition to the standard parameters supported by API Gateway directly, Claudia API Builder also provides a shortcut for setting headers. Use the key `headers`, and a map `string` to `string` of header names to values. 128 | 129 | 130 | ```javascript 131 | api.setGatewayResponse('DEFAULT_4XX', { 132 | statusCode: 411, 133 | headers: { 134 | 'x-response-claudia': 'yes', 135 | 'Content-Type': 'application/json', 136 | 'Access-Control-Allow-Origin': 'a.b.c' 137 | } 138 | } 139 | ); 140 | 141 | ``` 142 | 143 | -------------------------------------------------------------------------------- /docs/getting_started.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Claudia Api Builder 2 | 3 | This page has moved to [https://claudiajs.com/tutorials/hello-world-api-gateway.html](https://claudiajs.com/tutorials/hello-world-api-gateway.html) 4 | 5 | -------------------------------------------------------------------------------- /docs/intercepting.md: -------------------------------------------------------------------------------- 1 | # Intercepting requests 2 | 3 | API builder allows you to intercept requests and filter or modify them before proceeding with the normal routing process. To do that, call the `intercept` method 4 | 5 | ```javascript 6 | api.intercept(function (event) { ... }); 7 | ``` 8 | 9 | The following rules apply for intercepting requests: 10 | 11 | * stop without executing the request, override response: return an `ApiResponse` object (with full CORS headers if needed) 12 | * stop without executing the request, but don't cause an error: return a falsy value, or a promise resolving to a falsy value 13 | * stop without executing the request, but with an error: throw an exception, or return a promise that rejects 14 | * execute the original request: return the original event, or a promise resolving with the original event 15 | * execute a modified request: return the modified event, or a promise resolving with the modified event 16 | 17 | Check out the [Intercepting Requests Example](https://github.com/claudiajs/example-projects/tree/master/intercepting-requests) to see this in action. 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /docs/post-deploy.md: -------------------------------------------------------------------------------- 1 | # Adding generic post-deploy steps 2 | 3 | If you need to configure the API automatically (for example execute calls to third parties to set up webhooks and so on), add a post-deploy step to your API. The syntax is: 4 | 5 | ```javascript 6 | api.addPostDeployStep(stepName, function (commandLineOptions, lambdaProperties, utils) {} ) 7 | ``` 8 | 9 | * `stepName`: `string` – a unique post-deploy step name, that will be used to report results. 10 | * `commandLineOptions`: `object` – key-value pairs of options passed to Claudia from the command line. Use this to detect, for example, if the user required re-configuring the API, or to pass parameters to the configuration function from the command line 11 | * `lambdaProperties`: `object` – contains the following keys 12 | * `name`: `string` – the lambda function name 13 | * `alias`: `string` – the active lambda version alias. Use this as the stage name if you want to configure stage variables 14 | * `apiId`: `string` – the API Gateway API ID 15 | * `apiUrl`: `string` – the root URL of the API, accessible from the web 16 | * `region`: `string` – the AWS Region where the Lambda and API are deployed 17 | * `utils`: `object` – key-value hash containing utility objects that you can use to simplify common tasks without introducing additional dependencies to your API project 18 | * `Promise`: the A+ Promise implementation used by Claudia 19 | * `aws`: the AWS SDK object, initialised with the login details of the current user (note that the JavaScript API does not initialise the Region property by default, so you may need to pass that to an individual service when you use it). 20 | * `apiGatewayPromise`: a promisified version of the ApiGateway SDK (so instead of `createDeployment` that requires a callback, you can use `createDeploymentPromise` that returns a `Promise`). This object also takes care of AWS rate limits and automatically retries in case of `TooManyRequests` exceptions, so if you want to execute API gateway configuration calls from the post-deploy step, it's generally better to use this object instead of creating your own service instance. If you don't want this wrapping, just create a plain `ApiGateway` object using the `utils.aws` field. 21 | 22 | The post-deploy step method can return a string or an object, or a Promise returning a string or an object. If it returns a Promise, the deployment will pause until the Promise resolves. In case of multiple post-deployment steps, they get executed in sequence, not concurrently. Any values returned from the method, or resolved by the Promise, will be included in the final installation report presented to the users. So you can take advantage of this, for example, to provide configuration information for third-party components that users need to set up manually. 23 | 24 | Note that the sequence of post-deployment steps is not guaranteed. Create isolated steps, don't assume any particular order between them. 25 | 26 | To see this in action, see the [Post-deploy](https://github.com/claudiajs/example-projects/tree/master/web-api-postdeploy) example project. 27 | 28 | -------------------------------------------------------------------------------- /docs/request-object.md: -------------------------------------------------------------------------------- 1 | # The API Builder Request Object 2 | 3 | Claudia will automatically bundle all the parameters and pass it to your handler, so you do not have to define request and response models or worry about query strings and body parsing. 4 | 5 | ```javascript 6 | var ApiBuilder = require('claudia-api-builder'), 7 | api = new ApiBuilder(); 8 | api.get('/', function (request) { 9 | 10 | }); 11 | ``` 12 | 13 | Note that the Claudia Request Object differs from the API Gateway Proxy request, although they have similar properties. The reasons are historic - we created the API Builder project before API Gateway had the support for proxy requests, and we kept using the legacy structure by default for backwards compatibility. For greenfield projects, you can also [use the API Gateway proxy object directly](api-gateway-proxy-request.md). 14 | 15 | The `request` object passed to your handler contains the following properties: 16 | 17 | * `queryString`: a key-value map of query string arguments 18 | * `env`: a key-value map of the API Gateway stage variables, optionally merged with Lambda environment variables (see [Environment Variables](#environment-variables)) 19 | * `headers`: a key-value map of all the HTTP headers posted by the client (header names have the same case as in the request) 20 | * `normalizedHeaders`: a key-value map of all the HTTP headers posted by the client (header names are lowercased for easier processing) 21 | * `post`: in case of a FORM post (`application/x-www-form-urlencoded`), a key-value map of the values posted 22 | * `body`: in case of an `application/json`, the body of the request, parsed as a JSON object; in case of `application/xml` or `text/plain` POST or PUT, the body of the request as a string. In case of binary content, a `Buffer`. 23 | * `rawBody`: the unparsed body of the request as a string 24 | * `pathParams`: arguments from dynamic path parameter mappings (such as '/people/{name}') 25 | * `lambdaContext`: the [Lambda Context object](http://docs.aws.amazon.com/lambda/latest/dg/nodejs-prog-model-context.html) for the active request 26 | * `context`: a key-value map of elements from the API Gateway context, see the [$context variable](http://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html#context-variable-reference) documentation for more info on individual fields 27 | * `method`: HTTP invocation method 28 | * `path`: the active resource path (will include generic path components, eg /people/{name}) 29 | * `stage` : API Gateway stage 30 | * `sourceIp`: Source IP 31 | * `accountId`: identity account ID 32 | * `user` : user identity from the context 33 | * `userAgent` : user agent from the API Gateway context 34 | * `userArn` : user ARN from the API Gateway context 35 | * `caller` : caller identity 36 | * `apiKey`: API key used for the call 37 | * `authorizerPrincipalId` 38 | * `cognitoAuthenticationProvider` 39 | * `cognitoAuthenticationType` 40 | * `cognitoIdentityId` 41 | * `cognitoIdentityPoolId` 42 | 43 | 44 | -------------------------------------------------------------------------------- /docs/variables.md: -------------------------------------------------------------------------------- 1 | ## Configuring the API 2 | 3 | AWS Lambda and API Gateway have two methods of storing typical environment configuration: 4 | 5 | * Stage variables exist in the API gateway, separate for each stage (such as `dev` or `test`), and are added to each request as it passes through from a client to Lambda 6 | * Lambda environment variables exist in the Lambda process container, and they are configured for a numerical deployment (so if two labels such as `dev` and `prod` point to the same numerical deployment, they share the same environment variables). 7 | 8 | See the [Managing Lambda Versions](https://claudiajs.com/tutorials/versions.html) tutorial for an in-depth comparison of the two types of variables. 9 | 10 | When using Claudia API Builder, the `request.env` object contains by default only the stage variables, which are specific to the request. You can read the Lambda environment variables from `process.env`. To make it easier to use Lambda environment variables as well, you can use the `mergeVars` option (since `claudia-api-builder` 2.5.1) of the API Builder and get everything in `request.env`. 11 | 12 | ```javascript 13 | api = new ApiBuilder({mergeVars: true}); 14 | api.post('/upload', function (request) { 15 | // request.env now contains both stage and process.env vars 16 | } 17 | ``` 18 | 19 | The following rules apply when merging: 20 | 21 | * If the same key exists both in Lambda environment variables and Stage variables, stage variable wins (so you can override global config with stage-specific values) 22 | * Any process.env variables that start with a prefix of the stage name with an underscore are copied into the key without that prefix, so you can easily keep different environment variables for testing and production and Api Builder will load the correct ones. 23 | 24 | For example, if you've set the following variables on Lambda: 25 | 26 | ```bash 27 | dev_DB_NAME=dev-db 28 | dev_LOG_LEVEL=INFO 29 | prod_DB_NAME=prod-db 30 | APP_NAME=Lovely App 31 | MESSAGE_PREFIX=LA_1 32 | ``` 33 | 34 | and the following variables in the `dev` stage: 35 | 36 | ```bash 37 | LOG_LEVEL=debug 38 | APP_NAME=Lovely Debug 39 | ``` 40 | 41 | Without `{mergeVars:true}`, the handlers will get just LOG_LEVEL and APP_NAME in `request.env`, directly from stage variables. With `{mergeVars:true}`, the `request.env` object for the `dev` stage will look like this: 42 | 43 | ```bash 44 | DB_NAME=dev-db # from process.env, because it had a dev_ prefix 45 | APP_NAME=Lovely Debug # stage var win over non-prefixed lambda vars 46 | LOG_LEVEL=debug # stage vars win over prefixed lambda env vars 47 | MESSAGE_PREFIX=LA_1 # env var, no prefixed version to override it 48 | ``` 49 | 50 | ## Configuring stage variables using post-deployment steps 51 | 52 | If your API depends on configuration in stage variables, you can automate the configuration process during deployment. Claudia will then enable users to configure the variable value either from the command line, or by prompting interactively during deployment. The syntax is: 53 | 54 | ```javascript 55 | api.addPostDeployConfig(stageVarName, prompt, configOption); 56 | ``` 57 | 58 | * `stageVarName`: `string` – the name of the stage variable you want to configure. To stay safe, use alphanumeric characters only, API Gateway does not allow special characters in variable names 59 | * `prompt`: `string` – the text to display when prompting the users to interactively enter the variable 60 | * `configOption`: `string` – the name of the command-line option that will be used as a flag for the configuration process. 61 | 62 | 63 | If the configuration option is provided as a string, the value is automatically sent to AWS for the current stage without prompting. If the configuration option is provided without value, API Builder will ask the users to interactively enter it. Here's an example: 64 | 65 | ```javascript 66 | api.addPostDeployConfig('message', 'Enter a message:', 'custom-message'); 67 | ``` 68 | 69 | In this case, `message` is the name of the stage variable (it will be available as `request.env.message` later). `Enter a message:` is the prompt that the users will see during deployment, and `custom-message` is the configuration option required to trigger the step. When an API contains that line, you can make Claudia ask you to define the stage variable by running 70 | 71 | ```bash 72 | claudia update --custom-message 73 | ``` 74 | 75 | Likewise, you can provide the value directly in the command line for unattended operation 76 | 77 | ```bash 78 | claudia update --custom-message Ping 79 | ``` 80 | 81 | To see this in action, see the [Post-deploy configuration](https://github.com/claudiajs/example-projects/tree/master/web-api-postdeploy-configuration) example project. 82 | 83 | Note that the sequence of post-deployment steps is not guaranteed. Create isolated steps, don't assume any particular order between them. 84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /*global module, require*/ 2 | module.exports = require('./src/api-builder'); 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "claudia-api-builder", 3 | "version": "4.1.2", 4 | "description": "Simplify AWS ApiGateway handling", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/claudiajs/claudia-api-builder.git" 9 | }, 10 | "main": "index.js", 11 | "keywords": [ 12 | "claudia", 13 | "aws", 14 | "lambda", 15 | "apigateway", 16 | "s3", 17 | "api", 18 | "microservices", 19 | "serverless" 20 | ], 21 | "bugs": { 22 | "url": "https://github.com/claudiajs/claudia-api-builder/issues" 23 | }, 24 | "homepage": "http://claudiajs.com", 25 | "files": [ 26 | "src", 27 | "*.md", 28 | "*.js" 29 | ], 30 | "scripts": { 31 | "pretest": "eslint .", 32 | "test": "node spec/support/jasmine-runner.js", 33 | "debug": "node debug spec/support/jasmine-runner.js" 34 | }, 35 | "author": "Gojko Adzic http://gojko.net", 36 | "devDependencies": { 37 | "eslint": "^4.19.1", 38 | "eslint-config-crockford": "^0.2.0", 39 | "eslint-config-defaults": "^9.0.0", 40 | "jasmine": "^2.5.2", 41 | "jasmine-spec-reporter": "^2.7.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /spec/api-builder-spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, expect, jasmine, require, beforeEach, afterEach */ 2 | const ApiBuilder = require('../src/api-builder'), 3 | convertApiGWProxyRequest = require('../src/convert-api-gw-proxy-request'); 4 | describe('ApiBuilder', () => { 5 | 'use strict'; 6 | let underTest, requestHandler, lambdaContext, requestPromise, requestResolve, requestReject, 7 | postRequestHandler, prompter, logger; 8 | const responseHeaders = function (headerName) { 9 | const headers = lambdaContext.done.calls.argsFor(0)[1].headers; 10 | if (headerName) { 11 | return headers[headerName]; 12 | } else { 13 | return headers; 14 | } 15 | }, 16 | contentType = function () { 17 | return responseHeaders('Content-Type'); 18 | }, 19 | responseStatusCode = function () { 20 | return lambdaContext.done.calls.argsFor(0)[1].statusCode; 21 | }, 22 | responseBase64Flag = function () { 23 | return lambdaContext.done.calls.argsFor(0)[1].isBase64Encoded; 24 | }, 25 | responseBody = function () { 26 | return lambdaContext.done.calls.argsFor(0)[1].body; 27 | }; 28 | 29 | beforeEach(() => { 30 | prompter = jasmine.createSpy(); 31 | logger = jasmine.createSpy(); 32 | underTest = new ApiBuilder({prompter: prompter, logger: logger}); 33 | requestHandler = jasmine.createSpy('handler'); 34 | postRequestHandler = jasmine.createSpy('postHandler'); 35 | lambdaContext = jasmine.createSpyObj('lambdaContext', ['done']); 36 | requestPromise = new Promise(function (resolve, reject) { 37 | requestResolve = resolve; 38 | requestReject = reject; 39 | }); 40 | }); 41 | describe('methods', () => { 42 | it('should include a `get` method', () => { 43 | expect(typeof underTest.get).toEqual('function'); 44 | }); 45 | it('should include a `put` method', () => { 46 | expect(typeof underTest.put).toEqual('function'); 47 | }); 48 | it('should include a `post` method', () => { 49 | expect(typeof underTest.post).toEqual('function'); 50 | }); 51 | it('should include a `delete` method', () => { 52 | expect(typeof underTest.delete).toEqual('function'); 53 | }); 54 | it('should include a `head` method', () => { 55 | expect(typeof underTest.head).toEqual('function'); 56 | }); 57 | it('should include a `patch` method', () => { 58 | expect(typeof underTest.patch).toEqual('function'); 59 | }); 60 | it('should include a `any` method', () => { 61 | expect(typeof underTest.any).toEqual('function'); 62 | }); 63 | }); 64 | describe('configuration', () => { 65 | it('carries version 4', () => { 66 | expect(underTest.apiConfig().version).toEqual(4); 67 | }); 68 | it('can configure a single GET method', () => { 69 | underTest.get('/echo', requestHandler); 70 | expect(underTest.apiConfig().routes).toEqual({ 71 | 'echo': { 'GET': {}} 72 | }); 73 | }); 74 | it('can configure a single route with multiple methods', () => { 75 | underTest.get('/echo', requestHandler); 76 | underTest.post('/echo', postRequestHandler); 77 | expect(underTest.apiConfig().routes).toEqual({ 78 | 'echo': {'GET': {}, 'POST': {}} 79 | }); 80 | }); 81 | it('can override existing route', () => { 82 | underTest.get('/echo', requestHandler); 83 | underTest.get('/echo', postRequestHandler); 84 | expect(underTest.apiConfig().routes).toEqual({ 85 | 'echo': { 'GET': {}} 86 | }); 87 | }); 88 | it('can accept a route without a slash', () => { 89 | underTest.get('echo', requestHandler); 90 | expect(underTest.apiConfig().routes).toEqual({ 91 | 'echo': { 'GET': {}} 92 | }); 93 | }); 94 | it('can accept routes in mixed case', () => { 95 | underTest.get('EcHo', requestHandler); 96 | expect(underTest.apiConfig().routes).toEqual({ 97 | 'EcHo': { 'GET': {}} 98 | }); 99 | }); 100 | it('records options', () => { 101 | underTest.get('echo', requestHandler, {errorCode: 403}); 102 | expect(underTest.apiConfig().routes).toEqual({ 103 | 'echo': { 'GET': {errorCode: 403}} 104 | }); 105 | }); 106 | }); 107 | describe('router', () => { 108 | ['GET', 'PUT', 'POST', 'DELETE', 'PATCH', 'HEAD'].forEach(function (method) { 109 | it(`can route calls to a ${method} method`, done => { 110 | const apiRequest = { 111 | context: { 112 | path: '/test', 113 | method: method 114 | }, 115 | queryString: { 116 | a: 'b' 117 | } 118 | }; 119 | underTest[method.toLowerCase()]('/test', requestHandler); 120 | underTest.router(apiRequest, lambdaContext) 121 | .then(() => expect(requestHandler).toHaveBeenCalledWith(apiRequest, lambdaContext)) 122 | .then(done, done.fail); 123 | }); 124 | }); 125 | }); 126 | describe('proxyRouter', () => { 127 | let proxyRequest, apiRequest; 128 | beforeEach(() => { 129 | proxyRequest = { 130 | queryStringParameters: { 131 | 'a': 'b' 132 | }, 133 | requestContext: { 134 | resourcePath: '/', 135 | httpMethod: 'GET' 136 | } 137 | }; 138 | apiRequest = convertApiGWProxyRequest(proxyRequest, lambdaContext); 139 | underTest.get('/', requestHandler); 140 | }); 141 | it('converts API gateway proxy requests then routes call', done => { 142 | underTest.proxyRouter(proxyRequest, lambdaContext) 143 | .then(() => expect(requestHandler).toHaveBeenCalledWith(apiRequest, lambdaContext)) 144 | .then(done, done.fail); 145 | }); 146 | it('converts the request if request format = CLAUDIA_API_BUILDER', done => { 147 | underTest = new ApiBuilder({requestFormat: 'CLAUDIA_API_BUILDER'}); 148 | underTest.get('/', requestHandler); 149 | underTest.proxyRouter(proxyRequest, lambdaContext) 150 | .then(() => { 151 | expect(requestHandler).toHaveBeenCalledWith(jasmine.objectContaining({ 152 | lambdaContext: lambdaContext, 153 | proxyRequest: proxyRequest, 154 | queryString: { a: 'b' } 155 | }), lambdaContext); 156 | }) 157 | .then(done, done.fail); 158 | }); 159 | describe('variable merging', () => { 160 | let oldPe; 161 | beforeEach(() => { 162 | proxyRequest.requestContext.stage = 'stg1'; 163 | proxyRequest.stageVariables = { 164 | 'from_stage': 'stg', 165 | 'in_both': 'stg' 166 | }; 167 | oldPe = process.env; 168 | process.env = { 169 | stg1_from_process: 'pcs', 170 | stg1_in_both: 'pcs', 171 | global_process: 'pcs' 172 | }; 173 | }); 174 | afterEach(() => { 175 | process.env = oldPe; 176 | }); 177 | it('merges variables if options.mergeVars is set', done => { 178 | underTest = new ApiBuilder({mergeVars: true}); 179 | underTest.get('/', requestHandler); 180 | underTest.proxyRouter(proxyRequest, lambdaContext) 181 | .then(() => { 182 | expect(requestHandler).toHaveBeenCalledWith(jasmine.objectContaining({ 183 | env: { 184 | 'from_process': 'pcs', 185 | 'from_stage': 'stg', 186 | 'in_both': 'stg', 187 | 'global_process': 'pcs', 188 | 'stg1_from_process': 'pcs', 189 | 'stg1_in_both': 'pcs' 190 | } 191 | }), lambdaContext); 192 | }) 193 | .then(done, done.fail); 194 | }); 195 | it('does not merge variables if options.mergeVars is not set', done => { 196 | underTest = new ApiBuilder(); 197 | underTest.get('/', requestHandler); 198 | underTest.proxyRouter(proxyRequest, lambdaContext) 199 | .then(() => { 200 | expect(requestHandler).toHaveBeenCalledWith(jasmine.objectContaining({ 201 | env: { 202 | 'from_stage': 'stg', 203 | 'in_both': 'stg' 204 | } 205 | }), lambdaContext); 206 | }) 207 | .then(done, done.fail); 208 | }); 209 | 210 | }); 211 | it('responds with invalid request if conversion fails', done => { 212 | underTest = new ApiBuilder({requestFormat: 'CLAUDIA_API_BUILDER'}); 213 | underTest.get('/', requestHandler); 214 | proxyRequest.headers = { 215 | 'Content-Type': 'application/json' 216 | }; 217 | proxyRequest.body = 'birthyear=1905&press=%20OK%20'; 218 | underTest.proxyRouter(proxyRequest, lambdaContext) 219 | .then(() => { 220 | expect(responseStatusCode()).toEqual(500); 221 | expect(responseBody()).toEqual('The content does not match the supplied content type'); 222 | }) 223 | .then(done, done.fail); 224 | }); 225 | it('does not convert the request before routing if requestFormat = AWS_PROXY', done => { 226 | underTest = new ApiBuilder({requestFormat: 'AWS_PROXY'}); 227 | underTest.get('/', requestHandler); 228 | underTest.proxyRouter(proxyRequest, lambdaContext) 229 | .then(() => expect(requestHandler).toHaveBeenCalledWith(proxyRequest, lambdaContext)) 230 | .then(done, done.fail); 231 | }); 232 | ['GET', 'PUT', 'POST', 'DELETE', 'PATCH', 'HEAD'].forEach(function (method) { 233 | it(`can route calls to a ${method} method`, done => { 234 | proxyRequest.requestContext.httpMethod = method; 235 | proxyRequest.requestContext.resourcePath = '/test'; 236 | apiRequest.context.method = method; 237 | apiRequest.context.path = '/test'; 238 | underTest[method.toLowerCase()]('/test', requestHandler); 239 | underTest.proxyRouter(proxyRequest, lambdaContext) 240 | .then(() => expect(requestHandler).toHaveBeenCalledWith(apiRequest, lambdaContext)) 241 | .then(done, done.fail); 242 | }); 243 | }); 244 | }); 245 | describe('routing to ANY', () => { 246 | let proxyRequest, apiRequest, genericHandler, specificHandler; 247 | ['GET', 'PUT', 'POST', 'DELETE', 'PATCH', 'HEAD'].forEach(method => { 248 | describe(`when using ${method}`, () => { 249 | beforeEach(() => { 250 | proxyRequest = { 251 | queryStringParameters: { 252 | 'a': 'b' 253 | }, 254 | requestContext: { 255 | resourcePath: '/test1', 256 | httpMethod: method 257 | } 258 | }; 259 | genericHandler = jasmine.createSpy('genericHandler'); 260 | specificHandler = jasmine.createSpy('specificHandler'); 261 | apiRequest = convertApiGWProxyRequest(proxyRequest, lambdaContext); 262 | underTest.any('/test1', genericHandler); 263 | }); 264 | it('routes to the generic handler if it is set up and no handler is defined for the actual method', done => { 265 | underTest.proxyRouter(proxyRequest, lambdaContext) 266 | .then(() => expect(genericHandler).toHaveBeenCalledWith(apiRequest, lambdaContext)) 267 | .then(done, done.fail); 268 | }); 269 | it('routes to specific method handler over a generic handler', done => { 270 | underTest[method.toLowerCase()]('/test1', specificHandler); 271 | underTest.proxyRouter(proxyRequest, lambdaContext) 272 | .then(() => { 273 | expect(specificHandler).toHaveBeenCalledWith(apiRequest, lambdaContext); 274 | expect(genericHandler).not.toHaveBeenCalled(); 275 | }) 276 | .then(done, done.fail); 277 | }); 278 | it('reports all methods as allowed for CORS if a generic handler is set', done => { 279 | proxyRequest.requestContext.httpMethod = 'OPTIONS'; 280 | underTest.proxyRouter(proxyRequest, lambdaContext) 281 | .then(() => expect(responseHeaders('Access-Control-Allow-Methods')).toEqual('DELETE,GET,HEAD,PATCH,POST,PUT,OPTIONS')) 282 | .then(done, done.fail); 283 | }); 284 | it('does not duplicate methods in CORS headers if both specific and generic handlers are set', done => { 285 | underTest[method.toLowerCase()]('/test1', specificHandler); 286 | proxyRequest.requestContext.httpMethod = 'OPTIONS'; 287 | underTest.proxyRouter(proxyRequest, lambdaContext) 288 | .then(() => expect(responseHeaders('Access-Control-Allow-Methods')).toEqual('DELETE,GET,HEAD,PATCH,POST,PUT,OPTIONS')) 289 | .then(done, done.fail); 290 | }); 291 | }); 292 | }); 293 | }); 294 | describe('call execution', () => { 295 | let apiRequest, proxyRequest; 296 | beforeEach(() => { 297 | underTest.get('/echo', requestHandler); 298 | proxyRequest = { 299 | requestContext: { 300 | resourcePath: '/echo', 301 | httpMethod: 'GET' 302 | } 303 | }; 304 | apiRequest = convertApiGWProxyRequest(proxyRequest, lambdaContext); 305 | }); 306 | 307 | describe('routing calls', () => { 308 | it('can route to /', done => { 309 | underTest.get('/', postRequestHandler); 310 | proxyRequest.requestContext.resourcePath = apiRequest.context.path = '/'; 311 | underTest.proxyRouter(proxyRequest, lambdaContext) 312 | .then(() => expect(postRequestHandler).toHaveBeenCalledWith(apiRequest, lambdaContext)) 313 | .then(done, done.fail); 314 | }); 315 | it('complains about an unsupported route', done => { 316 | proxyRequest.requestContext.resourcePath = apiRequest.context.path = '/no'; 317 | underTest.proxyRouter(proxyRequest, lambdaContext) 318 | .then(() => expect(lambdaContext.done).toHaveBeenCalledWith('no handler for GET /no')) 319 | .then(done, done.fail); 320 | }); 321 | it('complains about an unsupported call', done => { 322 | underTest.proxyRouter({}, lambdaContext) 323 | .then(() => expect(lambdaContext.done).toHaveBeenCalledWith('event does not contain routing information')) 324 | .then(done, done.fail); 325 | }); 326 | it('can route calls to a single GET method', done => { 327 | underTest.proxyRouter(proxyRequest, lambdaContext) 328 | .then(() => expect(requestHandler).toHaveBeenCalledWith(apiRequest, lambdaContext)) 329 | .then(done, done.fail); 330 | }); 331 | it('can route calls in mixed case', done => { 332 | underTest.get('/CamelCase', postRequestHandler); 333 | proxyRequest.requestContext.resourcePath = apiRequest.context.path = '/CamelCase'; 334 | underTest.proxyRouter(proxyRequest, lambdaContext) 335 | .then(() => expect(postRequestHandler).toHaveBeenCalledWith(apiRequest, lambdaContext)) 336 | .then(done, done.fail); 337 | }); 338 | it('can route calls configured without a slash', done => { 339 | underTest.post('echo', postRequestHandler); 340 | proxyRequest.requestContext.httpMethod = apiRequest.context.method = 'POST'; 341 | underTest.proxyRouter(proxyRequest, lambdaContext) 342 | .then(() => { 343 | expect(postRequestHandler).toHaveBeenCalledWith(apiRequest, lambdaContext); 344 | expect(requestHandler).not.toHaveBeenCalled(); 345 | }) 346 | .then(done, done.fail); 347 | }); 348 | it('can route to multiple methods', done => { 349 | underTest.post('/echo', postRequestHandler); 350 | proxyRequest.requestContext.httpMethod = apiRequest.context.method = 'POST'; 351 | underTest.proxyRouter(proxyRequest, lambdaContext) 352 | .then(() => { 353 | expect(postRequestHandler).toHaveBeenCalledWith(apiRequest, lambdaContext); 354 | expect(requestHandler).not.toHaveBeenCalled(); 355 | }) 356 | .then(done, done.fail); 357 | }); 358 | it('can route to multiple routes', done => { 359 | underTest.post('/echo2', postRequestHandler); 360 | proxyRequest.requestContext.resourcePath = apiRequest.context.path = '/echo2'; 361 | proxyRequest.requestContext.httpMethod = apiRequest.context.method = 'POST'; 362 | underTest.proxyRouter(proxyRequest, lambdaContext) 363 | .then(() => { 364 | expect(postRequestHandler).toHaveBeenCalledWith(apiRequest, lambdaContext); 365 | expect(requestHandler).not.toHaveBeenCalled(); 366 | }) 367 | .then(done, done.fail); 368 | }); 369 | }); 370 | describe('response processing', () => { 371 | describe('synchronous', () => { 372 | it('can handle synchronous exceptions in the routed method', done => { 373 | requestHandler.and.throwError('Error'); 374 | underTest.proxyRouter(proxyRequest, lambdaContext) 375 | .then(() => expect(responseStatusCode()).toEqual(500)) 376 | .then(done, done.fail); 377 | }); 378 | it('can handle successful synchronous results from the request handler', done => { 379 | requestHandler.and.returnValue({hi: 'there'}); 380 | underTest.proxyRouter(proxyRequest, lambdaContext) 381 | .then(() => expect(responseStatusCode()).toEqual(200)) 382 | .then(done, done.fail); 383 | }); 384 | }); 385 | describe('asynchronous', () => { 386 | it('waits for promises to resolve or reject before responding', done => { 387 | requestHandler.and.callFake(() => { 388 | expect(requestHandler).toHaveBeenCalled(); 389 | expect(lambdaContext.done).not.toHaveBeenCalled(); 390 | done(); 391 | return new Promise(() => false); 392 | }); 393 | underTest.proxyRouter(proxyRequest, lambdaContext) 394 | .then(done.fail, done.fail); 395 | }); 396 | 397 | it('synchronously handles plain objects that have a then key, but are not promises', done => { 398 | requestHandler.and.returnValue({then: 1}); 399 | underTest.proxyRouter(proxyRequest, lambdaContext) 400 | .then(() => expect(responseStatusCode()).toEqual(200)) 401 | .then(done, done.fail); 402 | }); 403 | it('handles request promise rejecting', done => { 404 | requestHandler.and.returnValue(requestPromise); 405 | underTest.proxyRouter(proxyRequest, lambdaContext) 406 | .then(() => expect(responseStatusCode()).toEqual(500)) 407 | .then(done, done.fail); 408 | requestReject('Abort'); 409 | }); 410 | it('handles request promise resolving', done => { 411 | requestHandler.and.returnValue(requestPromise); 412 | underTest.proxyRouter(proxyRequest, lambdaContext) 413 | .then(() => expect(responseStatusCode()).toEqual(200)) 414 | .then(done, done.fail); 415 | requestResolve({hi: 'there'}); 416 | }); 417 | }); 418 | }); 419 | 420 | describe('result packaging', () => { 421 | describe('error handling', () => { 422 | beforeEach(() => { 423 | requestHandler.and.throwError('Oh!'); 424 | }); 425 | 426 | describe('status code', () => { 427 | it('uses 500 by default', done => { 428 | underTest.proxyRouter(proxyRequest, lambdaContext) 429 | .then(() => expect(responseStatusCode()).toEqual(500)) 430 | .then(done, done.fail); 431 | }); 432 | it('can configure code with handler error as a number', done => { 433 | underTest.get('/echo', requestHandler, { 434 | error: 404 435 | }); 436 | underTest.proxyRouter(proxyRequest, lambdaContext) 437 | .then(() => expect(responseStatusCode()).toEqual(404)) 438 | .then(done, done.fail); 439 | }); 440 | it('can configure code with handler error as an object key', done => { 441 | underTest.get('/echo', requestHandler, { 442 | error: { code: 404 } 443 | }); 444 | underTest.proxyRouter(proxyRequest, lambdaContext) 445 | .then(() => expect(responseStatusCode()).toEqual(404)) 446 | .then(done, done.fail); 447 | }); 448 | it('uses a default if handler error is defined as an object, but without code', done => { 449 | underTest.get('/echo', requestHandler, { 450 | error: { contentType: 'text/plain' } 451 | }); 452 | underTest.proxyRouter(proxyRequest, lambdaContext) 453 | .then(() => expect(responseStatusCode()).toEqual(500)) 454 | .then(done, done.fail); 455 | }); 456 | it('uses dynamic response code if provided', done => { 457 | requestHandler.and.returnValue(Promise.reject(new underTest.ApiResponse('', {}, 403))); 458 | underTest.proxyRouter(proxyRequest, lambdaContext) 459 | .then(() => expect(responseStatusCode()).toEqual(403)) 460 | .then(done, done.fail); 461 | }); 462 | it('uses dynamic response code over static definitions', done => { 463 | requestHandler.and.returnValue(Promise.reject(new underTest.ApiResponse('', {}, 503))); 464 | underTest.get('/echo', requestHandler, { 465 | error: 404 466 | }); 467 | underTest.proxyRouter(proxyRequest, lambdaContext) 468 | .then(() => expect(responseStatusCode()).toEqual(503)) 469 | .then(done, done.fail); 470 | }); 471 | it('uses a static definition with ApiResponse if code is not set', done => { 472 | underTest.get('/echo', requestHandler, { 473 | error: 404 474 | }); 475 | requestHandler.and.returnValue(Promise.reject(new underTest.ApiResponse('', {}))); 476 | underTest.proxyRouter(proxyRequest, lambdaContext) 477 | .then(() => expect(responseStatusCode()).toEqual(404)) 478 | .then(done, done.fail); 479 | }); 480 | it('uses 500 with ApiResponse if code is not set and there is no static override', done => { 481 | requestHandler.and.returnValue(Promise.reject(new underTest.ApiResponse('', {}))); 482 | underTest.proxyRouter(proxyRequest, lambdaContext) 483 | .then(() => expect(responseStatusCode()).toEqual(500)) 484 | .then(done, done.fail); 485 | }); 486 | }); 487 | 488 | /**/ 489 | 490 | describe('header values', () => { 491 | describe('Content-Type', () => { 492 | it('uses application/json as the content type by default', done => { 493 | underTest.proxyRouter(proxyRequest, lambdaContext) 494 | .then(() => expect(contentType()).toEqual('application/json')) 495 | .then(done, done.fail); 496 | }); 497 | it('uses content type is specified in the handler config', done => { 498 | underTest.get('/echo', requestHandler, { 499 | error: { contentType: 'text/plain' } 500 | }); 501 | underTest.proxyRouter(proxyRequest, lambdaContext) 502 | .then(() => expect(contentType()).toEqual('text/plain')) 503 | .then(done, done.fail); 504 | }); 505 | it('uses content type specified in a static header', done => { 506 | underTest.get('/echo', requestHandler, { 507 | error: { headers: { 'Content-Type': 'text/plain' } } 508 | }); 509 | underTest.proxyRouter(proxyRequest, lambdaContext) 510 | .then(() => expect(contentType()).toEqual('text/plain')) 511 | .then(done, done.fail); 512 | }); 513 | it('works with mixed case specified in a static header', done => { 514 | underTest.get('/echo', requestHandler, { 515 | error: { headers: { 'conTent-tyPe': 'text/plain' } } 516 | }); 517 | underTest.proxyRouter(proxyRequest, lambdaContext) 518 | .then(() => expect(contentType()).toEqual('text/plain')) 519 | .then(done, done.fail); 520 | }); 521 | it('ignores static headers that do not specify content type', done => { 522 | underTest.get('/echo', requestHandler, { 523 | error: { headers: { 'a-api-type': 'text/plain' } } 524 | }); 525 | underTest.proxyRouter(proxyRequest, lambdaContext) 526 | .then(() => expect(contentType()).toEqual('application/json')) 527 | .then(done, done.fail); 528 | }); 529 | it('ignores enumerated headers - backwards compatibility', done => { 530 | underTest.get('/echo', requestHandler, { 531 | error: { headers: ['a-api-type', 'text/plain'] } 532 | }); 533 | underTest.proxyRouter(proxyRequest, lambdaContext) 534 | .then(() => expect(contentType()).toEqual('application/json')) 535 | .then(done, done.fail); 536 | }); 537 | it('uses content type specified in a dynamic header', done => { 538 | requestHandler.and.returnValue(Promise.reject(new underTest.ApiResponse('', {'Content-Type': 'text/xml'}))); 539 | underTest.proxyRouter(proxyRequest, lambdaContext) 540 | .then(() => expect(contentType()).toEqual('text/xml')) 541 | .then(done, done.fail); 542 | }); 543 | it('works with mixed case specified in dynamic header', done => { 544 | requestHandler.and.returnValue(Promise.reject(new underTest.ApiResponse('', {'Content-Type': 'text/xml'}))); 545 | underTest.proxyRouter(proxyRequest, lambdaContext) 546 | .then(() => expect(contentType()).toEqual('text/xml')) 547 | .then(done, done.fail); 548 | }); 549 | it('uses dynamic header over everything else', done => { 550 | underTest.get('/echo', requestHandler, { 551 | error: { contentType: 'text/xml', headers: { 'Content-Type': 'text/plain' } } 552 | }); 553 | requestHandler.and.returnValue(Promise.reject(new underTest.ApiResponse('', {'Content-Type': 'text/markdown'}))); 554 | underTest.proxyRouter(proxyRequest, lambdaContext) 555 | .then(() => expect(contentType()).toEqual('text/markdown')) 556 | .then(done, done.fail); 557 | }); 558 | it('uses static header over handler config', done => { 559 | underTest.get('/echo', requestHandler, { 560 | error: { contentType: 'text/xml', headers: { 'Content-Type': 'text/plain' } } 561 | }); 562 | underTest.proxyRouter(proxyRequest, lambdaContext) 563 | .then(() => expect(contentType()).toEqual('text/plain')) 564 | .then(done, done.fail); 565 | }); 566 | }); 567 | describe('CORS headers', () => { 568 | it('automatically includes CORS headers with the response', done => { 569 | underTest.proxyRouter(proxyRequest, lambdaContext) 570 | .then(() => { 571 | expect(responseHeaders()).toEqual(jasmine.objectContaining({ 572 | 'Access-Control-Allow-Origin': '*', 573 | 'Access-Control-Allow-Headers': 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token', 574 | 'Access-Control-Allow-Methods': 'GET,OPTIONS' 575 | })); 576 | }) 577 | .then(done, done.fail); 578 | }); 579 | it('uses custom origin if provided', done => { 580 | underTest.corsOrigin('blah.com'); 581 | underTest.proxyRouter(proxyRequest, lambdaContext) 582 | .then(() => { 583 | expect(responseHeaders()).toEqual(jasmine.objectContaining({ 584 | 'Access-Control-Allow-Origin': 'blah.com', 585 | 'Access-Control-Allow-Headers': 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token', 586 | 'Access-Control-Allow-Methods': 'GET,OPTIONS' 587 | })); 588 | }) 589 | .then(done, done.fail); 590 | }); 591 | it('uses custom Allow-Headers if provided', done => { 592 | underTest.corsHeaders('X-Api-Key1'); 593 | underTest.proxyRouter(proxyRequest, lambdaContext) 594 | .then(() => expect(responseHeaders('Access-Control-Allow-Headers')).toEqual('X-Api-Key1')) 595 | .then(done, done.fail); 596 | }); 597 | it('clears headers if cors is not allowed', done => { 598 | underTest.corsOrigin(false); 599 | underTest.proxyRouter(proxyRequest, lambdaContext) 600 | .then(() => { 601 | const headers = responseHeaders(); 602 | expect (headers.hasOwnProperty('Access-Control-Allow-Origin')).toBeFalsy(); 603 | expect (headers.hasOwnProperty('Access-Control-Allow-Headers')).toBeFalsy(); 604 | expect (headers.hasOwnProperty('Access-Control-Allow-Methods')).toBeFalsy(); 605 | }) 606 | .then(done, done.fail); 607 | }); 608 | it('reports all methods as allowed in case of a {proxy+} route request not matching any configured route', done => { 609 | proxyRequest = { 610 | requestContext: { 611 | resourcePath: '/abc/def', 612 | httpMethod: 'OPTIONS' 613 | } 614 | }; 615 | underTest.corsOrigin(() => 'something.com'); 616 | underTest.proxyRouter(proxyRequest, lambdaContext) 617 | .then(() => { 618 | expect(responseHeaders()).toEqual(jasmine.objectContaining({ 619 | 'Access-Control-Allow-Origin': 'something.com', 620 | 'Access-Control-Allow-Headers': 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token', 621 | 'Access-Control-Allow-Methods': 'DELETE,GET,HEAD,PATCH,POST,PUT,OPTIONS' 622 | })); 623 | }) 624 | .then(done, done.fail); 625 | 626 | }); 627 | }); 628 | describe('static headers', () => { 629 | it('can supply additional static headers in the handler config', done => { 630 | underTest.get('/echo', requestHandler, { 631 | error: { contentType: 'text/xml', headers: { 'Api-Key': 'text123' } } 632 | }); 633 | underTest.proxyRouter(proxyRequest, lambdaContext) 634 | .then(() => expect(responseHeaders('Api-Key')).toEqual('text123')) 635 | .then(done, done.fail); 636 | }); 637 | it('ignores enumerated static headers -- backwards compatibility', done => { 638 | underTest.get('/echo', requestHandler, { 639 | error: { contentType: 'text/xml', headers: ['Api-Key'] } 640 | }); 641 | underTest.proxyRouter(proxyRequest, lambdaContext) 642 | .then(() => expect(responseHeaders('Api-Key')).toBeUndefined()) 643 | .then(done, done.fail); 644 | }); 645 | it('overrides CORS headers with static headers', done => { 646 | underTest.get('/echo', requestHandler, { 647 | error: { contentType: 'text/xml', headers: {'Access-Control-Allow-Origin': 'x.com' } } 648 | }); 649 | underTest.proxyRouter(proxyRequest, lambdaContext) 650 | .then(() => expect(responseHeaders('Access-Control-Allow-Origin')).toEqual('x.com')) 651 | .then(done, done.fail); 652 | }); 653 | }); 654 | describe('dynamic headers', () => { 655 | it('can supply additional dynamic headers in the response', done => { 656 | requestHandler.and.returnValue(Promise.reject(new ApiBuilder.ApiResponse('', {'Api-Type': 'text/markdown'}))); 657 | underTest.proxyRouter(proxyRequest, lambdaContext) 658 | .then(() => expect(responseHeaders('Api-Type')).toEqual('text/markdown')) 659 | .then(done, done.fail); 660 | }); 661 | it('overrides static headers with dynamic headers', done => { 662 | underTest.get('/echo', requestHandler, { 663 | error: { contentType: 'text/xml', headers: { 'Api-Type': '123'} } 664 | }); 665 | requestHandler.and.returnValue(Promise.resolve(new ApiBuilder.ApiResponse('', {'Api-Type': 'text/markdown'}))); 666 | underTest.proxyRouter(proxyRequest, lambdaContext) 667 | .then(() => expect(responseHeaders('Api-Type')).toEqual('text/markdown')) 668 | .then(done, done.fail); 669 | }); 670 | it('overrides CORS headers with dynamic headers', done => { 671 | requestHandler.and.returnValue(Promise.reject(new ApiBuilder.ApiResponse('', {'Access-Control-Allow-Origin': 'x.com'}))); 672 | underTest.proxyRouter(proxyRequest, lambdaContext) 673 | .then(() => expect(responseHeaders('Access-Control-Allow-Origin')).toEqual('x.com')) 674 | .then(done, done.fail); 675 | }); 676 | }); 677 | describe('when result code is a redirect', () => { 678 | beforeEach(() => { 679 | requestHandler.and.callFake(() => { 680 | throw 'https://www.google.com'; 681 | }); 682 | }); 683 | it('packs the result into the location header', done => { 684 | underTest.get('/echo', requestHandler, { 685 | error: 302 686 | }); 687 | underTest.proxyRouter(proxyRequest, lambdaContext) 688 | .then(() => expect(responseHeaders('Location')).toEqual('https://www.google.com')) 689 | .then(done, done.fail); 690 | }); 691 | it('includes CORS headers', done => { 692 | underTest.get('/echo', requestHandler, { 693 | error: 302 694 | }); 695 | underTest.proxyRouter(proxyRequest, lambdaContext) 696 | .then(() => expect(responseHeaders('Access-Control-Allow-Origin')).toEqual('*')) 697 | .then(done, done.fail); 698 | }); 699 | it('uses the dynamic headers if provided', done => { 700 | underTest.get('/echo', requestHandler, { 701 | error: 302 702 | }); 703 | requestHandler.and.returnValue(Promise.reject(new underTest.ApiResponse('https://www.google.com', {'Location': 'https://www.amazon.com'}))); 704 | underTest.proxyRouter(proxyRequest, lambdaContext) 705 | .then(() => expect(responseHeaders('Location')).toEqual('https://www.amazon.com')) 706 | .then(done, done.fail); 707 | }); 708 | it('uses body of a dynamic response if no location header', done => { 709 | underTest.get('/echo', requestHandler, { 710 | error: 302 711 | }); 712 | requestHandler.and.returnValue(Promise.reject(new underTest.ApiResponse('https://www.google.com', {'X-Val1': 'v2'}))); 713 | underTest.proxyRouter(proxyRequest, lambdaContext) 714 | .then(() => { 715 | expect(responseHeaders('Location')).toEqual('https://www.google.com'); 716 | expect(responseHeaders('X-Val1')).toEqual('v2'); 717 | }) 718 | .then(done, done.fail); 719 | }); 720 | it('uses mixed case dynamic header', done => { 721 | underTest.get('/echo', requestHandler, { 722 | error: 302 723 | }); 724 | requestHandler.and.returnValue(Promise.reject(new underTest.ApiResponse('https://www.google.com', {'LocaTion': 'https://www.amazon.com'}))); 725 | underTest.proxyRouter(proxyRequest, lambdaContext) 726 | .then(() => expect(responseHeaders('Location')).toEqual('https://www.amazon.com')) 727 | .then(done, done.fail); 728 | }); 729 | it('uses the static header if no response body', done => { 730 | underTest.get('/echo', requestHandler, { 731 | error: { code: 302, headers: {'Location': 'https://www.google.com'} } 732 | }); 733 | requestHandler.and.returnValue(Promise.reject()); 734 | underTest.proxyRouter(proxyRequest, lambdaContext) 735 | .then(() => expect(responseHeaders('Location')).toEqual('https://www.google.com')) 736 | .then(done, done.fail); 737 | }); 738 | it('uses the response body over the static header', done => { 739 | underTest.get('/echo', requestHandler, { 740 | error: { code: 302, headers: {'Location': 'https://www.google.com'} } 741 | }); 742 | requestHandler.and.returnValue(Promise.reject('https://www.xkcd.com')); 743 | underTest.proxyRouter(proxyRequest, lambdaContext) 744 | .then(() => expect(responseHeaders('Location')).toEqual('https://www.xkcd.com')) 745 | .then(done, done.fail); 746 | }); 747 | it('uses the dynamic header value over the static header', done => { 748 | underTest.get('/echo', requestHandler, { 749 | error: { code: 302, headers: {'Location': 'https://www.google.com'} } 750 | }); 751 | requestHandler.and.returnValue(Promise.reject(new underTest.ApiResponse('https://www.google.com', {'Location': 'https://www.amazon.com'}))); 752 | underTest.proxyRouter(proxyRequest, lambdaContext) 753 | .then(() => expect(responseHeaders('Location')).toEqual('https://www.amazon.com')) 754 | .then(done, done.fail); 755 | }); 756 | }); 757 | describe('when the result code is 3xx but not a redirect', () => { 758 | it('does not modify the body or the headers', done => { 759 | underTest.get('/echo', requestHandler, { 760 | success: { code: 304 } 761 | }); 762 | requestHandler.and.returnValue({hi: 'there'}); 763 | underTest.proxyRouter(proxyRequest, lambdaContext) 764 | .then(() => { 765 | expect(JSON.parse(responseBody())).toEqual({hi: 'there'}); 766 | expect(responseHeaders('Location')).toBeUndefined(); 767 | }) 768 | .then(done, done.fail); 769 | }); 770 | }); 771 | }); 772 | describe('error logging', () => { 773 | it('logs stack from error objects', done => { 774 | const e = new Error('exploded!'); 775 | underTest.proxyRouter(proxyRequest, lambdaContext) 776 | .then(() => expect(logger).toHaveBeenCalledWith(e.stack)) 777 | .then(done, done.fail); 778 | requestHandler.and.throwError(e); 779 | }); 780 | it('logs string error messages', done => { 781 | underTest.proxyRouter(proxyRequest, lambdaContext) 782 | .then(() => expect(logger).toHaveBeenCalledWith('boom!')) 783 | .then(done, done.fail); 784 | requestHandler.and.callFake(() => { 785 | throw 'boom!'; 786 | }); 787 | }); 788 | it('logs JSON stringify of an API response object', done => { 789 | const apiResp = new underTest.ApiResponse('boom!', {'X-Api': 1}, 404); 790 | underTest.proxyRouter(proxyRequest, lambdaContext) 791 | .then(() => expect(logger).toHaveBeenCalledWith(JSON.stringify(apiResp))) 792 | .then(done, done.fail); 793 | requestHandler.and.returnValue(Promise.reject(apiResp)); 794 | 795 | }); 796 | it('survives circular JSON when logging API response object', done => { 797 | const apiResp = new underTest.ApiResponse('boom!', {'X-Api': 1}, 404); 798 | apiResp.resp = apiResp; 799 | underTest.proxyRouter(proxyRequest, lambdaContext) 800 | .then(() => expect(logger).toHaveBeenCalledWith('[Circular]')) 801 | .then(done, done.fail); 802 | requestHandler.and.returnValue(Promise.reject(apiResp)); 803 | 804 | }); 805 | 806 | }); 807 | describe('result formatting', () => { 808 | ['application/json', 'application/json; charset=UTF-8'].forEach(respContentType => { 809 | describe(`when content type is ${respContentType}`, () => { 810 | beforeEach(() => { 811 | underTest.get('/echo', requestHandler, { 812 | error: { headers: { 'Content-Type': respContentType } } 813 | }); 814 | }); 815 | it('extracts message from Error objects', done => { 816 | underTest.proxyRouter(proxyRequest, lambdaContext) 817 | .then(() => expect(responseBody()).toEqual('{"errorMessage":"boom!"}')) 818 | .then(done, done.fail); 819 | requestHandler.and.throwError('boom!'); 820 | }); 821 | 822 | it('includes string error messages', done => { 823 | underTest.proxyRouter(proxyRequest, lambdaContext) 824 | .then(() => expect(responseBody()).toEqual('{"errorMessage":"boom!"}')) 825 | .then(done, done.fail); 826 | requestHandler.and.callFake(() => { 827 | throw 'boom!'; 828 | }); 829 | }); 830 | it('extracts message from rejected async Errors', done => { 831 | underTest.proxyRouter(proxyRequest, lambdaContext) 832 | .then(() => expect(responseBody()).toEqual('{"errorMessage":"boom!"}')) 833 | .then(done, done.fail); 834 | requestHandler.and.callFake(() => new Promise(() => { 835 | throw new Error('boom!'); 836 | })); 837 | }); 838 | it('extracts message from rejected promises', done => { 839 | underTest.proxyRouter(proxyRequest, lambdaContext) 840 | .then(() => expect(responseBody()).toEqual('{"errorMessage":"boom!"}')) 841 | .then(done, done.fail); 842 | requestHandler.and.returnValue(Promise.reject('boom!')); 843 | }); 844 | it('survives circular JSON', done => { 845 | const circular = {name: 'explosion'}; 846 | circular.circular = circular; 847 | 848 | underTest.proxyRouter(proxyRequest, lambdaContext) 849 | .then(() => expect(responseBody()).toEqual('{"errorMessage":"[Circular]"}')) 850 | .then(done, done.fail); 851 | requestHandler.and.returnValue(Promise.reject(circular)); 852 | }); 853 | it('extracts content from ApiResponse objects', done => { 854 | underTest.proxyRouter(proxyRequest, lambdaContext) 855 | .then(() => { 856 | expect(responseBody()).toEqual('{"errorMessage":"boom!"}'); 857 | expect(responseStatusCode()).toEqual(404); 858 | }).then(done, done.fail); 859 | requestHandler.and.returnValue(Promise.reject(new underTest.ApiResponse({errorMessage: 'boom!'}, {'X-Api': 1}, 404))); 860 | }); 861 | ['', undefined, null, false].forEach(literal => { 862 | it(`uses blank message for [${literal}]`, done => { 863 | underTest.proxyRouter(proxyRequest, lambdaContext) 864 | .then(() => expect(responseBody()).toEqual('{"errorMessage":""}')) 865 | .then(done, done.fail); 866 | requestHandler.and.returnValue(Promise.reject(literal)); 867 | }); 868 | }); 869 | }); 870 | }); 871 | describe('when content type is not JSON', () => { 872 | beforeEach(() => { 873 | underTest.get('/echo', requestHandler, { 874 | error: { headers: { 'Content-Type': 'application/xml' } } 875 | }); 876 | }); 877 | it('extracts message from Error objects', done => { 878 | underTest.proxyRouter(proxyRequest, lambdaContext) 879 | .then(() => expect(responseBody()).toEqual('boom!')) 880 | .then(done, done.fail); 881 | requestHandler.and.throwError('boom!'); 882 | }); 883 | it('includes string error messages', done => { 884 | underTest.proxyRouter(proxyRequest, lambdaContext) 885 | .then(() => expect(responseBody()).toEqual('boom!')) 886 | .then(done, done.fail); 887 | requestHandler.and.callFake(() => { 888 | throw 'boom!'; 889 | }); 890 | }); 891 | it('extracts message from rejected async Errors', done => { 892 | underTest.proxyRouter(proxyRequest, lambdaContext) 893 | .then(() => expect(responseBody()).toEqual('boom!')) 894 | .then(done, done.fail); 895 | requestHandler.and.callFake(() => new Promise(() => { 896 | throw new Error('boom!'); 897 | })); 898 | }); 899 | it('extracts message from rejected promises', done => { 900 | underTest.proxyRouter(proxyRequest, lambdaContext) 901 | .then(() => expect(responseBody()).toEqual('boom!')) 902 | .then(done, done.fail); 903 | requestHandler.and.returnValue(Promise.reject('boom!')); 904 | }); 905 | ['', undefined, null, false].forEach(literal => { 906 | it(`uses blank message for [${literal}]`, done => { 907 | underTest.proxyRouter(proxyRequest, lambdaContext) 908 | .then(() => expect(responseBody()).toEqual('')) 909 | .then(done, done.fail); 910 | requestHandler.and.returnValue(Promise.reject(literal)); 911 | }); 912 | }); 913 | }); 914 | }); 915 | /**/ 916 | 917 | }); 918 | describe('success handling', () => { 919 | describe('status code', () => { 920 | it('uses 200 by default', done => { 921 | requestHandler.and.returnValue({hi: 'there'}); 922 | underTest.proxyRouter(proxyRequest, lambdaContext) 923 | .then(() => expect(responseStatusCode()).toEqual(200)) 924 | .then(done, done.fail); 925 | }); 926 | it('can configure success code with handler success as a number', done => { 927 | requestHandler.and.returnValue({hi: 'there'}); 928 | underTest.get('/echo', requestHandler, { 929 | success: 204 930 | }); 931 | underTest.proxyRouter(proxyRequest, lambdaContext) 932 | .then(() => expect(responseStatusCode()).toEqual(204)) 933 | .then(done, done.fail); 934 | }); 935 | it('can configure success code with handler success as an object key', done => { 936 | requestHandler.and.returnValue({hi: 'there'}); 937 | underTest.get('/echo', requestHandler, { 938 | success: { code: 204 } 939 | }); 940 | underTest.proxyRouter(proxyRequest, lambdaContext) 941 | .then(() => expect(responseStatusCode()).toEqual(204)) 942 | .then(done, done.fail); 943 | }); 944 | it('uses a default if success is defined as an object, but without code', done => { 945 | requestHandler.and.returnValue({hi: 'there'}); 946 | underTest.get('/echo', requestHandler, { 947 | success: { contentType: 'text/plain' } 948 | }); 949 | underTest.proxyRouter(proxyRequest, lambdaContext) 950 | .then(() => expect(responseStatusCode()).toEqual(200)) 951 | .then(done, done.fail); 952 | }); 953 | it('uses dynamic response code if provided', done => { 954 | requestHandler.and.returnValue(new underTest.ApiResponse('', {}, 203)); 955 | underTest.proxyRouter(proxyRequest, lambdaContext) 956 | .then(() => expect(responseStatusCode()).toEqual(203)) 957 | .then(done, done.fail); 958 | }); 959 | it('uses dynamic response code over static definitions', done => { 960 | underTest.get('/echo', requestHandler, { 961 | success: 204 962 | }); 963 | requestHandler.and.returnValue(new underTest.ApiResponse('', {}, 203)); 964 | underTest.proxyRouter(proxyRequest, lambdaContext) 965 | .then(() => expect(responseStatusCode()).toEqual(203)) 966 | .then(done, done.fail); 967 | }); 968 | it('uses a static definition with ApiResponse if code is not set', done => { 969 | underTest.get('/echo', requestHandler, { 970 | success: 204 971 | }); 972 | requestHandler.and.returnValue(new underTest.ApiResponse('', {})); 973 | underTest.proxyRouter(proxyRequest, lambdaContext) 974 | .then(() => expect(responseStatusCode()).toEqual(204)) 975 | .then(done, done.fail); 976 | }); 977 | it('uses 200 with ApiResponse if code is not set and there is no static override', done => { 978 | requestHandler.and.returnValue(new underTest.ApiResponse('', {})); 979 | underTest.proxyRouter(proxyRequest, lambdaContext) 980 | .then(() => expect(responseStatusCode()).toEqual(200)) 981 | .then(done, done.fail); 982 | }); 983 | }); 984 | describe('isBase64Encoded', () => { 985 | it('is not set if the contentHandling is not defined', done => { 986 | underTest.get('/echo', requestHandler); 987 | requestHandler.and.returnValue('hi there'); 988 | underTest.proxyRouter(proxyRequest, lambdaContext) 989 | .then(() => expect(responseBase64Flag()).toBeUndefined()) 990 | .then(done, done.fail); 991 | }); 992 | it('is not set if the contentHandling is CONVERT_TO_TEXT', done => { 993 | underTest.get('/echo', requestHandler, { success: { contentHandling: 'CONVERT_TO_TEXT' }}); 994 | requestHandler.and.returnValue('hi there'); 995 | underTest.proxyRouter(proxyRequest, lambdaContext) 996 | .then(() => expect(responseBase64Flag()).toBeUndefined()) 997 | .then(done, done.fail); 998 | }); 999 | it('is set if the responseContentHandling is CONVERT_TO_BINARY', done => { 1000 | underTest.get('/echo', requestHandler, { success: {contentHandling: 'CONVERT_TO_BINARY' }}); 1001 | requestHandler.and.returnValue('hi there'); 1002 | underTest.proxyRouter(proxyRequest, lambdaContext) 1003 | .then(() => expect(responseBase64Flag()).toBe(true)) 1004 | .then(done, done.fail); 1005 | }); 1006 | }); 1007 | describe('header values', () => { 1008 | describe('Content-Type', () => { 1009 | it('uses application/json as the content type by default', done => { 1010 | requestHandler.and.returnValue({hi: 'there'}); 1011 | underTest.proxyRouter(proxyRequest, lambdaContext) 1012 | .then(() => expect(contentType()).toEqual('application/json')) 1013 | .then(done, done.fail); 1014 | }); 1015 | it('uses content type is specified in the handler config', done => { 1016 | underTest.get('/echo', requestHandler, { 1017 | success: { contentType: 'text/plain' } 1018 | }); 1019 | requestHandler.and.returnValue({hi: 'there'}); 1020 | underTest.proxyRouter(proxyRequest, lambdaContext) 1021 | .then(() => expect(contentType()).toEqual('text/plain')) 1022 | .then(done, done.fail); 1023 | }); 1024 | it('uses content type specified in a static header', done => { 1025 | underTest.get('/echo', requestHandler, { 1026 | success: { headers: { 'Content-Type': 'text/plain' } } 1027 | }); 1028 | requestHandler.and.returnValue({hi: 'there'}); 1029 | underTest.proxyRouter(proxyRequest, lambdaContext) 1030 | .then(() => expect(contentType()).toEqual('text/plain')) 1031 | .then(done, done.fail); 1032 | }); 1033 | it('works with mixed case specified in a static header', done => { 1034 | underTest.get('/echo', requestHandler, { 1035 | success: { headers: { 'conTent-tyPe': 'text/plain' } } 1036 | }); 1037 | requestHandler.and.returnValue({hi: 'there'}); 1038 | underTest.proxyRouter(proxyRequest, lambdaContext) 1039 | .then(() => expect(contentType()).toEqual('text/plain')) 1040 | .then(done, done.fail); 1041 | }); 1042 | it('ignores static headers that do not specify content type', done => { 1043 | underTest.get('/echo', requestHandler, { 1044 | success: { headers: { 'a-api-type': 'text/plain' } } 1045 | }); 1046 | requestHandler.and.returnValue({hi: 'there'}); 1047 | underTest.proxyRouter(proxyRequest, lambdaContext) 1048 | .then(() => expect(contentType()).toEqual('application/json')) 1049 | .then(done, done.fail); 1050 | 1051 | }); 1052 | it('ignores enumerated headers - backwards compatibility', done => { 1053 | underTest.get('/echo', requestHandler, { 1054 | success: { headers: ['a-api-type', 'text/plain'] } 1055 | }); 1056 | requestHandler.and.returnValue({hi: 'there'}); 1057 | underTest.proxyRouter(proxyRequest, lambdaContext) 1058 | .then(() => expect(contentType()).toEqual('application/json')) 1059 | .then(done, done.fail); 1060 | }); 1061 | it('uses content type specified in a dynamic header', done => { 1062 | requestHandler.and.returnValue(new underTest.ApiResponse('', {'Content-Type': 'text/xml'})); 1063 | underTest.proxyRouter(proxyRequest, lambdaContext) 1064 | .then(() => expect(contentType()).toEqual('text/xml')) 1065 | .then(done, done.fail); 1066 | }); 1067 | it('works with mixed case specified in dynamic header', done => { 1068 | requestHandler.and.returnValue(new underTest.ApiResponse('', {'Content-Type': 'text/xml'})); 1069 | underTest.proxyRouter(proxyRequest, lambdaContext) 1070 | .then(() => expect(contentType()).toEqual('text/xml')) 1071 | .then(done, done.fail); 1072 | }); 1073 | it('uses dynamic header over everything else', done => { 1074 | underTest.get('/echo', requestHandler, { 1075 | success: { contentType: 'text/xml', headers: { 'Content-Type': 'text/plain' } } 1076 | }); 1077 | requestHandler.and.returnValue(new underTest.ApiResponse('', {'Content-Type': 'text/markdown'})); 1078 | underTest.proxyRouter(proxyRequest, lambdaContext) 1079 | .then(() => expect(contentType()).toEqual('text/markdown')) 1080 | .then(done, done.fail); 1081 | }); 1082 | it('uses static header over handler config', done => { 1083 | underTest.get('/echo', requestHandler, { 1084 | success: { contentType: 'text/xml', headers: { 'Content-Type': 'text/plain' } } 1085 | }); 1086 | requestHandler.and.returnValue('abc'); 1087 | underTest.proxyRouter(proxyRequest, lambdaContext) 1088 | .then(() => expect(contentType()).toEqual('text/plain')) 1089 | .then(done, done.fail); 1090 | }); 1091 | }); 1092 | describe('CORS headers', () => { 1093 | beforeEach(() => { 1094 | requestHandler.and.returnValue('abc'); 1095 | }); 1096 | it('automatically includes CORS headers with the response', done => { 1097 | underTest.proxyRouter(proxyRequest, lambdaContext) 1098 | .then(() => { 1099 | expect(responseHeaders()).toEqual(jasmine.objectContaining({ 1100 | 'Access-Control-Allow-Origin': '*', 1101 | 'Access-Control-Allow-Headers': 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token', 1102 | 'Access-Control-Allow-Methods': 'GET,OPTIONS' 1103 | })); 1104 | }) 1105 | .then(done, done.fail); 1106 | }); 1107 | it('uses custom origin if provided', done => { 1108 | underTest.corsOrigin('blah.com'); 1109 | underTest.proxyRouter(proxyRequest, lambdaContext) 1110 | .then(() => { 1111 | expect(responseHeaders()).toEqual(jasmine.objectContaining({ 1112 | 'Access-Control-Allow-Origin': 'blah.com', 1113 | 'Access-Control-Allow-Headers': 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token', 1114 | 'Access-Control-Allow-Methods': 'GET,OPTIONS' 1115 | })); 1116 | }) 1117 | .then(done, done.fail); 1118 | }); 1119 | it('uses custom Allow-Headers if provided', done => { 1120 | underTest.corsHeaders('X-Api-Key1'); 1121 | underTest.proxyRouter(proxyRequest, lambdaContext) 1122 | .then(() => expect(responseHeaders('Access-Control-Allow-Headers')).toEqual('X-Api-Key1')) 1123 | .then(done, done.fail); 1124 | }); 1125 | it('clears headers if cors is not allowed', done => { 1126 | underTest.corsOrigin(false); 1127 | underTest.proxyRouter(proxyRequest, lambdaContext) 1128 | .then(() => { 1129 | const headers = responseHeaders(); 1130 | expect (headers.hasOwnProperty('Access-Control-Allow-Origin')).toBeFalsy(); 1131 | expect (headers.hasOwnProperty('Access-Control-Allow-Headers')).toBeFalsy(); 1132 | expect (headers.hasOwnProperty('Access-Control-Allow-Methods')).toBeFalsy(); 1133 | }) 1134 | .then(done, done.fail); 1135 | }); 1136 | }); 1137 | describe('static headers', () => { 1138 | it('can supply additional static headers in the handler config', done => { 1139 | underTest.get('/echo', requestHandler, { 1140 | success: { contentType: 'text/xml', headers: { 'Api-Key': 'text123' } } 1141 | }); 1142 | underTest.proxyRouter(proxyRequest, lambdaContext) 1143 | .then(() => expect(responseHeaders('Api-Key')).toEqual('text123')) 1144 | .then(done, done.fail); 1145 | }); 1146 | it('ignores enumerated static headers -- backwards compatibility', done => { 1147 | underTest.get('/echo', requestHandler, { 1148 | success: { contentType: 'text/xml', headers: ['Api-Key'] } 1149 | }); 1150 | underTest.proxyRouter(proxyRequest, lambdaContext) 1151 | .then(() => expect(responseHeaders('Api-Key')).toBeUndefined()) 1152 | .then(done, done.fail); 1153 | }); 1154 | it('overrides CORS headers with static headers', done => { 1155 | underTest.get('/echo', requestHandler, { 1156 | success: { contentType: 'text/xml', headers: {'Access-Control-Allow-Origin': 'x.com' } } 1157 | }); 1158 | underTest.proxyRouter(proxyRequest, lambdaContext) 1159 | .then(() => expect(responseHeaders('Access-Control-Allow-Origin')).toEqual('x.com')) 1160 | .then(done, done.fail); 1161 | }); 1162 | }); 1163 | describe('dynamic headers', () => { 1164 | it('can supply additional dynamic headers in the response', done => { 1165 | requestHandler.and.returnValue(new underTest.ApiResponse('', {'Api-Type': 'text/markdown'})); 1166 | underTest.proxyRouter(proxyRequest, lambdaContext) 1167 | .then(() => expect(responseHeaders('Api-Type')).toEqual('text/markdown')) 1168 | .then(done, done.fail); 1169 | }); 1170 | it('overrides static headers with dynamic headers', done => { 1171 | underTest.get('/echo', requestHandler, { 1172 | success: { contentType: 'text/xml', headers: { 'Api-Type': '123'} } 1173 | }); 1174 | requestHandler.and.returnValue(new underTest.ApiResponse('', {'Api-Type': 'text/markdown'})); 1175 | underTest.proxyRouter(proxyRequest, lambdaContext) 1176 | .then(() => expect(responseHeaders('Api-Type')).toEqual('text/markdown')) 1177 | .then(done, done.fail); 1178 | }); 1179 | it('overrides CORS headers with dynamic headers', done => { 1180 | requestHandler.and.returnValue(new underTest.ApiResponse('', {'Access-Control-Allow-Origin': 'x.com'})); 1181 | underTest.proxyRouter(proxyRequest, lambdaContext) 1182 | .then(() => expect(responseHeaders('Access-Control-Allow-Origin')).toEqual('x.com')) 1183 | .then(done, done.fail); 1184 | }); 1185 | }); 1186 | describe('when result code is a redirect', () => { 1187 | it('packs the result into the location header', done => { 1188 | underTest.get('/echo', requestHandler, { 1189 | success: 302 1190 | }); 1191 | requestHandler.and.returnValue('https://www.google.com'); 1192 | underTest.proxyRouter(proxyRequest, lambdaContext) 1193 | .then(() => expect(responseHeaders('Location')).toEqual('https://www.google.com')) 1194 | .then(done, done.fail); 1195 | }); 1196 | it('includes CORS headers', done => { 1197 | underTest.get('/echo', requestHandler, { 1198 | success: 302 1199 | }); 1200 | requestHandler.and.returnValue('https://www.google.com'); 1201 | underTest.proxyRouter(proxyRequest, lambdaContext) 1202 | .then(() => expect(responseHeaders('Access-Control-Allow-Origin')).toEqual('*')) 1203 | .then(done, done.fail); 1204 | }); 1205 | it('uses the dynamic headers if provided', done => { 1206 | underTest.get('/echo', requestHandler, { 1207 | success: 302 1208 | }); 1209 | requestHandler.and.returnValue(new underTest.ApiResponse('https://www.google.com', {'Location': 'https://www.amazon.com'})); 1210 | underTest.proxyRouter(proxyRequest, lambdaContext) 1211 | .then(() => expect(responseHeaders('Location')).toEqual('https://www.amazon.com')) 1212 | .then(done, done.fail); 1213 | }); 1214 | it('uses body of a dynamic response if no location header', done => { 1215 | underTest.get('/echo', requestHandler, { 1216 | success: 302 1217 | }); 1218 | requestHandler.and.returnValue(new underTest.ApiResponse('https://www.google.com', {'X-Val1': 'v2'})); 1219 | underTest.proxyRouter(proxyRequest, lambdaContext) 1220 | .then(() => { 1221 | expect(responseHeaders('Location')).toEqual('https://www.google.com'); 1222 | expect(responseHeaders('X-Val1')).toEqual('v2'); 1223 | }) 1224 | .then(done, done.fail); 1225 | }); 1226 | it('uses mixed case dynamic header', done => { 1227 | underTest.get('/echo', requestHandler, { 1228 | success: 302 1229 | }); 1230 | requestHandler.and.returnValue(new underTest.ApiResponse('https://www.google.com', {'LocaTion': 'https://www.amazon.com'})); 1231 | underTest.proxyRouter(proxyRequest, lambdaContext) 1232 | .then(() => expect(responseHeaders('Location')).toEqual('https://www.amazon.com')) 1233 | .then(done, done.fail); 1234 | }); 1235 | it('uses the static header if no response body', done => { 1236 | underTest.get('/echo', requestHandler, { 1237 | success: { code: 302, headers: {'Location': 'https://www.google.com'} } 1238 | }); 1239 | requestHandler.and.returnValue(''); 1240 | underTest.proxyRouter(proxyRequest, lambdaContext) 1241 | .then(() => expect(responseHeaders('Location')).toEqual('https://www.google.com')) 1242 | .then(done, done.fail); 1243 | }); 1244 | it('uses the response body over the static header', done => { 1245 | underTest.get('/echo', requestHandler, { 1246 | success: { code: 302, headers: {'Location': 'https://www.google.com'} } 1247 | }); 1248 | requestHandler.and.returnValue('https://www.xkcd.com'); 1249 | underTest.proxyRouter(proxyRequest, lambdaContext) 1250 | .then(() => expect(responseHeaders('Location')).toEqual('https://www.xkcd.com')) 1251 | .then(done, done.fail); 1252 | }); 1253 | it('uses the dynamic header value over the static header', done => { 1254 | underTest.get('/echo', requestHandler, { 1255 | success: { code: 302, headers: {'Location': 'https://www.google.com'} } 1256 | }); 1257 | requestHandler.and.returnValue(new underTest.ApiResponse('https://www.google.com', {'Location': 'https://www.amazon.com'})); 1258 | underTest.proxyRouter(proxyRequest, lambdaContext) 1259 | .then(() => expect(responseHeaders('Location')).toEqual('https://www.amazon.com')) 1260 | .then(done, done.fail); 1261 | }); 1262 | }); 1263 | 1264 | }); 1265 | describe('result formatting', () => { 1266 | ['application/json', 'application/json; charset=UTF-8'].forEach(function (respContentType) { 1267 | describe(`when content type is ${respContentType}`, () => { 1268 | beforeEach(() => { 1269 | underTest.get('/echo', requestHandler, { 1270 | success: { headers: { 'Content-Type': respContentType } } 1271 | }); 1272 | }); 1273 | it('stringifies objects', done => { 1274 | requestHandler.and.returnValue({hi: 'there'}); 1275 | underTest.proxyRouter(proxyRequest, lambdaContext) 1276 | .then(() => { 1277 | expect(responseBody()).toEqual('{"hi":"there"}'); 1278 | expect(responseStatusCode()).toEqual(200); 1279 | }) 1280 | .then(done, done.fail); 1281 | }); 1282 | it('survives circular results', done => { 1283 | const circular = {hi: 'there'}; 1284 | circular.circular = circular; 1285 | requestHandler.and.returnValue(circular); 1286 | underTest.proxyRouter(proxyRequest, lambdaContext) 1287 | .then(() => { 1288 | expect(responseStatusCode()).toEqual(500); 1289 | expect(responseBody()).toEqual('{"errorMessage":"Response contains a circular reference and cannot be serialized to JSON"}'); 1290 | }) 1291 | .then(done, done.fail); 1292 | }); 1293 | it('JSON-stringifies non objects', done => { 1294 | requestHandler.and.returnValue('OK'); 1295 | underTest.proxyRouter(proxyRequest, lambdaContext) 1296 | .then(() => expect(responseBody()).toEqual('"OK"')) 1297 | .then(done, done.fail); 1298 | }); 1299 | ['', undefined].forEach(function (literal) { 1300 | it(`uses blank object for [${literal}]`, done => { 1301 | requestHandler.and.returnValue(literal); 1302 | underTest.proxyRouter(proxyRequest, lambdaContext) 1303 | .then(() => expect(responseBody()).toEqual('{}')) 1304 | .then(done, done.fail); 1305 | }); 1306 | }); 1307 | [null, false].forEach(function (literal) { 1308 | it(`uses literal version for ${literal}`, done => { 1309 | requestHandler.and.returnValue(literal); 1310 | underTest.proxyRouter(proxyRequest, lambdaContext) 1311 | .then(() => expect(responseBody()).toEqual('' + literal)) 1312 | .then(done, done.fail); 1313 | }); 1314 | }); 1315 | }); 1316 | }); 1317 | describe('when content type is not JSON', () => { 1318 | beforeEach(() => { 1319 | underTest.get('/echo', requestHandler, { 1320 | success: { headers: { 'Content-Type': 'application/xml' } } 1321 | }); 1322 | }); 1323 | it('stringifies objects', done => { 1324 | requestHandler.and.returnValue({hi: 'there'}); 1325 | underTest.proxyRouter(proxyRequest, lambdaContext) 1326 | .then(() => expect(responseBody()).toEqual('{"hi":"there"}')) 1327 | .then(done, done.fail); 1328 | }); 1329 | it('survives circular responses', done => { 1330 | const circular = {hi: 'there'}; 1331 | circular.circular = circular; 1332 | requestHandler.and.returnValue(circular); 1333 | underTest.proxyRouter(proxyRequest, lambdaContext) 1334 | .then(() => { 1335 | expect(responseBody()).toEqual('[Circular]'); 1336 | expect(responseStatusCode()).toEqual(200); 1337 | }) 1338 | .then(done, done.fail); 1339 | }); 1340 | it('base64 encodes buffers', done => { 1341 | requestHandler.and.returnValue(new Buffer([100, 200, 300])); 1342 | underTest.proxyRouter(proxyRequest, lambdaContext) 1343 | .then(() => expect(responseBody()).toEqual('ZMgs')) 1344 | .then(done, done.fail); 1345 | }); 1346 | it('extracts content from ApiResponse objects', done => { 1347 | requestHandler.and.returnValue(new underTest.ApiResponse('content123', {})); 1348 | underTest.proxyRouter(proxyRequest, lambdaContext) 1349 | .then(() => expect(responseBody()).toEqual('content123')) 1350 | .then(done, done.fail); 1351 | }); 1352 | it('stringifies objects from ApiResponse objects', done => { 1353 | requestHandler.and.returnValue(new underTest.ApiResponse({'h1': 'content123'}, {})); 1354 | underTest.proxyRouter(proxyRequest, lambdaContext) 1355 | .then(() => expect(responseBody()).toEqual('{"h1":"content123"}')) 1356 | .then(done, done.fail); 1357 | }); 1358 | it('base64 encodes buffers from ApiResponse objects', done => { 1359 | requestHandler.and.returnValue(new underTest.ApiResponse(new Buffer([100, 200, 300]), {})); 1360 | underTest.proxyRouter(proxyRequest, lambdaContext) 1361 | .then(() => expect(responseBody()).toEqual('ZMgs')) 1362 | .then(done, done.fail); 1363 | }); 1364 | it('returns literal results for strings', done => { 1365 | requestHandler.and.returnValue('OK'); 1366 | underTest.proxyRouter(proxyRequest, lambdaContext) 1367 | .then(() => expect(responseBody()).toEqual('OK')) 1368 | .then(done, done.fail); 1369 | }); 1370 | it('returns string results for numbers', done => { 1371 | requestHandler.and.returnValue(123); 1372 | underTest.proxyRouter(proxyRequest, lambdaContext) 1373 | .then(() => expect(responseBody()).toEqual('123')) 1374 | .then(done, done.fail); 1375 | }); 1376 | it('returns string results for true', done => { 1377 | requestHandler.and.returnValue(true); 1378 | underTest.proxyRouter(proxyRequest, lambdaContext) 1379 | .then(() => expect(responseBody()).toEqual('true')) 1380 | .then(done, done.fail); 1381 | }); 1382 | describe('uses blank string for', () => { 1383 | [null, false, '', undefined].forEach(function (resp) { 1384 | it(`[${resp}]`, done => { 1385 | requestHandler.and.returnValue(resp); 1386 | underTest.proxyRouter(proxyRequest, lambdaContext) 1387 | .then(() => expect(responseBody()).toEqual('')) 1388 | .then(done, done.fail); 1389 | }); 1390 | }); 1391 | }); 1392 | }); 1393 | }); 1394 | }); 1395 | }); 1396 | 1397 | describe('intercepting calls', () => { 1398 | let interceptSpy; 1399 | beforeEach(() => { 1400 | interceptSpy = jasmine.createSpy(); 1401 | underTest.get('/echo', requestHandler); 1402 | underTest.post('/echo', postRequestHandler); 1403 | underTest.intercept(interceptSpy); 1404 | }); 1405 | it('passes the converted proxy request to interceptor if the request comes from API gateway', done => { 1406 | interceptSpy.and.returnValue(false); 1407 | underTest.proxyRouter(proxyRequest, lambdaContext) 1408 | .then(() => expect(interceptSpy.calls.argsFor(0)[0]).toEqual(apiRequest)) 1409 | .then(done, done.fail); 1410 | }); 1411 | it('passes the original request to interception if it does not come from API Gateway', done => { 1412 | const customObject = { 1413 | slackRequest: 'abc', 1414 | slackToken: 'def' 1415 | }; 1416 | underTest.proxyRouter(customObject, lambdaContext) 1417 | .then(() => expect(interceptSpy.calls.argsFor(0)[0]).toEqual(customObject)) 1418 | .then(done, done.fail); 1419 | }); 1420 | it('rejects if the intercept rejects', done => { 1421 | interceptSpy.and.returnValue(Promise.reject('BOOM')); 1422 | underTest.proxyRouter(proxyRequest, lambdaContext) 1423 | .then(() => { 1424 | expect(requestHandler).not.toHaveBeenCalled(); 1425 | expect(postRequestHandler).not.toHaveBeenCalled(); 1426 | expect(lambdaContext.done).toHaveBeenCalledWith('BOOM'); 1427 | }) 1428 | .then(done, done.fail); 1429 | }); 1430 | it('rejects if the intercept throws an exception', done => { 1431 | interceptSpy.and.throwError('BOOM'); 1432 | underTest.proxyRouter(proxyRequest, lambdaContext) 1433 | .then(() => { 1434 | expect(requestHandler).not.toHaveBeenCalled(); 1435 | expect(postRequestHandler).not.toHaveBeenCalled(); 1436 | expect(lambdaContext.done.calls.mostRecent().args[0].message).toEqual('BOOM'); 1437 | }) 1438 | .then(done, done.fail); 1439 | }); 1440 | it('passes if the intercept throws an ApiResponse exception', done => { 1441 | interceptSpy.and.returnValue(new underTest.ApiResponse('BODY', {}, 403)); 1442 | underTest.proxyRouter(proxyRequest, lambdaContext) 1443 | .then(() => { 1444 | expect(requestHandler).not.toHaveBeenCalled(); 1445 | expect(postRequestHandler).not.toHaveBeenCalled(); 1446 | expect(responseStatusCode()).toEqual(403); 1447 | expect(responseBody()).toEqual('"BODY"'); 1448 | expect(contentType()).toEqual('application/json'); 1449 | }) 1450 | .then(done, done.fail); 1451 | }); 1452 | it('routes the event returned from intercept', done => { 1453 | interceptSpy.and.returnValue({ 1454 | context: { 1455 | path: '/echo', 1456 | method: 'POST' 1457 | }, 1458 | queryString: { 1459 | c: 'd' 1460 | } 1461 | }); 1462 | underTest.proxyRouter(proxyRequest, lambdaContext).then(() => { 1463 | expect(requestHandler).not.toHaveBeenCalled(); 1464 | expect(postRequestHandler).toHaveBeenCalledWith(jasmine.objectContaining({ 1465 | context: { 1466 | path: '/echo', 1467 | method: 'POST' 1468 | }, 1469 | queryString: { 1470 | c: 'd' 1471 | } 1472 | }), lambdaContext); 1473 | }).then(done, done.fail); 1474 | }); 1475 | it('routes the event resolved by the intercept promise', done => { 1476 | interceptSpy.and.returnValue(Promise.resolve({ 1477 | context: { 1478 | path: '/echo', 1479 | method: 'POST' 1480 | }, 1481 | queryString: { 1482 | c: 'd' 1483 | } 1484 | })); 1485 | underTest.proxyRouter(proxyRequest, lambdaContext) 1486 | .then(() => { 1487 | expect(requestHandler).not.toHaveBeenCalled(); 1488 | expect(postRequestHandler).toHaveBeenCalledWith(jasmine.objectContaining({ 1489 | context: { 1490 | path: '/echo', 1491 | method: 'POST' 1492 | }, 1493 | queryString: { 1494 | c: 'd' 1495 | } 1496 | }), lambdaContext); 1497 | }) 1498 | .then(done, done.fail); 1499 | }); 1500 | it('aborts if the intercept returns a falsy value', done => { 1501 | interceptSpy.and.returnValue(false); 1502 | underTest.proxyRouter(proxyRequest, lambdaContext) 1503 | .then(() => { 1504 | expect(requestHandler).not.toHaveBeenCalled(); 1505 | expect(postRequestHandler).not.toHaveBeenCalled(); 1506 | expect(lambdaContext.done).toHaveBeenCalledWith(null, null); 1507 | }) 1508 | .then(done, done.fail); 1509 | }); 1510 | it('aborts if the intercept resolves with a falsy value', done => { 1511 | interceptSpy.and.returnValue(Promise.resolve(false)); 1512 | underTest.proxyRouter(proxyRequest, lambdaContext) 1513 | .then(() => { 1514 | expect(requestHandler).not.toHaveBeenCalled(); 1515 | expect(postRequestHandler).not.toHaveBeenCalled(); 1516 | expect(lambdaContext.done).toHaveBeenCalledWith(null, null); 1517 | }) 1518 | .then(done, done.fail); 1519 | }); 1520 | }); 1521 | }); 1522 | describe('unsupported event format', () => { 1523 | it('causes lambda context to complete with error if no custom handler', done => { 1524 | underTest.router({}, lambdaContext) 1525 | .then(() => expect(lambdaContext.done).toHaveBeenCalledWith('event does not contain routing information')) 1526 | .then(done, done.fail); 1527 | }); 1528 | it('calls custom handler if provided', done => { 1529 | const fakeCallback = jasmine.createSpy(); 1530 | underTest.unsupportedEvent((event, context, callback) => { 1531 | expect(event).toEqual({a: 1}); 1532 | expect(context).toEqual(lambdaContext); 1533 | expect(callback).toEqual(fakeCallback); 1534 | expect(lambdaContext.done).not.toHaveBeenCalled(); 1535 | expect(fakeCallback).not.toHaveBeenCalled(); 1536 | done(); 1537 | }); 1538 | underTest.proxyRouter({a: 1}, lambdaContext, fakeCallback); 1539 | }); 1540 | it('should be able to return a response', (done) => { 1541 | // Besides context.done and callback 1542 | // simply returning should be allowed 1543 | const fakeCallback = jasmine.createSpy(); 1544 | underTest.unsupportedEvent((event, context, callback) => { 1545 | expect(context.done).not.toHaveBeenCalled(); 1546 | expect(callback).not.toHaveBeenCalled(); 1547 | return 'Return of the Jedi'; 1548 | }); 1549 | underTest.proxyRouter({a: 1}, lambdaContext, fakeCallback).then(result => { 1550 | expect(result).toEqual('Return of the Jedi'); 1551 | done(); 1552 | }); 1553 | }); 1554 | }); 1555 | 1556 | describe('CORS handling', () => { 1557 | let apiRequest; 1558 | beforeEach(() => { 1559 | apiRequest = { context: { path: '/existing', method: 'OPTIONS' } }; 1560 | underTest.get('/existing', requestHandler); 1561 | }); 1562 | it('does not set corsHandlers unless corsOrigin called', () => { 1563 | expect(underTest.apiConfig().corsHandlers).toBeUndefined(); 1564 | }); 1565 | it('sets corsHandlers to false if called with false', () => { 1566 | underTest.corsOrigin(false); 1567 | expect(underTest.apiConfig().corsHandlers).toBe(false); 1568 | }); 1569 | it('sets corsHandlers to true if passed a function', () => { 1570 | underTest.corsOrigin(() => { }); 1571 | expect(underTest.apiConfig().corsHandlers).toBe(true); 1572 | }); 1573 | it('sets corsHandlers to true if passed a string', () => { 1574 | underTest.corsOrigin('origin'); 1575 | expect(underTest.apiConfig().corsHandlers).toBe(true); 1576 | }); 1577 | it('sets corsMaxAge to 10', () => { 1578 | underTest.corsMaxAge(10); 1579 | expect(underTest.apiConfig().corsMaxAge).toBe(10); 1580 | }); 1581 | 1582 | it('routes OPTIONS to return the default configuration if no parameters set', done => { 1583 | underTest.router(apiRequest, lambdaContext) 1584 | .then(() => { 1585 | expect(lambdaContext.done).toHaveBeenCalledWith(null, { 1586 | statusCode: 200, 1587 | headers: { 1588 | 'Access-Control-Allow-Origin': '*', 1589 | 'Access-Control-Allow-Headers': 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token', 1590 | 'Access-Control-Allow-Methods': 'GET,OPTIONS', 1591 | 'Access-Control-Allow-Credentials': 'true', 1592 | 'Access-Control-Max-Age': '0' 1593 | }, 1594 | body: '' 1595 | }); 1596 | }) 1597 | .then(done, done.fail); 1598 | }); 1599 | it('routes OPTIONS to return no header values if origin is set to false', done => { 1600 | underTest.corsOrigin(false); 1601 | underTest.router(apiRequest, lambdaContext) 1602 | .then(() => { 1603 | expect(lambdaContext.done).toHaveBeenCalledWith(null, { 1604 | statusCode: 200, 1605 | headers: { 1606 | }, 1607 | body: '' 1608 | }); 1609 | }) 1610 | .then(done, done.fail); 1611 | }); 1612 | it('routes OPTIONS to return the result of a custom CORS handler in the Allowed-Origins header', done => { 1613 | const corsHandler = jasmine.createSpy('corsHandler').and.returnValue('custom-origin'); 1614 | underTest.corsOrigin(corsHandler); 1615 | underTest.router(apiRequest, lambdaContext) 1616 | .then(() => { 1617 | expect(corsHandler).toHaveBeenCalledWith(apiRequest); 1618 | expect(lambdaContext.done).toHaveBeenCalledWith(null, { 1619 | statusCode: 200, 1620 | headers: { 1621 | 'Access-Control-Allow-Origin': 'custom-origin', 1622 | 'Access-Control-Allow-Headers': 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token', 1623 | 'Access-Control-Allow-Methods': 'GET,OPTIONS', 1624 | 'Access-Control-Allow-Credentials': 'true', 1625 | 'Access-Control-Max-Age': '0' 1626 | }, 1627 | body: '' 1628 | }); 1629 | }) 1630 | .then(done, done.fail); 1631 | }); 1632 | it('routes OPTIONS to return the result of a promise resolved by the CORS handler', done => { 1633 | const corsPromise = Promise.resolve('custom-origin'), 1634 | corsHandler = jasmine.createSpy('corsHandler').and.returnValue(corsPromise); 1635 | underTest.corsOrigin(corsHandler); 1636 | underTest.router(apiRequest, lambdaContext) 1637 | .then(() => { 1638 | expect(corsHandler).toHaveBeenCalledWith(apiRequest); 1639 | expect(lambdaContext.done).toHaveBeenCalledWith(null, { 1640 | statusCode: 200, 1641 | headers: { 1642 | 'Access-Control-Allow-Origin': 'custom-origin', 1643 | 'Access-Control-Allow-Headers': 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token', 1644 | 'Access-Control-Allow-Methods': 'GET,OPTIONS', 1645 | 'Access-Control-Allow-Credentials': 'true', 1646 | 'Access-Control-Max-Age': '0' 1647 | }, 1648 | body: '' 1649 | }); 1650 | }) 1651 | .then(done, done.fail); 1652 | }); 1653 | it('routes OPTIONS to return the string set by corsOrigin', done => { 1654 | underTest.corsOrigin('custom-origin-string'); 1655 | underTest.router(apiRequest, lambdaContext) 1656 | .then(() => { 1657 | expect(lambdaContext.done).toHaveBeenCalledWith(null, { 1658 | statusCode: 200, 1659 | headers: { 1660 | 'Access-Control-Allow-Origin': 'custom-origin-string', 1661 | 'Access-Control-Allow-Headers': 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token', 1662 | 'Access-Control-Allow-Methods': 'GET,OPTIONS', 1663 | 'Access-Control-Allow-Credentials': 'true', 1664 | 'Access-Control-Max-Age': '0' 1665 | }, 1666 | body: '' 1667 | }); 1668 | }) 1669 | .then(done, done.fail); 1670 | }); 1671 | it('does not set corsHeaders unless corsHeaders called', () => { 1672 | expect(underTest.apiConfig().corsHeaders).toBeUndefined(); 1673 | }); 1674 | it('sets corsHeaders to a string, if provided', () => { 1675 | underTest.corsHeaders('X-Api-Request'); 1676 | expect(underTest.apiConfig().corsHeaders).toEqual('X-Api-Request'); 1677 | }); 1678 | it('throws an error if the cors headers is not a string', () => { 1679 | expect(() => { 1680 | underTest.corsHeaders(() => { }); 1681 | }).toThrow('corsHeaders only accepts strings'); 1682 | }); 1683 | it('uses corsHeaders when routing OPTIONS', done => { 1684 | underTest.corsHeaders('X-Api-Request'); 1685 | underTest.router(apiRequest, lambdaContext) 1686 | .then(() => { 1687 | expect(lambdaContext.done).toHaveBeenCalledWith(null, { 1688 | statusCode: 200, 1689 | headers: { 1690 | 'Access-Control-Allow-Origin': '*', 1691 | 'Access-Control-Allow-Headers': 'X-Api-Request', 1692 | 'Access-Control-Allow-Methods': 'GET,OPTIONS', 1693 | 'Access-Control-Allow-Credentials': 'true', 1694 | 'Access-Control-Max-Age': '0' 1695 | }, 1696 | body: '' 1697 | }); 1698 | }) 1699 | .then(done, done.fail); 1700 | }); 1701 | it('uses all available methods on a resource in Allow-Methods', done => { 1702 | underTest.post('/existing', requestHandler); 1703 | underTest.put('/existing', requestHandler); 1704 | underTest.router(apiRequest, lambdaContext) 1705 | .then(() => { 1706 | expect(lambdaContext.done).toHaveBeenCalledWith(null, { 1707 | statusCode: 200, 1708 | headers: { 1709 | 'Access-Control-Allow-Origin': '*', 1710 | 'Access-Control-Allow-Headers': 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token', 1711 | 'Access-Control-Allow-Methods': 'GET,POST,PUT,OPTIONS', 1712 | 'Access-Control-Allow-Credentials': 'true', 1713 | 'Access-Control-Max-Age': '0' 1714 | }, 1715 | body: '' 1716 | }); 1717 | }) 1718 | .then(done, done.fail); 1719 | }); 1720 | it('routes OPTIONS to return the max-age set by corsMaxAge', done => { 1721 | underTest.corsOrigin('custom-origin-string'); 1722 | underTest.corsMaxAge(60); 1723 | underTest.router(apiRequest, lambdaContext) 1724 | .then(() => { 1725 | expect(lambdaContext.done).toHaveBeenCalledWith(null, { 1726 | statusCode: 200, 1727 | headers: { 1728 | 'Access-Control-Allow-Origin': 'custom-origin-string', 1729 | 'Access-Control-Allow-Headers': 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token', 1730 | 'Access-Control-Allow-Methods': 'GET,OPTIONS', 1731 | 'Access-Control-Allow-Credentials': 'true', 1732 | 'Access-Control-Max-Age': 60 1733 | }, 1734 | body: '' 1735 | }); 1736 | }) 1737 | .then(done, done.fail); 1738 | }); 1739 | }); 1740 | describe('post install hooks', () => { 1741 | let pResolve, postPromise, hook; 1742 | 1743 | beforeEach(() => { 1744 | postPromise = new Promise(resolve => { 1745 | pResolve = resolve; 1746 | }); 1747 | hook = jasmine.createSpy().and.returnValue(postPromise); 1748 | }); 1749 | it('can set up a single post-install hook', done => { 1750 | underTest.addPostDeployStep('first', (opts, config, utils) => { 1751 | expect(opts).toEqual({a: 1}); 1752 | expect(config).toEqual({c: 2}); 1753 | expect(utils).toEqual({}); 1754 | done(); 1755 | }); 1756 | underTest.postDeploy({a: 1}, {c: 2}, {}); 1757 | }); 1758 | it('complains if the first argument is not a step name', () => { 1759 | expect(() => { 1760 | underTest.addPostDeployStep(hook); 1761 | }).toThrowError('addPostDeployStep requires a step name as the first argument'); 1762 | }); 1763 | it('complains if the second argument is not a function', () => { 1764 | expect(() => { 1765 | underTest.addPostDeployStep('first'); 1766 | }).toThrowError('addPostDeployStep requires a function as the second argument'); 1767 | }); 1768 | it('does not execute the hook before postDeploy is called', () => { 1769 | underTest.addPostDeployStep('first', hook); 1770 | expect(hook).not.toHaveBeenCalled(); 1771 | }); 1772 | it('cannot add a hook with the same name twice', () => { 1773 | underTest.addPostDeployStep('first', hook); 1774 | expect(() => { 1775 | underTest.addPostDeployStep('first', hook); 1776 | }).toThrowError('Post deploy hook "first" already exists'); 1777 | }); 1778 | it('does not resolve until the post-install hook resolves', done => { 1779 | const hasResolved = jasmine.createSpy(); 1780 | underTest.addPostDeployStep('first', hook); 1781 | underTest.postDeploy({a: 1}, {c: 2}, {}) 1782 | .then(hasResolved, done.fail); 1783 | Promise.resolve() 1784 | .then(() => expect(hasResolved).not.toHaveBeenCalled()) 1785 | .then(done, done.fail); 1786 | }); 1787 | it('resolves when the post-install resolves', done => { 1788 | underTest.addPostDeployStep('first', hook); 1789 | underTest.postDeploy({a: 1}, {c: 2}, {}) 1790 | .then(result => expect(result).toEqual({first: { url: 'http://www.google.com' }})) 1791 | .then(done, done.fail); 1792 | pResolve({url: 'http://www.google.com'}); 1793 | }); 1794 | it('works with non-promise post-install hooks', done => { 1795 | underTest.addPostDeployStep('first', () => { 1796 | return 'yes'; 1797 | }); 1798 | underTest.postDeploy({a: 1}, {c: 2}, {}) 1799 | .then(result => expect(result).toEqual({first: 'yes'})) 1800 | .then(done, done.fail); 1801 | pResolve({url: 'http://www.google.com'}); 1802 | }); 1803 | it('returns false when post-deploy hooks are not set up', done => { 1804 | underTest.postDeploy({a: 1}, {c: 2}, {}) 1805 | .then(result => expect(result).toBeFalsy()) 1806 | .then(done, done.fail); 1807 | }); 1808 | describe('multiple hooks', () => { 1809 | let p2Resolve, p2Reject, 1810 | postPromise2, hook2; 1811 | beforeEach(() => { 1812 | postPromise2 = new Promise((resolve, reject) => { 1813 | p2Resolve = resolve; 1814 | p2Reject = reject; 1815 | }); 1816 | hook2 = jasmine.createSpy().and.returnValue(postPromise2); 1817 | underTest.addPostDeployStep('first', hook); 1818 | underTest.addPostDeployStep('second', hook2); 1819 | }); 1820 | it('does not execute the hooks immediately', () => { 1821 | expect(hook).not.toHaveBeenCalled(); 1822 | expect(hook2).not.toHaveBeenCalled(); 1823 | }); 1824 | it('does not execute the second hook until the first resolves', done => { 1825 | hook.and.callFake((opts, config, utils) => { 1826 | expect(opts).toEqual({a: 1}); 1827 | expect(config).toEqual({c: 2}); 1828 | expect(utils).toEqual({}); 1829 | expect(hook2).not.toHaveBeenCalled(); 1830 | done(); 1831 | return postPromise; 1832 | }); 1833 | underTest.postDeploy({a: 1}, {c: 2}, {}) 1834 | .then(done.fail, done.fail); 1835 | }); 1836 | it('executes the second hook after the first one resolves', done => { 1837 | underTest.postDeploy({a: 1}, {c: 2}, {}) 1838 | .then(done.fail, () => expect(hook2).toHaveBeenCalledWith({a: 1}, {c: 2}, {})) 1839 | .then(done); 1840 | pResolve({url: 'http://www.google.com'}); 1841 | p2Reject('boom'); 1842 | }); 1843 | it('resolves when the second hook resolves', done => { 1844 | underTest.postDeploy({a: 1}, {c: 2}, {}) 1845 | .then(result => { 1846 | expect(result).toEqual({ 1847 | first: { url: 'http://www.google.com' }, 1848 | second: { url: 'http://www.xkcd.com' } 1849 | }); 1850 | }) 1851 | .then(done, done.fail); 1852 | 1853 | pResolve({url: 'http://www.google.com'}); 1854 | p2Resolve({url: 'http://www.xkcd.com'}); 1855 | }); 1856 | }); 1857 | }); 1858 | describe('post-deploy config shortcut', () => { 1859 | let apiGatewayPromise, lambdaDetails, deploymentResolve, deploymentReject; 1860 | beforeEach(() => { 1861 | apiGatewayPromise = jasmine.createSpyObj('apiGatewayPromise', ['createDeploymentPromise']); 1862 | apiGatewayPromise.createDeploymentPromise.and.returnValue(new Promise((resolve, reject) => { 1863 | deploymentResolve = resolve; 1864 | deploymentReject = reject; 1865 | })); 1866 | lambdaDetails = { apiId: 'API_1', alias: 'dev' }; 1867 | underTest.addPostDeployConfig('stageVar', 'Enter var', 'config-var'); 1868 | }); 1869 | it('does nothing if the config arg is not set', done => { 1870 | underTest.postDeploy({a: 1}, lambdaDetails, {apiGatewayPromise: apiGatewayPromise}) 1871 | .then(() => expect(apiGatewayPromise.createDeploymentPromise).not.toHaveBeenCalled()) 1872 | .then(done, done.fail); 1873 | }); 1874 | describe('when the config arg is a string', () => { 1875 | it('sets the variable without prompting', done => { 1876 | underTest.postDeploy({a: 1, 'config-var': 'val-value'}, lambdaDetails, {apiGatewayPromise: apiGatewayPromise}) 1877 | .then(result => { 1878 | expect(apiGatewayPromise.createDeploymentPromise).toHaveBeenCalledWith({ 1879 | restApiId: 'API_1', 1880 | stageName: 'dev', 1881 | variables: { stageVar: 'val-value' } 1882 | }); 1883 | expect(prompter).not.toHaveBeenCalled(); 1884 | expect(result).toEqual({stageVar: 'val-value'}); 1885 | }) 1886 | .then(done, done.fail); 1887 | deploymentResolve('OK'); 1888 | }); 1889 | it('rejects if the deployment rejects', done => { 1890 | underTest.postDeploy({a: 1, 'config-var': 'val-value'}, lambdaDetails, {apiGatewayPromise: apiGatewayPromise}) 1891 | .then(done.fail, err => expect(err).toEqual('BOOM!')) 1892 | .then(done); 1893 | deploymentReject('BOOM!'); 1894 | }); 1895 | }); 1896 | describe('when the config arg is true', () => { 1897 | it('prompts for the variable', done => { 1898 | underTest.postDeploy({a: 1, 'config-var': true}, lambdaDetails, {apiGatewayPromise: apiGatewayPromise}) 1899 | .then(done.fail, done.fail); 1900 | prompter.and.callFake(arg => { 1901 | expect(arg).toEqual('Enter var'); 1902 | done(); 1903 | return Promise.resolve('X'); 1904 | }); 1905 | }); 1906 | it('deploys the stage variable returned by the prompter', done => { 1907 | underTest.postDeploy({a: 1, 'config-var': true}, lambdaDetails, {apiGatewayPromise: apiGatewayPromise}) 1908 | .then(result => { 1909 | expect(apiGatewayPromise.createDeploymentPromise).toHaveBeenCalledWith({ 1910 | restApiId: 'API_1', 1911 | stageName: 'dev', 1912 | variables: { stageVar: 'X' } 1913 | }); 1914 | expect(result).toEqual({stageVar: 'X'}); 1915 | }) 1916 | .then(done, done.fail); 1917 | prompter.and.returnValue(Promise.resolve('X')); 1918 | deploymentResolve('OK'); 1919 | }); 1920 | it('rejects if the prompter rejects', done => { 1921 | underTest.postDeploy({a: 1, 'config-var': true}, lambdaDetails, {apiGatewayPromise: apiGatewayPromise}) 1922 | .then(done.fail, err => { 1923 | expect(err).toEqual('BOOM'); 1924 | expect(apiGatewayPromise.createDeploymentPromise).not.toHaveBeenCalled(); 1925 | }) 1926 | .then(done); 1927 | prompter.and.returnValue(Promise.reject('BOOM')); 1928 | }); 1929 | it('rejects if the deployment rejects', done => { 1930 | underTest.postDeploy({a: 1, 'config-var': true}, lambdaDetails, {apiGatewayPromise: apiGatewayPromise}) 1931 | .then(done.fail, err => expect(err).toEqual('BOOM')) 1932 | .then(done); 1933 | prompter.and.returnValue(Promise.resolve('OK')); 1934 | deploymentReject('BOOM'); 1935 | }); 1936 | }); 1937 | }); 1938 | describe('lambda context control', () => { 1939 | it('sets the flag to kill the node vm without waiting for the event loop to empty after serializing context to request', done => { 1940 | const apiRequest = { 1941 | context: { 1942 | path: '/test', 1943 | method: 'GET' 1944 | }, 1945 | queryString: { 1946 | a: 'b' 1947 | } 1948 | }; 1949 | underTest.get('/test', requestHandler); 1950 | underTest.router(apiRequest, lambdaContext) 1951 | .then(() => expect(lambdaContext.callbackWaitsForEmptyEventLoop).toBe(false)) 1952 | .then(done, done.fail); 1953 | }); 1954 | }); 1955 | describe('setGatewayResponse', () => { 1956 | it('does not create any custom responses by default', () => { 1957 | expect(underTest.apiConfig().customResponses).toBeUndefined(); 1958 | }); 1959 | it('adds a custom response by type', () => { 1960 | underTest.setGatewayResponse('DEFAULT_4XX', {statusCode: 411}); 1961 | expect(underTest.apiConfig().customResponses).toEqual({ 1962 | 'DEFAULT_4XX': {statusCode: 411} 1963 | }); 1964 | }); 1965 | it('adds multiple responses', () => { 1966 | underTest.setGatewayResponse('DEFAULT_4XX', {statusCode: 411}); 1967 | underTest.setGatewayResponse('DEFAULT_5XX', {statusCode: 511}); 1968 | expect(underTest.apiConfig().customResponses).toEqual({ 1969 | 'DEFAULT_4XX': {statusCode: 411}, 1970 | 'DEFAULT_5XX': {statusCode: 511} 1971 | }); 1972 | }); 1973 | it('rejects to redefine a response', () => { 1974 | underTest.setGatewayResponse('DEFAULT_4XX', {statusCode: 411}); 1975 | expect(() => underTest.setGatewayResponse('DEFAULT_4XX', {statusCode: 411})).toThrowError('Response type DEFAULT_4XX is already defined'); 1976 | }); 1977 | it('rejects to define a blank response', () => { 1978 | expect(() => underTest.setGatewayResponse('', {statusCode: 411})).toThrowError('response type must be a string'); 1979 | }); 1980 | it('rejects to define a unconfigured response', () => { 1981 | expect(() => underTest.setGatewayResponse('DEFAULT_4XX', {})).toThrowError('Response type DEFAULT_4XX configuration is invalid'); 1982 | expect(() => underTest.setGatewayResponse('DEFAULT_4XX', 5)).toThrowError('Response type DEFAULT_4XX configuration is invalid'); 1983 | }); 1984 | }); 1985 | describe('registerAuthorizer', () => { 1986 | it('creates no authorizers by default', () => { 1987 | expect(underTest.apiConfig().authorizers).toBeUndefined(); 1988 | }); 1989 | it('can register an authorizer by name', () => { 1990 | underTest.registerAuthorizer('first', { lambdaName: 'blob1' }); 1991 | expect(underTest.apiConfig().authorizers).toEqual({ 1992 | first: { lambdaName: 'blob1' } 1993 | }); 1994 | }); 1995 | it('can register multiple authorizers by name', () => { 1996 | underTest.registerAuthorizer('first', { lambdaName: 'blob1' }); 1997 | underTest.registerAuthorizer('second', { lambdaName: 'blob2' }); 1998 | expect(underTest.apiConfig().authorizers).toEqual({ 1999 | first: { lambdaName: 'blob1' }, 2000 | second: { lambdaName: 'blob2' } 2001 | }); 2002 | }); 2003 | it('complains about the same name used twice', () => { 2004 | underTest.registerAuthorizer('first', { lambdaName: 'blob1' }); 2005 | expect(() => { 2006 | underTest.registerAuthorizer('first', { lambdaName: 'blob2' }); 2007 | }).toThrowError('Authorizer first is already defined'); 2008 | }); 2009 | it('complains about no config authorizers', () => { 2010 | expect(() => { 2011 | underTest.registerAuthorizer('first', {}); 2012 | }).toThrowError('Authorizer first configuration is invalid'); 2013 | expect(() => { 2014 | underTest.registerAuthorizer('first'); 2015 | }).toThrowError('Authorizer first configuration is invalid'); 2016 | }); 2017 | it('complains about nameless authorizers', () => { 2018 | expect(() => { 2019 | underTest.registerAuthorizer('', {}); 2020 | }).toThrowError('Authorizer must have a name'); 2021 | expect(() => { 2022 | underTest.registerAuthorizer(); 2023 | }).toThrowError('Authorizer must have a name'); 2024 | }); 2025 | }); 2026 | describe('setBinaryMediaTypes', () => { 2027 | it('keeps default binaryMediaTypes undefined if not set', () => { 2028 | expect(underTest.apiConfig().binaryMediaTypes).toEqual( 2029 | ['image/webp', 'image/*', 'image/jpg', 'image/jpeg', 'image/gif', 'image/png', 'application/octet-stream', 'application/pdf', 'application/zip'] 2030 | ); 2031 | }); 2032 | it('removes binaryMediaTypes to if set to false', () => { 2033 | underTest.setBinaryMediaTypes(false); 2034 | expect(underTest.apiConfig().binaryMediaTypes).toBeUndefined(); 2035 | }); 2036 | it('sets binaryMediaTypes to a given array', () => { 2037 | underTest.setBinaryMediaTypes(['image/jpg']); 2038 | expect(underTest.apiConfig().binaryMediaTypes).toEqual(['image/jpg']); 2039 | }); 2040 | }); 2041 | describe('error status code', () => { 2042 | it('assigns the default code to a thrown error', done => { 2043 | underTest.get('/error', () => { 2044 | const error = new Error('DB Unavailable'); 2045 | throw error; 2046 | }); 2047 | const event = { 2048 | requestContext: { 2049 | httpMethod: 'GET', 2050 | resourcePath: '/error' 2051 | } 2052 | }; 2053 | return underTest.proxyRouter(event, lambdaContext) 2054 | .then(() => { 2055 | expect(responseStatusCode()).toEqual(500); 2056 | expect(responseBody()).toEqual('{"errorMessage":"DB Unavailable"}'); 2057 | }).then(done, done.fail); 2058 | }); 2059 | it('assigns the code of a thrown ApiResponse', done => { 2060 | underTest.get('/error', () => { 2061 | const error = new ApiBuilder.ApiResponse('O dear!', {}, 555); 2062 | throw error; 2063 | }); 2064 | const event = { 2065 | requestContext: { 2066 | httpMethod: 'GET', 2067 | resourcePath: '/error' 2068 | } 2069 | }; 2070 | return underTest.proxyRouter(event, lambdaContext) 2071 | .then(() => { 2072 | expect(responseStatusCode()).toEqual(555); 2073 | expect(responseBody()).toEqual('O dear!'); 2074 | }).then(done, done.fail); 2075 | }); 2076 | }); 2077 | }); 2078 | -------------------------------------------------------------------------------- /spec/ask-spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, expect, process, require, beforeEach, spyOn, jasmine */ 2 | const readline = require('readline'), 3 | ask = require('../src/ask'); 4 | describe('ask', () => { 5 | 'use strict'; 6 | let fakeReadline; 7 | beforeEach(() => { 8 | fakeReadline = jasmine.createSpyObj('readline', ['question', 'close']); 9 | spyOn(readline, 'createInterface').and.returnValue(fakeReadline); 10 | }); 11 | it('invokes the question without resolving the promise', done => { 12 | fakeReadline.question.and.callFake(prompt => { 13 | expect(readline.createInterface).toHaveBeenCalledWith({ 14 | input: process.stdin, 15 | output: process.stdout 16 | }); 17 | expect(prompt).toEqual('Hi there '); 18 | done(); 19 | }); 20 | ask('Hi there') 21 | .then(done.fail, done.fail); 22 | }); 23 | it('rejects when the question throws error', done => { 24 | fakeReadline.question.and.throwError('BOOM'); 25 | ask('Hi') 26 | .then(done.fail, err => expect(err.message).toEqual('BOOM')) 27 | .then(done); 28 | }); 29 | it('rejects when the value is blank', done => { 30 | fakeReadline.question.and.callFake((prompt, callback) => callback('')); 31 | ask('Number') 32 | .then(done.fail, err => expect(err).toEqual('Number must be provided')) 33 | .then(done); 34 | }); 35 | it('resolves with the value', done => { 36 | fakeReadline.question.and.callFake((prompt, callback) => callback('838')); 37 | ask('Number') 38 | .then(val => expect(val).toEqual('838')) 39 | .then(done, done.fail); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /spec/convert-api-gw-proxy-request-spec.js: -------------------------------------------------------------------------------- 1 | /*global require, describe, it, expect, beforeEach, afterEach */ 2 | const underTest = require('../src/convert-api-gw-proxy-request'); 3 | describe('convertApiGWProxyRequest', () => { 4 | 'use strict'; 5 | let apiGWRequest; 6 | beforeEach(() => { 7 | apiGWRequest = { 8 | 'stageVariables': { 9 | 'lambdaVersion': 'latest' 10 | }, 11 | 'headers': { 12 | 'Authorization': 'abc-DeF', 13 | 'X-Forwarded-Port': '443', 14 | 'Content-Type': 'application/x-www-form-urlencoded', 15 | 'X-Forwarded-For': '24.15.46.241, 54.239.167.121' 16 | }, 17 | 'body': 'birthyear=1905&press=%20OK%20', 18 | 'httpMethod': 'POST', 19 | 'pathParameters': { 20 | 'name': 'sub1' 21 | }, 22 | 'resource': '/hello/{name}', 23 | 'path': '/hello/sub1', 24 | 'queryStringParameters': { 25 | 'a': 'b', 26 | 'c': 'd', 27 | 'code': '403' 28 | }, 29 | 'requestContext': { 30 | 'resourceId': 'rxcwwa', 31 | 'resourcePath': '/hello/{name}', 32 | 'authorizer': { 33 | 'principalId': 'abc' 34 | }, 35 | 'httpMethod': 'POST', 36 | 'identity': { 37 | 'accountId': 'acc-id', 38 | 'userAgent': 'curl/7.43.0', 39 | 'apiKey': 'api-key', 40 | 'cognitoIdentityId': 'cognito-identity-id', 41 | 'user': 'request-user', 42 | 'cognitoIdentityPoolId': 'cognito-pool-id', 43 | 'cognitoAuthenticationProvider': 'cognito-auth-provider', 44 | 'caller': 'request-caller', 45 | 'userArn': 'user-arn', 46 | 'sourceIp': '24.15.46.241', 47 | 'cognitoAuthenticationType': 'cognito-auth-type' 48 | }, 49 | 'accountId': '818931230230', 50 | 'apiId': 'txdif4prz3', 51 | 'stage': 'latest', 52 | 'requestId': 'c1a20045-80ee-11e6-b878-a1b0067c1281' 53 | } 54 | }; 55 | }); 56 | describe('versioning', () => { 57 | it('adds version 3', () => { 58 | expect(underTest(apiGWRequest).v).toEqual(3); 59 | }); 60 | }); 61 | it('does not modify the original request', () => { 62 | const original = JSON.parse(JSON.stringify(apiGWRequest)); 63 | underTest(apiGWRequest); 64 | expect(apiGWRequest).toEqual(original); 65 | }); 66 | describe('pathParams', () => { 67 | it('copies pathParameters into pathParams', () => { 68 | expect(underTest(apiGWRequest).pathParams).toEqual({ 69 | 'name': 'sub1' 70 | }); 71 | }); 72 | it('uses empty object if the original path params are not defined', () => { 73 | apiGWRequest.pathParameters = null; 74 | expect(underTest(apiGWRequest).pathParams).toEqual({}); 75 | }); 76 | }); 77 | describe('queryString', () => { 78 | it('copies queryStringParameters into queryString', () => { 79 | expect(underTest(apiGWRequest).queryString).toEqual({ 80 | 'a': 'b', 81 | 'c': 'd', 82 | 'code': '403' 83 | }); 84 | }); 85 | it('uses empty object if the original query string is not defined', () => { 86 | apiGWRequest.queryStringParameters = null; 87 | expect(underTest(apiGWRequest).queryString).toEqual({}); 88 | }); 89 | }); 90 | describe('env', () => { 91 | it('copies stageVariables into env', () => { 92 | expect(underTest(apiGWRequest).env).toEqual({ 93 | 'lambdaVersion': 'latest' 94 | }); 95 | }); 96 | it('uses empty object original stage variables are not defined', () => { 97 | apiGWRequest.stageVariables = null; 98 | expect(underTest(apiGWRequest).env).toEqual({}); 99 | }); 100 | }); 101 | describe('headers', () => { 102 | it('copies headers intact', () => { 103 | expect(underTest(apiGWRequest).headers).toEqual({ 104 | 'Authorization': 'abc-DeF', 105 | 'X-Forwarded-Port': '443', 106 | 'Content-Type': 'application/x-www-form-urlencoded', 107 | 'X-Forwarded-For': '24.15.46.241, 54.239.167.121' 108 | }); 109 | }); 110 | it('replaces headers with an empty object if not defined', () => { 111 | apiGWRequest.headers = null; 112 | expect(underTest(apiGWRequest).headers).toEqual({}); 113 | }); 114 | }); 115 | describe('normalizedHeaders', () => { 116 | it('creates a copy of headers with lowercase header names', () => { 117 | expect(underTest(apiGWRequest).normalizedHeaders).toEqual({ 118 | 'authorization': 'abc-DeF', 119 | 'x-forwarded-port': '443', 120 | 'content-type': 'application/x-www-form-urlencoded', 121 | 'x-forwarded-for': '24.15.46.241, 54.239.167.121' 122 | }); 123 | }); 124 | it('uses empty object if headers are not defined', () => { 125 | apiGWRequest.headers = null; 126 | expect(underTest(apiGWRequest).normalizedHeaders).toEqual({}); 127 | }); 128 | }); 129 | describe('post', () => { 130 | it('is not present if the content type is not application/x-www-form-urlencoded', () => { 131 | apiGWRequest.headers['Content-Type'] = 'application/xml; charset=ISO-8859-1'; 132 | expect(Object.keys(underTest(apiGWRequest))).not.toContain('post'); 133 | }); 134 | it('is not present if the content type header is not defined', () => { 135 | delete apiGWRequest.headers['Content-Type']; 136 | expect(Object.keys(underTest(apiGWRequest))).not.toContain('post'); 137 | }); 138 | it('is a decoded URL string if the content type is application/x-www-form-urlencoded', () => { 139 | expect(underTest(apiGWRequest).post).toEqual({ birthyear: '1905', press: ' OK ' }); 140 | }); 141 | it('works with mixed case content type header', () => { 142 | delete apiGWRequest.headers['Content-Type']; 143 | apiGWRequest.headers['content-Type'] = 'application/x-www-form-urlencoded'; 144 | expect(underTest(apiGWRequest).post).toEqual({ birthyear: '1905', press: ' OK ' }); 145 | }); 146 | it('is an empty object if body is not defined', () => { 147 | apiGWRequest.body = null; 148 | expect(underTest(apiGWRequest).post).toEqual({}); 149 | }); 150 | it('works even if the client provides a charset with the content type header', () => { 151 | apiGWRequest.headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=ISO-8859-1'; 152 | expect(underTest(apiGWRequest).post).toEqual({ birthyear: '1905', press: ' OK ' }); 153 | }); 154 | }); 155 | describe('body', () => { 156 | ['', 'text/plain', 'application/xml', 'text/xml', 'application/x-www-form-urlencoded'].forEach(function (contentType) { 157 | describe('if content type is ' + (contentType || 'not provided'), () => { 158 | beforeEach(() => { 159 | apiGWRequest.headers['Content-Type'] = contentType; 160 | }); 161 | it('is just copied', () => { 162 | expect(underTest(apiGWRequest).body).toEqual('birthyear=1905&press=%20OK%20'); 163 | }); 164 | it('is a blank string if the original body is null ', () => { 165 | apiGWRequest.body = null; 166 | expect(underTest(apiGWRequest).body).toEqual(''); 167 | }); 168 | }); 169 | }); 170 | describe('if content type is application/json', () => { 171 | beforeEach(() => { 172 | apiGWRequest.body = JSON.stringify({ birthyear: '1905', press: ' OK ' }); 173 | apiGWRequest.headers['Content-Type'] = 'application/json'; 174 | }); 175 | it('is a parsed JSON object if the content type is application/json', () => { 176 | expect(underTest(apiGWRequest).body).toEqual({ birthyear: '1905', press: ' OK ' }); 177 | }); 178 | it('works with mixed case content type header', () => { 179 | delete apiGWRequest.headers['Content-Type']; 180 | apiGWRequest.headers['content-Type'] = 'application/json'; 181 | expect(underTest(apiGWRequest).body).toEqual({ birthyear: '1905', press: ' OK ' }); 182 | }); 183 | it('works even if the client provides a charset with the content type header', () => { 184 | apiGWRequest.headers['Content-Type'] = 'application/json'; 185 | expect(underTest(apiGWRequest).body).toEqual({ birthyear: '1905', press: ' OK ' }); 186 | }); 187 | it('throws an error if JSON cannot be parsed', () => { 188 | apiGWRequest.body = '{ "a": "b"'; 189 | expect(() => { 190 | underTest(apiGWRequest); 191 | }).toThrowError('The content does not match the supplied content type'); 192 | }); 193 | ['', null, undefined].forEach(function (body) { 194 | it(`is a blank object if body was [${body}]`, () => { 195 | apiGWRequest.body = body; 196 | expect(underTest(apiGWRequest).body).toEqual({}); 197 | }); 198 | }); 199 | }); 200 | describe('when it is base64encoded', () => { 201 | let encoded, decoded; 202 | beforeEach(() => { 203 | decoded = JSON.stringify({ a: 'b' }); 204 | encoded = new Buffer(decoded).toString('base64'); 205 | apiGWRequest.body = encoded; 206 | apiGWRequest.isBase64Encoded = true; 207 | }); 208 | it('decodes then parses application/json', () => { 209 | apiGWRequest.headers['Content-Type'] = 'application/json'; 210 | expect(underTest(apiGWRequest).body).toEqual(JSON.parse(decoded)); 211 | expect(underTest(apiGWRequest).rawBody).toEqual(encoded); 212 | }); 213 | ['text/plain', 'application/xml', 'text/xml'].forEach(textContent => { 214 | it(`decodes ${textContent} into utf8`, () => { 215 | apiGWRequest.headers['Content-Type'] = textContent; 216 | expect(underTest(apiGWRequest).body).toEqual(decoded); 217 | expect(underTest(apiGWRequest).rawBody).toEqual(encoded); 218 | }); 219 | }); 220 | it('keeps other types as a binary buffer', () => { 221 | apiGWRequest.headers['Content-Type'] = 'application/octet-stream'; 222 | expect(underTest(apiGWRequest).body).toEqual(new Buffer(decoded)); 223 | expect(underTest(apiGWRequest).rawBody).toEqual(encoded); 224 | }); 225 | it('decodes application/x-www-form-urlencoded', () => { 226 | apiGWRequest.headers['Content-Type'] = 'application/x-www-form-urlencoded'; 227 | apiGWRequest.body = new Buffer('birthyear=1905&press=%20OK%20').toString('base64'); 228 | 229 | const result = underTest(apiGWRequest); 230 | 231 | expect(result.body).toEqual('birthyear=1905&press=%20OK%20'); 232 | expect(result.rawBody).toEqual(apiGWRequest.body); 233 | expect(result.post).toEqual({birthyear: '1905', press: ' OK '}); 234 | }); 235 | }); 236 | 237 | }); 238 | describe('rawBody', () => { 239 | ['application/json', 'text/plain', 'text/xml', null, '', undefined].forEach(function (contentType) { 240 | describe(`when content type is "${contentType}"`, () => { 241 | beforeEach(() => { 242 | apiGWRequest.headers['Content-Type'] = contentType; 243 | apiGWRequest.body = '{"a": "b"}'; 244 | }); 245 | it('contains the original copy of the body', function () { 246 | expect(underTest(apiGWRequest).rawBody).toEqual('{"a": "b"}'); 247 | }); 248 | it('is a blank string if the original body was null', () => { 249 | apiGWRequest.body = null; 250 | expect(underTest(apiGWRequest).rawBody).toEqual(''); 251 | }); 252 | }); 253 | }); 254 | }); 255 | describe('rawBody contains object', () => { 256 | describe('when content type is "application/json" and body is an object body', () => { 257 | beforeEach(() => { 258 | apiGWRequest.headers['Content-Type'] = 'application/json'; 259 | apiGWRequest.body = { a: 'b' }; 260 | }); 261 | it('contains the original copy of the body', () => { 262 | expect(underTest(apiGWRequest).body).toEqual({ a: 'b' }); 263 | }); 264 | it('is a empty object {} if the original body was null', () => { 265 | apiGWRequest.body = null; 266 | expect(underTest(apiGWRequest).rawBody).toEqual(''); 267 | expect(underTest(apiGWRequest).body).toEqual({}); 268 | }); 269 | it('is a empty object {} if the original body was undefined', () => { 270 | apiGWRequest.body = undefined; 271 | expect(underTest(apiGWRequest).rawBody).toEqual(''); 272 | expect(underTest(apiGWRequest).body).toEqual({}); 273 | }); 274 | }); 275 | }); 276 | describe('lambdaContext', () => { 277 | it('contains the value of the second argument, if provided', () => { 278 | expect(underTest(apiGWRequest, {a: 'b123'}).lambdaContext).toEqual({a: 'b123'}); 279 | }); 280 | }); 281 | describe('proxyRequest', () => { 282 | it('contains the original API GW Proxy request', () => { 283 | expect(underTest(apiGWRequest).proxyRequest).toEqual(apiGWRequest); 284 | }); 285 | }); 286 | describe('context', () => { 287 | describe('method', () => { 288 | it('contains the http method', () => { 289 | expect(underTest(apiGWRequest).context.method).toEqual('POST'); 290 | }); 291 | it('is always uppercase', () => { 292 | apiGWRequest.requestContext.httpMethod = 'opTiOnS'; 293 | expect(underTest(apiGWRequest).context.method).toEqual('OPTIONS'); 294 | }); 295 | it('is GET if httpMethod is not specified', () => { 296 | apiGWRequest.requestContext.httpMethod = null; 297 | expect(underTest(apiGWRequest).context.method).toEqual('GET'); 298 | }); 299 | }); 300 | describe('path', () => { 301 | it('contains the request path', () => { 302 | expect(underTest(apiGWRequest).context.path).toEqual('/hello/{name}'); 303 | }); 304 | }); 305 | describe('stage', () => { 306 | it('containst the api gateway stage', () => { 307 | expect(underTest(apiGWRequest).context.stage).toEqual('latest'); 308 | }); 309 | }); 310 | describe('sourceIp', () => { 311 | it('containst the api request source IP', () => { 312 | expect(underTest(apiGWRequest).context.sourceIp).toEqual('24.15.46.241'); 313 | }); 314 | }); 315 | describe('accountId', () => { 316 | it('containst the request account ID', () => { 317 | expect(underTest(apiGWRequest).context.accountId).toEqual('acc-id'); 318 | }); 319 | }); 320 | describe('user', () => { 321 | it('containst the request AWS user', () => { 322 | expect(underTest(apiGWRequest).context.user).toEqual('request-user'); 323 | }); 324 | }); 325 | describe('userAgent', () => { 326 | it('containst the request user agent', () => { 327 | expect(underTest(apiGWRequest).context.user).toEqual('request-user'); 328 | }); 329 | }); 330 | describe('userArn', () => { 331 | it('containst the request AWS user ARN', () => { 332 | expect(underTest(apiGWRequest).context.userArn).toEqual('user-arn'); 333 | }); 334 | }); 335 | describe('caller', () => { 336 | it('containst the request caller identity', () => { 337 | expect(underTest(apiGWRequest).context.caller).toEqual('request-caller'); 338 | }); 339 | }); 340 | describe('apiKey', () => { 341 | it('containst the API key used for the call', () => { 342 | expect(underTest(apiGWRequest).context.apiKey).toEqual('api-key'); 343 | }); 344 | }); 345 | describe('authorizerPrincipalId', () => { 346 | it('containst the authorizer principal, if provided', () => { 347 | expect(underTest(apiGWRequest).context.authorizerPrincipalId).toEqual('abc'); 348 | }); 349 | it('is null if authorizer context is not present', () => { 350 | apiGWRequest.requestContext.authorizer = null; 351 | expect(underTest(apiGWRequest).context.authorizerPrincipalId).toEqual(null); 352 | }); 353 | }); 354 | describe('authorizer', () => { 355 | it('containst the authorizer information, if provided', () => { 356 | expect(underTest(apiGWRequest).context.authorizer).toEqual({principalId: 'abc'}); 357 | }); 358 | it('is null if authorizer context is not present', () => { 359 | apiGWRequest.requestContext.authorizer = null; 360 | expect(underTest(apiGWRequest).context.authorizer).toEqual(null); 361 | }); 362 | }); 363 | describe('cognitoAuthenticationProvider', () => { 364 | it('containst the cognito authentication provider', () => { 365 | expect(underTest(apiGWRequest).context.cognitoAuthenticationProvider).toEqual('cognito-auth-provider'); 366 | }); 367 | }); 368 | describe('cognitoAuthenticationType', () => { 369 | it('containst the cognito authentication type', () => { 370 | expect(underTest(apiGWRequest).context.cognitoAuthenticationType).toEqual('cognito-auth-type'); 371 | }); 372 | }); 373 | describe('cognitoIdentityId', () => { 374 | it('containst the cognito identity ID', () => { 375 | expect(underTest(apiGWRequest).context.cognitoIdentityId).toEqual('cognito-identity-id'); 376 | }); 377 | }); 378 | describe('cognitoIdentityPoolId', () => { 379 | it('containst the cognito identity pool ID', () => { 380 | expect(underTest(apiGWRequest).context.cognitoIdentityPoolId).toEqual('cognito-pool-id'); 381 | }); 382 | }); 383 | it('is an empty object if request context is not defined', () => { 384 | apiGWRequest.requestContext = null; 385 | expect(underTest(apiGWRequest).context).toEqual({}); 386 | }); 387 | }); 388 | describe('env variable handling', () => { 389 | let oldPe; 390 | beforeEach(() => { 391 | apiGWRequest.stageVariables = { 392 | s: 'stage2', 393 | sg: 'stage3', 394 | sp: 'stage4', 395 | sgp: 'stage1' 396 | }; 397 | oldPe = process.env; 398 | process.env = { 399 | g: 'process1', 400 | gp: 'process2', 401 | sg: 'process3', 402 | sgp: 'process4', 403 | latest_gp: 'process5', 404 | latest_sgp: 'process6', 405 | latest_sp: 'process7', 406 | latest_p: 'process8' 407 | }; 408 | }); 409 | afterEach(() => { 410 | process.env = oldPe; 411 | }); 412 | it('does not change stage vars if mergeVars is false', () => { 413 | expect(underTest(apiGWRequest).env).toEqual({ 414 | s: 'stage2', 415 | sg: 'stage3', 416 | sp: 'stage4', 417 | sgp: 'stage1' 418 | }); 419 | }); 420 | it('merges process.env over stage vars if mergeVars is true', () => { 421 | expect(underTest(apiGWRequest, {}, true).env).toEqual({ 422 | s: 'stage2', 423 | sg: 'stage3', 424 | sp: 'stage4', 425 | sgp: 'stage1', 426 | g: 'process1', 427 | gp: 'process5', 428 | latest_gp: 'process5', 429 | latest_sgp: 'process6', 430 | latest_sp: 'process7', 431 | latest_p: 'process8', 432 | p: 'process8' 433 | }); 434 | }); 435 | }); 436 | }); 437 | -------------------------------------------------------------------------------- /spec/lowercase-keys-spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, expect, require */ 2 | const lowercaseKeys = require('../src/lowercase-keys'); 3 | describe('lowercaseKeys', () => { 4 | 'use strict'; 5 | it('produces a blank object with literal values', () => { 6 | expect(lowercaseKeys(1)).toEqual({}); 7 | expect(lowercaseKeys(null)).toEqual({}); 8 | expect(lowercaseKeys(false)).toEqual({}); 9 | expect(lowercaseKeys(true)).toEqual({}); 10 | expect(lowercaseKeys('true')).toEqual({}); 11 | }); 12 | it('produces a blank object with arrays', () => { 13 | expect(lowercaseKeys([1, 2, 'abc'])).toEqual({}); 14 | }); 15 | it('downcases keys on an object', () => { 16 | expect(lowercaseKeys({ blah: 'Yes', BaBx: 'Nope', 'X-123': 'al'})).toEqual({ blah: 'Yes', babx: 'Nope', 'x-123': 'al'}); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /spec/merge-vars-spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, expect, require */ 2 | const mergeVars = require('../src/merge-vars'); 3 | describe('mergeVars', function () { 4 | 'use strict'; 5 | it('merges vars with a prefix', function () { 6 | expect(mergeVars({a: 1}, {'v1_b': 'b1', 'v2_b': 'b2'}, 'v2_')).toEqual({a: 1, b: 'b2'}); 7 | expect(mergeVars({a: 1, b: 3}, {'v1_b': 'b1', 'v2_b': 'b2'}, 'v2_')).toEqual({a: 1, b: 'b2'}); 8 | expect(mergeVars({a: 1, b: 3}, {'v1_b': 'b1', 'v2_c': 23, 'v2_b': 'b2'}, 'v2_')).toEqual({a: 1, b: 'b2', c: 23}); 9 | expect(mergeVars({}, {'v1_b': 'b1', 'v2_c': 23, 'v2_b': 'b2'}, 'v2_')).toEqual({b: 'b2', c: 23}); 10 | expect(mergeVars({a: 1, b: 3}, {'v1_b': 'b1', 'v2_c': 23, 'v2_b': 'b2'}, 'v3_')).toEqual({a: 1, b: 3}); 11 | expect(mergeVars({a: 1, b: 3}, {}, 'v3_')).toEqual({a: 1, b: 3}); 12 | expect(mergeVars(undefined, {'v3_a': 1}, 'v3_')).toEqual({a: 1}); 13 | expect(mergeVars(undefined, {'v3_a': 1}, 'v2_')).toEqual({}); 14 | expect(mergeVars({a: 1}, undefined, 'v2_')).toEqual({a: 1}); 15 | expect(mergeVars(undefined, undefined, 'v2_')).toEqual({}); 16 | }); 17 | it('copies all the vars if no prefix given', function () { 18 | expect(mergeVars({a: 1, b: 3}, {c: 'd'}, '')).toEqual({a: 1, b: 3, c: 'd'}); 19 | expect(mergeVars({a: 1, b: 3}, {c: 'd'})).toEqual({a: 1, b: 3, c: 'd'}); 20 | }); 21 | it('does not modify its arguments', function () { 22 | const from = {'v1_a': 1}, 23 | to = {a: 2}; 24 | mergeVars(to, from, 'v1_'); 25 | expect(from).toEqual({'v1_a': 1}); 26 | expect(to).toEqual({a: 2}); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /spec/sequential-promise-map-spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, expect, require, beforeEach, Promise,setTimeout */ 2 | const sequentialPromiseMap = require('../src/sequential-promise-map'); 3 | describe('sequentialPromiseMap', () => { 4 | 'use strict'; 5 | let promises; 6 | const waitFor = function (index) { 7 | return new Promise(resolve => { 8 | const poll = function () { 9 | if (promises[index]) { 10 | resolve({ promise: promises[index] }); 11 | } else { 12 | setTimeout(poll, 50); 13 | } 14 | }; 15 | poll(); 16 | }); 17 | }, 18 | generator = function (arg) { 19 | let next = {}, res, rej; 20 | next = new Promise((resolve, reject) => { 21 | res = resolve; 22 | rej = reject; 23 | }); 24 | next.reject = rej; 25 | next.resolve = res; 26 | next.arg = arg; 27 | promises.push(next); 28 | return next; 29 | }; 30 | beforeEach(() => { 31 | promises = []; 32 | }); 33 | it('resolves immediately if no arguments', done => { 34 | sequentialPromiseMap([], generator).then(result => { 35 | expect(promises.length).toEqual(0); 36 | expect(result).toEqual([]); 37 | }).then(done, done.fail); 38 | }); 39 | it('calls the generator with the argument and an index', done => { 40 | sequentialPromiseMap(['a', 'b', 'c'], (txt, index) => Promise.resolve(txt + index)) 41 | .then(result => expect(result).toEqual(['a0', 'b1', 'c2'])) 42 | .then(done, done.fail); 43 | }); 44 | it('executes a single promise mapping', done => { 45 | sequentialPromiseMap(['a'], generator).then(result => { 46 | expect(promises.length).toEqual(1); 47 | expect(result).toEqual(['eee']); 48 | expect(promises[0].arg).toEqual('a'); 49 | }).then(done, done.fail); 50 | waitFor(0).then(promiseContainer => { 51 | expect(promiseContainer.promise.arg).toEqual('a'); 52 | promiseContainer.promise.resolve('eee'); 53 | }).catch(done.fail); 54 | }); 55 | it('does not resolve until all promises resolve', done => { 56 | sequentialPromiseMap(['a', 'b', 'c'], generator).then(done.fail, done.fail); 57 | waitFor(0).then(promiseContainer => promiseContainer.promise.resolve('eee')); 58 | waitFor(1).then(() => expect(promises.length).toEqual(2)).then(done); 59 | }); 60 | it('resolves after all the promises resolve', done => { 61 | sequentialPromiseMap(['a', 'b'], generator) 62 | .then(result => expect(result).toEqual(['aaa', 'bbb'])) 63 | .then(done, done.fail); 64 | waitFor(0).then(promiseContainer => promiseContainer.promise.resolve('aaa')); 65 | waitFor(1).then(promiseContainer => promiseContainer.promise.resolve('bbb')); 66 | }); 67 | it('does not modify the original array', done => { 68 | const originalArray = ['a', 'b']; 69 | sequentialPromiseMap(originalArray, generator) 70 | .then(() => expect(originalArray).toEqual(['a', 'b'])) 71 | .then(done, done.fail); 72 | waitFor(0).then(promiseContainer => promiseContainer.promise.resolve('aaa')); 73 | waitFor(1).then(promiseContainer => promiseContainer.promise.resolve('bbb')); 74 | }); 75 | it('does not execute subsequent promises after a failure', done => { 76 | sequentialPromiseMap(['a', 'b'], generator).then(done.fail, () => { 77 | expect(promises.length).toEqual(1); 78 | done(); 79 | }); 80 | waitFor(0).then(promiseContainer => promiseContainer.promise.reject('aaa')); 81 | }); 82 | it('rejects with the error of the first rejected promise', done => { 83 | sequentialPromiseMap(['a', 'b', 'c'], generator) 84 | .then(done.fail, err => { 85 | expect(err).toEqual('boom'); 86 | done(); 87 | }); 88 | waitFor(0).then(promiseContainer => promiseContainer.promise.resolve('aaa')); 89 | waitFor(1).then(promiseContainer => promiseContainer.promise.reject('boom')); 90 | }); 91 | it('rejects if the first argument is not an array', () => { 92 | expect(() => sequentialPromiseMap({}, generator)).toThrowError('the first argument must be an array'); 93 | }); 94 | it('rejects if the second argument is not a function', () => { 95 | expect(() => sequentialPromiseMap(['x'], 2)).toThrowError('the second argument must be a function'); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /spec/support/jasmine-runner.js: -------------------------------------------------------------------------------- 1 | /*global jasmine, require, process*/ 2 | const Jasmine = require('jasmine'), 3 | SpecReporter = require('jasmine-spec-reporter'), 4 | jrunner = new Jasmine(), 5 | runJasmine = function () { 6 | 'use strict'; 7 | let filter; 8 | process.argv.slice(2).forEach(option => { 9 | if (option === 'full') { 10 | jasmine.getEnv().clearReporters(); 11 | jasmine.getEnv().addReporter(new SpecReporter({ 12 | displayStacktrace: 'all' 13 | })); 14 | } 15 | if (option === 'ci') { 16 | jasmine.getEnv().clearReporters(); 17 | jasmine.getEnv().addReporter(new SpecReporter({ 18 | displayStacktrace: 'all', 19 | displaySpecDuration: true, 20 | displaySuiteNumber: true, 21 | colors: false, 22 | prefixes: { 23 | success: '[pass] ', 24 | failure: '[fail] ', 25 | pending: '[skip] ' 26 | } 27 | })); 28 | } 29 | if (option.match('^filter=')) { 30 | filter = option.match('^filter=(.*)')[1]; 31 | } 32 | }); 33 | jrunner.loadConfig({ 34 | 'spec_dir': 'spec', 35 | 'spec_files': [ 36 | '**/*[sS]pec.js' 37 | ], 38 | 'helpers': [ 39 | 'helpers/**/*.js' 40 | ] 41 | }); 42 | jrunner.execute(undefined, filter); 43 | }; 44 | 45 | runJasmine(); 46 | -------------------------------------------------------------------------------- /spec/valid-http-code-spec.js: -------------------------------------------------------------------------------- 1 | /*global describe, it, require, expect */ 2 | const underTest = require('../src/valid-http-code'); 3 | describe('validHttpCode', () => { 4 | 'use strict'; 5 | it('returns true for integers 200-599', () => { 6 | expect(underTest(199)).toBeFalsy(); 7 | expect(underTest(0)).toBeFalsy(); 8 | expect(underTest(-1)).toBeFalsy(); 9 | expect(underTest(200)).toBeTruthy(); 10 | expect(underTest(201)).toBeTruthy(); 11 | expect(underTest(500)).toBeTruthy(); 12 | expect(underTest(599)).toBeTruthy(); 13 | expect(underTest(600)).toBeFalsy(); 14 | }); 15 | it('returns true for 200-599 strings as numbers', () => { 16 | expect(underTest('199')).toBeFalsy(); 17 | expect(underTest('0')).toBeFalsy(); 18 | expect(underTest('-1')).toBeFalsy(); 19 | expect(underTest('200')).toBeTruthy(); 20 | expect(underTest('201')).toBeTruthy(); 21 | expect(underTest('500')).toBeTruthy(); 22 | expect(underTest('599')).toBeTruthy(); 23 | expect(underTest('600')).toBeFalsy(); 24 | }); 25 | it('returns false for structures', () => { 26 | expect(underTest({})).toBeFalsy(); 27 | expect(underTest([])).toBeFalsy(); 28 | expect(underTest({a: 1})).toBeFalsy(); 29 | expect(underTest([1, 2, 3])).toBeFalsy(); 30 | }); 31 | it('returns false for non-numeric strings', () => { 32 | expect(underTest('abc')).toBeFalsy(); 33 | expect(underTest('def203')).toBeFalsy(); 34 | expect(underTest('201.4def')).toBeFalsy(); 35 | }); 36 | it('returns false for floats and float strings', () => { 37 | expect(underTest(302.3)).toBeFalsy(); 38 | expect(underTest('302.3')).toBeFalsy(); 39 | }); 40 | it('returns false for booleans and falsy values', () => { 41 | expect(underTest(true)).toBeFalsy(); 42 | expect(underTest(false)).toBeFalsy(); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/api-builder.js: -------------------------------------------------------------------------------- 1 | const util = require('util'), 2 | convertApiGWProxyRequest = require('./convert-api-gw-proxy-request'), 3 | sequentialPromiseMap = require('./sequential-promise-map'), 4 | lowercaseKeys = require('./lowercase-keys'); 5 | module.exports = function ApiBuilder(options) { 6 | 'use strict'; 7 | let customCorsHandler, 8 | customCorsHeaders, 9 | customCorsMaxAge, 10 | customResponses, 11 | unsupportedEventCallback, 12 | authorizers, 13 | interceptCallback, 14 | requestFormat, 15 | binaryMediaTypes; 16 | 17 | const self = this, 18 | safeStringify = function (object) { 19 | return util.format('%j', object); 20 | }, 21 | getRequestFormat = function (newFormat) { 22 | const supportedFormats = ['AWS_PROXY', 'CLAUDIA_API_BUILDER']; 23 | if (!newFormat) { 24 | return 'CLAUDIA_API_BUILDER'; 25 | } else { 26 | if (supportedFormats.indexOf(newFormat) >= 0) { 27 | return newFormat; 28 | } else { 29 | throw `Unsupported request format ${newFormat}`; 30 | } 31 | } 32 | }, 33 | defaultBinaryMediaTypes = [ 34 | 'image/webp', 35 | 'image/*', 36 | 'image/jpg', 37 | 'image/jpeg', 38 | 'image/gif', 39 | 'image/png', 40 | 'application/octet-stream', 41 | 'application/pdf', 42 | 'application/zip' 43 | ], 44 | logger = (options && options.logger) || console.log, 45 | methodConfigurations = {}, 46 | routes = {}, 47 | postDeploySteps = {}, 48 | v2DeprecationWarning = function (what) { 49 | logger(`${what} are deprecated, and be removed in claudia api builder v3. Check https://claudiajs.com/tutorials/migrating_to_2.html`); 50 | }, 51 | supportedMethods = ['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'PATCH'], 52 | prompter = (options && options.prompter) || require('./ask'), 53 | isApiResponse = function (obj) { 54 | return obj && (typeof obj === 'object') && (Object.getPrototypeOf(obj) === self.ApiResponse.prototype); 55 | }, 56 | mergeObjects = function (from, to) { 57 | return Object.assign(to, from); 58 | }, 59 | isRedirect = function (code) { 60 | return /3[0-9][1-3]/.test(code); 61 | }, 62 | getContentType = function (configuration, result) { 63 | const staticHeader = (configuration && configuration.headers && lowercaseKeys(configuration.headers)['content-type']), 64 | dynamicHeader = (result && isApiResponse(result) && result.headers && lowercaseKeys(result.headers)['content-type']), 65 | staticConfig = configuration && configuration.contentType; 66 | 67 | return dynamicHeader || staticHeader || staticConfig || 'application/json'; 68 | }, 69 | getStatusCode = function (configuration, result, resultType) { 70 | const defaultCode = { 71 | 'success': 200, 72 | 'error': 500 73 | }, 74 | staticCode = (configuration && configuration.code) || (typeof configuration === 'number' && configuration), 75 | dynamicCode = result && (isApiResponse(result) && result.code); 76 | return dynamicCode || staticCode || defaultCode[resultType]; 77 | }, 78 | getRedirectLocation = function (configuration, result) { 79 | const dynamicHeader = result && isApiResponse(result) && result.headers && lowercaseKeys(result.headers).location, 80 | dynamicBody = isApiResponse(result) ? result.response : result, 81 | staticHeader = configuration && configuration.headers && lowercaseKeys(configuration.headers).location; 82 | return dynamicHeader || dynamicBody || staticHeader; 83 | }, 84 | getCanonicalContentType = function (contentType) { 85 | return (contentType && contentType.split(';')[0]) || 'application/json'; 86 | }, 87 | safeToString = function (contents) { 88 | if (!contents) { 89 | return ''; 90 | } 91 | if (Buffer.isBuffer(contents)) { 92 | return contents.toString('base64'); 93 | } 94 | if (typeof contents === 'string') { 95 | return contents; 96 | } 97 | if (typeof contents === 'object') { 98 | return safeStringify(contents); 99 | } 100 | return String(contents); 101 | }, 102 | getSuccessBody = function (contentType, handlerResult) { 103 | const contents = isApiResponse(handlerResult) ? handlerResult.response : handlerResult; 104 | if (getCanonicalContentType(contentType) === 'application/json') { 105 | if (contents === '' || contents === undefined) { 106 | return '{}'; 107 | } else { 108 | try { 109 | return JSON.stringify(contents); 110 | } catch (e) { 111 | throw new Error('Response contains a circular reference and cannot be serialized to JSON'); 112 | } 113 | } 114 | } else { 115 | return safeToString(contents); 116 | } 117 | }, 118 | isError = function (object) { 119 | return object && (object.message !== undefined) && object.stack; 120 | }, 121 | logError = function (err) { 122 | let logInfo = err; 123 | if (isApiResponse(err)) { 124 | logInfo = safeStringify(err); 125 | } else if (isError(err)) { 126 | logInfo = err.stack; 127 | } 128 | logger(logInfo); 129 | }, 130 | getErrorResponseContents = function (handlerResult) { 131 | if (!handlerResult) { 132 | return ''; 133 | } 134 | if (isApiResponse(handlerResult)) { 135 | return handlerResult.response; 136 | } 137 | if (isError(handlerResult) && handlerResult.message) { 138 | return handlerResult.message; 139 | } 140 | return handlerResult; 141 | }, 142 | getErrorBody = function (contentType, handlerResult) { 143 | const responseContents = safeToString(getErrorResponseContents(handlerResult)); 144 | 145 | if (isApiResponse(handlerResult) || getCanonicalContentType(contentType) !== 'application/json') { 146 | return responseContents; 147 | } 148 | return JSON.stringify({errorMessage: responseContents || '' }); 149 | }, 150 | getBody = function (contentType, handlerResult, resultType) { 151 | return resultType === 'success' ? getSuccessBody(contentType, handlerResult) : getErrorBody(contentType, handlerResult); 152 | }, 153 | packResult = function (handlerResult, routingInfo, corsHeaders, resultType) { 154 | const path = routingInfo.path.replace(/^\//, ''), 155 | method = routingInfo.method, 156 | configuration = methodConfigurations[path] && methodConfigurations[path][method] && methodConfigurations[path][method][resultType], 157 | customHeaders = configuration && configuration.headers, 158 | contentType = getContentType(configuration, handlerResult), 159 | statusCode = getStatusCode(configuration, handlerResult, resultType), 160 | result = { 161 | statusCode: statusCode, 162 | headers: { 'Content-Type': contentType }, 163 | body: getBody(contentType, handlerResult, resultType) 164 | }; 165 | if (configuration && configuration.contentHandling === 'CONVERT_TO_BINARY' && resultType === 'success') { 166 | result.isBase64Encoded = true; 167 | } 168 | mergeObjects(corsHeaders, result.headers); 169 | if (customHeaders) { 170 | if (Array.isArray(customHeaders)) { 171 | v2DeprecationWarning('enumerated headers'); 172 | } else { 173 | mergeObjects(customHeaders, result.headers); 174 | } 175 | } 176 | if (isApiResponse(handlerResult)) { 177 | mergeObjects(handlerResult.headers, result.headers); 178 | } 179 | if (isRedirect(statusCode)) { 180 | result.headers.Location = getRedirectLocation(configuration, handlerResult); 181 | } 182 | return result; 183 | }, 184 | getCorsHeaders = function (request, methods) { 185 | if (methods.indexOf('ANY') >= 0 || methods.length === 0) { 186 | methods = supportedMethods; 187 | } 188 | return Promise.resolve().then(() => { 189 | if (customCorsHandler === false) { 190 | return ''; 191 | } else if (customCorsHandler) { 192 | return customCorsHandler(request); 193 | } else { 194 | return '*'; 195 | } 196 | }).then(corsOrigin => { 197 | if (!corsOrigin) { 198 | return {}; 199 | }; 200 | return { 201 | 'Access-Control-Allow-Origin': corsOrigin, 202 | 'Access-Control-Allow-Headers': (customCorsHeaders || 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token'), 203 | 'Access-Control-Allow-Methods': methods.sort().join(',') + ',OPTIONS', 204 | 'Access-Control-Allow-Credentials': 'true', 205 | 'Access-Control-Max-Age': customCorsMaxAge || '0' 206 | }; 207 | }); 208 | }, 209 | routeEvent = function (routingInfo, event, context) { 210 | if (!routingInfo) { 211 | throw 'routingInfo not set'; 212 | } 213 | const handler = routes[routingInfo.path] && ( 214 | routes[routingInfo.path][routingInfo.method] || 215 | routes[routingInfo.path].ANY 216 | ); 217 | return getCorsHeaders(event, Object.keys(routes[routingInfo.path] || {})) 218 | .then(corsHeaders => { 219 | if (routingInfo.method === 'OPTIONS') { 220 | return { 221 | statusCode: 200, 222 | body: '', 223 | headers: corsHeaders 224 | }; 225 | } else if (handler) { 226 | return Promise.resolve() 227 | .then(() => handler(event, context)) 228 | .then(result => packResult(result, routingInfo, corsHeaders, 'success')) 229 | .catch(error => { 230 | logError(error); 231 | return packResult(error, routingInfo, corsHeaders, 'error'); 232 | }); 233 | } else { 234 | return Promise.reject(`no handler for ${routingInfo.method} ${routingInfo.path}`); 235 | } 236 | }); 237 | 238 | }, 239 | getRequestRoutingInfo = function (request) { 240 | if (requestFormat === 'AWS_PROXY') { 241 | if (!request.requestContext) { 242 | return {}; 243 | } 244 | return { 245 | path: request.requestContext.resourcePath, 246 | method: request.requestContext.httpMethod 247 | }; 248 | } else { 249 | return request.context || {}; 250 | } 251 | }, 252 | isFromApiGw = function (event) { 253 | return event && event.requestContext && event.requestContext.resourcePath && event.requestContext.httpMethod; 254 | }, 255 | getRequest = function (event, context) { 256 | if (requestFormat === 'AWS_PROXY' || requestFormat === 'DEPRECATED' || !isFromApiGw(event)) { 257 | return event; 258 | } else { 259 | return convertApiGWProxyRequest(event, context, options && options.mergeVars); 260 | } 261 | }, 262 | executeInterceptor = function (request, context) { 263 | if (!interceptCallback) { 264 | return Promise.resolve(request); 265 | } else { 266 | return Promise.resolve() 267 | .then(() => interceptCallback(request, context)); 268 | } 269 | }, 270 | setUpHandler = function (method) { 271 | self[method.toLowerCase()] = function (route, handler, options) { 272 | const pathPart = route.replace(/^\//, ''); 273 | let canonicalRoute = route; 274 | if (!/^\//.test(canonicalRoute)) { 275 | canonicalRoute = '/' + route; 276 | } 277 | if (!methodConfigurations[pathPart]) { 278 | methodConfigurations[pathPart] = {}; 279 | } 280 | methodConfigurations[pathPart][method] = (options || {}); 281 | if (!routes[canonicalRoute]) { 282 | routes[canonicalRoute] = {}; 283 | } 284 | routes[canonicalRoute][method] = handler; 285 | }; 286 | }; 287 | 288 | self.apiConfig = function () { 289 | const result = {version: 4, routes: methodConfigurations}; 290 | if (customCorsHandler !== undefined) { 291 | result.corsHandlers = !!customCorsHandler; 292 | } 293 | if (customCorsHeaders) { 294 | result.corsHeaders = customCorsHeaders; 295 | } 296 | if (customCorsMaxAge) { 297 | result.corsMaxAge = customCorsMaxAge; 298 | } 299 | if (authorizers) { 300 | result.authorizers = authorizers; 301 | } 302 | if (binaryMediaTypes) { 303 | result.binaryMediaTypes = binaryMediaTypes; 304 | } 305 | if (customResponses) { 306 | result.customResponses = customResponses; 307 | } 308 | return result; 309 | }; 310 | self.corsOrigin = function (handler) { 311 | if (!handler) { 312 | customCorsHandler = false; 313 | } else { 314 | if (typeof handler === 'function') { 315 | customCorsHandler = handler; 316 | } else { 317 | customCorsHandler = function () { 318 | return handler; 319 | }; 320 | } 321 | } 322 | }; 323 | self.corsHeaders = function (headers) { 324 | if (typeof headers === 'string') { 325 | customCorsHeaders = headers; 326 | } else { 327 | throw 'corsHeaders only accepts strings'; 328 | } 329 | }; 330 | self.corsMaxAge = function (age) { 331 | if (!isNaN(age)) { 332 | customCorsMaxAge = age; 333 | } else { 334 | throw 'corsMaxAge only accepts numbers'; 335 | } 336 | }; 337 | self.ApiResponse = module.exports.ApiResponse; // TODO deprecated, remove for next major version? 338 | self.unsupportedEvent = function (callback) { 339 | v2DeprecationWarning('.unsupportedEvent handlers'); 340 | unsupportedEventCallback = callback; 341 | }; 342 | self.intercept = function (callback) { 343 | interceptCallback = callback; 344 | }; 345 | self.proxyRouter = function (event, context, callback) { 346 | let routingInfo, request; 347 | const handleError = function (e) { 348 | context.done(e); 349 | }; 350 | context.callbackWaitsForEmptyEventLoop = false; 351 | try { 352 | request = getRequest(event, context); 353 | } catch (e) { 354 | return Promise.resolve().then(() => context.done(null, { 355 | statusCode: 500, 356 | headers: { 'Content-Type': 'text/plain' }, 357 | body: (e && e.message) || 'Invalid request' 358 | })); 359 | } 360 | return executeInterceptor(request, context) 361 | .then(modifiedRequest => { 362 | if (!modifiedRequest) { 363 | return context.done(null, null); 364 | } else if (isApiResponse(modifiedRequest)) { 365 | return context.done(null, packResult(modifiedRequest, getRequestRoutingInfo(request), {}, 'success')); 366 | } else { 367 | routingInfo = getRequestRoutingInfo(modifiedRequest); 368 | if (routingInfo && routingInfo.path && routingInfo.method) { 369 | return routeEvent(routingInfo, modifiedRequest, context, callback) 370 | .then(result => context.done(null, result)); 371 | } else { 372 | if (unsupportedEventCallback) { 373 | return unsupportedEventCallback(event, context, callback); 374 | } else { 375 | return Promise.reject('event does not contain routing information'); 376 | } 377 | } 378 | } 379 | }).catch(handleError); 380 | }; 381 | self.router = function (event, context, callback) { 382 | requestFormat = 'DEPRECATED'; 383 | event.lambdaContext = context; 384 | v2DeprecationWarning('.router methods'); 385 | return self.proxyRouter(event, context, callback); 386 | }; 387 | self.addPostDeployStep = function (name, stepFunction) { 388 | if (typeof name !== 'string') { 389 | throw new Error('addPostDeployStep requires a step name as the first argument'); 390 | } 391 | if (typeof stepFunction !== 'function') { 392 | throw new Error('addPostDeployStep requires a function as the second argument'); 393 | } 394 | if (postDeploySteps[name]) { 395 | throw new Error(`Post deploy hook "${name}" already exists`); 396 | } 397 | postDeploySteps[name] = stepFunction; 398 | }; 399 | self.addPostDeployConfig = function (stageVarName, prompt, configOption) { 400 | self.addPostDeployStep(stageVarName, (options, lambdaDetails, utils) => { 401 | const configureDeployment = function (varValue) { 402 | const result = { 403 | restApiId: lambdaDetails.apiId, 404 | stageName: lambdaDetails.alias, 405 | variables: { } 406 | }; 407 | result.variables[stageVarName] = varValue; 408 | return result; 409 | }, 410 | deployStageVar = function (deployment) { 411 | return utils.apiGatewayPromise.createDeploymentPromise(deployment) 412 | .then(() => deployment.variables[stageVarName]); 413 | }, 414 | getVariable = function () { 415 | if (typeof options[configOption] === 'string') { 416 | return Promise.resolve(options[configOption]); 417 | } else { 418 | return prompter(prompt); 419 | } 420 | }; 421 | if (options[configOption]) { 422 | return getVariable() 423 | .then(configureDeployment) 424 | .then(deployStageVar); 425 | } 426 | }); 427 | }; 428 | self.postDeploy = function (options, lambdaDetails, utils) { 429 | const steps = Object.keys(postDeploySteps), 430 | stepResults = {}, 431 | executeStepMapper = function (stepName) { 432 | return Promise.resolve() 433 | .then(() => postDeploySteps[stepName](options, lambdaDetails, utils)) 434 | .then(result => stepResults[stepName] = result); 435 | }; 436 | if (!steps.length) { 437 | return Promise.resolve(false); 438 | } 439 | return sequentialPromiseMap(steps, executeStepMapper) 440 | .then(() => stepResults); 441 | }; 442 | self.registerAuthorizer = function (name, config) { 443 | if (!name || typeof name !== 'string' || name.length === 0) { 444 | throw new Error('Authorizer must have a name'); 445 | } 446 | if (!config || typeof config !== 'object' || Object.keys(config).length === 0) { 447 | throw new Error(`Authorizer ${name} configuration is invalid`); 448 | } 449 | if (!authorizers) { 450 | authorizers = {}; 451 | } 452 | if (authorizers[name]) { 453 | throw new Error(`Authorizer ${name} is already defined`); 454 | } 455 | authorizers[name] = config; 456 | }; 457 | self.setBinaryMediaTypes = function (types) { 458 | binaryMediaTypes = types; 459 | }; 460 | self.setGatewayResponse = function (responseType, config) { 461 | if (!responseType || typeof responseType !== 'string') { 462 | throw new Error('response type must be a string'); 463 | } 464 | if (!config || typeof config !== 'object' || Object.keys(config).length === 0) { 465 | throw new Error(`Response type ${responseType} configuration is invalid`); 466 | } 467 | if (!customResponses) { 468 | customResponses = {}; 469 | } 470 | if (customResponses[responseType]) { 471 | throw new Error(`Response type ${responseType} is already defined`); 472 | } 473 | customResponses[responseType] = config; 474 | }; 475 | binaryMediaTypes = defaultBinaryMediaTypes; 476 | requestFormat = getRequestFormat(options && options.requestFormat); 477 | ['ANY'].concat(supportedMethods).forEach(setUpHandler); 478 | }; 479 | 480 | module.exports.ApiResponse = function (response, headers, code) { 481 | 'use strict'; 482 | this.response = response; 483 | this.headers = headers; 484 | this.code = code; 485 | }; 486 | 487 | -------------------------------------------------------------------------------- /src/ask.js: -------------------------------------------------------------------------------- 1 | /*global require, module, process */ 2 | const readline = require('readline'); 3 | 4 | module.exports = function ask(question) { 5 | 'use strict'; 6 | return new Promise((resolve, reject) => { 7 | const rl = readline.createInterface({ 8 | input: process.stdin, 9 | output: process.stdout 10 | }); 11 | rl.question(`${question} `, answer => { 12 | rl.close(); 13 | if (answer) { 14 | resolve(answer); 15 | } else { 16 | reject(`${question} must be provided`); 17 | } 18 | }); 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /src/convert-api-gw-proxy-request.js: -------------------------------------------------------------------------------- 1 | /*global module, require */ 2 | const qs = require('querystring'), 3 | lowercaseKeys = require('./lowercase-keys'), 4 | mergeVars = require('./merge-vars'), 5 | getCanonicalContentType = function (normalizedHeaders) { 6 | 'use strict'; 7 | let contentType = normalizedHeaders['content-type'] || ''; 8 | if (contentType.indexOf(';') >= 0) { 9 | contentType = contentType.split(';')[0]; 10 | } 11 | return contentType; 12 | }, 13 | copyProperties = function (from, to, keyMappings) { 14 | 'use strict'; 15 | Object.keys(keyMappings).forEach(key => to[key] = from[keyMappings[key]] || {}); 16 | }, 17 | convertContext = function (requestContext) { 18 | 'use strict'; 19 | const identity = requestContext.identity || {}; 20 | return { 21 | method: (requestContext.httpMethod || 'GET').toUpperCase(), 22 | path: requestContext.resourcePath, 23 | stage: requestContext.stage, 24 | sourceIp: identity.sourceIp, 25 | accountId: identity.accountId, 26 | user: identity.user, 27 | userAgent: identity.userAgent, 28 | userArn: identity.userArn, 29 | caller: identity.caller, 30 | apiKey: identity.apiKey, 31 | authorizerPrincipalId: requestContext.authorizer ? requestContext.authorizer.principalId : null, 32 | cognitoAuthenticationProvider: identity.cognitoAuthenticationProvider, 33 | cognitoAuthenticationType: identity.cognitoAuthenticationType, 34 | cognitoIdentityId: identity.cognitoIdentityId, 35 | cognitoIdentityPoolId: identity.cognitoIdentityPoolId, 36 | authorizer: requestContext.authorizer 37 | }; 38 | }, 39 | getConvertedBody = function (body, contentType, isBase64Encoded) { 40 | 'use strict'; 41 | const textContentTypes = ['application/json', 'text/plain', 'application/xml', 'text/xml', 'application/x-www-form-urlencoded']; 42 | if (!isBase64Encoded) { 43 | return body; 44 | } else { 45 | const buffer = new Buffer(body, 'base64'); 46 | if (textContentTypes.indexOf(contentType) >= 0) { 47 | return buffer.toString('utf8'); 48 | } else { 49 | return buffer; 50 | } 51 | } 52 | }; 53 | 54 | module.exports = function convertApiGWProxyRequest(request, lambdaContext, mergeEnvironmentVariables) { 55 | 'use strict'; 56 | const result = { 57 | v: 3, 58 | rawBody: request.body || '', 59 | normalizedHeaders: lowercaseKeys(request.headers), 60 | lambdaContext: lambdaContext, 61 | proxyRequest: request 62 | }, 63 | canonicalContentType = getCanonicalContentType(result.normalizedHeaders), 64 | convertedBody = getConvertedBody(result.rawBody, canonicalContentType, request.isBase64Encoded); 65 | 66 | copyProperties(request, result, { 67 | queryString: 'queryStringParameters', 68 | env: 'stageVariables', 69 | headers: 'headers', 70 | pathParams: 'pathParameters' 71 | }); 72 | if (mergeEnvironmentVariables && request.requestContext && request.requestContext.stage) { 73 | result.env = mergeVars(mergeVars(process.env, process.env, request.requestContext.stage + '_'), request.stageVariables); 74 | } 75 | if (canonicalContentType === 'application/x-www-form-urlencoded') { 76 | result.post = Object.assign({}, qs.parse(convertedBody)); 77 | } 78 | if (canonicalContentType === 'application/json' && 79 | (typeof convertedBody !== 'object' || !convertedBody) // null will also result in type 'object' 80 | ) { 81 | try { 82 | result.body = JSON.parse(convertedBody || '{}'); 83 | } catch (e) { 84 | throw new Error('The content does not match the supplied content type'); 85 | } 86 | } else { 87 | result.body = convertedBody; 88 | } 89 | result.context = request.requestContext ? convertContext(request.requestContext) : {}; 90 | return result; 91 | }; 92 | -------------------------------------------------------------------------------- /src/lowercase-keys.js: -------------------------------------------------------------------------------- 1 | /*global module */ 2 | module.exports = function lowercaseKeys(object) { 3 | 'use strict'; 4 | const result = {}; 5 | if (object && typeof object === 'object' && !Array.isArray(object)) { 6 | Object.keys(object).forEach(key => result[key.toLowerCase()] = object[key]); 7 | } 8 | return result; 9 | }; 10 | -------------------------------------------------------------------------------- /src/merge-vars.js: -------------------------------------------------------------------------------- 1 | /*global module */ 2 | module.exports = function mergeVars(baseObject, replacementObject, keyPrefix) { 3 | 'use strict'; 4 | const matchesPrefix = function (key) { 5 | return key.indexOf(keyPrefix) === 0; 6 | }, 7 | merged = {}; 8 | 9 | if (baseObject) { 10 | Object.keys(baseObject).forEach(key => merged[key] = baseObject[key]); 11 | } 12 | if (replacementObject) { 13 | if (keyPrefix) { 14 | Object.keys(replacementObject).filter(matchesPrefix).forEach(key => merged[key.slice(keyPrefix.length)] = replacementObject[key]); 15 | } else { 16 | Object.keys(replacementObject).forEach(key => merged[key] = replacementObject[key]); 17 | } 18 | } 19 | return merged; 20 | }; 21 | -------------------------------------------------------------------------------- /src/sequential-promise-map.js: -------------------------------------------------------------------------------- 1 | module.exports = function sequentialPromiseMap(array, generator) { 2 | 'use strict'; 3 | if (!Array.isArray(array)) { 4 | throw new Error('the first argument must be an array'); 5 | } 6 | if (typeof generator !== 'function') { 7 | throw new Error('the second argument must be a function'); 8 | } 9 | let index = 0; 10 | const results = [], 11 | items = (array && array.slice()) || [], 12 | sendSingle = function (item) { 13 | return generator(item, index++) 14 | .then(result => results.push(result)); 15 | }, 16 | sendAll = function () { 17 | if (!items.length) { 18 | return Promise.resolve(results); 19 | } else { 20 | return sendSingle(items.shift()) 21 | .then(sendAll); 22 | } 23 | }; 24 | return sendAll(); 25 | }; 26 | -------------------------------------------------------------------------------- /src/valid-http-code.js: -------------------------------------------------------------------------------- 1 | /*global module */ 2 | module.exports = function validHttpCode(code) { 3 | 'use strict'; 4 | if (isNaN(code) || !code) { 5 | return false; 6 | } 7 | if (String(parseInt(code)) !== String(code)) { 8 | return false; 9 | } 10 | return code > 199 && code < 600; 11 | }; 12 | --------------------------------------------------------------------------------