├── .github ├── dependabot.yml └── workflows │ └── nodejs.yml ├── .gitignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── events │ ├── request.js │ └── response.js └── index.js └── test └── events ├── request.test.js ├── response.test.js └── test_events.js /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "11:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: y18n 11 | versions: 12 | - 4.0.1 13 | - 4.0.2 14 | - dependency-name: mocha 15 | versions: 16 | - 8.2.1 17 | - 8.3.0 18 | - 8.3.1 19 | - 8.3.2 20 | - dependency-name: chai 21 | versions: 22 | - 4.3.0 23 | - 4.3.1 24 | - 4.3.3 25 | - dependency-name: mime 26 | versions: 27 | - 2.5.0 28 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | on: [push, pull_request] 3 | env: 4 | CI: true 5 | 6 | jobs: 7 | test: 8 | name: Node ${{ matrix.node }} on ${{ matrix.os }} 9 | runs-on: ${{ matrix.os }} 10 | 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | node: [10, 12, 14] 15 | os: [ubuntu-latest] 16 | 17 | steps: 18 | - name: Clone repository 19 | uses: actions/checkout@v2 20 | 21 | - name: Set Node.js version 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.node }} 25 | 26 | - name: Install npm dependencies 27 | run: npm install # switch to `npm ci` when Node.js 6 support is dropped 28 | 29 | - name: Run lint 30 | run: npm run lint 31 | 32 | - name: Run tests 33 | run: npm run test 34 | 35 | - name: Coveralls 36 | uses: coverallsapp/github-action@master 37 | with: 38 | github-token: ${{ secrets.COVERALLS_TOKEN }} 39 | flag-name: ${{matrix.os}}-node-${{ matrix.node }} 40 | parallel: true 41 | 42 | finish: 43 | needs: test 44 | runs-on: ubuntu-latest 45 | steps: 46 | - name: Coveralls Finished 47 | uses: coverallsapp/github-action@master 48 | with: 49 | github-token: ${{ secrets.COVERALLS_TOKEN }} 50 | parallel-finished: true 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist 3 | /coverage 4 | .nyc_output 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Geoff Dutton 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lambda-proxy-utils 2 | [![npm version](https://badge.fury.io/js/lambda-proxy-utils.svg)](https://badge.fury.io/js/lambda-proxy-utils) 3 | [![Build Status](https://travis-ci.org/geoffdutton/lambda-proxy-utils.svg?branch=master)](https://travis-ci.org/geoffdutton/lambda-proxy-utils) 4 | [![Coverage Status](https://coveralls.io/repos/github/geoffdutton/lambda-proxy-utils/badge.svg?branch=master)](https://coveralls.io/github/geoffdutton/lambda-proxy-utils?branch=master) 5 | [![Dependency Status](https://david-dm.org/geoffdutton/lambda-proxy-utils.svg)](https://david-dm.org/geoffdutton/lambda-proxy-utils/) 6 | [![Standard - JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](http://standardjs.com/) 7 | 8 | Lambda event helpers for AWS API Gateway lambda-proxy integration 9 | 10 | ## Install 11 | ``` 12 | npm install --save lambda-proxy-utils 13 | ``` 14 | 15 | ## Update for version 2+ 16 | Node 4 is unsupported. 17 | 18 | ## Update for version 1.4.0 19 | AWS Lambda doesn't allow arrays for headers, so this is the hack way of returning multiple cookies per this thread: 20 | https://forums.aws.amazon.com/thread.jspa?threadID=205782 21 | 22 | Basically you need to set multiple varations of `Set-Cookie` on the headers response object passed to the lambda callback like: 23 | ```javascript 24 | const response = { 25 | body: 'something', 26 | headers: { 27 | 'Content-Type': 'text/plain', 28 | 'Set-Cookie': 'some=cookie; Path=/', 29 | 'Set-cookie': 'another=cookie; Path=/', 30 | 'SEt-cookie': 'and_another=cookie; Path=/', 31 | }, 32 | statusCode: 200 33 | } 34 | ``` 35 | 36 | Using [binary-case](https://www.npmjs.com/package/binary-case), we can generate 512 variations of `Set-Cookie`, so there's a hard limit, but hopefully you aren't setting 512 cookies. 37 | 38 | ## Request 39 | Takes an [API Gateway](http://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-set-up-simple-proxy.html#api-gateway-set-up-lambda-proxy-integration-on-proxy-resource) lambda proxy integration event and returns an object that is similar to an express.js Request object. 40 | ```javascript 41 | // Example API Gateway proxy integration event passed to lambda 42 | { 43 | "resource": "/api/pipe/{pathParam}", 44 | "path": "/api/pipe/hooray/", 45 | "httpMethod": "GET", 46 | "headers": { 47 | "Accept": "*/*", 48 | "Accept-Encoding": "gzip, deflate, sdch, br", 49 | "Accept-Language": "en-US,en;q=0.8", 50 | "Cache-Control": "no-cache", 51 | "CloudFront-Forwarded-Proto": "https", 52 | "CloudFront-Is-Desktop-Viewer": "true", 53 | "CloudFront-Is-Mobile-Viewer": "false", 54 | "CloudFront-Is-SmartTV-Viewer": "false", 55 | "CloudFront-Is-Tablet-Viewer": "false", 56 | "CloudFront-Viewer-Country": "US", 57 | "Cookie": "some=thing; testbool=false; testnull=null", 58 | "Host": "services.cheekyroad.com", 59 | "Pragma": "no-cache", 60 | "Referer": "https://cheekyroad.com/paht/?cool=true", 61 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.95 Safari/537.36", 62 | "Via": "1.1 1a1a1a1.cloudfront.net (CloudFront)", 63 | "X-Amz-Cf-Id": "2b2b2b2b2==", 64 | "X-Forwarded-For": "111.111.111.111, 222.222.222.222", 65 | "X-Forwarded-Port": "443", 66 | "X-Forwarded-Proto": "https" 67 | }, 68 | "queryStringParameters": { 69 | "et": "something" 70 | }, 71 | "pathParameters": { 72 | "pathParam": "hooray" 73 | }, 74 | "stageVariables": null, 75 | "requestContext": { 76 | "accountId": "111111111111", 77 | "resourceId": "blah", 78 | "stage": "dev", 79 | "requestId": "08e3e2d0-daca-11e6-8d84-394b4374a71a", 80 | "identity": { 81 | "cognitoIdentityPoolId": null, 82 | "accountId": null, 83 | "cognitoIdentityId": null, 84 | "caller": null, 85 | "apiKey": null, 86 | "sourceIp": "111.111.111.111", 87 | "accessKey": null, 88 | "cognitoAuthenticationType": null, 89 | "cognitoAuthenticationProvider": null, 90 | "userArn": null, 91 | "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.95 Safari/537.36", 92 | "user": null 93 | }, 94 | "resourcePath": "/api/pipe/{pathParam}", 95 | "httpMethod": "GET", 96 | "apiId": "cdcd4" 97 | }, 98 | "body": null, 99 | "isBase64Encoded": false 100 | } 101 | 102 | const Request = require('lambda-proxy-utils').Request 103 | 104 | module.exports.lambdaHandler = function(event, context, callback) { 105 | const req = new Request(event) 106 | req.ip // '111.111.111.111' 107 | req.userAgent // 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.95 Safari/537.36' 108 | 109 | // Get's a field value in the order of query string param -> cookie -> header 110 | req.get('host') // 'services.cheekyroad.com' 111 | req.get('testnull') // null 112 | 113 | // Or be specific 114 | req.getHeader('x-forwarded-proto') // 'https' 115 | 116 | // Check the type 117 | req.is('html') // false 118 | 119 | // Get an AWS API Gateway requestContext property 120 | req.context('requestId') // '08e3e2d0-daca-11e6-8d84-394b4374a71a' 121 | 122 | // Get the unmodified Lambda Proxy event 123 | req.getLambdaEvent() 124 | } 125 | ``` 126 | 127 | ## Response 128 | Creates an express.js-like Response object, and outputs the API Gateway [response format](http://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-set-up-simple-proxy.html#api-gateway-simple-proxy-for-lambda-output-format) 129 | ```javascript 130 | const Response = require('lambda-proxy-utils').Response 131 | 132 | module.exports.lambdaHandler = function(event, context, callback) { 133 | const res = new Response() 134 | // stringifies objects and set correct content type header 135 | callback(null, res.send({ some: 'object' })) 136 | /* 137 | { 138 | statusCode: 200, 139 | headers: { 140 | 'Content-Type': 'application/json' 141 | }, 142 | body: '{ "some": "object" }' 143 | } 144 | */ 145 | 146 | // Support for CORS 147 | const res = new Response({ cors: true }) 148 | callback(null, res.send({ some: 'object' })) 149 | /* 150 | { 151 | statusCode: 200, 152 | headers: { 153 | 'Content-Type': 'application/json', 154 | 'Access-Control-Allow-Origin': '*' 155 | }, 156 | body: '{ "some": "object" }' 157 | } 158 | */ 159 | 160 | // Add a cookie 161 | const res = new Response() 162 | res.cookie('cookie', 'monster') 163 | callback(null, res.send({ some: 'object' })) 164 | /* 165 | { 166 | statusCode: 200, 167 | headers: { 168 | 'Content-Type': 'application/json', 169 | 'Set-Cookie': 'cookie=monster; Path=/' 170 | }, 171 | body: '{ "some": "object" }' 172 | } 173 | */ 174 | 175 | // Add a header 176 | const res = new Response() 177 | res.set('X-Random-Header', 1) 178 | callback(null, res.send({ some: 'object' })) 179 | /* 180 | { 181 | statusCode: 200, 182 | headers: { 183 | 'Content-Type': 'application/json', 184 | 'X-Random-Header': '1' 185 | }, 186 | body: '{ "some": "object" }' 187 | } 188 | */ 189 | } 190 | ``` 191 | 192 | ## Contributing 193 | I'd happily welcome pull requests. I've chosen to use Standard as the style with a few slight modifications. I'd like to keep the code coverage as high as possible. 194 | 195 | ## Credits 196 | I borrowed a lot from [express](https://github.com/expressjs/express) 197 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lambda-proxy-utils", 3 | "version": "3.0.3", 4 | "description": "Lambda Proxy event helpers", 5 | "author": "Geoff Dutton ", 6 | "keywords": [ 7 | "aws", 8 | "lambda", 9 | "api gateway", 10 | "lambda proxy" 11 | ], 12 | "engines": { 13 | "node": ">=10" 14 | }, 15 | "license": "MIT", 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/geoffdutton/lambda-proxy-utils.git" 19 | }, 20 | "homepage": "https://github.com/geoffdutton/lambda-proxy-utils", 21 | "main": "./src/index.js", 22 | "scripts": { 23 | "coveralls": "nyc report --reporter=text-lcov | coveralls", 24 | "lint": "standard", 25 | "test": "npm run lint && nyc mocha --recursive test/", 26 | "tdd": "mocha --watch --colors --reporter spec --recursive test/" 27 | }, 28 | "dependencies": { 29 | "accepts": "^1.3.7", 30 | "binary-case": "^1.1.4", 31 | "cookie": "^0.4.1", 32 | "header-case-normalizer": "^1.0.3", 33 | "lodash.clonedeep": "^4.5.0", 34 | "lodash.get": "^4.4.2", 35 | "lodash.has": "^4.5.2", 36 | "lodash.tostring": "^4.1.4", 37 | "mime": "^2.5.2", 38 | "type-is": "^1.6.18" 39 | }, 40 | "devDependencies": { 41 | "chai": "^4.3.4", 42 | "coveralls": "^3.1.0", 43 | "mocha": "^9.0.3", 44 | "nyc": "^15.1.0", 45 | "standard": "^16.0.3" 46 | }, 47 | "standard": { 48 | "env": { 49 | "mocha": true 50 | } 51 | }, 52 | "files": [ 53 | "src" 54 | ] 55 | } 56 | -------------------------------------------------------------------------------- /src/events/request.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const _get = require('lodash.get') 3 | const _has = require('lodash.has') 4 | const typeis = require('type-is').is 5 | const urlParse = require('url').parse // eslint-disable-line node/no-deprecated-api 6 | const _toString = require('lodash.tostring') 7 | const cookie = require('cookie') 8 | const accepts = require('accepts') 9 | 10 | /** 11 | * Turns a lambda proxy event into an express.js-like 12 | * request object. 13 | * 14 | * A lot was borrowed from here https://expressjs.com/en/api.html#req (Thanks!) 15 | * 16 | * @module lambda-proxy-utils 17 | * @class Request 18 | */ 19 | class Request { 20 | /** 21 | * @param {object} rawLambdaEvent -- The event passed to the lambda function 22 | * with lambda-proxy type integration 23 | */ 24 | constructor (rawLambdaEvent) { 25 | /** 26 | * This basically means it's a serialized version of a 27 | * Request instance, unless it's JSON that has the key 28 | * 'rawLambdaEvent' 29 | */ 30 | const isSerialized = _has(rawLambdaEvent, 'rawLambdaEvent') 31 | const _sget = (path) => { 32 | return isSerialized ? _get(rawLambdaEvent, path) : null 33 | } 34 | 35 | /** 36 | * JSON body or null 37 | * @TODO Support all content types for body 38 | * @type {null|{}|string} 39 | */ 40 | this.body = _sget('body') || Request.parseBody(rawLambdaEvent) 41 | 42 | /** 43 | * Parsed and normalized headers 44 | * @type {{}} 45 | */ 46 | this.headers = _sget('headers') || Request.parseHeaders(rawLambdaEvent) 47 | 48 | /** 49 | * Parsed cookies or empty object 50 | * @type {{}} 51 | */ 52 | this.cookies = _sget('cookies') || Request.parseCookies(this.headers.cookie) 53 | 54 | /** 55 | * sourceIp 56 | * @type {string} 57 | */ 58 | this.ip = _sget('ip') || _get(rawLambdaEvent, 'requestContext.identity.sourceIp') || '' 59 | 60 | /** 61 | * This property is an object containing properties mapped to the named route “parameters”. For example, 62 | * if you have the route /user/:name, then the “name” property is available as req.params.name. 63 | * This object defaults to {}. 64 | * @type {{}} 65 | */ 66 | this.params = _sget('params') || _get(rawLambdaEvent, 'pathParameters') || {} 67 | 68 | /** 69 | * Passed query string parameters. Defaults to {}. 70 | * @type {{}} 71 | */ 72 | this.query = _sget('query') || _get(rawLambdaEvent, 'queryStringParameters') || {} 73 | 74 | /** 75 | * Contains the path part of the request URL. 76 | * @type {string} 77 | */ 78 | this.path = _sget('path') || _get(rawLambdaEvent, 'path') || '' 79 | 80 | /** 81 | * A Boolean property that is true if the request’s X-Requested-With header field is “XMLHttpRequest”, 82 | * indicating that the request was issued by a client library such as jQuery. 83 | * @type {boolean} 84 | */ 85 | this.xhr = false 86 | 87 | /** 88 | * Contains a string corresponding to the HTTP method of the request: GET, POST, PUT, and so on. 89 | * @type {string} 90 | */ 91 | this.method = _sget('method') || 92 | _toString(_get(rawLambdaEvent, 'httpMethod')).toUpperCase() || 'GET' 93 | 94 | /** 95 | * Parsed referring including parsed query 96 | * @type {Url} 97 | */ 98 | this.referrer = urlParse(_toString(this.headers.referrer), true) 99 | // Hack for .hasOwnProperty since the native Url parsing creates a null object 100 | Object.defineProperty(this.referrer.query, 'hasOwnProperty', { 101 | enumerable: false, 102 | value (prop) { 103 | return Object.prototype.hasOwnProperty.call(this, prop) 104 | } 105 | }) 106 | 107 | // Hack for .toString() since the native Url returns [Object object] 108 | Object.defineProperty(this.referrer, 'toString', { 109 | enumerable: false, 110 | value () { 111 | return this.href 112 | } 113 | }) 114 | 115 | /** 116 | * User agent passed from API Gateway 117 | * @type {string} 118 | */ 119 | this.userAgent = _sget('userAgent') || 120 | _get(rawLambdaEvent, 'requestContext.identity.userAgent') || '' 121 | 122 | /** 123 | * Raw API Gateway event 124 | * @type {object} 125 | */ 126 | this.rawLambdaEvent = _sget('rawLambdaEvent') || rawLambdaEvent 127 | } 128 | 129 | /** 130 | * Returns the field from the requestContext object from AWS API Gateway 131 | * 132 | * Returns undefined if nothing is found. 133 | * The Referrer and Referer fields are interchangeable. 134 | * @param {string} propertyPath 135 | * @returns {string|object} 136 | */ 137 | context (propertyPath) { 138 | return _get(this.rawLambdaEvent, `requestContext.${propertyPath}`) 139 | } 140 | 141 | /** 142 | * Returns the field from one of the objects in this order: 143 | * - query parameters 144 | * - cookies 145 | * - header 146 | * 147 | * Returns undefined if nothing is found. 148 | * The Referrer and Referer fields are interchangeable. 149 | * @param {string} field 150 | */ 151 | get (field) { 152 | const val = this.query[field] || this.cookies[field] 153 | if (typeof val !== 'undefined') { 154 | return val 155 | } 156 | return this.headers[field.toLowerCase()] 157 | } 158 | 159 | /** 160 | * Returns query param value filtered for 'null', 'false', true', etc 161 | * 162 | * Returns undefined if nothing is found. 163 | * @param {string} param 164 | */ 165 | getQueryParam (param) { 166 | return Request.valueFilter(this.query[param]) 167 | } 168 | 169 | /** 170 | * Returns the cookie value, case-insensitive 171 | * 172 | * Returns undefined if nothing is found. 173 | * @param {string} name 174 | */ 175 | getCookie (name) { 176 | return this.cookies[name.toLowerCase()] 177 | } 178 | 179 | /** 180 | * Returns the header value, case-insensitive 181 | * 182 | * Returns undefined if nothing is found. 183 | * @param {string} field 184 | */ 185 | getHeader (field) { 186 | return this.headers[field.toLowerCase()] 187 | } 188 | 189 | /** 190 | * Returns the unmodified Lambda Proxy Event 191 | * 192 | * @returns {object} - Lambda event 193 | */ 194 | getLambdaEvent () { 195 | return this.rawLambdaEvent 196 | } 197 | 198 | /** 199 | * Returns true if the incoming request’s “Content-Type” HTTP header field matches the MIME type 200 | * specified by the type parameter. Returns false otherwise. 201 | * 202 | * // With Content-Type: text/html; charset=utf-8 203 | * req.is('html'); 204 | * req.is('text/html'); 205 | * req.is('text/*'); 206 | * // => true 207 | * 208 | * // When Content-Type is application/json 209 | * req.is('json'); 210 | * req.is('application/json'); 211 | * req.is('application/*'); 212 | * // => true 213 | * 214 | * req.is('html'); 215 | * // => false 216 | * 217 | * @param {string} type -- string or pattern 218 | * @returns {boolean} 219 | */ 220 | is (type) { 221 | return !!typeis(this.headers['content-type'], type) 222 | } 223 | 224 | /** 225 | * Check if the given `type(s)` is acceptable, returning 226 | * the best match when true, otherwise `undefined`, in which 227 | * case you should respond with 406 "Not Acceptable". 228 | * 229 | * The `type` value may be a single MIME type string 230 | * such as "application/json", an extension name 231 | * such as "json", a comma-delimited list such as "json, html, text/plain", 232 | * an argument list such as `"json", "html", "text/plain"`, 233 | * or an array `["json", "html", "text/plain"]`. When a list 234 | * or array is given, the _best_ match, if any is returned. 235 | * 236 | * Examples: 237 | * 238 | * // Accept: text/html 239 | * req.accepts('html'); 240 | * // => "html" 241 | * 242 | * // Accept: text/*, application/json 243 | * req.accepts('html'); 244 | * // => "html" 245 | * req.accepts('text/html'); 246 | * // => "text/html" 247 | * req.accepts('json, text'); 248 | * // => "json" 249 | * req.accepts('application/json'); 250 | * // => "application/json" 251 | * 252 | * // Accept: text/*, application/json 253 | * req.accepts('image/png'); 254 | * req.accepts('png'); 255 | * // => undefined 256 | * 257 | * // Accept: text/*;q=.5, application/json 258 | * req.accepts(['html', 'json']); 259 | * req.accepts('html', 'json'); 260 | * req.accepts('html, json'); 261 | * // => "json" 262 | * 263 | * @param {String|Array} type(s) 264 | * @return {String|Array|Boolean} 265 | * @public 266 | */ 267 | 268 | accepts () { 269 | const accept = accepts(this) 270 | return accept.types.apply(accept, arguments) 271 | } 272 | 273 | /** 274 | * Parses and normalizes the headers 275 | * @param lambdaEvent 276 | */ 277 | static parseHeaders (lambdaEvent) { 278 | const headers = _get(lambdaEvent, 'headers') 279 | return headers 280 | ? Object.keys(headers).reduce((result, key) => { 281 | result[key.toLowerCase()] = headers[key] 282 | // set 'referrer' too because the internet can't decide 283 | if (key.toLowerCase() === 'referer') { 284 | result.referrer = headers[key] 285 | } 286 | return result 287 | }, {}) 288 | : {} 289 | } 290 | 291 | /** 292 | * Parses and normalizes the cookies 293 | * @param cookieString 294 | */ 295 | static parseCookies (cookieString) { 296 | const parsedCookie = cookie.parse(_toString(cookieString)) 297 | return Object.keys(parsedCookie).reduce((result, key) => { 298 | result[key.toLowerCase()] = Request.valueFilter(parsedCookie[key]) 299 | return result 300 | }, {}) 301 | } 302 | 303 | /** 304 | * Parses body 305 | * @param lambdaEvent 306 | */ 307 | static parseBody (lambdaEvent) { 308 | const bodyString = _get(lambdaEvent, 'body') || null 309 | if (bodyString && typeof bodyString === 'object') { 310 | return bodyString 311 | } 312 | 313 | let body = bodyString 314 | if (typeof bodyString === 'string') { 315 | try { 316 | body = JSON.parse(bodyString) 317 | } catch (_) {} 318 | } 319 | return body 320 | } 321 | 322 | /** 323 | * Converts 'null' to null, 'false' to false, etc 324 | * @param (val) val 325 | */ 326 | static valueFilter (val) { 327 | if (typeof val !== 'string') { 328 | return val 329 | } 330 | 331 | const testVal = val.toLowerCase() 332 | 333 | if (testVal === 'true') { 334 | return true 335 | } 336 | 337 | if (testVal === 'false') { 338 | return false 339 | } 340 | 341 | if (testVal === 'null') { 342 | return null 343 | } 344 | 345 | return val 346 | } 347 | } 348 | 349 | module.exports = Request 350 | -------------------------------------------------------------------------------- /src/events/response.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const _toString = require('lodash.tostring') 3 | const cookie = require('cookie') 4 | const mime = require('mime') 5 | const normalizeHeader = require('header-case-normalizer') 6 | const binaryCase = require('binary-case') 7 | 8 | const SET_COOKIE = 'Set-Cookie' 9 | const corsHeader = { 10 | 'Access-Control-Allow-Origin': '*' 11 | } 12 | 13 | /** 14 | * Turns a lambda-proxy integration event into an express.js-like 15 | * response object ready to be based to the callback 16 | * 17 | * A lot was borrowed from here https://expressjs.com/en/api.html#res 18 | * and https://github.com/expressjs/express/blob/master/lib/response.js (Thanks!) 19 | * 20 | * @module lambda-proxy-utils 21 | * @class Response 22 | */ 23 | class Response { 24 | /** 25 | * Options = { 26 | * statusCode: 200 27 | * headers: { } <-- will merge headers 28 | * body: '' <-- sets default body 29 | * cors: true <-- adds cors headers 30 | * } 31 | * @param {object} [options] 32 | */ 33 | constructor (options) { 34 | const opts = options || {} 35 | /** 36 | * HTTP status code to respond with 37 | * @type {number} 38 | */ 39 | this.statusCode = opts.statusCode || 200 40 | 41 | /** 42 | * Body to send, which will be JSON stringified if its not a string 43 | * @type {string} 44 | */ 45 | this.body = '' 46 | 47 | /** 48 | * Map of lowercase headers to include with the request 49 | * @type {{}} 50 | */ 51 | this.headers = {} 52 | 53 | if (typeof opts.headers === 'object') { 54 | Object.keys(opts.headers).forEach(key => { 55 | this.set(key, opts.headers[key]) 56 | }) 57 | } 58 | 59 | /** 60 | * Indicates whether this is cors or not, 61 | * if so it'll add headers 62 | * @type {boolean} 63 | */ 64 | this.cors = opts.cors 65 | 66 | if (this.cors) { 67 | this.set(corsHeader) 68 | } 69 | 70 | /** 71 | * @type {boolean} 72 | */ 73 | this.isBase64Encoded = !!opts.isBase64Encoded 74 | } 75 | 76 | /** 77 | * Creates a response. 78 | * 79 | * Examples: 80 | * 81 | * res.send({ some: 'json' }); 82 | * res.send('some text'); 83 | * 84 | * returns and object like: 85 | * { 86 | * statusCode: 200, 87 | * headers: { 88 | * 'Content-Type': 'text/plain' , 89 | * 'Content-Length': '9' 90 | * }, 91 | * body: 'some text' 92 | * } 93 | * 94 | * @param {string|number|boolean|object} [body] 95 | * @returns {object} 96 | */ 97 | send (body) { 98 | body = typeof body === 'undefined' ? this.body : body 99 | 100 | switch (typeof body) { 101 | // string defaulting to plain text 102 | case 'string': 103 | if (!this.get('Content-Type')) { 104 | this.contentType('text') 105 | } 106 | break 107 | case 'boolean': 108 | case 'number': 109 | case 'object': 110 | if (body === null) { 111 | body = '' 112 | if (!this.get('Content-Type')) { 113 | this.contentType('text') 114 | } 115 | } else { 116 | return this.json(body) 117 | } 118 | break 119 | } 120 | 121 | const res = { 122 | statusCode: this.statusCode, 123 | headers: Object.keys(this.headers).reduce((result, key) => { 124 | result[normalizeHeader(key)] = this.headers[key] 125 | return result 126 | }, {}), 127 | body: body 128 | } 129 | 130 | if (this.isBase64Encoded) { 131 | res.isBase64Encoded = true 132 | } 133 | 134 | // AWS Lambda doesn't allow arrays for headers, so this is the hack way of 135 | // returning multiple cookies per this thread: 136 | // https://forums.aws.amazon.com/thread.jspa?threadID=205782 137 | if ({}.hasOwnProperty.call(res.headers, SET_COOKIE) && Array.isArray(res.headers[SET_COOKIE])) { 138 | const allCookes = res.headers['Set-Cookie'] 139 | res.headers[SET_COOKIE] = allCookes.shift() 140 | for (let i = 0; i < allCookes.length; i++) { 141 | const setCookieCase = binaryCase(SET_COOKIE, i + 1, { allowOverflow: false }) 142 | if (!setCookieCase) { 143 | return res 144 | } 145 | res.headers[setCookieCase] = allCookes[i] 146 | } 147 | } 148 | 149 | return res 150 | } 151 | 152 | /** 153 | * Creates JSON response. 154 | * 155 | * Examples: 156 | * 157 | * res.json(null); 158 | * res.json({ user: 'tj' }); 159 | * 160 | * @param {string|number|boolean|object} obj 161 | */ 162 | json (obj) { 163 | this.contentType('json') 164 | return this.send(JSON.stringify(obj)) 165 | } 166 | 167 | /** 168 | * Set cookie `name` to `value`, with the given `options`. 169 | * 170 | * Options: 171 | * 172 | * - `maxAge` max-age in milliseconds, converted to `expires` 173 | * - `path` defaults to "/" 174 | * 175 | * Examples: 176 | * 177 | * // "Remember Me" for 15 minutes 178 | * res.cookie('rememberme', '1', { expires: new Date(Date.now() + 900000), httpOnly: true }); 179 | * 180 | * // save as above 181 | * res.cookie('rememberme', '1', { maxAge: 900000, httpOnly: true }) 182 | * 183 | * @param {String} name 184 | * @param {String|Object} value 185 | * @param {object} [options] 186 | * @return {Response} for chaining 187 | * @public 188 | */ 189 | cookie (name, value, options) { 190 | const opts = options || {} 191 | 192 | const val = typeof value === 'object' 193 | ? `j:${JSON.stringify(value)}` 194 | : _toString(value) 195 | 196 | if (!opts.path) { 197 | opts.path = '/' 198 | } 199 | 200 | if ('maxAge' in opts) { 201 | opts.expires = new Date(Date.now() + opts.maxAge) 202 | opts.maxAge /= 1000 203 | } 204 | 205 | this.append('Set-Cookie', cookie.serialize(name, val, opts)) 206 | return this 207 | } 208 | 209 | /** 210 | * Get value for header `field`. 211 | * 212 | * @param {String} field 213 | * @return {String} 214 | * @public 215 | */ 216 | get (field) { 217 | return this.headers[field.toLowerCase()] 218 | } 219 | 220 | /** 221 | * Set header `field` to `val`, or pass 222 | * an object of header fields. 223 | * 224 | * Examples: 225 | * 226 | * res.set('Foo', ['bar', 'baz']); 227 | * res.set('Accept', 'application/json'); 228 | * res.set({ Accept: 'text/plain', 'X-API-Key': 'tobi' }); 229 | * 230 | * Aliased as `res.header()`. 231 | * 232 | * @param {String|Object} field 233 | * @param {String|Array} [val] 234 | * @return {Response} for chaining 235 | * @public 236 | */ 237 | set (field, val) { 238 | if (arguments.length === 2) { 239 | this.headers[field.toLowerCase()] = Array.isArray(val) 240 | ? val.map(_toString) 241 | : _toString(val) 242 | } else { 243 | Object.keys(field).forEach(key => { 244 | this.set(key, field[key]) 245 | }) 246 | } 247 | return this 248 | } 249 | 250 | /** 251 | * Append additional header `field` with value `val`. 252 | * 253 | * Example: 254 | * 255 | * res.append('Link', ['', '']); 256 | * res.append('Set-Cookie', 'foo=bar; Path=/; HttpOnly'); 257 | * res.append('Warning', '199 Miscellaneous warning'); 258 | * 259 | * @param {String} field 260 | * @param {String|Array} val 261 | * @return {Response} for chaining 262 | * @public 263 | */ 264 | append (field, val) { 265 | const previousValue = this.get(field) 266 | 267 | if (previousValue) { 268 | val = Array.isArray(previousValue) 269 | ? previousValue.concat(val) 270 | : Array.isArray(val) 271 | ? [previousValue].concat(val) 272 | : [previousValue, val] 273 | } 274 | 275 | return this.set(field, val) 276 | } 277 | 278 | /** 279 | * Set status `code`. 280 | * 281 | * @param {Number} code 282 | * @return {Response} 283 | * @public 284 | */ 285 | status (code) { 286 | this.statusCode = code 287 | return this 288 | } 289 | 290 | /** 291 | * Set _Content-Type_ response header with `type` through `mime.lookup()` 292 | * when it does not contain "/", or set the Content-Type to `type` otherwise. 293 | * 294 | * Examples: 295 | * 296 | * res.type('.html'); 297 | * res.type('html'); 298 | * res.type('json'); 299 | * res.type('application/json'); 300 | * res.type('png'); 301 | * 302 | * @param {String} type 303 | * @return {Response} for chaining 304 | * @public 305 | */ 306 | contentType (type) { 307 | const ct = type.indexOf('/') === -1 308 | ? mime.getType(type) 309 | : type 310 | 311 | return this.set('Content-Type', ct) 312 | } 313 | } 314 | 315 | module.exports = Response 316 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const Request = require('./events/request') 3 | const Response = require('./events/response') 4 | /** 5 | * @type {Request} 6 | */ 7 | module.exports.Request = Request 8 | /** 9 | * @type {Response} 10 | */ 11 | module.exports.Response = Response 12 | 13 | /** 14 | * @type {Request} 15 | */ 16 | module.exports.request = function (opts) { 17 | return new Request(opts) 18 | } 19 | /** 20 | * @type {Response} 21 | */ 22 | module.exports.response = function (opts) { 23 | return new Response(opts) 24 | } 25 | -------------------------------------------------------------------------------- /test/events/request.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | 3 | 'use strict' 4 | const expect = require('chai').expect 5 | const Request = require('../../src/events/request') 6 | const GET = require('./test_events').GET 7 | const urlParse = require('url').parse // eslint-disable-line node/no-deprecated-api 8 | 9 | describe('events', () => { 10 | describe('Request', () => { 11 | let lambdaEvent 12 | 13 | beforeEach(() => { 14 | lambdaEvent = GET() 15 | }) 16 | 17 | context('GET', () => { 18 | it('should set defaults', () => { 19 | const req = new Request() 20 | expect(req.body).to.be.null 21 | expect(req.headers).to.eql({}) 22 | expect(req.cookies).to.eql({}) 23 | expect(req.ip).to.eq('') 24 | expect(req.params).to.eql({}) 25 | expect(req.query).to.eql({}) 26 | expect(req.path).to.eq('') 27 | expect(req.xhr).to.be.false 28 | expect(req.method).to.eq('GET') 29 | expect(req.referrer).to.eql(urlParse('', true)) 30 | expect(req.userAgent).to.eq('') 31 | }) 32 | 33 | it('should parse headers', () => { 34 | const req = new Request(lambdaEvent) 35 | expect(req.headers).to.eql({ 36 | accept: '*/*', 37 | 'accept-encoding': 'gzip, deflate, sdch, br', 38 | 'accept-language': 'en-US,en;q=0.8', 39 | 'cache-control': 'no-cache', 40 | 'cloudfront-forwarded-proto': 'https', 41 | 'cloudfront-is-desktop-viewer': 'true', 42 | 'cloudfront-is-mobile-viewer': 'false', 43 | 'cloudfront-is-smarttv-viewer': 'false', 44 | 'cloudfront-is-tablet-viewer': 'false', 45 | 'cloudfront-viewer-country': 'US', 46 | cookie: 'some=thing; testbool=false; testnull=null', 47 | host: 'services.cheekyroad.com', 48 | pragma: 'no-cache', 49 | referer: 'https://cheekyroad.com/paht/?cool=true', 50 | referrer: 'https://cheekyroad.com/paht/?cool=true', 51 | 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.95 Safari/537.36', // eslint-disable-line max-len 52 | via: '1.1 1a1a1a1.cloudfront.net (CloudFront)', 53 | 'x-amz-cf-id': '2b2b2b2b2==', 54 | 'x-forwarded-for': '111.111.111.111, 222.222.222.222', 55 | 'x-forwarded-port': '443', 56 | 'x-forwarded-proto': 'https' 57 | }) 58 | }) 59 | 60 | it('should parse cookies', () => { 61 | const req = new Request(lambdaEvent) 62 | expect(req.cookies).to.eql({ 63 | some: 'thing', 64 | testbool: false, 65 | testnull: null 66 | }) 67 | }) 68 | 69 | it('cookies should always be an object', () => { 70 | expect(Request.parseCookies(null)).to.eql({}) 71 | }) 72 | 73 | it('should parse path params', () => { 74 | const req = new Request(lambdaEvent) 75 | expect(req.params).to.eql({ 76 | pathParam: 'hooray' 77 | }) 78 | }) 79 | 80 | it('should parse query params', () => { 81 | const req = new Request(lambdaEvent) 82 | expect(req.query).to.eql({ 83 | et: 'something' 84 | }) 85 | }) 86 | 87 | it('should set query to empty object if null', () => { 88 | lambdaEvent.queryStringParameters = null 89 | const req = new Request(lambdaEvent) 90 | expect(req.query).to.eql({}) 91 | }) 92 | 93 | it('should set param to empty object if null', () => { 94 | lambdaEvent.pathParameters = null 95 | const req = new Request(lambdaEvent) 96 | expect(req.params).to.eql({}) 97 | }) 98 | 99 | it('should parse referrer url', () => { 100 | lambdaEvent.headers.Referer = 'https://cheekyroad.com/paht/?cool=true#somehash' 101 | const req = new Request(lambdaEvent) 102 | expect(req.referrer).to.eql(urlParse('https://cheekyroad.com/paht/?cool=true#somehash', true)) 103 | expect({}.hasOwnProperty.call(req.referrer.query, 'cool')).to.be.true 104 | // It should allow accessing the native way 105 | expect(req.referrer.query.hasOwnProperty('cool')).to.be.true // eslint-disable-line no-prototype-builtins 106 | expect(req.referrer.toString()).to.eq('https://cheekyroad.com/paht/?cool=true#somehash') 107 | }) 108 | 109 | it('should set other properties', () => { 110 | const req = new Request(lambdaEvent) 111 | expect(req.ip).to.eq('111.111.111.111') 112 | expect(req.userAgent).to.eq('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.95 Safari/537.36') // eslint-disable-line max-len 113 | expect(req.method).to.eq('GET') 114 | expect(req.path).to.eq('/api/pipe/hooray/') 115 | expect(req.xhr).to.be.false 116 | }) 117 | }) 118 | 119 | context('with JSON body', () => { 120 | it('should parse JSON', () => { 121 | const body = { some: 'object', thing: true } 122 | lambdaEvent.body = body 123 | let req = new Request(lambdaEvent) 124 | expect(req.body).to.eql(body) 125 | 126 | lambdaEvent.body = JSON.stringify(body) 127 | req = new Request(lambdaEvent) 128 | expect(req.body).to.eql(body) 129 | }) 130 | }) 131 | 132 | context('with string body', () => { 133 | it('should leave as string if not JSON', () => { 134 | const body = '' 135 | lambdaEvent.body = body 136 | const req = new Request(lambdaEvent) 137 | expect(req.body).to.eql(body) 138 | }) 139 | }) 140 | 141 | context('with JSON serialization of Reqest instance', () => { 142 | it('should restore to eql objects', () => { 143 | const req = new Request(JSON.parse(JSON.stringify(lambdaEvent))) 144 | const req2 = new Request(JSON.parse(JSON.stringify(req))) 145 | expect(req).to.deep.equal(req2) 146 | }) 147 | }) 148 | 149 | describe('#get', () => { 150 | it('should return undefined', () => { 151 | const req = new Request(lambdaEvent) 152 | expect(req.get('nothing_That_exists')).to.be.undefined 153 | }) 154 | 155 | it('should get query param first', () => { 156 | lambdaEvent.queryStringParameters = { find: 'me1' } 157 | lambdaEvent.headers.Cookie = 'find=me2' 158 | lambdaEvent.headers.Find = 'me3' 159 | const req = new Request(lambdaEvent) 160 | expect(req.get('find')).to.eq('me1') 161 | }) 162 | 163 | it('should get cookie second', () => { 164 | lambdaEvent.headers.Cookie = 'find=me2' 165 | lambdaEvent.headers.Find = 'me3' 166 | const req = new Request(lambdaEvent) 167 | expect(req.get('find')).to.eq('me2') 168 | }) 169 | 170 | it('should get header third', () => { 171 | lambdaEvent.headers.Find = 'me3' 172 | const req = new Request(lambdaEvent) 173 | expect(req.get('find')).to.eq('me3') 174 | }) 175 | }) 176 | 177 | describe('#getCookie', () => { 178 | it('should return undefined', () => { 179 | const req = new Request(lambdaEvent) 180 | expect(req.getCookie('nothing_That_exists')).to.be.undefined 181 | }) 182 | 183 | it('should be case-insensitive', () => { 184 | lambdaEvent.headers.Cookie = 'Find=me2' 185 | const req = new Request(lambdaEvent) 186 | expect(req.getCookie('fInd')).to.eq('me2') 187 | }) 188 | }) 189 | 190 | describe('#getHeader', () => { 191 | it('should return undefined', () => { 192 | const req = new Request(lambdaEvent) 193 | expect(req.getHeader('nothing_That_exists')).to.be.undefined 194 | }) 195 | 196 | it('should be case-insensitive', () => { 197 | lambdaEvent.headers.Find = 'me2' 198 | const req = new Request(lambdaEvent) 199 | expect(req.getHeader('fInd')).to.eq('me2') 200 | }) 201 | }) 202 | 203 | describe('#getQueryParam', () => { 204 | it('should return undefined', () => { 205 | const req = new Request(lambdaEvent) 206 | expect(req.getQueryParam('nothing_That_exists')).to.be.undefined 207 | }) 208 | 209 | it('should parse bools', () => { 210 | lambdaEvent.queryStringParameters.success = 'true' 211 | let req = new Request(lambdaEvent) 212 | expect(req.getQueryParam('success')).to.be.true 213 | 214 | lambdaEvent.queryStringParameters.success = 'false' 215 | req = new Request(lambdaEvent) 216 | expect(req.getQueryParam('success')).to.be.false 217 | }) 218 | 219 | it('should parse null', () => { 220 | lambdaEvent.queryStringParameters.success = 'null' 221 | const req = new Request(lambdaEvent) 222 | expect(req.getQueryParam('success')).to.be.null 223 | }) 224 | }) 225 | 226 | describe('#is', () => { 227 | it('should match partial', () => { 228 | lambdaEvent.headers['Content-Type'] = 'text/html; charset=utf-8' 229 | const req = new Request(lambdaEvent) 230 | expect(req.is('html')).to.be.true 231 | expect(req.is('text/html')).to.be.true 232 | expect(req.is('text/*')).to.be.true 233 | expect(req.is('json')).to.be.false 234 | }) 235 | 236 | it('should get cookie second', () => { 237 | lambdaEvent.headers.Cookie = 'find=me2' 238 | lambdaEvent.headers.Find = 'me3' 239 | const req = new Request(lambdaEvent) 240 | expect(req.get('find')).to.eq('me2') 241 | }) 242 | 243 | it('should get header third', () => { 244 | lambdaEvent.headers.Find = 'me3' 245 | const req = new Request(lambdaEvent) 246 | expect(req.get('find')).to.eq('me3') 247 | }) 248 | }) 249 | 250 | describe('#valueFilter', () => { 251 | it('should not lowercase the value', () => { 252 | expect(Request.valueFilter('SomeThing')).to.eq('SomeThing') 253 | }) 254 | }) 255 | 256 | describe('#accepts', () => { 257 | context('single type', () => { 258 | it('should return type when Accept is not present', () => { 259 | lambdaEvent.headers.Accept = undefined 260 | const req = new Request(lambdaEvent) 261 | expect(req.accepts('html')).to.eq('html') 262 | }) 263 | 264 | it('should return type when Accept is present and match', () => { 265 | lambdaEvent.headers.Accept = 'image/webp,image/*' 266 | const req = new Request(lambdaEvent) 267 | expect(req.accepts('gif')).to.eq('gif') 268 | }) 269 | 270 | it('should return false when Accept is present but not a match', () => { 271 | lambdaEvent.headers.Accept = 'image/webp,image/*' 272 | const req = new Request(lambdaEvent) 273 | expect(req.accepts('json')).to.be.false 274 | }) 275 | }) 276 | 277 | context('list of types', () => { 278 | it('should return first when Accept is not present', () => { 279 | lambdaEvent.headers.Accept = undefined 280 | const req = new Request(lambdaEvent) 281 | expect(req.accepts('json', 'image')).to.eq('json') 282 | }) 283 | 284 | it('should return false when Accept is present but no a match', () => { 285 | lambdaEvent.headers.Accept = 'image/webp,image/*' 286 | const req = new Request(lambdaEvent) 287 | expect(req.accepts('json', 'html')).to.be.false 288 | }) 289 | 290 | it('should recognize q value', () => { 291 | lambdaEvent.headers.Accept = '*/html; q=.5, application/json' 292 | const req = new Request(lambdaEvent) 293 | expect(req.accepts(['text/html', 'application/json'])).to.eq('application/json') 294 | }) 295 | 296 | it('should return the first acceptable type', () => { 297 | lambdaEvent.headers.Accept = 'image/webp,image/*,*/*;q=0.8' 298 | const req = new Request(lambdaEvent) 299 | expect(req.accepts(['application/json', 'image/*'])).to.eq('image/*') 300 | }) 301 | }) 302 | }) 303 | 304 | describe('#context', () => { 305 | it('should return a requestContext property', () => { 306 | const req = new Request(lambdaEvent) 307 | expect(req.context('requestId')).to.eq('08e3e2d0-daca-11e6-8d84-394b4374a71a') 308 | }) 309 | }) 310 | 311 | describe('#getLambdaEvent', () => { 312 | it('should return the raw lambda event', () => { 313 | expect(new Request(lambdaEvent).getLambdaEvent()).to.eq(lambdaEvent) 314 | }) 315 | }) 316 | }) 317 | }) 318 | -------------------------------------------------------------------------------- /test/events/response.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | 3 | 'use strict' 4 | const binaryCase = require('binary-case') 5 | const expect = require('chai').expect 6 | const Response = require('../../src/events/response') 7 | 8 | describe('events', () => { 9 | describe('Response', () => { 10 | describe('#set', () => { 11 | it('should set a lowercase key with value in headers object', () => { 12 | const res = new Response() 13 | expect(res.headers).to.eql({}) 14 | res.set('Some', 'Header') 15 | expect(res.headers.some).to.eq('Header') 16 | }) 17 | 18 | it('should force a string', () => { 19 | const res = new Response() 20 | res.set('X-Limit', 2000) 21 | expect(res.headers['x-limit']).to.eq('2000') 22 | }) 23 | 24 | it('should set a list of header values', () => { 25 | const res = new Response() 26 | res.set('X-Limits', [100, 200]) 27 | expect(res.headers['x-limits']).to.eql(['100', '200']) 28 | }) 29 | 30 | it('should set a an object of headers', () => { 31 | const res = new Response() 32 | res.set({ 33 | 'X-Limit': 200, 34 | 'X-Blah': 'meh' 35 | }) 36 | expect(res.headers['x-limit']).to.eq('200') 37 | expect(res.headers['x-blah']).to.eq('meh') 38 | }) 39 | 40 | it('should be chainable', () => { 41 | const res = new Response() 42 | res.set({ 43 | 'X-Blah': 'meh' 44 | }) 45 | .set('X-Limit', 200) 46 | 47 | expect(res.headers['x-limit']).to.eq('200') 48 | expect(res.headers['x-blah']).to.eq('meh') 49 | }) 50 | }) 51 | 52 | describe('#append', () => { 53 | it('should set key with value in headers object', () => { 54 | const res = new Response() 55 | expect(res.headers).to.eql({}) 56 | res.append('Some', 'Header') 57 | expect(res.headers.some).to.eq('Header') 58 | }) 59 | 60 | it('should be overriden with set', () => { 61 | const res = new Response() 62 | res.append('X-Limits', [100, 200]) 63 | res.set('X-Limits', 300) 64 | expect(res.headers['x-limits']).to.eq('300') 65 | }) 66 | 67 | it('should be chainable', () => { 68 | const res = new Response() 69 | res.append('X-Blah', 'meh') 70 | .append('X-Blah', 200) 71 | .append('X-Blah', 'another') 72 | 73 | expect(res.headers['x-blah']).to.eql(['meh', '200', 'another']) 74 | }) 75 | 76 | it('should convert previous value to an array if array is passed', () => { 77 | const res = new Response() 78 | res.set('X-Blah', 'meh') 79 | .append('X-Blah', [200]) 80 | 81 | expect(res.headers['x-blah']).to.eql(['meh', '200']) 82 | }) 83 | }) 84 | 85 | describe('#status', () => { 86 | it('should set the status code', () => { 87 | const res = new Response() 88 | res.status(400) 89 | expect(res.statusCode).to.eq(400) 90 | }) 91 | }) 92 | 93 | describe('#contentType', () => { 94 | it('should set content type with mime look up', () => { 95 | const res = new Response() 96 | res.contentType('html') 97 | expect(res.headers['content-type']).to.eq('text/html') 98 | }) 99 | 100 | it('should set content string with / content type', () => { 101 | const res = new Response() 102 | res.contentType('text/plain') 103 | expect(res.headers['content-type']).to.eq('text/plain') 104 | }) 105 | }) 106 | 107 | describe('#get', () => { 108 | it('should return undefined if nothing found', () => { 109 | const res = new Response() 110 | expect(res.get('something')).to.be.undefined 111 | }) 112 | 113 | it('should be case insensitive', () => { 114 | const res = new Response() 115 | res.set('X-Api-Key', 'blahblah') 116 | expect(res.get('x-api-key')).to.eq('blahblah') 117 | }) 118 | }) 119 | 120 | describe('#cookie', () => { 121 | it('should add cookie header', () => { 122 | const res = new Response() 123 | res.cookie('some', 'value') 124 | expect(res.headers['set-cookie']).to.eql('some=value; Path=/') 125 | }) 126 | 127 | it('should accept path as an options', () => { 128 | const res = new Response() 129 | res.cookie('some', 'value', { path: '/somepath' }) 130 | expect(res.headers['set-cookie']).to.eql('some=value; Path=/somepath') 131 | }) 132 | 133 | it('should be chainable', () => { 134 | const res = new Response() 135 | res.cookie('some', 'value') 136 | .cookie('another', 'value') 137 | 138 | expect(res.headers['set-cookie']).to.eql(['some=value; Path=/', 'another=value; Path=/']) 139 | }) 140 | 141 | it('should handle a JSON-able object', () => { 142 | const res = new Response() 143 | res.cookie('guy', { blah: 'meh' }) 144 | expect(res.headers['set-cookie']).to.eql(`guy=${encodeURIComponent('j:{"blah":"meh"}')}; Path=/`) 145 | }) 146 | 147 | it('should support options', () => { 148 | const res = new Response() 149 | res.cookie('some', 'value', { secure: true, httpOnly: true }) 150 | expect(res.headers['set-cookie']).to.eql('some=value; Path=/; HttpOnly; Secure') 151 | }) 152 | 153 | it('should set max-age relative to now', () => { 154 | const res = new Response() 155 | res.cookie('some', 'value', { maxAge: 1000 }) 156 | expect(res.headers['set-cookie'][0]).not.to.contain('Thu, 01 Jan 1970 00:00:01 GMT') 157 | }) 158 | }) 159 | 160 | describe('#send', () => { 161 | it('should return a lambda-proxy response', () => { 162 | const res = new Response() 163 | expect(res.send('blah')).to.eql({ 164 | statusCode: 200, 165 | headers: { 'Content-Type': 'text/plain' }, 166 | body: 'blah' 167 | }) 168 | }) 169 | 170 | it('should return normalized headers', () => { 171 | const res = new Response({ 172 | headers: { 'X-Some-Header': 'some-val' } 173 | }) 174 | expect(res.send('blah')).to.eql({ 175 | statusCode: 200, 176 | headers: { 'Content-Type': 'text/plain', 'X-Some-Header': 'some-val' }, 177 | body: 'blah' 178 | }) 179 | }) 180 | 181 | it('should not overwrite content type if set', () => { 182 | const res = new Response() 183 | res.contentType('html') 184 | expect(res.send('blah')).to.eql({ 185 | statusCode: 200, 186 | headers: { 'Content-Type': 'text/html' }, 187 | body: 'blah' 188 | }) 189 | }) 190 | 191 | it('should add isBase64Encoded if true', () => { 192 | const base64EmptyGif = 'R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==' 193 | const res = new Response({ 194 | headers: { 195 | 'Content-Type': 'image/gif' 196 | }, 197 | isBase64Encoded: true 198 | }) 199 | expect(res.send(base64EmptyGif)).to.eql({ 200 | statusCode: 200, 201 | headers: { 202 | 'Content-Type': 'image/gif' 203 | }, 204 | body: base64EmptyGif, 205 | isBase64Encoded: true 206 | }) 207 | }) 208 | 209 | context('passing an object', () => { 210 | it('should return empty string if undefined', () => { 211 | const res = new Response() 212 | expect(res.send()).to.eql({ 213 | statusCode: 200, 214 | headers: { 'Content-Type': 'text/plain' }, 215 | body: '' 216 | }) 217 | }) 218 | 219 | it('should return empty string if null with content-type text/plain', () => { 220 | const res = new Response() 221 | res.set('Content-Type', 'text/html') 222 | expect(res.send(null)).to.eql({ 223 | statusCode: 200, 224 | headers: { 'Content-Type': 'text/html' }, 225 | body: '' 226 | }) 227 | }) 228 | 229 | it('should return empty string if null with set content-type', () => { 230 | const res = new Response() 231 | expect(res.send(null)).to.eql({ 232 | statusCode: 200, 233 | headers: { 'Content-Type': 'text/plain' }, 234 | body: '' 235 | }) 236 | }) 237 | 238 | it('should stringify if boolean', () => { 239 | const res = new Response() 240 | expect(res.send(false)).to.eql({ 241 | statusCode: 200, 242 | headers: { 'Content-Type': 'application/json' }, 243 | body: JSON.stringify(false) 244 | }) 245 | }) 246 | 247 | it('should stringify if number', () => { 248 | const res = new Response() 249 | expect(res.send(6000)).to.eql({ 250 | statusCode: 200, 251 | headers: { 'Content-Type': 'application/json' }, 252 | body: JSON.stringify(6000) 253 | }) 254 | }) 255 | 256 | it('should stringify and set content type', () => { 257 | const res = new Response() 258 | expect(res.send({ some: 'object' })).to.eql({ 259 | statusCode: 200, 260 | headers: { 'Content-Type': 'application/json' }, 261 | body: JSON.stringify({ some: 'object' }) 262 | }) 263 | }) 264 | }) 265 | 266 | context('with cookies', () => { 267 | it('should return variations of Set-Cookie if more than 1 cookie', () => { 268 | const res = new Response() 269 | res.cookie('some', 'value') 270 | .cookie('another', 'value') 271 | 272 | const sent = res.send() 273 | expect(sent.headers['Set-Cookie']).to.be.a('string') 274 | expect(sent.headers['set-Cookie']).to.be.a('string') 275 | }) 276 | 277 | it('should accept up to 512 cookies', () => { 278 | const res = new Response() 279 | for (let i = 0; i < 514; i++) { 280 | res.cookie(`some_${i}`, `value_${i}`) 281 | } 282 | 283 | const sent = res.send() 284 | for (let i = 0; i < 512; i++) { 285 | const setCookieCase = binaryCase('Set-Cookie', i, { 286 | allowOverflow: false 287 | }) 288 | 289 | if (i < 512) { 290 | expect(sent.headers[setCookieCase]).to.be.a('string') 291 | } else { 292 | expect(sent.headers[setCookieCase]).not.to.exist() 293 | } 294 | } 295 | }) 296 | }) 297 | }) 298 | 299 | describe('#json', () => { 300 | it('should return a lambda-proxy response', () => { 301 | const res = new Response() 302 | expect(res.json({ blah: 'meh' })).to.eql({ 303 | statusCode: 200, 304 | headers: { 305 | 'Content-Type': 'application/json' 306 | }, 307 | body: JSON.stringify({ blah: 'meh' }) 308 | }) 309 | }) 310 | }) 311 | 312 | describe('constructor options', () => { 313 | it('should set status code', () => { 314 | const res = new Response({ 315 | statusCode: 304 316 | }) 317 | expect(res.statusCode).to.eq(304) 318 | }) 319 | 320 | it('should set headers', () => { 321 | const res = new Response({ 322 | headers: { 'X-Some': 'Thing' } 323 | }) 324 | expect(res.headers['x-some']).to.eq('Thing') 325 | res.set('Blah', '1') 326 | expect(Object.keys(res.headers)).to.have.lengthOf(2) 327 | }) 328 | 329 | it('should add CORS header if true', () => { 330 | const res = new Response({ 331 | cors: true 332 | }) 333 | expect(res.headers['access-control-allow-origin']).to.eq('*') 334 | }) 335 | }) 336 | }) 337 | }) 338 | -------------------------------------------------------------------------------- /test/events/test_events.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /* eslint-disable */ 3 | const cloneDeep = require('lodash.clonedeep') 4 | 5 | module.exports._GET = { 6 | "resource": "/api/pipe/{pathParam}", 7 | "path": "/api/pipe/hooray/", 8 | "httpMethod": "GET", 9 | "headers": { 10 | "Accept": "*/*", 11 | "Accept-Encoding": "gzip, deflate, sdch, br", 12 | "Accept-Language": "en-US,en;q=0.8", 13 | "Cache-Control": "no-cache", 14 | "CloudFront-Forwarded-Proto": "https", 15 | "CloudFront-Is-Desktop-Viewer": "true", 16 | "CloudFront-Is-Mobile-Viewer": "false", 17 | "CloudFront-Is-SmartTV-Viewer": "false", 18 | "CloudFront-Is-Tablet-Viewer": "false", 19 | "CloudFront-Viewer-Country": "US", 20 | "Cookie": "some=thing; testbool=false; testnull=null", 21 | "Host": "services.cheekyroad.com", 22 | "Pragma": "no-cache", 23 | "Referer": "https://cheekyroad.com/paht/?cool=true", 24 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.95 Safari/537.36", 25 | "Via": "1.1 1a1a1a1.cloudfront.net (CloudFront)", 26 | "X-Amz-Cf-Id": "2b2b2b2b2==", 27 | "X-Forwarded-For": "111.111.111.111, 222.222.222.222", 28 | "X-Forwarded-Port": "443", 29 | "X-Forwarded-Proto": "https" 30 | }, 31 | "queryStringParameters": { 32 | "et": "something" 33 | }, 34 | "pathParameters": { 35 | "pathParam": "hooray" 36 | }, 37 | "stageVariables": null, 38 | "requestContext": { 39 | "accountId": "111111111111", 40 | "resourceId": "blah", 41 | "stage": "dev", 42 | "requestId": "08e3e2d0-daca-11e6-8d84-394b4374a71a", 43 | "identity": { 44 | "cognitoIdentityPoolId": null, 45 | "accountId": null, 46 | "cognitoIdentityId": null, 47 | "caller": null, 48 | "apiKey": null, 49 | "sourceIp": "111.111.111.111", 50 | "accessKey": null, 51 | "cognitoAuthenticationType": null, 52 | "cognitoAuthenticationProvider": null, 53 | "userArn": null, 54 | "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.95 Safari/537.36", 55 | "user": null 56 | }, 57 | "resourcePath": "/api/pipe/{pathParam}", 58 | "httpMethod": "GET", 59 | "apiId": "cdcd4" 60 | }, 61 | "body": null, 62 | "isBase64Encoded": false 63 | } 64 | 65 | module.exports.GET = () => cloneDeep(module.exports._GET) --------------------------------------------------------------------------------