├── __tests__ ├── test.txt ├── _testData.js ├── _testRoutes-v3.js ├── sample-context-alb1.json ├── sample-context-apigateway1.json ├── _testRoutes-v2.js ├── _testRoutes-v1.js ├── _testApp.js ├── sample-event-alb1.json ├── sample-event-alb2.json ├── sample-event-consoletest1.json ├── prettyPrint.unit.js ├── context.unit.js ├── unit.js ├── finally.unit.js ├── basePath.unit.js ├── namespaces.unit.js ├── sample-event-apigateway-v2.json ├── modules.unit.js ├── sample-event-apigateway-v1.json ├── etag.unit.js ├── run.unit.js ├── attachments.unit.js ├── lastModified.unit.js ├── cacheControl.unit.js ├── getLink.unit.js ├── executionStacks.unit.js ├── register.unit.js ├── download.unit.js ├── requests.unit.js ├── cookies.unit.js ├── sampling.unit.js └── sendFile.unit.js ├── .prettierrc.json ├── .eslintignore ├── .prettierignore ├── SECURITY.md ├── .gitignore ├── .eslintrc.json ├── LICENSE ├── .github └── workflows │ ├── build.yml │ └── pull-request.yml ├── lib ├── errors.js ├── s3-service.js ├── compression.js ├── prettyPrint.js ├── statusCodes.js ├── mimemap.js ├── utils.js ├── logger.js └── request.js ├── package.json └── index.d.ts /__tests__/test.txt: -------------------------------------------------------------------------------- 1 | Test file for sendFile 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage 2 | node_modules 3 | __tests__ 4 | *.test.js 5 | dist -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | coverage 2 | node_modules 3 | __tests__ 4 | *.test.js 5 | dist -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | Please email contact@jeremydaly.com to report vunerabilities. 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directories 2 | node_modules 3 | 4 | # Optional npm cache directory 5 | .npm 6 | 7 | # Local REDIS test data 8 | dump.rdb 9 | 10 | # Coverage reports 11 | .nyc_output 12 | coverage 13 | -------------------------------------------------------------------------------- /__tests__/_testData.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = { 4 | 5 | dataCall: function() { 6 | return { 7 | "foo": "sample data", 8 | "bar": "additional sample data" 9 | } 10 | } 11 | 12 | } // end exports 13 | -------------------------------------------------------------------------------- /__tests__/_testRoutes-v3.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = function(app, opts) { 4 | 5 | app.get('/test-register-no-options', function(req,res) { 6 | res.json({ path: req.path, route: req.route, method: req.method }) 7 | }) 8 | 9 | } // end 10 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true, 5 | "jest": true 6 | }, 7 | "extends": ["eslint:recommended", "prettier"], 8 | "parserOptions": { 9 | "ecmaVersion": 2018, 10 | "sourceType": "module" 11 | }, 12 | "rules": {}, 13 | "globals": { 14 | "expect": true, 15 | "it": true 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /__tests__/sample-context-alb1.json: -------------------------------------------------------------------------------- 1 | { 2 | "callbackWaitsForEmptyEventLoop": true, 3 | "logGroupName": "/aws/lambda/test-alb", 4 | "logStreamName": "2018/12/22/[$LATEST]21a094d27de15adeaceaf073140d5aca", 5 | "functionName": "test-alb", 6 | "memoryLimitInMB": "1024", 7 | "functionVersion": "$LATEST", 8 | "invokeid": "59327015-07f1-11e9-a63e-9f9eb869059e", 9 | "awsRequestId": "59327015-07f1-11e9-a63e-9f9eb869059e", 10 | "invokedFunctionArn": "arn:aws:lambda:us-east-1:XXXXXXXXXX:function:test-alb" 11 | } 12 | -------------------------------------------------------------------------------- /__tests__/sample-context-apigateway1.json: -------------------------------------------------------------------------------- 1 | { 2 | "callbackWaitsForEmptyEventLoop": false, 3 | "logGroupName": "/aws/lambda/test-apigateway", 4 | "logStreamName": "2018/12/22/[$LATEST]21a094d27de15adeaceaf073140d5aca", 5 | "functionName": "test-alb", 6 | "memoryLimitInMB": "1024", 7 | "functionVersion": "$LATEST", 8 | "invokeid": "59327015-07f1-11e9-a63e-9f9eb869059e", 9 | "awsRequestId": "59327015-07f1-11e9-a63e-9f9eb869059e", 10 | "invokedFunctionArn": "arn:aws:lambda:us-east-1:XXXXXXXXXX:function:test-apigateway" 11 | } 12 | -------------------------------------------------------------------------------- /__tests__/_testRoutes-v2.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = function(app, opts) { 4 | 5 | app.get('/test-register', function(req,res) { 6 | res.json({ path: req.path, route: req.route, method: req.method }) 7 | }) 8 | 9 | app.get('/test-register/sub1', function(req,res) { 10 | res.json({ path: req.path, route: req.route, method: req.method }) 11 | }) 12 | 13 | app.get('/test-register/sub2', function(req,res) { 14 | res.json({ path: req.path, route: req.route, method: req.method }) 15 | }) 16 | 17 | app.get('/test-register/:param1/test/', function(req,res) { 18 | res.json({ path: req.path, route: req.route, method: req.method, params: req.params }) 19 | }) 20 | 21 | } // end 22 | -------------------------------------------------------------------------------- /__tests__/_testRoutes-v1.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = function(app, opts) { 4 | 5 | app.get('/test-register', function(req,res) { 6 | res.json({ path: req.path, route: req.route, method: req.method }) 7 | }) 8 | 9 | app.get('/test-register/sub1', function(req,res) { 10 | res.json({ path: req.path, route: req.route, method: req.method }) 11 | }) 12 | 13 | app.get('test-register/:param1/test/', function(req,res) { 14 | res.json({ path: req.path, route: req.route, method: req.method, params: req.params }) 15 | }) 16 | 17 | if (opts.prefix === '/vX/vY') { 18 | app.register(require('./_testRoutes-v1'), { prefix: '/vZ' }) 19 | } 20 | 21 | app.get('/test-register/sub2/', function(req,res) { 22 | res.json({ path: req.path, route: req.route, method: req.method }) 23 | }) 24 | 25 | } // end 26 | -------------------------------------------------------------------------------- /__tests__/_testApp.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const delay = ms => new Promise(res => setTimeout(res, ms)) 4 | 5 | module.exports = { 6 | 7 | app: function(req,res) { 8 | // do something 9 | res.json({ method:'get', status:'ok', app:'app1'}) 10 | }, 11 | 12 | promise: function(req,res) { 13 | let start = Date.now() 14 | delay(100).then((x) => { 15 | res.json({ method:'get', status:'ok', app:'app2'}) 16 | }) 17 | }, 18 | 19 | calledError: function(req,res) { 20 | res.status(500).error('This is a called module error') 21 | }, 22 | 23 | thrownError: function(req,res) { 24 | throw new Error('This is a thrown module error') 25 | }, 26 | 27 | dataTest: function(req,res) { 28 | // Use data namespace 29 | let data = req.ns.data.dataCall() 30 | res.json({ method:'get', status:'ok', data: data }) 31 | }, 32 | 33 | } // end exports 34 | -------------------------------------------------------------------------------- /__tests__/sample-event-alb1.json: -------------------------------------------------------------------------------- 1 | { 2 | "requestContext": { 3 | "elb": { 4 | "targetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:XXXXXXXXXX:targetgroup/Test-ALB-Lambda/XXXXXXX" 5 | } 6 | }, 7 | "httpMethod": "GET", 8 | "path": "/test/hello", 9 | "queryStringParameters": { 10 | "qs1": "foo" 11 | }, 12 | "headers": { 13 | "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", 14 | "accept-encoding": "br, gzip, deflate", 15 | "accept-language": "en-us", 16 | "cookie": "", 17 | "host": "wt6mne2s9k.execute-api.us-west-2.amazonaws.com", 18 | "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36 OPR/39.0.2256.48", 19 | "x-amzn-trace-id": "Root=1-5c1db69f-XXXXXXXXXXX", 20 | "x-forwarded-for": "192.168.100.1", 21 | "x-forwarded-port": "443", 22 | "x-forwarded-proto": "https" 23 | }, 24 | "body": "", 25 | "isBase64Encoded": true 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2023 Jeremy Daly 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 | -------------------------------------------------------------------------------- /__tests__/sample-event-alb2.json: -------------------------------------------------------------------------------- 1 | { 2 | "requestContext": { 3 | "elb": { 4 | "targetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:XXXXXXXXXX:targetgroup/Test-ALB-Lambda/XXXXXXX" 5 | } 6 | }, 7 | "httpMethod": "GET", 8 | "path": "/test/hello", 9 | "multiValueQueryStringParameters": { 10 | "qs1": [ "foo" ], 11 | "qs2": [ "foo", "bar" ], 12 | "qs3": [ "foo", "bar", "bat" ] 13 | }, 14 | "multiValueHeaders": { 15 | "accept": ["text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"], 16 | "accept-encoding": ["br, gzip, deflate"], 17 | "accept-language": ["en-us"], 18 | "cookie": [""], 19 | "host": ["wt6mne2s9k.execute-api.us-west-2.amazonaws.com"], 20 | "user-agent": ["Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36 OPR/39.0.2256.48"], 21 | "x-amzn-trace-id": ["Root=1-5c1db69f-XXXXXXXXXXX"], 22 | "x-forwarded-for": ["192.168.100.1"], 23 | "x-forwarded-port": ["443"], 24 | "x-forwarded-proto": ["https"], 25 | "test-header": ["val1","val2"] 26 | }, 27 | "body": "", 28 | "isBase64Encoded": true 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: 'Build' 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | node-version: [14.x] 14 | 15 | steps: 16 | - name: Get branch name (merge) 17 | if: github.event_name != 'pull_request' 18 | shell: bash 19 | run: echo "BRANCH_NAME=$(echo ${GITHUB_REF#refs/heads/} | tr / -)" >> $GITHUB_ENV 20 | 21 | - name: Get branch name (pull request) 22 | if: github.event_name == 'pull_request' 23 | shell: bash 24 | run: echo "BRANCH_NAME=$(echo ${GITHUB_HEAD_REF} | tr / -)" >> $GITHUB_ENV 25 | 26 | - uses: actions/checkout@v2 27 | - name: Use Node.js ${{ matrix.node-version }} 28 | uses: actions/setup-node@v1 29 | with: 30 | node-version: ${{ matrix.node-version }} 31 | - name: Install NPM 32 | run: npm install -g npm@9 33 | - run: npm ci 34 | - run: npm run test-ci 35 | env: 36 | COVERALLS_SERVICE_NAME: GithubActions 37 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} 38 | COVERALLS_GIT_BRANCH: ${{ env.BRANCH_NAME }} 39 | -------------------------------------------------------------------------------- /lib/errors.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /** 3 | * Lightweight web framework for your serverless applications 4 | * @author Jeremy Daly 5 | * @license MIT 6 | */ 7 | 8 | /* 9 | Custom error types 10 | */ 11 | 12 | class RouteError extends Error { 13 | constructor(message, path) { 14 | super(message); 15 | this.name = 'RouteError'; 16 | this.path = path; 17 | } 18 | } 19 | 20 | class MethodError extends Error { 21 | constructor(message, method, path) { 22 | super(message); 23 | this.name = 'MethodError'; 24 | this.method = method; 25 | this.path = path; 26 | } 27 | } 28 | 29 | class ConfigurationError extends Error { 30 | constructor(message) { 31 | super(message); 32 | this.name = 'ConfigurationError'; 33 | } 34 | } 35 | 36 | class ResponseError extends Error { 37 | constructor(message, code) { 38 | super(message); 39 | this.name = 'ResponseError'; 40 | this.code = code; 41 | } 42 | } 43 | 44 | class FileError extends Error { 45 | constructor(message, err) { 46 | super(message); 47 | this.name = 'FileError'; 48 | for (let e in err) this[e] = err[e]; 49 | } 50 | } 51 | 52 | // Export the response object 53 | module.exports = { 54 | RouteError, 55 | MethodError, 56 | ConfigurationError, 57 | ResponseError, 58 | FileError, 59 | }; 60 | -------------------------------------------------------------------------------- /lib/s3-service.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /** 3 | * Lightweight web framework for your serverless applications 4 | * @author Jeremy Daly 5 | * @license MIT 6 | */ 7 | 8 | // Require AWS SDK 9 | const { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3'); // AWS SDK 10 | const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); 11 | const { streamToBuffer } = require('./utils'); 12 | 13 | // Export 14 | exports.client = new S3Client(); 15 | exports.setConfig = (config) => (exports.client = new S3Client(config)); 16 | 17 | exports.getObject = (params) => { 18 | return { 19 | promise: async () => { 20 | const res = await this.client.send(new GetObjectCommand(params)); 21 | 22 | if (!res.Body) return res; 23 | 24 | return { 25 | ...res, 26 | Body: await streamToBuffer(res.Body), 27 | }; 28 | }, 29 | }; 30 | }; 31 | 32 | exports.getSignedUrl = async ( 33 | type, 34 | { Expires, ...params }, 35 | callback = () => {} 36 | ) => { 37 | let command; 38 | switch (type) { 39 | case 'getObject': 40 | command = new GetObjectCommand(params); 41 | break; 42 | default: 43 | throw new Error('Invalid command type'); 44 | } 45 | return getSignedUrl(this.client, command, { expiresIn: Expires }) 46 | .then((url) => { 47 | callback(null, url); 48 | return url; 49 | }) 50 | .catch((err) => { 51 | callback(err); 52 | }); 53 | }; 54 | -------------------------------------------------------------------------------- /lib/compression.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /** 3 | * Lightweight web framework for your serverless applications 4 | * @author Jeremy Daly 5 | * @license MIT 6 | */ 7 | 8 | const zlib = require('zlib'); 9 | 10 | const defaultEnabledEcodings = ['gzip', 'deflate']; 11 | 12 | exports.compress = (input, headers, _enabledEncodings) => { 13 | const enabledEncodings = new Set(_enabledEncodings || defaultEnabledEcodings); 14 | const acceptEncodingHeader = headers['accept-encoding'] || ''; 15 | const acceptableEncodings = new Set( 16 | acceptEncodingHeader 17 | .toLowerCase() 18 | .split(',') 19 | .map((str) => str.trim()) 20 | ); 21 | 22 | // Handle Brotli compression (Only supported in Node v10 and later) 23 | if ( 24 | acceptableEncodings.has('br') && 25 | enabledEncodings.has('br') && 26 | typeof zlib.brotliCompressSync === 'function' 27 | ) { 28 | return { 29 | data: zlib.brotliCompressSync(input), 30 | contentEncoding: 'br', 31 | }; 32 | } 33 | 34 | // Handle Gzip compression 35 | if (acceptableEncodings.has('gzip') && enabledEncodings.has('gzip')) { 36 | return { 37 | data: zlib.gzipSync(input), 38 | contentEncoding: 'gzip', 39 | }; 40 | } 41 | 42 | // Handle deflate compression 43 | if (acceptableEncodings.has('deflate') && enabledEncodings.has('deflate')) { 44 | return { 45 | data: zlib.deflateSync(input), 46 | contentEncoding: 'deflate', 47 | }; 48 | } 49 | 50 | return { 51 | data: input, 52 | contentEncoding: null, 53 | }; 54 | }; 55 | -------------------------------------------------------------------------------- /__tests__/sample-event-consoletest1.json: -------------------------------------------------------------------------------- 1 | { 2 | "resource": "/{proxy+}", 3 | "path": "/test/hello", 4 | "httpMethod": "GET", 5 | "headers": null, 6 | "multiValueHeaders": null, 7 | "queryStringParameters": null, 8 | "multiValueQueryStringParameters": null, 9 | "pathParameters": { 10 | "proxy": "test/hello" 11 | }, 12 | "stageVariables": null, 13 | "requestContext": { 14 | "path": "/{proxy+}", 15 | "accountId": "012345678900", 16 | "resourceId": "s1swyk", 17 | "stage": "test-invoke-stage", 18 | "domainPrefix": "testPrefix", 19 | "requestId": "3b5b1ca9-80ba-11e9-b4af-a3ad35996092", 20 | "identity": { 21 | "cognitoIdentityPoolId": null, 22 | "cognitoIdentityId": null, 23 | "apiKey": "test-invoke-api-key", 24 | "principalOrgId": null, 25 | "cognitoAuthenticationType": null, 26 | "userArn": "arn:aws:iam::012345678900:root", 27 | "apiKeyId": "test-invoke-api-key-id", 28 | "userAgent": "aws-internal/3 aws-sdk-java/1.11.534 Linux/4.9.137-0.1.ac.218.74.329.metal1.x86_64 OpenJDK_64-Bit_Server_VM/25.202-b08 java/1.8.0_202 vendor/Oracle_Corporation", 29 | "accountId": "012345678900", 30 | "caller": "012345678900", 31 | "sourceIp": "test-invoke-source-ip", 32 | "accessKey": "AAAAAAAAAAAAAAAAAAAA", 33 | "cognitoAuthenticationProvider": null, 34 | "user": "012345678900" 35 | }, 36 | "domainName": "testPrefix.testDomainName", 37 | "resourcePath": "/{proxy+}", 38 | "httpMethod": "GET", 39 | "extendedRequestId": "aW9E_GA3PHcFX0A=", 40 | "apiId": "8gd934em46" 41 | }, 42 | "body": null, 43 | "isBase64Encoded": false 44 | } -------------------------------------------------------------------------------- /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | name: 'Pull Request' 2 | on: 3 | pull_request: 4 | types: [opened, reopened, synchronize] 5 | 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | node: [14, 16, 18] 12 | name: Node ${{ matrix.node }} 13 | steps: 14 | - name: 'Checkout latest code' 15 | uses: actions/checkout@v3 16 | with: 17 | ref: ${{ github.event.pull_request.head.sha }} 18 | - name: Set up node 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: ${{ matrix.node }} 22 | cache: 'npm' 23 | - name: Install NPM 24 | run: npm install -g npm@9 25 | - name: Install dependencies 26 | run: npm ci 27 | - name: Run tests 28 | run: npm run test 29 | 30 | lint: 31 | name: 'ESLint' 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: Checkout latest code 35 | uses: actions/checkout@v3 36 | with: 37 | ref: ${{ github.event.pull_request.head.sha }} 38 | - name: Set up node 39 | uses: actions/setup-node@v3 40 | with: 41 | node-version: '16' 42 | cache: 'npm' 43 | - name: Install dependencies 44 | run: npm ci 45 | - name: Run ESLint 46 | run: npm run lint:check 47 | 48 | prettier: 49 | name: 'Prettier' 50 | runs-on: ubuntu-latest 51 | steps: 52 | - name: Checkout latest code 53 | uses: actions/checkout@v3 54 | with: 55 | ref: ${{ github.event.pull_request.head.sha }} 56 | - name: Set up node 57 | uses: actions/setup-node@v3 58 | with: 59 | node-version: '16' 60 | - name: Install dependencies 61 | run: npm ci 62 | - name: Run Prettier 63 | run: npm run prettier:check 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lambda-api", 3 | "version": "1.0.3", 4 | "description": "Lightweight web framework for your serverless applications", 5 | "main": "index.js", 6 | "types": "index.d.ts", 7 | "scripts": { 8 | "test": "jest unit", 9 | "lint:check": "eslint .", 10 | "lint:fix": "eslint . --fix", 11 | "prettier:check": "prettier --check .", 12 | "prettier:write": "prettier --write .", 13 | "test-cov": "jest unit --coverage", 14 | "test-ci": "npm run lint:check && npm run prettier:check && jest unit --coverage && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js", 15 | "prepublishOnly": "npm test && npm run lint:check", 16 | "changelog": "git log $(git describe --tags --abbrev=0)..HEAD --oneline" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/jeremydaly/lambda-api.git" 21 | }, 22 | "keywords": [ 23 | "serverless", 24 | "nodejs", 25 | "api", 26 | "AWS Lambda", 27 | "API Gateway", 28 | "web framework", 29 | "json", 30 | "schema", 31 | "open" 32 | ], 33 | "author": "Jeremy Daly ", 34 | "license": "MIT", 35 | "bugs": { 36 | "url": "https://github.com/jeremydaly/lambda-api/issues" 37 | }, 38 | "homepage": "https://github.com/jeremydaly/lambda-api#readme", 39 | "peerDependencies": { 40 | "@aws-sdk/client-s3": "^3.0.0", 41 | "@aws-sdk/s3-request-presigner": "^3.0.0" 42 | }, 43 | "devDependencies": { 44 | "@types/aws-lambda": "^8.10.51", 45 | "@types/node": "^10.17.21", 46 | "bluebird": "^3.7.2", 47 | "coveralls": "^3.1.0", 48 | "eslint": "^7.22.0", 49 | "eslint-config-prettier": "^8.3.0", 50 | "jest": "^26.6.3", 51 | "prettier": "^2.3.2", 52 | "sinon": "^4.5.0" 53 | }, 54 | "files": [ 55 | "index.js", 56 | "index.d.ts", 57 | "lib/" 58 | ] 59 | } 60 | -------------------------------------------------------------------------------- /__tests__/prettyPrint.unit.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const prettyPrint = require("../lib/prettyPrint"); 4 | 5 | /******************************************************************************/ 6 | /*** BEGIN TESTS ***/ 7 | /******************************************************************************/ 8 | 9 | describe("PrettyPrint Tests:", function () { 10 | it("Minimum header widths", function () { 11 | expect( 12 | prettyPrint([ 13 | ["GET", "/", ["unnamed"]], 14 | ["POST", "/", ["unnamed"]], 15 | ["DELETE", "/", ["unnamed"]], 16 | ]) 17 | ).toBe( 18 | "╔══════════╤═════════╤═══════════╗\n║ \u001b[1mMETHOD\u001b[0m │ \u001b[1mROUTE\u001b[0m │ \u001b[1mSTACK \u001b[0m ║\n╟──────────┼─────────┼───────────╢\n║ GET │ / │ unnamed ║\n╟──────────┼─────────┼───────────╢\n║ POST │ / │ unnamed ║\n╟──────────┼─────────┼───────────╢\n║ DELETE │ / │ unnamed ║\n╚══════════╧═════════╧═══════════╝" 19 | ); 20 | }); // end it 21 | 22 | it("Adjusted header widths", function () { 23 | expect( 24 | prettyPrint([ 25 | ["GET", "/", ["unnamed"]], 26 | ["POST", "/testing", ["unnamed"]], 27 | ["DELETE", "/long-url-path-name", ["unnamed"]], 28 | ]) 29 | ).toBe( 30 | "╔══════════╤═══════════════════════╤═══════════╗\n║ \u001b[1mMETHOD\u001b[0m │ \u001b[1mROUTE \u001b[0m │ \u001b[1mSTACK \u001b[0m ║\n╟──────────┼───────────────────────┼───────────╢\n║ GET │ / │ unnamed ║\n╟──────────┼───────────────────────┼───────────╢\n║ POST │ /testing │ unnamed ║\n╟──────────┼───────────────────────┼───────────╢\n║ DELETE │ /long-url-path-name │ unnamed ║\n╚══════════╧═══════════════════════╧═══════════╝" 31 | ); 32 | }); // end it 33 | }); // end UTILITY tests 34 | -------------------------------------------------------------------------------- /__tests__/context.unit.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Init API instance 4 | const api = require('../index')({ version: 'v1.0' }) 5 | 6 | // NOTE: Set test to true 7 | api._test = true; 8 | 9 | let event = { 10 | httpMethod: 'get', 11 | path: '/test', 12 | body: {}, 13 | multiValueHeaders: { 14 | 'Content-Type': ['application/json'] 15 | } 16 | } 17 | 18 | /******************************************************************************/ 19 | /*** DEFINE TEST ROUTES ***/ 20 | /******************************************************************************/ 21 | 22 | api.get('/', function(req,res) { 23 | res.send({ 24 | id: req.id, 25 | context: req.context 26 | }) 27 | }) 28 | 29 | /******************************************************************************/ 30 | /*** BEGIN TESTS ***/ 31 | /******************************************************************************/ 32 | 33 | describe('Context Tests:', function() { 34 | 35 | it('Parse ID and context object', async function() { 36 | let _event = Object.assign({},event,{ path: '/'}) 37 | let result = await new Promise(r => api.run(_event,{ 38 | functionName: 'testFunction', 39 | awsRequestId: '1234', 40 | log_group_name: 'testLogGroup', 41 | log_stream_name: 'testLogStream', 42 | clientContext: {}, 43 | identity: { cognitoIdentityId: 321 } 44 | },(e,res) => { r(res) })) 45 | expect(result).toEqual({ 46 | multiValueHeaders: { 'content-type': ['application/json'] }, 47 | statusCode: 200, 48 | body: '{"id":"1234","context":{"functionName":"testFunction","awsRequestId":"1234","log_group_name":"testLogGroup","log_stream_name":"testLogStream","clientContext":{},"identity":{"cognitoIdentityId":321}}}', 49 | isBase64Encoded: false }) 50 | }) // end it 51 | 52 | }) // end ERROR HANDLING tests 53 | -------------------------------------------------------------------------------- /__tests__/unit.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Init API instance 4 | const api = require('../index')({ version: 'v1.0' }) 5 | 6 | 7 | // NOTE: Set test to true 8 | api._test = true; 9 | 10 | let event = { 11 | httpMethod: 'get', 12 | path: '/test', 13 | body: {}, 14 | headers: { 15 | 'Content-Type': 'application/json' 16 | } 17 | } 18 | 19 | /******************************************************************************/ 20 | /*** BEGIN TESTS ***/ 21 | /******************************************************************************/ 22 | 23 | describe('Unit Tests:', function() { 24 | 25 | it('setRoute', async function() { 26 | // let routes = {} 27 | // api.setRoute(routes,'GET', { route: '/testPath' }, ['testPath']) 28 | // api.setRoute(routes,'GET', { route: '/testPath/testx' }, ['testPath','testx']) 29 | // expect(routes).toEqual({ 30 | // ROUTES: { 31 | // testPath: { 32 | // METHODS: { 33 | // GET: { route: '/testPath' } 34 | // }, 35 | // ROUTES: { 36 | // testx: { 37 | // METHODS: { 38 | // GET: { route: '/testPath/testx' } 39 | // } 40 | // } 41 | // } 42 | // } 43 | // } 44 | // }) 45 | }) // end it 46 | 47 | 48 | // it('setRoute - null path', async function() { 49 | // let routes = { testPath: null } 50 | // api.setRoute(routes,{['_GET']: { route: '/testPath/testx' } },'testPath.testx') 51 | // expect(routes).toEqual({ testPath: { testx: { _GET: { route: '/testPath/testx' } } } }) 52 | // }) // end it 53 | // 54 | // it('setRoute - null single path', async function() { 55 | // let routes = { testPath: null } 56 | // api.setRoute(routes,{['_GET']: { route: '/testPath' } },['testPath']) 57 | // expect(routes).toEqual({ testPath: { _GET: { route: '/testPath' } } }) 58 | // }) // end it 59 | 60 | 61 | }) // end UNIT tests 62 | -------------------------------------------------------------------------------- /lib/prettyPrint.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /** 3 | * Lightweight web framework for your serverless applications 4 | * @author Jeremy Daly 5 | * @license MIT 6 | */ 7 | 8 | module.exports = (routes) => { 9 | let out = ''; 10 | 11 | // Calculate column widths 12 | let widths = routes.reduce( 13 | (acc, row) => { 14 | return [ 15 | Math.max(acc[0], Math.max(6, row[0].length)), 16 | Math.max(acc[1], Math.max(5, row[1].length)), 17 | Math.max(acc[2], Math.max(6, row[2].join(', ').length)), 18 | ]; 19 | }, 20 | [0, 0, 0] 21 | ); 22 | 23 | out += 24 | '╔══' + 25 | ''.padEnd(widths[0], '═') + 26 | '══╤══' + 27 | ''.padEnd(widths[1], '═') + 28 | '══╤══' + 29 | ''.padEnd(widths[2], '═') + 30 | '══╗\n'; 31 | out += 32 | '║ ' + 33 | '\u001b[1m' + 34 | 'METHOD'.padEnd(widths[0]) + 35 | '\u001b[0m' + 36 | ' │ ' + 37 | '\u001b[1m' + 38 | 'ROUTE'.padEnd(widths[1]) + 39 | '\u001b[0m' + 40 | ' │ ' + 41 | '\u001b[1m' + 42 | 'STACK'.padEnd(widths[2]) + 43 | '\u001b[0m' + 44 | ' ║\n'; 45 | out += 46 | '╟──' + 47 | ''.padEnd(widths[0], '─') + 48 | '──┼──' + 49 | ''.padEnd(widths[1], '─') + 50 | '──┼──' + 51 | ''.padEnd(widths[2], '─') + 52 | '──╢\n'; 53 | routes.forEach((route, i) => { 54 | out += 55 | '║ ' + 56 | route[0].padEnd(widths[0]) + 57 | ' │ ' + 58 | route[1].padEnd(widths[1]) + 59 | ' │ ' + 60 | route[2].join(', ').padEnd(widths[2]) + 61 | ' ║\n'; 62 | if (i < routes.length - 1) { 63 | out += 64 | '╟──' + 65 | ''.padEnd(widths[0], '─') + 66 | '──┼──' + 67 | ''.padEnd(widths[1], '─') + 68 | '──┼──' + 69 | ''.padEnd(widths[2], '─') + 70 | '──╢\n'; 71 | } // end if 72 | }); 73 | out += 74 | '╚══' + 75 | ''.padEnd(widths[0], '═') + 76 | '══╧══' + 77 | ''.padEnd(widths[1], '═') + 78 | '══╧══' + 79 | ''.padEnd(widths[2], '═') + 80 | '══╝'; 81 | 82 | return out; 83 | }; 84 | -------------------------------------------------------------------------------- /lib/statusCodes.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /** 3 | * Lightweight web framework for your serverless applications 4 | * @author Jeremy Daly 5 | * @license MIT 6 | */ 7 | 8 | /* 9 | HTTP status code map (IANA Status Code Registry) 10 | */ 11 | 12 | module.exports = { 13 | // 1xx: Informational 14 | 100: 'Continue', 15 | 101: 'Switching Protocols', 16 | 102: 'Processing', 17 | 103: 'Early Hints', 18 | 19 | // 2xx: Success 20 | 200: 'OK', 21 | 201: 'Created', 22 | 202: 'Accepted', 23 | 203: 'Non-Authoritative Information', 24 | 204: 'No Content', 25 | 205: 'Reset Content', 26 | 206: 'Partial Content', 27 | 207: 'Multi-Status', 28 | 208: 'Already Reported', 29 | 226: 'IM Used', 30 | 31 | // 3xx: Redirection 32 | 300: 'Multiple Choices', 33 | 301: 'Moved Permanently', 34 | 302: 'Found', 35 | 303: 'See Other', 36 | 304: 'Not Modified', 37 | 305: 'Use Proxy', 38 | 307: 'Temporary Redirect', 39 | 308: 'Permanent Redirect', 40 | 41 | // 4xx: Client Error 42 | 400: 'Bad Request', 43 | 401: 'Unauthorized', 44 | 402: 'Payment Required', 45 | 403: 'Forbidden', 46 | 404: 'Not Found', 47 | 405: 'Method Not Allowed', 48 | 406: 'Not Acceptable', 49 | 407: 'Proxy Authentication Required', 50 | 408: 'Request Timeout', 51 | 409: 'Conflict', 52 | 410: 'Gone', 53 | 411: 'Length Required', 54 | 412: 'Precondition Failed', 55 | 413: 'Payload Too Large', 56 | 414: 'URI Too Long', 57 | 415: 'Unsupported Media Type', 58 | 416: 'Range Not Satisfiable', 59 | 417: 'Expectation Failed', 60 | 421: 'Misdirected Request', 61 | 422: 'Unprocessable Entity', 62 | 423: 'Locked', 63 | 424: 'Failed Dependency', 64 | 425: 'Too Early', 65 | 426: 'Upgrade Required', 66 | 428: 'Precondition Required', 67 | 429: 'Too Many Requests', 68 | 431: 'Request Header Fields Too Large', 69 | 451: 'Unavailable For Legal Reasons', 70 | 71 | // 5xx: Server Error 72 | 500: 'Internal Server Error', 73 | 501: 'Not Implemented', 74 | 502: 'Bad Gateway', 75 | 503: 'Service Unavailable', 76 | 504: 'Gateway Timeout', 77 | 505: 'HTTP Version Not Supported', 78 | 506: 'Variant Also Negotiates', 79 | 507: 'Insufficient Storage', 80 | 508: 'Loop Detected', 81 | 510: 'Not Extended', 82 | 511: 'Network Authentication Required', 83 | }; 84 | -------------------------------------------------------------------------------- /__tests__/finally.unit.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Init API instance 4 | const api = require('../index')({ version: 'v1.0' }) 5 | 6 | // Simulate database module 7 | const fakeDatabase = { connected: true } 8 | 9 | // NOTE: Set test to true 10 | api._test = true; 11 | 12 | let event = { 13 | httpMethod: 'get', 14 | path: '/test', 15 | body: {}, 16 | multiValueHeaders: { 17 | 'Content-Type': 'application/json' 18 | } 19 | } 20 | 21 | /******************************************************************************/ 22 | /*** DEFINE TEST ROUTE ***/ 23 | /******************************************************************************/ 24 | api.get('/test', function(req,res) { 25 | res.status(200).json({ 26 | method: 'get', 27 | status: 'ok', 28 | connected: fakeDatabase.connected.toString() 29 | }) 30 | }) 31 | 32 | /******************************************************************************/ 33 | /*** DEFINE FINALLY METHOD ***/ 34 | /******************************************************************************/ 35 | api.finally(function(req,res) { 36 | fakeDatabase.connected = false 37 | }) 38 | 39 | /******************************************************************************/ 40 | /*** BEGIN TESTS ***/ 41 | /******************************************************************************/ 42 | 43 | describe('Finally Tests:', function() { 44 | 45 | it('Connected on first execution and after callback', async function() { 46 | let _event = Object.assign({},event,{}) 47 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 48 | expect(result).toEqual({ multiValueHeaders: { 'content-type': ['application/json'] }, statusCode: 200, body: '{"method":"get","status":"ok","connected":"true"}', isBase64Encoded: false }) 49 | }) // end it 50 | 51 | it('Disconnected on second execution', async function() { 52 | let _event = Object.assign({},event,{}) 53 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 54 | expect(result).toEqual({ multiValueHeaders: { 'content-type': ['application/json'] }, statusCode: 200, body: '{"method":"get","status":"ok","connected":"false"}', isBase64Encoded: false }) 55 | }) // end it 56 | 57 | }) // end FINALLY tests 58 | -------------------------------------------------------------------------------- /__tests__/basePath.unit.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | 5 | // Init API instance 6 | const api = require('../index')({ version: 'v1.0', base: '/v1' }) 7 | 8 | // NOTE: Set test to true 9 | api._test = true; 10 | 11 | let event = { 12 | httpMethod: 'get', 13 | path: '/test', 14 | body: {}, 15 | multiValueHeaders: { 16 | 'Content-Type': ['application/json'] 17 | } 18 | } 19 | 20 | /******************************************************************************/ 21 | /*** DEFINE TEST ROUTES ***/ 22 | /******************************************************************************/ 23 | api.get('/test', function(req,res) { 24 | res.status(200).json({ method: 'get', status: 'ok' }) 25 | }) 26 | 27 | api.get('/test/:test', function(req,res) { 28 | // console.log(req) 29 | res.status(200).json({ method: 'get', status: 'ok', param: req.params.test }) 30 | }) 31 | 32 | api.get('/test/test2/test3', function(req,res) { 33 | res.status(200).json({ path: req.path, method: 'get', status: 'ok' }) 34 | }) 35 | 36 | 37 | /******************************************************************************/ 38 | /*** BEGIN TESTS ***/ 39 | /******************************************************************************/ 40 | 41 | describe('Base Path Tests:', function() { 42 | 43 | it('Simple path with base: /v1/test', async function() { 44 | let _event = Object.assign({},event,{ path: '/v1/test' }) 45 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 46 | expect(result).toEqual({ multiValueHeaders: { 'content-type': ['application/json'] }, statusCode: 200, body: '{"method":"get","status":"ok"}', isBase64Encoded: false }) 47 | }) // end it 48 | 49 | it('Path with base and parameter: /v1/test/123', async function() { 50 | let _event = Object.assign({},event,{ path: '/v1/test/123' }) 51 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 52 | expect(result).toEqual({ multiValueHeaders: { 'content-type': ['application/json'] }, statusCode: 200, body: '{"method":"get","status":"ok","param":"123"}', isBase64Encoded: false }) 53 | }) // end it 54 | 55 | it('Nested path with base: /v1/test/test2/test3', async function() { 56 | let _event = Object.assign({},event,{ path: '/v1/test/test2/test3' }) 57 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 58 | expect(result).toEqual({ multiValueHeaders: { 'content-type': ['application/json'] }, statusCode: 200, body: '{"path":"/v1/test/test2/test3","method":"get","status":"ok"}', isBase64Encoded: false }) 59 | }) // end it 60 | 61 | }) // end BASEPATH tests 62 | -------------------------------------------------------------------------------- /lib/mimemap.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /** 3 | * Lightweight web framework for your serverless applications 4 | * @author Jeremy Daly 5 | * @license MIT 6 | */ 7 | 8 | /* 9 | Minimal mime map for common file types 10 | */ 11 | 12 | module.exports = { 13 | // images 14 | gif: 'image/gif', 15 | ico: 'image/x-icon', 16 | jpg: 'image/jpeg', 17 | jpeg: 'image/jpeg', 18 | png: 'image/png', 19 | svg: 'image/svg+xml', 20 | svgz: 'image/svg+xml', 21 | 22 | // text 23 | atom: 'application/atom+xml', 24 | css: 'text/css', 25 | csv: 'text/csv', 26 | html: 'text/html', 27 | htm: 'text/html', 28 | js: 'application/javascript', 29 | json: 'application/json', 30 | map: 'application/json', 31 | rdf: 'application/rdf+xml', 32 | rss: 'application/rss+xml', 33 | txt: 'text/plain', 34 | webmanifest: 'application/manifest+json', 35 | xml: 'application/xml', 36 | 37 | // other binary 38 | gz: 'application/gzip', 39 | pdf: 'application/pdf', 40 | zip: 'application/zip', 41 | 42 | // fonts 43 | woff: 'application/font-woff', 44 | 45 | // MS file Types 46 | doc: 'application/msword', 47 | dot: 'application/msword', 48 | docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 49 | dotx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.template', 50 | docm: 'application/vnd.ms-word.document.macroEnabled.12', 51 | dotm: 'application/vnd.ms-word.template.macroEnabled.12', 52 | xls: 'application/vnd.ms-excel', 53 | xlt: 'application/vnd.ms-excel', 54 | xla: 'application/vnd.ms-excel', 55 | xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 56 | xltx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.template', 57 | xlsm: 'application/vnd.ms-excel.sheet.macroEnabled.12', 58 | xltm: 'application/vnd.ms-excel.template.macroEnabled.12', 59 | xlam: 'application/vnd.ms-excel.addin.macroEnabled.12', 60 | xlsb: 'application/vnd.ms-excel.sheet.binary.macroEnabled.12', 61 | ppt: 'application/vnd.ms-powerpoint', 62 | pot: 'application/vnd.ms-powerpoint', 63 | pps: 'application/vnd.ms-powerpoint', 64 | ppa: 'application/vnd.ms-powerpoint', 65 | pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 66 | potx: 'application/vnd.openxmlformats-officedocument.presentationml.template', 67 | ppsx: 'application/vnd.openxmlformats-officedocument.presentationml.slideshow', 68 | ppam: 'application/vnd.ms-powerpoint.addin.macroEnabled.12', 69 | pptm: 'application/vnd.ms-powerpoint.presentation.macroEnabled.12', 70 | potm: 'application/vnd.ms-powerpoint.template.macroEnabled.12', 71 | ppsm: 'application/vnd.ms-powerpoint.slideshow.macroEnabled.12', 72 | mdb: 'application/vnd.ms-access', 73 | }; 74 | -------------------------------------------------------------------------------- /__tests__/namespaces.unit.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Init API instance 4 | const api = require('../index')({ version: 'v1.0' }) 5 | 6 | // Add the data namespace 7 | api.app('data',require('./_testData')) 8 | 9 | // Add additional namespaces using object 10 | api.app({ 11 | 'data2': require('./_testData'), 12 | 'data3': require('./_testData') 13 | }) 14 | 15 | // NOTE: Set test to true 16 | api._test = true; 17 | 18 | let event = { 19 | httpMethod: 'get', 20 | path: '/test', 21 | body: {}, 22 | multiValueHeaders: { 23 | 'content-type': ['application/json'] 24 | } 25 | } 26 | 27 | /******************************************************************************/ 28 | /*** DEFINE TEST ROUTES ***/ 29 | /******************************************************************************/ 30 | 31 | // This route invokes 'dataCall' using the 'data' namespace 32 | api.get('/testData', function(req,res) { 33 | let data = req.namespace.data.dataCall() 34 | res.json({ method:'get', status:'ok', data: data }) 35 | }) 36 | 37 | // This route loads a module directly which accesses the data namespace 38 | api.get('/testAppData', require('./_testApp').dataTest) 39 | 40 | 41 | 42 | /******************************************************************************/ 43 | /*** BEGIN TESTS ***/ 44 | /******************************************************************************/ 45 | 46 | describe('Namespace Tests:', function() { 47 | 48 | it('Check namespace loading', function() { 49 | expect(Object.keys(api._app).length).toBe(3) 50 | expect(api._app).toHaveProperty('data') 51 | expect(api._app).toHaveProperty('data2') 52 | expect(api._app).toHaveProperty('data3') 53 | expect(api._app.data).toHaveProperty('dataCall') 54 | }) // end it 55 | 56 | it('Invoke namespace', async function() { 57 | let _event = Object.assign({},event,{ path:'/testData' }) 58 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 59 | expect(result).toEqual({ multiValueHeaders: { 'content-type': ['application/json'] }, statusCode: 200, body: '{"method":"get","status":"ok","data":{"foo":"sample data","bar":"additional sample data"}}', isBase64Encoded: false }) 60 | }) // end it 61 | 62 | it('Invoke namespace via required module', async function() { 63 | let _event = Object.assign({},event,{ path:'/testAppData' }) 64 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 65 | expect(result).toEqual({ multiValueHeaders: { 'content-type': ['application/json'] }, statusCode: 200, body: '{"method":"get","status":"ok","data":{"foo":"sample data","bar":"additional sample data"}}', isBase64Encoded: false }) 66 | }) // end it 67 | 68 | }) // end MODULE tests 69 | -------------------------------------------------------------------------------- /__tests__/sample-event-apigateway-v2.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "routeKey": "$default", 4 | "rawPath": "/test/hello", 5 | "rawQueryString": "parameter1=value1¶meter1=value2¶meter2=value", 6 | "cookies": [ 7 | "cookie1=test", 8 | "cookie2=123" 9 | ], 10 | "headers": { 11 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", 12 | "Accept-Encoding": "gzip, deflate, lzma, sdch, br", 13 | "Accept-Language": "en-US,en;q=0.8", 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 | "Host": "wt6mne2s9k.execute-api.us-west-2.amazonaws.com", 21 | "Upgrade-Insecure-Requests": "1", 22 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36 OPR/39.0.2256.48", 23 | "Via": "1.1 fb7cca60f0ecd82ce07790c9c5eef16c.cloudfront.net (CloudFront)", 24 | "X-Amz-Cf-Id": "nBsWBOrSHMgnaROZJK1wGCZ9PcRcSpq_oSXZNQwQ10OTZL4cimZo3g==", 25 | "X-Forwarded-For": "192.168.100.1, 192.168.1.1", 26 | "X-Forwarded-Port": "443", 27 | "X-Forwarded-Proto": "https", 28 | "test-header": "val1,val2" 29 | }, 30 | "queryStringParameters": { 31 | "qs1": "foo", 32 | "qs2": "foo,bar", 33 | "qs3": "bat,baz" 34 | }, 35 | "requestContext": { 36 | "accountId": "123456789012", 37 | "apiId": "api-id", 38 | "authentication": { 39 | "clientCert": { 40 | "clientCertPem": "CERT_CONTENT", 41 | "subjectDN": "www.example.com", 42 | "issuerDN": "Example issuer", 43 | "serialNumber": "a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1", 44 | "validity": { 45 | "notBefore": "May 28 12:30:02 2019 GMT", 46 | "notAfter": "Aug 5 09:36:04 2021 GMT" 47 | } 48 | } 49 | }, 50 | "authorizer": { 51 | "jwt": { 52 | "claims": { 53 | "claim1": "value1", 54 | "claim2": "value2" 55 | }, 56 | "scopes": [ 57 | "scope1", 58 | "scope2" 59 | ] 60 | } 61 | }, 62 | "domainName": "id.execute-api.us-east-1.amazonaws.com", 63 | "domainPrefix": "id", 64 | "http": { 65 | "method": "GET", 66 | "path": "/test/hello", 67 | "protocol": "HTTP/1.1", 68 | "sourceIp": "IP", 69 | "userAgent": "agent" 70 | }, 71 | "requestId": "id", 72 | "routeKey": "$default", 73 | "stage": "$default", 74 | "time": "12/Mar/2020:19:03:58 +0000", 75 | "timeEpoch": 1583348638390 76 | }, 77 | "body": "Hello from Lambda", 78 | "pathParameters": { 79 | "proxy": "hello" 80 | }, 81 | "isBase64Encoded": false, 82 | "stageVariables": { 83 | "stageVarName": "stageVarValue" 84 | } 85 | } -------------------------------------------------------------------------------- /__tests__/modules.unit.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Init API instance 4 | const api = require('../index')({ version: 'v1.0' }) 5 | 6 | const appTest = require('./_testApp') 7 | 8 | let event = { 9 | httpMethod: 'get', 10 | path: '/test', 11 | body: {}, 12 | multiValueHeaders: { 13 | 'Content-Type': ['application/json'] 14 | } 15 | } 16 | 17 | /******************************************************************************/ 18 | /*** DEFINE TEST ROUTES ***/ 19 | /******************************************************************************/ 20 | 21 | api.get('/testApp', function(req,res) { 22 | appTest.app(req,res) 23 | }) 24 | 25 | api.get('/testAppPromise', function(req,res) { 26 | appTest.promise(req,res) 27 | }) 28 | 29 | api.get('/testAppError', function(req,res) { 30 | appTest.calledError(req,res) 31 | }) 32 | 33 | api.get('/testAppThrownError', function(req,res) { 34 | appTest.thrownError(req,res) 35 | }) 36 | 37 | 38 | /******************************************************************************/ 39 | /*** BEGIN TESTS ***/ 40 | /******************************************************************************/ 41 | 42 | describe('Module Tests:', function() { 43 | 44 | // this.slow(300); 45 | 46 | it('Standard module response', async function() { 47 | let _event = Object.assign({},event,{ path:'/testApp' }) 48 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 49 | expect(result).toEqual({ multiValueHeaders: { 'content-type': ['application/json'] }, statusCode: 200, body: '{"method":"get","status":"ok","app":"app1"}', isBase64Encoded: false }) 50 | }) // end it 51 | 52 | it('Module with promise', async function() { 53 | let _event = Object.assign({},event,{ path:'/testAppPromise' }) 54 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 55 | expect(result).toEqual({ multiValueHeaders: { 'content-type': ['application/json'] }, statusCode: 200, body: '{"method":"get","status":"ok","app":"app2"}', isBase64Encoded: false }) 56 | }) // end it 57 | 58 | it('Module with called error', async function() { 59 | let _event = Object.assign({},event,{ path:'/testAppError' }) 60 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 61 | expect(result).toEqual({ multiValueHeaders: { 'content-type': ['application/json'] }, statusCode: 500, body: '{"error":"This is a called module error"}', isBase64Encoded: false }) 62 | }) // end it 63 | 64 | it('Module with thrown error', async function() { 65 | let _event = Object.assign({},event,{ path:'/testAppThrownError' }) 66 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 67 | expect(result).toEqual({ multiValueHeaders: { 'content-type': ['application/json'] }, statusCode: 500, body: '{"error":"This is a thrown module error"}', isBase64Encoded: false }) 68 | }) // end it 69 | 70 | }) // end MODULE tests 71 | -------------------------------------------------------------------------------- /__tests__/sample-event-apigateway-v1.json: -------------------------------------------------------------------------------- 1 | { 2 | "path": "/test/hello", 3 | "headers": { 4 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", 5 | "Accept-Encoding": "gzip, deflate, lzma, sdch, br", 6 | "Accept-Language": "en-US,en;q=0.8", 7 | "CloudFront-Forwarded-Proto": "https", 8 | "CloudFront-Is-Desktop-Viewer": "true", 9 | "CloudFront-Is-Mobile-Viewer": "false", 10 | "CloudFront-Is-SmartTV-Viewer": "false", 11 | "CloudFront-Is-Tablet-Viewer": "false", 12 | "CloudFront-Viewer-Country": "US", 13 | "Host": "wt6mne2s9k.execute-api.us-west-2.amazonaws.com", 14 | "Upgrade-Insecure-Requests": "1", 15 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36 OPR/39.0.2256.48", 16 | "Via": "1.1 fb7cca60f0ecd82ce07790c9c5eef16c.cloudfront.net (CloudFront)", 17 | "X-Amz-Cf-Id": "nBsWBOrSHMgnaROZJK1wGCZ9PcRcSpq_oSXZNQwQ10OTZL4cimZo3g==", 18 | "X-Forwarded-For": "192.168.100.1, 192.168.1.1", 19 | "X-Forwarded-Port": "443", 20 | "X-Forwarded-Proto": "https" 21 | }, 22 | "multiValueHeaders": { 23 | "accept": ["text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"], 24 | "accept-encoding": ["gzip, deflate, lzma, sdch, br"], 25 | "accept-language": ["en-US,en;q=0.8"], 26 | "cloudfront-forwarded-proto": ["https"], 27 | "cloudfront-is-desktop-viewer": ["true"], 28 | "cloudfront-is-mobile-viewer": ["false"], 29 | "cloudfront-is-smarttv-viewer": ["false"], 30 | "cloudfront-is-tablet-viewer": ["false"], 31 | "cloudfront-viewer-country": ["US"], 32 | "host": ["wt6mne2s9k.execute-api.us-west-2.amazonaws.com"], 33 | "upgrade-insecure-requests": ["1"], 34 | "user-agent": ["Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36 OPR/39.0.2256.48"], 35 | "via": ["1.1 fb7cca60f0ecd82ce07790c9c5eef16c.cloudfront.net (CloudFront)"], 36 | "x-amz-cf-id": ["nBsWBOrSHMgnaROZJK1wGCZ9PcRcSpq_oSXZNQwQ10OTZL4cimZo3g=="], 37 | "x-forwarded-for": ["192.168.100.1, 192.168.1.1"], 38 | "x-forwarded-port": ["443"], 39 | "x-forwarded-proto": ["https"], 40 | "test-header": ["val1","val2"] 41 | }, 42 | "pathParameters": { 43 | "proxy": "hello" 44 | }, 45 | "requestContext": { 46 | "accountId": "123456789012", 47 | "resourceId": "us4z18", 48 | "stage": "test", 49 | "requestId": "41b45ea3-70b5-11e6-b7bd-69b5aaebc7d9", 50 | "identity": { 51 | "cognitoIdentityPoolId": "", 52 | "accountId": "", 53 | "cognitoIdentityId": "", 54 | "caller": "", 55 | "apiKey": "", 56 | "sourceIp": "192.168.100.12", 57 | "cognitoAuthenticationType": "", 58 | "cognitoAuthenticationProvider": "", 59 | "userArn": "", 60 | "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36 OPR/39.0.2256.48", 61 | "user": "" 62 | }, 63 | "resourcePath": "/{proxy+}", 64 | "httpMethod": "GET", 65 | "apiId": "wt6mne2s9k" 66 | }, 67 | "resource": "/{proxy+}", 68 | "httpMethod": "GET", 69 | "queryStringParameters": { 70 | "qs1": "foo", 71 | "qs2": "bar" 72 | }, 73 | "multiValueQueryStringParameters": { 74 | "qs2": [ "foo", "bar" ], 75 | "qs3": [ "bat", "baz" ] 76 | }, 77 | "stageVariables": { 78 | "stageVarName": "stageVarValue" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /__tests__/etag.unit.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Init API instance 4 | const api = require('../index')({ version: 'v1.0' }) 5 | 6 | // NOTE: Set test to true 7 | api._test = true; 8 | 9 | let event = { 10 | httpMethod: 'get', 11 | path: '/test', 12 | body: {}, 13 | multiValueHeaders: { 14 | 'Content-Type': 'application/json' 15 | } 16 | } 17 | 18 | /******************************************************************************/ 19 | /*** DEFINE TEST ROUTES ***/ 20 | /******************************************************************************/ 21 | 22 | api.get('/testEtag', function(req,res) { 23 | res.etag(true).send({ test: true }) 24 | }) 25 | 26 | api.get('/testEtag2', function(req,res) { 27 | res.etag(true).send({ test: false }) 28 | }) 29 | 30 | api.get('/testEtagFalse', function(req,res) { 31 | res.etag(false).send({ noEtag: true }) 32 | }) 33 | 34 | /******************************************************************************/ 35 | /*** BEGIN TESTS ***/ 36 | /******************************************************************************/ 37 | 38 | describe('Etag Tests:', function() { 39 | 40 | it('Initial request', async function() { 41 | let _event = Object.assign({},event,{ path: '/testEtag'}) 42 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 43 | expect(result).toEqual({ multiValueHeaders: { 44 | 'content-type': ['application/json'], 45 | 'etag': ['"6fd977db9b2afe87a9ceee4843288129"'] 46 | }, statusCode: 200, body: '{"test":true}', isBase64Encoded: false }) 47 | }) // end it 48 | 49 | it('Initial request 2', async function() { 50 | let _event = Object.assign({},event,{ path: '/testEtag2'}) 51 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 52 | expect(result).toEqual({ multiValueHeaders: { 53 | 'content-type': ['application/json'], 54 | 'etag': ['"ad2ba8d138b3cda185243603ec9fcaa7"'] 55 | }, statusCode: 200, body: '{"test":false}', isBase64Encoded: false }) 56 | }) // end it 57 | 58 | it('Second request', async function() { 59 | let _event = Object.assign({},event,{ path: '/testEtag', multiValueHeaders: { 'If-None-Match': ['"6fd977db9b2afe87a9ceee4843288129"'] }}) 60 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 61 | expect(result).toEqual({ multiValueHeaders: { 62 | 'content-type': ['application/json'], 63 | 'etag': ['"6fd977db9b2afe87a9ceee4843288129"'] 64 | }, statusCode: 304, body: '', isBase64Encoded: false }) 65 | }) // end it 66 | 67 | it('Second request 2', async function() { 68 | let _event = Object.assign({},event,{ path: '/testEtag2', multiValueHeaders: { 'If-None-Match': ['"ad2ba8d138b3cda185243603ec9fcaa7"'] }}) 69 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 70 | expect(result).toEqual({ multiValueHeaders: { 71 | 'content-type': ['application/json'], 72 | 'etag': ['"ad2ba8d138b3cda185243603ec9fcaa7"'] 73 | }, statusCode: 304, body: '', isBase64Encoded: false }) 74 | }) // end it 75 | 76 | it('Non-matching Etags', async function() { 77 | let _event = Object.assign({},event,{ path: '/testEtag', multiValueHeaders: { 'If-None-Match': ['"ad2ba8d138b3cda185243603ec9fcaa7"'] }}) 78 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 79 | expect(result).toEqual({ multiValueHeaders: { 80 | 'content-type': ['application/json'], 81 | 'etag': ['"6fd977db9b2afe87a9ceee4843288129"'] 82 | }, statusCode: 200, body: '{"test":true}', isBase64Encoded: false }) 83 | }) // end it 84 | 85 | it('Disable Etag', async function() { 86 | let _event = Object.assign({},event,{ path: '/testEtagFalse' }) 87 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 88 | expect(result).toEqual({ multiValueHeaders: { 89 | 'content-type': ['application/json'] 90 | }, statusCode: 200, body: '{"noEtag":true}', isBase64Encoded: false }) 91 | }) // end it 92 | 93 | 94 | }) // end ERROR HANDLING tests 95 | -------------------------------------------------------------------------------- /__tests__/run.unit.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | 5 | // Init API instance 6 | const api = require('../index')({ version: 'v1.0' }) 7 | const api_error = require('../index')({ version: 'v1.0' }) 8 | const api_error_path = require('../index')({ version: 'v1.0' }) 9 | 10 | // NOTE: Set test to true 11 | api._test = true; 12 | api_error._test = true; 13 | api_error_path._test = true; 14 | 15 | let event = { 16 | httpMethod: 'get', 17 | path: '/test', 18 | body: {}, 19 | multiValueHeaders: { 20 | 'content-type': ['application/json'] 21 | } 22 | } 23 | 24 | /******************************************************************************/ 25 | /*** DEFINE TEST ROUTE ***/ 26 | /******************************************************************************/ 27 | 28 | api.get('/', function(req,res) { 29 | res.status(200).json({ 30 | method: 'get', 31 | status: 'ok' 32 | }) 33 | }) 34 | 35 | api.get('/test', function(req,res) { 36 | res.status(200).json({ 37 | method: 'get', 38 | status: 'ok' 39 | }) 40 | }) 41 | 42 | api.get('/testError', function(req,res) { 43 | res.error(404,'some error') 44 | }) 45 | 46 | api_error.get('/testErrorThrown', function(req,res) { 47 | throw new Error('some thrown error') 48 | }) 49 | 50 | 51 | /******************************************************************************/ 52 | /*** BEGIN TESTS ***/ 53 | /******************************************************************************/ 54 | 55 | describe('Main handler Async/Await:', function() { 56 | 57 | it('With context object', async function() { 58 | let _event = Object.assign({},event,{}) 59 | let result = await api.run(_event,{}) 60 | expect(result).toEqual({ multiValueHeaders: { 'content-type': ['application/json'] }, statusCode: 200, body: '{"method":"get","status":"ok"}', isBase64Encoded: false }) 61 | }) // end it 62 | 63 | it('Without context object', async function() { 64 | let _event = Object.assign({},event,{}) 65 | let result = await api.run(_event) 66 | expect(result).toEqual({ multiValueHeaders: { 'content-type': ['application/json'] }, statusCode: 200, body: '{"method":"get","status":"ok"}', isBase64Encoded: false }) 67 | }) // end it 68 | 69 | it('With callback', async function() { 70 | let _event = Object.assign({},event,{}) 71 | let result = await api.run(_event,{},(err,res) => {}) 72 | expect(result).toEqual({ multiValueHeaders: { 'content-type': ['application/json'] }, statusCode: 200, body: '{"method":"get","status":"ok"}', isBase64Encoded: false }) 73 | }) // end it 74 | 75 | it('Triggered Error', async function() { 76 | let _event = Object.assign({},event,{ path: '/testError' }) 77 | let result = await api.run(_event,{}) 78 | expect(result).toEqual({ multiValueHeaders: { 'content-type': ['application/json'] }, statusCode: 404, body: '{"error":"some error"}', isBase64Encoded: false }) 79 | }) // end it 80 | 81 | it('Thrown Error', async function() { 82 | let _event = Object.assign({},event,{ path: '/testErrorThrown' }) 83 | let result = await api_error.run(_event,{}) 84 | expect(result).toEqual({ multiValueHeaders: { 'content-type': ['application/json'] }, statusCode: 500, body: '{"error":"some thrown error"}', isBase64Encoded: false }) 85 | }) // end it 86 | 87 | it('Routes Error', async function() { 88 | let _event = Object.assign({},event,{ path: '/testRoute' }) 89 | let result = await api_error_path.run(_event,{}) 90 | expect(result).toEqual({ multiValueHeaders: { 'content-type': ['application/json'] }, statusCode: 404, body: '{"error":"Route not found"}', isBase64Encoded: false }) 91 | }) // end it 92 | 93 | it('Without event', async function() { 94 | let _event = {} 95 | let result = await api.run(_event,{}) 96 | expect(result).toEqual({ headers: { 'content-type': 'application/json' }, statusCode: 405, body: '{"error":"Method not allowed"}', isBase64Encoded: false }) 97 | }) // end it 98 | 99 | it('With undefined event', async function() { 100 | let _event = undefined 101 | let result = await api.run(_event,{}) 102 | expect(result).toEqual({ headers: { 'content-type': 'application/json' }, statusCode: 405, body: '{"error":"Method not allowed"}', isBase64Encoded: false }) 103 | }) // end it 104 | 105 | it('With null event', async function() { 106 | let _event = null 107 | let result = await api.run(_event,{}) 108 | expect(result).toEqual({ headers: { 'content-type': 'application/json' }, statusCode: 405, body: '{"error":"Method not allowed"}', isBase64Encoded: false }) 109 | }) // end it 110 | 111 | 112 | }) // end tests 113 | -------------------------------------------------------------------------------- /__tests__/attachments.unit.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | 5 | // Init API instance 6 | const api = require('../index')({ version: 'v1.0', mimeTypes: { test: 'text/test' } }) 7 | 8 | // NOTE: Set test to true 9 | api._test = true; 10 | 11 | let event = { 12 | httpMethod: 'get', 13 | path: '/', 14 | body: {}, 15 | multiValueHeaders: { 16 | 'Content-Type': ['application/json'] 17 | } 18 | } 19 | 20 | /******************************************************************************/ 21 | /*** DEFINE TEST ROUTES ***/ 22 | /******************************************************************************/ 23 | 24 | api.get('/attachment', function(req,res) { 25 | res.attachment().send({ status: 'ok' }) 26 | }) 27 | 28 | api.get('/attachment/pdf', function(req,res) { 29 | res.attachment('/test/foo.pdf').send('filedata') 30 | }) 31 | 32 | api.get('/attachment/png', function(req,res) { 33 | res.attachment('/test/foo.png').send('filedata') 34 | }) 35 | 36 | api.get('/attachment/csv', function(req,res) { 37 | res.attachment('test/path/foo.csv').send('filedata') 38 | }) 39 | 40 | api.get('/attachment/custom', function(req,res) { 41 | res.attachment('/test/path/foo.test').send('filedata') 42 | }) 43 | 44 | api.get('/attachment/empty-string', function(req,res) { 45 | res.attachment(' ').send('filedata') 46 | }) 47 | 48 | api.get('/attachment/null-string', function(req,res) { 49 | res.attachment(null).send('filedata') 50 | }) 51 | 52 | 53 | /******************************************************************************/ 54 | /*** BEGIN TESTS ***/ 55 | /******************************************************************************/ 56 | 57 | describe('Attachment Tests:', function() { 58 | 59 | it('Simple attachment', async function() { 60 | let _event = Object.assign({},event,{ path: '/attachment' }) 61 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 62 | expect(result).toEqual({ multiValueHeaders: { 'content-disposition': ['attachment'], 'content-type': ['application/json'] }, statusCode: 200, body: '{"status":"ok"}', isBase64Encoded: false }) 63 | }) // end it 64 | 65 | it('PDF attachment w/ path', async function() { 66 | let _event = Object.assign({},event,{ path: '/attachment/pdf' }) 67 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 68 | expect(result).toEqual({ multiValueHeaders: { 'content-disposition': ['attachment; filename=\"foo.pdf\"'], 'content-type': ['application/pdf'] }, statusCode: 200, body: 'filedata', isBase64Encoded: false }) 69 | }) // end it 70 | 71 | it('PNG attachment w/ path', async function() { 72 | let _event = Object.assign({},event,{ path: '/attachment/png' }) 73 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 74 | expect(result).toEqual({ multiValueHeaders: { 'content-disposition': ['attachment; filename=\"foo.png\"'], 'content-type': ['image/png'] }, statusCode: 200, body: 'filedata', isBase64Encoded: false }) 75 | }) // end it 76 | 77 | it('CSV attachment w/ path', async function() { 78 | let _event = Object.assign({},event,{ path: '/attachment/csv' }) 79 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 80 | expect(result).toEqual({ multiValueHeaders: { 'content-disposition': ['attachment; filename=\"foo.csv\"'], 'content-type': ['text/csv'] }, statusCode: 200, body: 'filedata', isBase64Encoded: false }) 81 | }) // end it 82 | 83 | it('Custom MIME type attachment w/ path', async function() { 84 | let _event = Object.assign({},event,{ path: '/attachment/custom' }) 85 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 86 | expect(result).toEqual({ multiValueHeaders: { 'content-disposition': ['attachment; filename=\"foo.test\"'], 'content-type': ['text/test'] }, statusCode: 200, body: 'filedata', isBase64Encoded: false }) 87 | }) // end it 88 | 89 | it('Empty string', async function() { 90 | let _event = Object.assign({},event,{ path: '/attachment/empty-string' }) 91 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 92 | expect(result).toEqual({ multiValueHeaders: { 'content-disposition': ['attachment'], 'content-type': ['application/json'] }, statusCode: 200, body: 'filedata', isBase64Encoded: false }) 93 | }) // end it 94 | 95 | it('Null string', async function() { 96 | let _event = Object.assign({},event,{ path: '/attachment/empty-string' }) 97 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 98 | expect(result).toEqual({ multiValueHeaders: { 'content-disposition': ['attachment'], 'content-type': ['application/json'] }, statusCode: 200, body: 'filedata', isBase64Encoded: false }) 99 | }) // end it 100 | 101 | }) // end HEADER tests 102 | -------------------------------------------------------------------------------- /__tests__/lastModified.unit.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Init API instance 4 | const api = require('../index')({ version: 'v1.0' }) 5 | 6 | 7 | // NOTE: Set test to true 8 | api._test = true; 9 | 10 | let event = { 11 | httpMethod: 'get', 12 | path: '/test', 13 | body: {}, 14 | multiValueHeaders: { 15 | 'content-type': ['application/json'] 16 | } 17 | } 18 | 19 | 20 | /******************************************************************************/ 21 | /*** DEFINE TEST ROUTES ***/ 22 | /******************************************************************************/ 23 | 24 | api.get('/modified', function(req,res) { 25 | res.modified().send('cache') 26 | }) 27 | 28 | api.get('/modifiedTrue', function(req,res) { 29 | res.modified(true).send('cache') 30 | }) 31 | 32 | api.get('/modifiedFalse', function(req,res) { 33 | res.modified(false).send('cache') 34 | }) 35 | 36 | api.get('/modifiedDate', function(req,res) { 37 | res.modified(new Date('2018-08-01')).send('cache') 38 | }) 39 | 40 | api.get('/modifiedString', function(req,res) { 41 | res.modified('2018-08-01').send('cache') 42 | }) 43 | 44 | api.get('/modifiedBadString', function(req,res) { 45 | res.modified('test').send('cache') 46 | }) 47 | 48 | /******************************************************************************/ 49 | /*** BEGIN TESTS ***/ 50 | /******************************************************************************/ 51 | 52 | describe('modified Tests:', function() { 53 | 54 | it('modified (no options)', async function() { 55 | let _event = Object.assign({},event,{ path: '/modified' }) 56 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 57 | expect(result).toEqual({ 58 | multiValueHeaders: { 59 | 'content-type': ['application/json'], 60 | 'last-modified': result.multiValueHeaders['last-modified'] 61 | }, 62 | statusCode: 200, 63 | body: 'cache', 64 | isBase64Encoded: false 65 | }) 66 | expect(typeof result.multiValueHeaders['last-modified']).toBe('object') 67 | // expect(typeof result.multiValueHeaders['last-modified']).to.not.be.empty 68 | }) // end it 69 | 70 | it('modified (true)', async function() { 71 | let _event = Object.assign({},event,{ path: '/modifiedTrue' }) 72 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 73 | expect(result).toEqual({ 74 | multiValueHeaders: { 75 | 'content-type': ['application/json'], 76 | 'last-modified': result.multiValueHeaders['last-modified'] 77 | }, 78 | statusCode: 200, 79 | body: 'cache', 80 | isBase64Encoded: false 81 | }) 82 | expect(typeof result.multiValueHeaders['last-modified']).toBe('object') 83 | // expect(typeof result.multiValueHeaders['last-modified']).to.not.be.empty 84 | }) // end it 85 | 86 | it('modified (false)', async function() { 87 | let _event = Object.assign({},event,{ path: '/modifiedFalse' }) 88 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 89 | expect(result).toEqual({ 90 | multiValueHeaders: { 91 | 'content-type': ['application/json'] 92 | }, 93 | statusCode: 200, 94 | body: 'cache', 95 | isBase64Encoded: false 96 | }) 97 | }) // end it 98 | 99 | it('modified (date)', async function() { 100 | let _event = Object.assign({},event,{ path: '/modifiedDate' }) 101 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 102 | expect(result).toEqual({ 103 | multiValueHeaders: { 104 | 'content-type': ['application/json'], 105 | 'last-modified': ['Wed, 01 Aug 2018 00:00:00 GMT'] 106 | }, 107 | statusCode: 200, 108 | body: 'cache', 109 | isBase64Encoded: false 110 | }) 111 | }) // end it 112 | 113 | it('modified (string)', async function() { 114 | let _event = Object.assign({},event,{ path: '/modifiedString' }) 115 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 116 | expect(result).toEqual({ 117 | multiValueHeaders: { 118 | 'content-type': ['application/json'], 119 | 'last-modified': ['Wed, 01 Aug 2018 00:00:00 GMT'] 120 | }, 121 | statusCode: 200, 122 | body: 'cache', 123 | isBase64Encoded: false 124 | }) 125 | 126 | }) // end it 127 | 128 | it('modified (invalid date)', async function() { 129 | let _event = Object.assign({},event,{ path: '/modifiedBadString' }) 130 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 131 | expect(result).toEqual({ 132 | multiValueHeaders: { 133 | 'content-type': ['application/json'], 134 | 'last-modified': result.multiValueHeaders['last-modified'] 135 | }, 136 | statusCode: 200, 137 | body: 'cache', 138 | isBase64Encoded: false 139 | }) 140 | expect(new Date(result.multiValueHeaders['last-modified']).getTime()).toBeGreaterThan(new Date('2018-08-02').getTime()) 141 | 142 | }) // end it 143 | 144 | }) // end lastModified tests 145 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /** 3 | * Lightweight web framework for your serverless applications 4 | * @author Jeremy Daly 5 | * @license MIT 6 | */ 7 | 8 | const QS = require('querystring'); // Require the querystring library 9 | const crypto = require('crypto'); // Require Node.js crypto library 10 | const { FileError } = require('./errors'); // Require custom errors 11 | 12 | const entityMap = { 13 | '&': '&', 14 | '<': '<', 15 | '>': '>', 16 | '"': '"', 17 | "'": ''', 18 | }; 19 | 20 | exports.escapeHtml = (html) => html.replace(/[&<>"']/g, (s) => entityMap[s]); 21 | 22 | // From encodeurl by Douglas Christopher Wilson 23 | let ENCODE_CHARS_REGEXP = 24 | /(?:[^\x21\x25\x26-\x3B\x3D\x3F-\x5B\x5D\x5F\x61-\x7A\x7E]|%(?:[^0-9A-Fa-f]|[0-9A-Fa-f][^0-9A-Fa-f]|$))+/g; 25 | let UNMATCHED_SURROGATE_PAIR_REGEXP = 26 | /(^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]|[\uD800-\uDBFF]([^\uDC00-\uDFFF]|$)/g; 27 | let UNMATCHED_SURROGATE_PAIR_REPLACE = '$1\uFFFD$2'; 28 | 29 | exports.encodeUrl = (url) => 30 | String(url) 31 | .replace(UNMATCHED_SURROGATE_PAIR_REGEXP, UNMATCHED_SURROGATE_PAIR_REPLACE) 32 | .replace(ENCODE_CHARS_REGEXP, encodeURI); 33 | 34 | const encodeBody = (body, serializer) => { 35 | const encode = typeof serializer === 'function' ? serializer : JSON.stringify; 36 | return typeof body === 'object' 37 | ? encode(body) 38 | : body && typeof body !== 'string' 39 | ? body.toString() 40 | : body 41 | ? body 42 | : ''; 43 | }; 44 | 45 | exports.encodeBody = encodeBody; 46 | 47 | exports.parsePath = (path) => { 48 | return path 49 | ? path 50 | .trim() 51 | .split('?')[0] 52 | .replace(/^\/(.*?)(\/)*$/, '$1') 53 | .split('/') 54 | : []; 55 | }; 56 | 57 | exports.parseBody = (body) => { 58 | try { 59 | return JSON.parse(body); 60 | } catch (e) { 61 | return body; 62 | } 63 | }; 64 | 65 | // Parses auth values into known formats 66 | const parseAuthValue = (type, value) => { 67 | switch (type) { 68 | case 'Basic': { 69 | let creds = Buffer.from(value, 'base64').toString().split(':'); 70 | return { 71 | type, 72 | value, 73 | username: creds[0], 74 | password: creds[1] ? creds[1] : null, 75 | }; 76 | } 77 | case 'OAuth': { 78 | let params = QS.parse( 79 | value.replace(/",\s*/g, '&').replace(/"/g, '').trim() 80 | ); 81 | return Object.assign({ type, value }, params); 82 | } 83 | default: { 84 | return { type, value }; 85 | } 86 | } 87 | }; 88 | 89 | exports.parseAuth = (authStr) => { 90 | let auth = authStr && typeof authStr === 'string' ? authStr.split(' ') : []; 91 | return auth.length > 1 && 92 | ['Bearer', 'Basic', 'Digest', 'OAuth'].includes(auth[0]) 93 | ? parseAuthValue(auth[0], auth.slice(1).join(' ').trim()) 94 | : { type: 'none', value: null }; 95 | }; 96 | 97 | const mimeMap = require('./mimemap.js'); // MIME Map 98 | 99 | exports.mimeLookup = (input, custom = {}) => { 100 | let type = input.trim().replace(/^\./, ''); 101 | 102 | // If it contains a slash, return unmodified 103 | if (/.*\/.*/.test(type)) { 104 | return input.trim(); 105 | } else { 106 | // Lookup mime type 107 | let mime = Object.assign(mimeMap, custom)[type]; 108 | return mime ? mime : false; 109 | } 110 | }; 111 | 112 | const statusCodes = require('./statusCodes.js'); // MIME Map 113 | 114 | exports.statusLookup = (status) => { 115 | return status in statusCodes ? statusCodes[status] : 'Unknown'; 116 | }; 117 | 118 | // Parses routes into readable array 119 | const extractRoutes = (routes, table = []) => { 120 | // Loop through all routes 121 | for (let route in routes['ROUTES']) { 122 | // Add methods 123 | for (let method in routes['ROUTES'][route]['METHODS']) { 124 | table.push([ 125 | method, 126 | routes['ROUTES'][route]['METHODS'][method].path, 127 | routes['ROUTES'][route]['METHODS'][method].stack.map((x) => 128 | x.name.trim() !== '' ? x.name : 'unnamed' 129 | ), 130 | ]); 131 | } 132 | extractRoutes(routes['ROUTES'][route], table); 133 | } 134 | return table; 135 | }; 136 | 137 | exports.extractRoutes = extractRoutes; 138 | 139 | // Generate an Etag for the supplied value 140 | exports.generateEtag = (data) => 141 | crypto 142 | .createHash('sha256') 143 | .update(encodeBody(data)) 144 | .digest('hex') 145 | .substr(0, 32); 146 | 147 | // Check if valid S3 path 148 | exports.isS3 = (path) => /^s3:\/\/.+\/.+/i.test(path); 149 | 150 | // Parse S3 path 151 | exports.parseS3 = (path) => { 152 | if (!this.isS3(path)) throw new FileError('Invalid S3 path', { path }); 153 | let s3object = path.replace(/^s3:\/\//i, '').split('/'); 154 | return { Bucket: s3object.shift(), Key: s3object.join('/') }; 155 | }; 156 | 157 | // Deep Merge 158 | exports.deepMerge = (a, b) => { 159 | Object.keys(b).forEach((key) => { 160 | if (key === '__proto__') return; 161 | if (typeof b[key] !== 'object') return Object.assign(a, b); 162 | return key in a ? this.deepMerge(a[key], b[key]) : Object.assign(a, b); 163 | }); 164 | return a; 165 | }; 166 | 167 | // Concatenate arrays when merging two objects 168 | exports.mergeObjects = (obj1, obj2) => 169 | Object.keys(Object.assign({}, obj1, obj2)).reduce((acc, key) => { 170 | if ( 171 | obj1[key] && 172 | obj2[key] && 173 | obj1[key].every((e) => obj2[key].includes(e)) 174 | ) { 175 | return Object.assign(acc, { [key]: obj1[key] }); 176 | } else { 177 | return Object.assign(acc, { 178 | [key]: obj1[key] 179 | ? obj2[key] 180 | ? obj1[key].concat(obj2[key]) 181 | : obj1[key] 182 | : obj2[key], 183 | }); 184 | } 185 | }, {}); 186 | 187 | // Concats values from an array to ',' separated string 188 | exports.fromArray = (val) => 189 | val && val instanceof Array ? val.toString() : undefined; 190 | 191 | // Stringify multi-value headers 192 | exports.stringifyHeaders = (headers) => 193 | Object.keys(headers).reduce( 194 | (acc, key) => 195 | Object.assign(acc, { 196 | // set-cookie cannot be concatenated with a comma 197 | [key]: 198 | key === 'set-cookie' 199 | ? headers[key].slice(-1)[0] 200 | : headers[key].toString(), 201 | }), 202 | {} 203 | ); 204 | 205 | exports.streamToBuffer = (stream) => 206 | new Promise((resolve, reject) => { 207 | const chunks = []; 208 | stream.on('data', (chunk) => chunks.push(chunk)); 209 | stream.on('error', reject); 210 | stream.on('end', () => resolve(Buffer.concat(chunks))); 211 | }); 212 | -------------------------------------------------------------------------------- /__tests__/cacheControl.unit.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Init API instance 4 | const api = require('../index')({ version: 'v1.0' }) 5 | 6 | // NOTE: Set test to true 7 | api._test = true; 8 | 9 | let event = { 10 | httpMethod: 'get', 11 | path: '/test', 12 | body: {}, 13 | multiValueHeaders: { 14 | 'Content-Type': ['application/json'] 15 | } 16 | } 17 | 18 | 19 | /******************************************************************************/ 20 | /*** DEFINE TEST ROUTES ***/ 21 | /******************************************************************************/ 22 | 23 | api.get('/cache', function(req,res) { 24 | res.cache().send('cache') 25 | }) 26 | 27 | api.get('/cacheTrue', function(req,res) { 28 | res.cache(true).send('cache') 29 | }) 30 | 31 | api.get('/cacheFalse', function(req,res) { 32 | res.cache(false).send('cache') 33 | }) 34 | 35 | api.get('/cacheMaxAge', function(req,res) { 36 | res.cache(1000).send('cache') 37 | }) 38 | 39 | api.get('/cachePrivate', function(req,res) { 40 | res.cache(1000,true).send('cache') 41 | }) 42 | 43 | api.get('/cachePrivateFalse', function(req,res) { 44 | res.cache(1000,false).send('cache') 45 | }) 46 | 47 | api.get('/cachePrivateInvalid', function(req,res) { 48 | res.cache(1000,'test').send('cache') 49 | }) 50 | 51 | api.get('/cacheCustom', function(req,res) { 52 | res.cache('custom value').send('cache') 53 | }) 54 | 55 | api.get('/cacheCustomUndefined', function(req,res) { 56 | res.cache(undefined).send('cache') 57 | }) 58 | 59 | api.get('/cacheCustomNull', function(req,res) { 60 | res.cache(null).send('cache') 61 | }) 62 | 63 | 64 | /******************************************************************************/ 65 | /*** BEGIN TESTS ***/ 66 | /******************************************************************************/ 67 | 68 | describe('cacheControl Tests:', function() { 69 | 70 | it('Basic cacheControl (no options)', async function() { 71 | let _event = Object.assign({},event,{ path: '/cache' }) 72 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 73 | expect(result).toEqual({ 74 | multiValueHeaders: { 75 | 'content-type': ['application/json'], 76 | 'cache-control': ['max-age=0'], 77 | 'expires': result.multiValueHeaders.expires 78 | }, 79 | statusCode: 200, 80 | body: 'cache', 81 | isBase64Encoded: false 82 | }) 83 | }) // end it 84 | 85 | it('Basic cacheControl (true)', async function() { 86 | let _event = Object.assign({},event,{ path: '/cacheTrue' }) 87 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 88 | expect(result).toEqual({ 89 | multiValueHeaders: { 90 | 'content-type': ['application/json'], 91 | 'cache-control': ['max-age=0'], 92 | 'expires': result.multiValueHeaders.expires 93 | }, 94 | statusCode: 200, 95 | body: 'cache', 96 | isBase64Encoded: false 97 | }) 98 | }) // end it 99 | 100 | it('Basic cacheControl (false)', async function() { 101 | let _event = Object.assign({},event,{ path: '/cacheFalse' }) 102 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 103 | expect(result).toEqual({ 104 | multiValueHeaders: { 105 | 'content-type': ['application/json'], 106 | 'cache-control': ['no-cache, no-store, must-revalidate'] 107 | }, 108 | statusCode: 200, 109 | body: 'cache', 110 | isBase64Encoded: false 111 | }) 112 | }) // end it 113 | 114 | it('Basic cacheControl (maxAge)', async function() { 115 | let _event = Object.assign({},event,{ path: '/cacheMaxAge' }) 116 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 117 | expect(result).toEqual({ 118 | multiValueHeaders: { 119 | 'content-type': ['application/json'], 120 | 'cache-control': ['max-age=1'], 121 | 'expires': result.multiValueHeaders.expires 122 | }, 123 | statusCode: 200, 124 | body: 'cache', 125 | isBase64Encoded: false 126 | }) 127 | }) // end it 128 | 129 | it('Basic cacheControl (private)', async function() { 130 | let _event = Object.assign({},event,{ path: '/cachePrivate' }) 131 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 132 | expect(result).toEqual({ 133 | multiValueHeaders: { 134 | 'content-type': ['application/json'], 135 | 'cache-control': ['private, max-age=1'], 136 | 'expires': result.multiValueHeaders.expires 137 | }, 138 | statusCode: 200, 139 | body: 'cache', 140 | isBase64Encoded: false 141 | }) 142 | }) // end it 143 | 144 | it('Basic cacheControl (disable private)', async function() { 145 | let _event = Object.assign({},event,{ path: '/cachePrivateFalse' }) 146 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 147 | expect(result).toEqual({ 148 | multiValueHeaders: { 149 | 'content-type': ['application/json'], 150 | 'cache-control': ['max-age=1'], 151 | 'expires': result.multiValueHeaders.expires 152 | }, 153 | statusCode: 200, 154 | body: 'cache', 155 | isBase64Encoded: false 156 | }) 157 | }) // end it 158 | 159 | it('Basic cacheControl (invalid private value)', async function() { 160 | let _event = Object.assign({},event,{ path: '/cachePrivateInvalid' }) 161 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 162 | expect(result).toEqual({ 163 | multiValueHeaders: { 164 | 'content-type': ['application/json'], 165 | 'cache-control': ['max-age=1'], 166 | 'expires': result.multiValueHeaders.expires 167 | }, 168 | statusCode: 200, 169 | body: 'cache', 170 | isBase64Encoded: false 171 | }) 172 | }) // end it 173 | 174 | it('Basic cacheControl (undefined)', async function() { 175 | let _event = Object.assign({},event,{ path: '/cacheCustomUndefined' }) 176 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 177 | expect(result).toEqual({ 178 | multiValueHeaders: { 179 | 'content-type': ['application/json'], 180 | 'cache-control': ['max-age=0'], 181 | 'expires': result.multiValueHeaders.expires 182 | }, 183 | statusCode: 200, 184 | body: 'cache', 185 | isBase64Encoded: false 186 | }) 187 | }) // end it 188 | 189 | it('Basic cacheControl (null)', async function() { 190 | let _event = Object.assign({},event,{ path: '/cacheCustomNull' }) 191 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 192 | expect(result).toEqual({ 193 | multiValueHeaders: { 194 | 'content-type': ['application/json'], 195 | 'cache-control': ['max-age=0'], 196 | 'expires': result.multiValueHeaders.expires 197 | }, 198 | statusCode: 200, 199 | body: 'cache', 200 | isBase64Encoded: false 201 | }) 202 | }) // end it 203 | 204 | it('Custom cacheControl (string)', async function() { 205 | let _event = Object.assign({},event,{ path: '/cacheCustom' }) 206 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 207 | expect(result).toEqual({ 208 | multiValueHeaders: { 209 | 'content-type': ['application/json'], 210 | 'cache-control': ['custom value'] 211 | }, 212 | statusCode: 200, 213 | body: 'cache', 214 | isBase64Encoded: false 215 | }) 216 | }) // end it 217 | 218 | }) // end UNIT tests 219 | -------------------------------------------------------------------------------- /__tests__/getLink.unit.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const delay = ms => new Promise(res => setTimeout(res, ms)) 4 | 5 | // Require Sinon.js library 6 | const sinon = require('sinon') 7 | 8 | const S3 = require('../lib/s3-service') // Init S3 Service 9 | 10 | // Init API instance 11 | const api = require('../index')({ version: 'v1.0' }) 12 | 13 | 14 | // NOTE: Set test to true 15 | api._test = true; 16 | 17 | let event = { 18 | httpMethod: 'get', 19 | path: '/test', 20 | body: {}, 21 | multiValueHeaders: { 22 | 'Content-Type': 'application/json' 23 | } 24 | } 25 | 26 | 27 | /******************************************************************************/ 28 | /*** DEFINE TEST ROUTES ***/ 29 | /******************************************************************************/ 30 | 31 | api.get('/s3Link', async function(req,res) { 32 | stub.callsArgWith(2, null, 'https://s3.amazonaws.com/my-test-bucket/test/test.txt?AWSAccessKeyId=AKXYZ&Expires=1534290845&Signature=XYZ') 33 | let url = await res.getLink('s3://my-test-bucket/test/test.txt') 34 | res.send(url) 35 | }) 36 | 37 | api.get('/s3LinkExpire', async function(req,res) { 38 | stub.callsArgWith(2, null, 'https://s3.amazonaws.com/my-test-bucket/test/test.txt?AWSAccessKeyId=AKXYZ&Expires=1534290845&Signature=XYZ') 39 | let url = await res.getLink('s3://my-test-bucket/test/test.txt',60) 40 | res.send(url) 41 | }) 42 | 43 | api.get('/s3LinkInvalidExpire', async function(req,res) { 44 | stub.callsArgWith(2, null, 'https://s3.amazonaws.com/my-test-bucket/test/test.txt?AWSAccessKeyId=AKXYZ&Expires=1534290845&Signature=XYZ') 45 | let url = await res.getLink('s3://my-test-bucket/test/test.txt','test') 46 | res.send(url) 47 | }) 48 | 49 | 50 | api.get('/s3LinkExpireFloat', async function(req,res) { 51 | stub.callsArgWith(2, null, 'https://s3.amazonaws.com/my-test-bucket/test/test.txt?AWSAccessKeyId=AKXYZ&Expires=1534290845&Signature=XYZ') 52 | let url = await res.getLink('s3://my-test-bucket/test/test.txt',3.145) 53 | res.send(url) 54 | }) 55 | 56 | api.get('/s3LinkError', async function(req,res) { 57 | stub.callsArgWith(2, 'getSignedUrl error', null) 58 | let url = await res.getLink('s3://my-test-bucket/test/test.txt', async (e) => { 59 | return await delay(100).then(() => {}) 60 | }) 61 | res.send(url) 62 | }) 63 | 64 | api.get('/s3LinkErrorCustom', async function(req,res) { 65 | stub.callsArgWith(2, 'getSignedUrl error', null) 66 | let url = await res.getLink('s3://my-test-bucket/test/test.txt', 60 ,async (e) => { 67 | return await delay(100).then(() => { 68 | res.error('Custom error') 69 | }) 70 | }) 71 | res.send(url) 72 | }) 73 | 74 | api.get('/s3LinkErrorStandard', async function(req,res) { 75 | stub.callsArgWith(2, 'getSignedUrl error', null) 76 | let url = await res.getLink('s3://my-test-bucket/test/test.txt', 900) 77 | res.send(url) 78 | }) 79 | 80 | api.get('/s3LinkInvalid', async function(req,res) { 81 | //stub.callsArgWith(2, 'getSignedUrl error', null) 82 | let url = await res.getLink('s3://my-test-bucket', 900) 83 | res.send(url) 84 | }) 85 | 86 | 87 | 88 | 89 | 90 | 91 | /******************************************************************************/ 92 | /*** BEGIN TESTS ***/ 93 | /******************************************************************************/ 94 | 95 | let stub 96 | 97 | describe('getLink Tests:', function() { 98 | 99 | // this.slow(300) 100 | 101 | beforeEach(function() { 102 | // Stub getSignedUrl 103 | stub = sinon.stub(S3,'getSignedUrl') 104 | }) 105 | 106 | it('Simple path', async function() { 107 | let _event = Object.assign({},event,{ path: '/s3Link' }) 108 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 109 | expect(result).toEqual({ 110 | multiValueHeaders: { 'content-type': ['application/json'] }, 111 | statusCode: 200, 112 | body: 'https://s3.amazonaws.com/my-test-bucket/test/test.txt?AWSAccessKeyId=AKXYZ&Expires=1534290845&Signature=XYZ', 113 | isBase64Encoded: false 114 | }) 115 | expect(stub.lastCall.args[1]).toEqual({ Bucket: 'my-test-bucket', Key: 'test/test.txt', Expires: 900 }) 116 | }) // end it 117 | 118 | it('Simple path (with custom expiration)', async function() { 119 | let _event = Object.assign({},event,{ path: '/s3LinkExpire' }) 120 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 121 | expect(result).toEqual({ 122 | multiValueHeaders: { 'content-type': ['application/json'] }, 123 | statusCode: 200, 124 | body: 'https://s3.amazonaws.com/my-test-bucket/test/test.txt?AWSAccessKeyId=AKXYZ&Expires=1534290845&Signature=XYZ', 125 | isBase64Encoded: false 126 | }) 127 | // console.log(stub); 128 | expect(stub.lastCall.args[1]).toEqual({ Bucket: 'my-test-bucket', Key: 'test/test.txt', Expires: 60 }) 129 | }) // end it 130 | 131 | it('Simple path (with invalid expiration)', async function() { 132 | let _event = Object.assign({},event,{ path: '/s3LinkInvalidExpire' }) 133 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 134 | expect(result).toEqual({ 135 | multiValueHeaders: { 'content-type': ['application/json'] }, 136 | statusCode: 200, 137 | body: 'https://s3.amazonaws.com/my-test-bucket/test/test.txt?AWSAccessKeyId=AKXYZ&Expires=1534290845&Signature=XYZ', 138 | isBase64Encoded: false 139 | }) 140 | // console.log(stub); 141 | expect(stub.lastCall.args[1]).toEqual({ Bucket: 'my-test-bucket', Key: 'test/test.txt', Expires: 900 }) 142 | }) // end it 143 | 144 | it('Simple path (with float expiration)', async function() { 145 | let _event = Object.assign({},event,{ path: '/s3LinkExpireFloat' }) 146 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 147 | expect(result).toEqual({ 148 | multiValueHeaders: { 'content-type': ['application/json'] }, 149 | statusCode: 200, 150 | body: 'https://s3.amazonaws.com/my-test-bucket/test/test.txt?AWSAccessKeyId=AKXYZ&Expires=1534290845&Signature=XYZ', 151 | isBase64Encoded: false 152 | }) 153 | // console.log(stub); 154 | expect(stub.lastCall.args[1]).toEqual({ Bucket: 'my-test-bucket', Key: 'test/test.txt', Expires: 3 }) 155 | }) // end it 156 | 157 | it('Error (with delayed callback)', async function() { 158 | let _event = Object.assign({},event,{ path: '/s3LinkError' }) 159 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 160 | expect(result).toEqual({ 161 | multiValueHeaders: { 'content-type': ['application/json'] }, 162 | statusCode: 500, 163 | body: '{"error":"getSignedUrl error"}', 164 | isBase64Encoded: false 165 | }) 166 | expect(stub.lastCall.args[1]).toEqual({ Bucket: 'my-test-bucket', Key: 'test/test.txt', Expires: 900 }) 167 | }) // end it 168 | 169 | it('Custom Error (with delayed callback)', async function() { 170 | let _event = Object.assign({},event,{ path: '/s3LinkErrorCustom' }) 171 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 172 | expect(result).toEqual({ 173 | multiValueHeaders: { 'content-type': ['application/json'] }, 174 | statusCode: 500, 175 | body: '{"error":"Custom error"}', 176 | isBase64Encoded: false 177 | }) 178 | expect(stub.lastCall.args[1]).toEqual({ Bucket: 'my-test-bucket', Key: 'test/test.txt', Expires: 60 }) 179 | }) // end it 180 | 181 | it('Error (with default callback)', async function() { 182 | let _event = Object.assign({},event,{ path: '/s3LinkErrorStandard' }) 183 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 184 | expect(result).toEqual({ 185 | multiValueHeaders: { 'content-type': ['application/json'] }, 186 | statusCode: 500, 187 | body: '{"error":"getSignedUrl error"}', 188 | isBase64Encoded: false 189 | }) 190 | expect(stub.lastCall.args[1]).toEqual({ Bucket: 'my-test-bucket', Key: 'test/test.txt', Expires: 900 }) 191 | }) // end it 192 | 193 | it('Error (invalid S3 path)', async function() { 194 | let _event = Object.assign({},event,{ path: '/s3LinkInvalid' }) 195 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 196 | expect(result).toEqual({ 197 | multiValueHeaders: { 'content-type': ['application/json'] }, 198 | statusCode: 500, 199 | body: '{"error":"Invalid S3 path"}', 200 | isBase64Encoded: false 201 | }) 202 | }) // end it 203 | 204 | afterEach(function() { 205 | stub.restore() 206 | }) 207 | 208 | }) // end getLink tests 209 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import { 2 | APIGatewayEventRequestContext, 3 | APIGatewayProxyEvent, 4 | APIGatewayProxyEventV2, 5 | Context, 6 | } from 'aws-lambda'; 7 | import { S3ClientConfig } from '@aws-sdk/client-s3'; 8 | 9 | export declare interface CookieOptions { 10 | domain?: string; 11 | expires?: Date; 12 | httpOnly?: boolean; 13 | maxAge?: number; 14 | path?: string; 15 | secure?: boolean; 16 | sameSite?: boolean | 'Strict' | 'Lax' | 'None'; 17 | } 18 | 19 | export declare interface CorsOptions { 20 | credentials?: boolean; 21 | exposeHeaders?: string; 22 | headers?: string; 23 | maxAge?: number; 24 | methods?: string; 25 | origin?: string; 26 | } 27 | 28 | export declare interface FileOptions { 29 | maxAge?: number; 30 | root?: string; 31 | lastModified?: boolean | string; 32 | headers?: { [key: string]: string }; 33 | cacheControl?: boolean | string; 34 | private?: boolean; 35 | } 36 | 37 | export declare interface RegisterOptions { 38 | prefix?: string; 39 | } 40 | 41 | export type Package = any; 42 | 43 | export declare interface App { 44 | [namespace: string]: Package; 45 | } 46 | 47 | export declare type Middleware = ( 48 | req: Request, 49 | res: Response, 50 | next: NextFunction 51 | ) => void; 52 | export declare type ErrorHandlingMiddleware = ( 53 | error: Error, 54 | req: Request, 55 | res: Response, 56 | next: NextFunction 57 | ) => void; 58 | export declare type ErrorCallback = (error?: Error) => void; 59 | export declare type HandlerFunction = ( 60 | req: Request, 61 | res: Response, 62 | next?: NextFunction 63 | ) => void | any | Promise; 64 | 65 | export declare type LoggerFunction = ( 66 | message?: any, 67 | additionalInfo?: LoggerFunctionAdditionalInfo 68 | ) => void; 69 | export declare type LoggerFunctionAdditionalInfo = 70 | | string 71 | | number 72 | | boolean 73 | | null 74 | | LoggerFunctionAdditionalInfo[] 75 | | { [key: string]: LoggerFunctionAdditionalInfo }; 76 | 77 | export declare type NextFunction = () => void; 78 | export declare type TimestampFunction = () => string; 79 | export declare type SerializerFunction = (body: object) => string; 80 | export declare type FinallyFunction = (req: Request, res: Response) => void; 81 | export declare type METHODS = 82 | | 'GET' 83 | | 'POST' 84 | | 'PUT' 85 | | 'PATCH' 86 | | 'DELETE' 87 | | 'OPTIONS' 88 | | 'HEAD' 89 | | 'ANY'; 90 | 91 | export declare interface SamplingOptions { 92 | route?: string; 93 | target?: number; 94 | rate?: number; 95 | period?: number; 96 | method?: string | string[]; 97 | } 98 | 99 | export declare interface LoggerOptions { 100 | access?: boolean | string; 101 | customKey?: string; 102 | errorLogging?: boolean; 103 | detail?: boolean; 104 | level?: string; 105 | levels?: { 106 | [key: string]: string; 107 | }; 108 | messageKey?: string; 109 | nested?: boolean; 110 | timestamp?: boolean | TimestampFunction; 111 | sampling?: { 112 | target?: number; 113 | rate?: number; 114 | period?: number; 115 | rules?: SamplingOptions[]; 116 | }; 117 | serializers?: { 118 | [name: string]: (prop: any) => any; 119 | }; 120 | stack?: boolean; 121 | } 122 | 123 | export declare interface Options { 124 | base?: string; 125 | callbackName?: string; 126 | logger?: boolean | LoggerOptions; 127 | mimeTypes?: { 128 | [key: string]: string; 129 | }; 130 | serializer?: SerializerFunction; 131 | version?: string; 132 | errorHeaderWhitelist?: string[]; 133 | isBase64?: boolean; 134 | compression?: boolean; 135 | headers?: object; 136 | s3Config?: S3ClientConfig; 137 | } 138 | 139 | export declare class Request { 140 | app: API; 141 | version: string; 142 | id: string; 143 | params: { 144 | [key: string]: string | undefined; 145 | }; 146 | method: string; 147 | path: string; 148 | query: { 149 | [key: string]: string | undefined; 150 | }; 151 | multiValueQuery: { 152 | [key: string]: string[] | undefined; 153 | }; 154 | headers: { 155 | [key: string]: string | undefined; 156 | }; 157 | rawHeaders?: { 158 | [key: string]: string | undefined; 159 | }; 160 | body: any; 161 | rawBody: string; 162 | route: ''; 163 | requestContext: APIGatewayEventRequestContext; 164 | isBase64Encoded: boolean; 165 | pathParameters: { [name: string]: string } | null; 166 | stageVariables: { [name: string]: string } | null; 167 | auth: { 168 | [key: string]: any; 169 | type: 'Bearer' | 'Basic' | 'OAuth' | 'Digest' | 'none'; 170 | value: string | null; 171 | }; 172 | cookies: { 173 | [key: string]: string; 174 | }; 175 | context: Context; 176 | coldStart: boolean; 177 | requestCount: number; 178 | ip: string; 179 | userAgent: string; 180 | clientType: 'desktop' | 'mobile' | 'tv' | 'tablet' | 'unknown'; 181 | clientCountry: string; 182 | namespace: App; 183 | 184 | log: { 185 | trace: LoggerFunction; 186 | debug: LoggerFunction; 187 | info: LoggerFunction; 188 | warn: LoggerFunction; 189 | error: LoggerFunction; 190 | fatal: LoggerFunction; 191 | }; 192 | 193 | [key: string]: any; 194 | } 195 | 196 | export declare class Response { 197 | status(code: number): this; 198 | 199 | sendStatus(code: number): void; 200 | 201 | header(key: string, value?: string | Array, append?: boolean): this; 202 | 203 | getHeader(key: string): string; 204 | 205 | hasHeader(key: string): boolean; 206 | 207 | removeHeader(key: string): this; 208 | 209 | getLink( 210 | s3Path: string, 211 | expires?: number, 212 | callback?: ErrorCallback 213 | ): Promise; 214 | 215 | send(body: any): void; 216 | 217 | json(body: any): void; 218 | 219 | jsonp(body: any): void; 220 | 221 | html(body: any): void; 222 | 223 | type(type: string): this; 224 | 225 | location(path: string): this; 226 | 227 | redirect(status: number, path: string): void; 228 | redirect(path: string): void; 229 | 230 | cors(options: CorsOptions): this; 231 | 232 | error(message: string, detail?: any): void; 233 | error(code: number, message: string, detail?: any): void; 234 | 235 | cookie(name: string, value: string, options?: CookieOptions): this; 236 | 237 | clearCookie(name: string, options?: CookieOptions): this; 238 | 239 | etag(enable?: boolean): this; 240 | 241 | cache(age?: boolean | number | string, private?: boolean): this; 242 | 243 | modified(date: boolean | string | Date): this; 244 | 245 | attachment(fileName?: string): this; 246 | 247 | download( 248 | file: string | Buffer, 249 | fileName?: string, 250 | options?: FileOptions, 251 | callback?: ErrorCallback 252 | ): void; 253 | 254 | sendFile( 255 | file: string | Buffer, 256 | options?: FileOptions, 257 | callback?: ErrorCallback 258 | ): Promise; 259 | } 260 | 261 | export declare class API { 262 | app(namespace: string, package: Package): App; 263 | app(packages: App): App; 264 | 265 | get( 266 | path: string, 267 | ...middlewaresAndHandler: (Middleware | HandlerFunction)[] 268 | ): void; 269 | get(...middlewaresAndHandler: (Middleware | HandlerFunction)[]): void; 270 | 271 | post( 272 | path: string, 273 | ...middlewaresAndHandler: (Middleware | HandlerFunction)[] 274 | ): void; 275 | post(...middlewaresAndHandler: (Middleware | HandlerFunction)[]): void; 276 | 277 | put( 278 | path: string, 279 | ...middlewaresAndHandler: (Middleware | HandlerFunction)[] 280 | ): void; 281 | put(...middlewaresAndHandler: (Middleware | HandlerFunction)[]): void; 282 | 283 | patch( 284 | path: string, 285 | ...middlewaresAndHandler: (Middleware | HandlerFunction)[] 286 | ): void; 287 | patch(...middlewaresAndHandler: (Middleware | HandlerFunction)[]): void; 288 | 289 | delete( 290 | path: string, 291 | ...middlewaresAndHandler: (Middleware | HandlerFunction)[] 292 | ): void; 293 | delete(...middlewaresAndHandler: HandlerFunction[]): void; 294 | 295 | options( 296 | path: string, 297 | ...middlewaresAndHandler: (Middleware | HandlerFunction)[] 298 | ): void; 299 | options(...middlewaresAndHandler: (Middleware | HandlerFunction)[]): void; 300 | 301 | head( 302 | path: string, 303 | ...middlewaresAndHandler: (Middleware | HandlerFunction)[] 304 | ): void; 305 | head(...middlewaresAndHandler: (Middleware | HandlerFunction)[]): void; 306 | 307 | any( 308 | path: string, 309 | ...middlewaresAndHandler: (Middleware | HandlerFunction)[] 310 | ): void; 311 | any(...middlewaresAndHandler: (Middleware | HandlerFunction)[]): void; 312 | 313 | METHOD( 314 | method: METHODS | METHODS[], 315 | path: string, 316 | ...middlewaresAndHandler: (Middleware | HandlerFunction)[] 317 | ): void; 318 | METHOD( 319 | method: METHODS | METHODS[], 320 | ...middlewaresAndHandler: (Middleware | HandlerFunction)[] 321 | ): void; 322 | 323 | register( 324 | routes: (api: API, options?: RegisterOptions) => void, 325 | options?: RegisterOptions 326 | ): void; 327 | 328 | routes(format: true): void; 329 | routes(format: false): string[][]; 330 | routes(): string[][]; 331 | 332 | use(path: string, ...middleware: Middleware[]): void; 333 | use(paths: string[], ...middleware: Middleware[]): void; 334 | use(...middleware: (Middleware | ErrorHandlingMiddleware)[]): void; 335 | 336 | finally(callback: FinallyFunction): void; 337 | 338 | run( 339 | event: APIGatewayProxyEvent | APIGatewayProxyEventV2, 340 | context: Context, 341 | cb: (err: Error, result: any) => void 342 | ): void; 343 | run( 344 | event: APIGatewayProxyEvent | APIGatewayProxyEventV2, 345 | context: Context 346 | ): Promise; 347 | } 348 | 349 | export declare class RouteError extends Error { 350 | constructor(message: string, path: string); 351 | } 352 | 353 | export declare class MethodError extends Error { 354 | constructor(message: string, method: METHODS, path: string); 355 | } 356 | 357 | export declare class ConfigurationError extends Error { 358 | constructor(message: string); 359 | } 360 | 361 | export declare class ResponseError extends Error { 362 | constructor(message: string, code: number); 363 | } 364 | 365 | export declare class FileError extends Error { 366 | constructor(message: string, err: object); 367 | } 368 | 369 | declare function createAPI(options?: Options): API; 370 | 371 | export default createAPI; 372 | -------------------------------------------------------------------------------- /lib/logger.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /** 3 | * Lightweight web framework for your serverless applications 4 | * @author Jeremy Daly 5 | * @license MIT 6 | */ 7 | 8 | // IDEA: add unique function identifier 9 | // IDEA: response length 10 | // https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html#context-variable-reference 11 | 12 | const UTILS = require('./utils'); // Require utils library 13 | const { ConfigurationError } = require('./errors'); // Require custom errors 14 | 15 | // Config logger 16 | exports.config = (config, levels) => { 17 | let cfg = config ? config : {}; 18 | 19 | // Add custom logging levels 20 | if (cfg.levels && typeof cfg.levels === 'object') { 21 | for (let lvl in cfg.levels) { 22 | if (!/^[A-Za-z_]\w*$/.test(lvl) || isNaN(cfg.levels[lvl])) { 23 | throw new ConfigurationError('Invalid level configuration'); 24 | } 25 | } 26 | levels = Object.assign(levels, cfg.levels); 27 | } 28 | 29 | // Configure sampling rules 30 | let sampling = cfg.sampling 31 | ? parseSamplerConfig(cfg.sampling, levels) 32 | : false; 33 | 34 | // Parse/default the logging level 35 | let level = 36 | cfg === true 37 | ? 'info' 38 | : cfg.level && levels[cfg.level.toLowerCase()] 39 | ? cfg.level.toLowerCase() 40 | : cfg.level === 'none' 41 | ? 'none' 42 | : Object.keys(cfg).length > 0 43 | ? 'info' 44 | : 'none'; 45 | 46 | let messageKey = 47 | cfg.messageKey && typeof cfg.messageKey === 'string' 48 | ? cfg.messageKey.trim() 49 | : 'msg'; 50 | 51 | let customKey = 52 | cfg.customKey && typeof cfg.customKey === 'string' 53 | ? cfg.customKey.trim() 54 | : 'custom'; 55 | 56 | let timestamp = 57 | cfg.timestamp === false 58 | ? () => undefined 59 | : typeof cfg.timestamp === 'function' 60 | ? cfg.timestamp 61 | : () => Date.now(); 62 | 63 | let timer = 64 | cfg.timer === false ? () => undefined : (start) => Date.now() - start; 65 | 66 | let nested = cfg.nested === true ? true : false; // nest serializers 67 | let stack = cfg.stack === true ? true : false; // show stack traces in errors 68 | let access = 69 | cfg.access === true ? true : cfg.access === 'never' ? 'never' : false; // create access logs 70 | let detail = cfg.detail === true ? true : false; // add req/res detail to all logs 71 | 72 | let multiValue = cfg.multiValue === true ? true : false; // return qs as multiValue 73 | 74 | let defaults = { 75 | req: (req) => { 76 | return { 77 | path: req.path, 78 | ip: req.ip, 79 | ua: req.userAgent, 80 | device: req.clientType, 81 | country: req.clientCountry, 82 | version: req.version, 83 | qs: multiValue 84 | ? Object.keys(req.multiValueQuery).length > 0 85 | ? req.multiValueQuery 86 | : undefined 87 | : Object.keys(req.query).length > 0 88 | ? req.query 89 | : undefined, 90 | }; 91 | }, 92 | res: () => { 93 | return {}; 94 | }, 95 | context: (context) => { 96 | return { 97 | remaining: 98 | context.getRemainingTimeInMillis && 99 | context.getRemainingTimeInMillis(), 100 | function: context.functionName && context.functionName, 101 | memory: context.memoryLimitInMB && context.memoryLimitInMB, 102 | }; 103 | }, 104 | custom: (custom) => 105 | (typeof custom === 'object' && !Array.isArray(custom)) || nested 106 | ? custom 107 | : { [customKey]: custom }, 108 | }; 109 | 110 | let serializers = { 111 | main: 112 | cfg.serializers && typeof cfg.serializers.main === 'function' 113 | ? cfg.serializers.main 114 | : () => {}, 115 | req: 116 | cfg.serializers && typeof cfg.serializers.req === 'function' 117 | ? cfg.serializers.req 118 | : () => {}, 119 | res: 120 | cfg.serializers && typeof cfg.serializers.res === 'function' 121 | ? cfg.serializers.res 122 | : () => {}, 123 | context: 124 | cfg.serializers && typeof cfg.serializers.context === 'function' 125 | ? cfg.serializers.context 126 | : () => {}, 127 | custom: 128 | cfg.serializers && typeof cfg.serializers.custom === 'function' 129 | ? cfg.serializers.custom 130 | : () => {}, 131 | }; 132 | 133 | // Overridable logging function 134 | let logger = 135 | cfg.log && typeof cfg.log === 'function' 136 | ? cfg.log 137 | : (...a) => console.log(...a); // eslint-disable-line no-console 138 | 139 | // Main logging function 140 | let log = (level, msg, req, context, custom) => { 141 | let _context = Object.assign( 142 | {}, 143 | defaults.context(context), 144 | serializers.context(context) 145 | ); 146 | let _custom = 147 | typeof custom === 'object' && !Array.isArray(custom) 148 | ? Object.assign({}, defaults.custom(custom), serializers.custom(custom)) 149 | : defaults.custom(custom); 150 | 151 | return Object.assign( 152 | {}, 153 | { 154 | level, 155 | time: timestamp(), 156 | id: req.id, 157 | route: req.route, 158 | method: req.method, 159 | [messageKey]: msg, 160 | timer: timer(req._start), 161 | int: req.interface, 162 | sample: req._sample ? true : undefined, 163 | }, 164 | serializers.main(req), 165 | nested ? { [customKey]: _custom } : _custom, 166 | nested ? { context: _context } : _context 167 | ); 168 | }; // end log 169 | 170 | // Formatting function for additional log data enrichment 171 | let format = function (info, req, res) { 172 | let _req = Object.assign({}, defaults.req(req), serializers.req(req)); 173 | let _res = Object.assign({}, defaults.res(res), serializers.res(res)); 174 | 175 | return Object.assign( 176 | {}, 177 | info, 178 | nested ? { req: _req } : _req, 179 | nested ? { res: _res } : _res 180 | ); 181 | }; // end format 182 | 183 | // Return logger object 184 | return { 185 | level, 186 | stack, 187 | logger, 188 | log, 189 | format, 190 | access, 191 | detail, 192 | sampling, 193 | errorLogging: cfg.errorLogging !== false, 194 | }; 195 | }; 196 | 197 | // Determine if we should sample this request 198 | exports.sampler = (app, req) => { 199 | if (app._logger.sampling) { 200 | // Default level to false 201 | let level = false; 202 | 203 | // Create local reference to the rulesMap 204 | let map = app._logger.sampling.rulesMap; 205 | 206 | // Parse the current route 207 | let route = UTILS.parsePath(req.route); 208 | 209 | // Default wildcard mapping 210 | let wildcard = {}; 211 | 212 | // Loop the map and see if this route matches 213 | route.forEach((part) => { 214 | // Capture wildcard mappings 215 | if (map['*']) wildcard = map['*']; 216 | // Traverse map 217 | map = map[part] ? map[part] : {}; 218 | }); // end for loop 219 | 220 | // Set rule reference based on route 221 | let ref = 222 | typeof map['__' + req.method] === 'number' 223 | ? map['__' + req.method] 224 | : typeof map['__ANY'] === 'number' 225 | ? map['__ANY'] 226 | : typeof wildcard['__' + req.method] === 'number' 227 | ? wildcard['__' + req.method] 228 | : typeof wildcard['__ANY'] === 'number' 229 | ? wildcard['__ANY'] 230 | : -1; 231 | 232 | let rule = 233 | ref >= 0 234 | ? app._logger.sampling.rules[ref] 235 | : app._logger.sampling.defaults; 236 | 237 | // Assign rule reference to the REQUEST 238 | req._sampleRule = rule; 239 | 240 | // Get last sample time (default start, last, fixed count, period count and total count) 241 | let counts = 242 | app._sampleCounts[rule.default ? 'default' : req.route] || 243 | Object.assign(app._sampleCounts, { 244 | [rule.default ? 'default' : req.route]: { 245 | start: 0, 246 | fCount: 0, 247 | pCount: 0, 248 | tCount: 0, 249 | }, 250 | })[rule.default ? 'default' : req.route]; 251 | 252 | let now = Date.now(); 253 | 254 | // Calculate the current velocity 255 | let velocity = 256 | rule.rate > 0 257 | ? (rule.period * 1000) / 258 | ((counts.tCount / (now - app._initTime)) * 259 | rule.period * 260 | 1000 * 261 | rule.rate) 262 | : 0; 263 | 264 | // If this is a new period, reset values 265 | if (now - counts.start > rule.period * 1000) { 266 | counts.start = now; 267 | counts.pCount = 0; 268 | 269 | // If a rule target is set, sample the start 270 | if (rule.target > 0) { 271 | counts.fCount = 1; 272 | level = rule.level; // set the sample level 273 | // console.log('\n*********** NEW PERIOD ***********'); 274 | } 275 | // Enable sampling if last sample is passed target split 276 | } else if ( 277 | rule.target > 0 && 278 | counts.start + 279 | Math.floor(((rule.period * 1000) / rule.target) * counts.fCount) < 280 | now 281 | ) { 282 | level = rule.level; 283 | counts.fCount++; 284 | // console.log('\n*********** FIXED ***********'); 285 | } else if ( 286 | rule.rate > 0 && 287 | counts.start + Math.floor(velocity * counts.pCount + velocity / 2) < now 288 | ) { 289 | level = rule.level; 290 | counts.pCount++; 291 | // console.log('\n*********** RATE ***********'); 292 | } 293 | 294 | // Increment total count 295 | counts.tCount++; 296 | 297 | return level; 298 | } // end if sampling 299 | 300 | return false; 301 | }; 302 | 303 | // Parse sampler configuration 304 | const parseSamplerConfig = (config, levels) => { 305 | // Default config 306 | let cfg = typeof config === 'object' ? config : config === true ? {} : false; 307 | 308 | // Error on invalid config 309 | if (cfg === false) 310 | throw new ConfigurationError('Invalid sampler configuration'); 311 | 312 | // Create rule default 313 | let defaults = (inputs) => { 314 | return { 315 | // target, rate, period, method, level 316 | target: Number.isInteger(inputs.target) ? inputs.target : 1, 317 | rate: !isNaN(inputs.rate) && inputs.rate <= 1 ? inputs.rate : 0.1, 318 | period: Number.isInteger(inputs.period) ? inputs.period : 60, // in seconds 319 | level: Object.keys(levels).includes(inputs.level) 320 | ? inputs.level 321 | : 'trace', 322 | }; 323 | }; 324 | 325 | // Init ruleMap 326 | let rulesMap = {}; 327 | 328 | // Parse and default rules 329 | let rules = Array.isArray(cfg.rules) 330 | ? cfg.rules.map((rule, i) => { 331 | // Error if missing route or not a string 332 | if (!rule.route || typeof rule.route !== 'string') 333 | throw new ConfigurationError('Invalid route specified in rule'); 334 | 335 | // Parse methods into array (if not already) 336 | let methods = ( 337 | Array.isArray(rule.method) 338 | ? rule.method 339 | : typeof rule.method === 'string' 340 | ? rule.method.split(',') 341 | : ['ANY'] 342 | ).map((x) => x.toString().trim().toUpperCase()); 343 | 344 | let map = {}; 345 | let recursive = map; // create recursive reference 346 | 347 | UTILS.parsePath(rule.route).forEach((part) => { 348 | Object.assign(recursive, { [part === '' ? '/' : part]: {} }); 349 | recursive = recursive[part === '' ? '/' : part]; 350 | }); 351 | 352 | Object.assign( 353 | recursive, 354 | methods.reduce((acc, method) => { 355 | return Object.assign(acc, { ['__' + method]: i }); 356 | }, {}) 357 | ); 358 | 359 | // Deep merge the maps 360 | UTILS.deepMerge(rulesMap, map); 361 | 362 | return defaults(rule); 363 | }, {}) 364 | : {}; 365 | 366 | return { 367 | defaults: Object.assign(defaults(cfg), { default: true }), 368 | rules, 369 | rulesMap, 370 | }; 371 | }; // end parseSamplerConfig 372 | -------------------------------------------------------------------------------- /__tests__/executionStacks.unit.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { expectation } = require("sinon"); 4 | 5 | // Init API instance 6 | // const api = require("../index")({ version: "v1.0", logger: false }); 7 | 8 | let event = { 9 | httpMethod: "get", 10 | path: "/", 11 | body: {}, 12 | multiValueHeaders: { 13 | "content-type": ["application/json"], 14 | }, 15 | }; 16 | 17 | /******************************************************************************/ 18 | /*** DEFINE TEST ROUTES ***/ 19 | /******************************************************************************/ 20 | 21 | // const middleware1 = (res, req, next) => { 22 | // next(); 23 | // }; 24 | // const middleware2 = (res, req, next) => { 25 | // next(); 26 | // }; 27 | // const middleware3 = (res, req, next) => { 28 | // next(); 29 | // }; 30 | // const middleware4 = (res, req, next) => { 31 | // next(); 32 | // }; 33 | // const getRoute = (res, req) => {}; 34 | // const getRoute2 = (res, req) => {}; 35 | // const getRoute3 = (res, req) => {}; 36 | // const postRoute = (res, req) => {}; 37 | 38 | // // api.use((err,req,res,next) => {}) 39 | 40 | // // api.post("/foo/bar", postRoute); 41 | 42 | // // api.use(middleware1); 43 | // // api.use(middleware2); 44 | 45 | // // api.use("/foo/*", middleware4); 46 | 47 | // // // api.get('/*',middleware4,getRoute2) 48 | // // api.get("/foo", getRoute3); 49 | // // api.get("/foo/bar", getRoute); 50 | // // api.use(middleware3); 51 | // // // api.get('/test',getRoute2) 52 | // // // api.use('/foo/:baz',middleware3) 53 | 54 | // // api.get("/foo/bar", getRoute2); 55 | 56 | // // // api.get("/foo/:bat", getRoute3); 57 | 58 | // // //console.log(api.routes()) 59 | // // console.log(JSON.stringify(api._routes, null, 2)); 60 | // // api.routes(true); 61 | 62 | // // api.get('/test/*',getRoute) 63 | // // api.get('/test/testx',getRoute3) 64 | 65 | // api.use("/:test", middleware3); 66 | // api.get("/", function baseGetRoute(req, res) {}); 67 | // api.get("/p1/p2/:paramName", function getRoute(req, res) {}); 68 | // api.get("/p1/p2", function getRoute(req, res, next) {}); 69 | // api.get("/p1/p2", function getRoute2(req, res) {}); 70 | // api.get("/p1/*", (req, res) => {}); 71 | 72 | /******************************************************************************/ 73 | /*** BEGIN TESTS ***/ 74 | /******************************************************************************/ 75 | 76 | describe("Execution Stacks:", function () { 77 | it("generates basic routes (no middleware)", () => { 78 | // Init API instance 79 | const api = require("../index")({ version: "v1.0", logger: false }); 80 | 81 | api.get("/", function getRoute() {}); 82 | api.get("/foo", function getFooRoute() {}); 83 | api.get("/foo/bar", function getBarRoute() {}); 84 | 85 | expect(api.routes()).toEqual([ 86 | ["GET", "/", ["getRoute"]], 87 | ["GET", "/foo", ["getFooRoute"]], 88 | ["GET", "/foo/bar", ["getBarRoute"]], 89 | ]); 90 | }); 91 | 92 | it("generates basic routes (single middleware)", () => { 93 | // Init API instance 94 | const api = require("../index")({ version: "v1.0", logger: false }); 95 | 96 | api.use(function middleware(req, res, next) {}); 97 | api.get("/", function getRoute() {}); 98 | api.get("/foo", function getFooRoute() {}); 99 | api.get("/foo/bar", function getBarRoute() {}); 100 | 101 | expect(api.routes()).toEqual([ 102 | ["GET", "/", ["middleware", "getRoute"]], 103 | ["GET", "/foo", ["middleware", "getFooRoute"]], 104 | ["GET", "/foo/bar", ["middleware", "getBarRoute"]], 105 | ]); 106 | }); 107 | 108 | it("adds single middleware after a route", () => { 109 | // Init API instance 110 | const api = require("../index")({ version: "v1.0", logger: false }); 111 | 112 | api.get("/", function getRoute() {}); 113 | api.use(function middleware(req, res, next) {}); 114 | api.get("/foo", function getFooRoute() {}); 115 | api.get("/foo/bar", function getBarRoute() {}); 116 | 117 | expect(api.routes()).toEqual([ 118 | ["GET", "/", ["getRoute"]], 119 | ["GET", "/foo", ["middleware", "getFooRoute"]], 120 | ["GET", "/foo/bar", ["middleware", "getBarRoute"]], 121 | ]); 122 | }); 123 | 124 | it("adds single middleware after a deeper route", () => { 125 | // Init API instance 126 | const api = require("../index")({ version: "v1.0", logger: false }); 127 | 128 | api.get("/foo", function getFooRoute() {}); 129 | api.use(function middleware(req, res, next) {}); 130 | api.get("/", function getRoute() {}); 131 | api.get("/foo/bar", function getBarRoute() {}); 132 | 133 | expect(api.routes()).toEqual([ 134 | ["GET", "/foo", ["getFooRoute"]], 135 | ["GET", "/foo/bar", ["middleware", "getBarRoute"]], 136 | ["GET", "/", ["middleware", "getRoute"]], 137 | ]); 138 | }); 139 | 140 | it("adds route-based middleware", () => { 141 | // Init API instance 142 | const api = require("../index")({ version: "v1.0", logger: false }); 143 | 144 | api.use("/foo", function middleware(req, res, next) {}); 145 | api.get("/", function getRoute() {}); 146 | api.get("/foo", function getFooRoute() {}); 147 | api.get("/foo/bar", function getBarRoute() {}); 148 | 149 | expect(api.routes()).toEqual([ 150 | ["GET", "/foo", ["middleware", "getFooRoute"]], 151 | ["GET", "/foo/bar", ["getBarRoute"]], 152 | ["GET", "/", ["getRoute"]], 153 | ]); 154 | }); 155 | 156 | it("adds route-based middleware with *", () => { 157 | // Init API instance 158 | const api = require("../index")({ version: "v1.0", logger: false }); 159 | 160 | api.use("/foo/*", function middleware(req, res, next) {}); 161 | api.get("/", function getRoute() {}); 162 | api.get("/foo", function getFooRoute() {}); 163 | api.get("/foo/bar", function getBarRoute() {}); 164 | 165 | expect(api.routes()).toEqual([ 166 | ["GET", "/foo", ["getFooRoute"]], 167 | ["GET", "/foo/bar", ["middleware", "getBarRoute"]], 168 | ["GET", "/", ["getRoute"]], 169 | ]); 170 | }); 171 | 172 | it("adds method-based middleware", () => { 173 | // Init API instance 174 | const api = require("../index")({ version: "v1.0", logger: false }); 175 | 176 | api.get("/foo", function middleware(req, res, next) {}); 177 | api.get("/", function getRoute() {}); 178 | api.get("/foo", function getFooRoute() {}); 179 | api.post("/foo", function postFooRoute() {}); 180 | api.get("/foo/bar", function getBarRoute() {}); 181 | 182 | expect(api.routes()).toEqual([ 183 | ["GET", "/foo", ["middleware", "getFooRoute"]], 184 | ["POST", "/foo", ["postFooRoute"]], 185 | ["GET", "/foo/bar", ["getBarRoute"]], 186 | ["GET", "/", ["getRoute"]], 187 | ]); 188 | }); 189 | 190 | it("adds method-based middleware to multiple routes", () => { 191 | // Init API instance 192 | const api = require("../index")({ version: "v1.0", logger: false }); 193 | 194 | api.get("/foo", function middleware(req, res, next) {}); 195 | api.get("/foo/bar", function middleware2(req, res, next) {}); 196 | api.get("/", function getRoute() {}); 197 | api.get("/foo", function getFooRoute() {}); 198 | api.post("/foo", function postFooRoute() {}); 199 | api.get("/foo/bar", function getBarRoute() {}); 200 | api.get("/foo/baz", function getBazRoute() {}); 201 | 202 | expect(api.routes()).toEqual([ 203 | ["GET", "/foo", ["middleware", "getFooRoute"]], 204 | ["POST", "/foo", ["postFooRoute"]], 205 | ["GET", "/foo/bar", ["middleware2", "getBarRoute"]], 206 | ["GET", "/foo/baz", ["getBazRoute"]], 207 | ["GET", "/", ["getRoute"]], 208 | ]); 209 | }); 210 | 211 | it("adds middleware multiple routes", () => { 212 | // Init API instance 213 | const api = require("../index")({ version: "v1.0", logger: false }); 214 | 215 | api.use(["/foo", "/foo/baz"], function middleware(req, res, next) {}); 216 | api.get("/", function getRoute() {}); 217 | api.get("/foo", function getFooRoute() {}); 218 | api.post("/foo", function postFooRoute() {}); 219 | api.get("/foo/bar", function getBarRoute() {}); 220 | api.get("/foo/baz", function getBazRoute() {}); 221 | 222 | expect(api.routes()).toEqual([ 223 | ["GET", "/foo", ["middleware", "getFooRoute"]], 224 | ["POST", "/foo", ["middleware", "postFooRoute"]], 225 | ["GET", "/foo/baz", ["middleware", "getBazRoute"]], 226 | ["GET", "/foo/bar", ["getBarRoute"]], 227 | ["GET", "/", ["getRoute"]], 228 | ]); 229 | }); 230 | 231 | it("adds method-based middleware using *", () => { 232 | // Init API instance 233 | const api = require("../index")({ version: "v1.0", logger: false }); 234 | 235 | api.get("/foo/*", function middleware(req, res, next) {}); 236 | api.get("/", function getRoute() {}); 237 | api.get("/foo", function getFooRoute() {}); 238 | api.post("/foo", function postFooRoute() {}); 239 | api.get("/foo/bar", function getBarRoute() {}); 240 | api.get("/foo/baz", function getBazRoute() {}); 241 | 242 | expect(api.routes()).toEqual([ 243 | ["GET", "/foo", ["getFooRoute"]], 244 | ["POST", "/foo", ["postFooRoute"]], 245 | ["GET", "/foo/*", ["middleware"]], 246 | ["GET", "/foo/bar", ["middleware", "getBarRoute"]], 247 | ["GET", "/foo/baz", ["middleware", "getBazRoute"]], 248 | ["GET", "/", ["getRoute"]], 249 | ]); 250 | }); 251 | 252 | it("assign multiple middleware to parameterized path", () => { 253 | // Init API instance 254 | const api = require("../index")({ version: "v1.0", logger: false }); 255 | 256 | api.use( 257 | function middleware(req, res, next) {}, 258 | function middleware2(req, res, next) {} 259 | ); 260 | api.get("/foo/:bar", function getParamRoute() {}); 261 | 262 | expect(api.routes()).toEqual([ 263 | ["GET", "/foo/:bar", ["middleware", "middleware2", "getParamRoute"]], 264 | ]); 265 | }); 266 | 267 | it("inherit middleware based on * routes and parameterized paths", () => { 268 | // Init API instance 269 | const api = require("../index")({ version: "v1.0", logger: false }); 270 | 271 | api.use("/*", function middleware(req, res, next) {}); 272 | api.get("/", function baseGetRoute(req, res) {}); 273 | api.get("/p1/p2/:paramName", function getRoute(req, res) {}); 274 | api.get("/p1/p2", function getRoute(req, res, next) {}); 275 | api.get("/p1/p2", function getRoute2(req, res) {}); 276 | api.get("/p1/p3", function getRoute3(req, res) {}); 277 | api.get("/p1/*", function starRoute(req, res) {}); 278 | api.get("/p1/p2/:paramName", function getRoute4(req, res) {}); 279 | 280 | // console.log(api.routes()); 281 | expect(api.routes()).toEqual([ 282 | ["GET", "/", ["middleware", "baseGetRoute"]], 283 | ["GET", "/p1/p2", ["middleware", "getRoute", "getRoute2"]], 284 | ["GET", "/p1/p2/:paramName", ["middleware", "getRoute", "getRoute4"]], 285 | ["GET", "/p1/p3", ["middleware", "getRoute3"]], 286 | ["GET", "/p1/*", ["middleware", "starRoute"]], 287 | ]); 288 | }); 289 | 290 | it.skip("inherit additional middleware after a * middleware is applied", () => { 291 | // Init API instance 292 | const api = require("../index")({ version: "v1.0", logger: false }); 293 | 294 | api.use(function middleware(req, res, next) {}); 295 | // api.use(["/data"], function middlewareX(req, res, next) {}); 296 | api.use(["/data/*"], function middleware2(req, res, next) {}); 297 | api.use(function middleware3(req, res, next) {}); 298 | 299 | api.get("/", function getRoute(req, res) {}); 300 | api.get("/data", function getDataRoute(req, res) {}); 301 | api.get("/data/test", function getNestedDataRoute(req, res) {}); 302 | 303 | console.log(api.routes()); 304 | // expect(api.routes()).toEqual([ 305 | // ["GET", "/", ["middleware", "baseGetRoute"]], 306 | // ["GET", "/p1/p2", ["middleware", "getRoute", "getRoute2"]], 307 | // ["GET", "/p1/p2/:paramName", ["middleware", "getRoute", "getRoute4"]], 308 | // ["GET", "/p1/p3", ["middleware", "getRoute3"]], 309 | // ["GET", "/p1/*", ["middleware", "starRoute"]], 310 | // ]); 311 | }); 312 | }); // end ROUTE tests 313 | -------------------------------------------------------------------------------- /__tests__/register.unit.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // Init API instance 4 | const api = require("../index")({ version: "v1.0" }); 5 | const api2 = require("../index")({ version: "v2.0" }); 6 | 7 | let event = { 8 | httpMethod: "get", 9 | path: "/test", 10 | body: {}, 11 | multiValueHeaders: { 12 | "content-type": ["application/json"], 13 | }, 14 | }; 15 | 16 | /******************************************************************************/ 17 | /*** REGISTER ROUTES ***/ 18 | /******************************************************************************/ 19 | 20 | api.register(require("./_testRoutes-v1"), { prefix: "/v1" }); 21 | api.register(require("./_testRoutes-v1"), { prefix: "/vX/vY" }); 22 | api.register(require("./_testRoutes-v1"), { prefix: "" }); 23 | api.register(require("./_testRoutes-v2"), { prefix: "/v2" }); 24 | api.register(require("./_testRoutes-v3")); // no options 25 | api2.register(require("./_testRoutes-v3"), ["array"]); // w/ array options 26 | api2.register(require("./_testRoutes-v3"), "string"); // w/ string 27 | 28 | /******************************************************************************/ 29 | /*** BEGIN TESTS ***/ 30 | /******************************************************************************/ 31 | 32 | describe("Register Tests:", function () { 33 | it("No prefix", async function () { 34 | let _event = Object.assign({}, event, { path: "/test-register" }); 35 | let result = await new Promise((r) => 36 | api.run(_event, {}, (e, res) => { 37 | r(res); 38 | }) 39 | ); 40 | expect(result).toEqual({ 41 | multiValueHeaders: { "content-type": ["application/json"] }, 42 | statusCode: 200, 43 | body: '{"path":"/test-register","route":"/test-register","method":"GET"}', 44 | isBase64Encoded: false, 45 | }); 46 | }); // end it 47 | 48 | it("No prefix (nested)", async function () { 49 | let _event = Object.assign({}, event, { path: "/test-register/sub1" }); 50 | let result = await new Promise((r) => 51 | api.run(_event, {}, (e, res) => { 52 | r(res); 53 | }) 54 | ); 55 | expect(result).toEqual({ 56 | multiValueHeaders: { "content-type": ["application/json"] }, 57 | statusCode: 200, 58 | body: '{"path":"/test-register/sub1","route":"/test-register/sub1","method":"GET"}', 59 | isBase64Encoded: false, 60 | }); 61 | }); // end it 62 | 63 | it("No prefix (nested w/ param)", async function () { 64 | let _event = Object.assign({}, event, { path: "/test-register/TEST/test" }); 65 | let result = await new Promise((r) => 66 | api.run(_event, {}, (e, res) => { 67 | r(res); 68 | }) 69 | ); 70 | expect(result).toEqual({ 71 | multiValueHeaders: { "content-type": ["application/json"] }, 72 | statusCode: 200, 73 | body: '{"path":"/test-register/TEST/test","route":"/test-register/:param1/test","method":"GET","params":{"param1":"TEST"}}', 74 | isBase64Encoded: false, 75 | }); 76 | }); // end it 77 | 78 | it("With prefix", async function () { 79 | let _event = Object.assign({}, event, { path: "/v1/test-register" }); 80 | let result = await new Promise((r) => 81 | api.run(_event, {}, (e, res) => { 82 | r(res); 83 | }) 84 | ); 85 | expect(result).toEqual({ 86 | multiValueHeaders: { "content-type": ["application/json"] }, 87 | statusCode: 200, 88 | body: '{"path":"/v1/test-register","route":"/test-register","method":"GET"}', 89 | isBase64Encoded: false, 90 | }); 91 | }); // end it 92 | 93 | it("With prefix (nested)", async function () { 94 | let _event = Object.assign({}, event, { path: "/v1/test-register/sub1" }); 95 | let result = await new Promise((r) => 96 | api.run(_event, {}, (e, res) => { 97 | r(res); 98 | }) 99 | ); 100 | expect(result).toEqual({ 101 | multiValueHeaders: { "content-type": ["application/json"] }, 102 | statusCode: 200, 103 | body: '{"path":"/v1/test-register/sub1","route":"/test-register/sub1","method":"GET"}', 104 | isBase64Encoded: false, 105 | }); 106 | }); // end it 107 | 108 | it("With prefix (nested w/ param)", async function () { 109 | let _event = Object.assign({}, event, { 110 | path: "/v1/test-register/TEST/test", 111 | }); 112 | let result = await new Promise((r) => 113 | api.run(_event, {}, (e, res) => { 114 | r(res); 115 | }) 116 | ); 117 | expect(result).toEqual({ 118 | multiValueHeaders: { "content-type": ["application/json"] }, 119 | statusCode: 200, 120 | body: '{"path":"/v1/test-register/TEST/test","route":"/test-register/:param1/test","method":"GET","params":{"param1":"TEST"}}', 121 | isBase64Encoded: false, 122 | }); 123 | }); // end it 124 | 125 | it("With double prefix", async function () { 126 | let _event = Object.assign({}, event, { path: "/vX/vY/test-register" }); 127 | let result = await new Promise((r) => 128 | api.run(_event, {}, (e, res) => { 129 | r(res); 130 | }) 131 | ); 132 | expect(result).toEqual({ 133 | multiValueHeaders: { "content-type": ["application/json"] }, 134 | statusCode: 200, 135 | body: '{"path":"/vX/vY/test-register","route":"/test-register","method":"GET"}', 136 | isBase64Encoded: false, 137 | }); 138 | }); // end it 139 | 140 | it("With double prefix (nested)", async function () { 141 | let _event = Object.assign({}, event, { 142 | path: "/vX/vY/test-register/sub1", 143 | }); 144 | let result = await new Promise((r) => 145 | api.run(_event, {}, (e, res) => { 146 | r(res); 147 | }) 148 | ); 149 | expect(result).toEqual({ 150 | multiValueHeaders: { "content-type": ["application/json"] }, 151 | statusCode: 200, 152 | body: '{"path":"/vX/vY/test-register/sub1","route":"/test-register/sub1","method":"GET"}', 153 | isBase64Encoded: false, 154 | }); 155 | }); // end it 156 | 157 | it("With double prefix (nested w/ param)", async function () { 158 | let _event = Object.assign({}, event, { 159 | path: "/vX/vY/test-register/TEST/test", 160 | }); 161 | let result = await new Promise((r) => 162 | api.run(_event, {}, (e, res) => { 163 | r(res); 164 | }) 165 | ); 166 | expect(result).toEqual({ 167 | multiValueHeaders: { "content-type": ["application/json"] }, 168 | statusCode: 200, 169 | body: '{"path":"/vX/vY/test-register/TEST/test","route":"/test-register/:param1/test","method":"GET","params":{"param1":"TEST"}}', 170 | isBase64Encoded: false, 171 | }); 172 | }); // end it 173 | 174 | it("With recursive prefix", async function () { 175 | let _event = Object.assign({}, event, { path: "/vX/vY/vZ/test-register" }); 176 | let result = await new Promise((r) => 177 | api.run(_event, {}, (e, res) => { 178 | r(res); 179 | }) 180 | ); 181 | expect(result).toEqual({ 182 | multiValueHeaders: { "content-type": ["application/json"] }, 183 | statusCode: 200, 184 | body: '{"path":"/vX/vY/vZ/test-register","route":"/test-register","method":"GET"}', 185 | isBase64Encoded: false, 186 | }); 187 | }); // end it 188 | 189 | it("With recursive prefix (nested)", async function () { 190 | let _event = Object.assign({}, event, { 191 | path: "/vX/vY/vZ/test-register/sub1", 192 | }); 193 | let result = await new Promise((r) => 194 | api.run(_event, {}, (e, res) => { 195 | r(res); 196 | }) 197 | ); 198 | expect(result).toEqual({ 199 | multiValueHeaders: { "content-type": ["application/json"] }, 200 | statusCode: 200, 201 | body: '{"path":"/vX/vY/vZ/test-register/sub1","route":"/test-register/sub1","method":"GET"}', 202 | isBase64Encoded: false, 203 | }); 204 | }); // end it 205 | 206 | it("With recursive prefix (nested w/ param)", async function () { 207 | let _event = Object.assign({}, event, { 208 | path: "/vX/vY/vZ/test-register/TEST/test", 209 | }); 210 | let result = await new Promise((r) => 211 | api.run(_event, {}, (e, res) => { 212 | r(res); 213 | }) 214 | ); 215 | expect(result).toEqual({ 216 | multiValueHeaders: { "content-type": ["application/json"] }, 217 | statusCode: 200, 218 | body: '{"path":"/vX/vY/vZ/test-register/TEST/test","route":"/test-register/:param1/test","method":"GET","params":{"param1":"TEST"}}', 219 | isBase64Encoded: false, 220 | }); 221 | }); // end it 222 | 223 | it("After recursive interation", async function () { 224 | let _event = Object.assign({}, event, { 225 | path: "/vX/vY/test-register/sub2", 226 | }); 227 | let result = await new Promise((r) => 228 | api.run(_event, {}, (e, res) => { 229 | r(res); 230 | }) 231 | ); 232 | expect(result).toEqual({ 233 | multiValueHeaders: { "content-type": ["application/json"] }, 234 | statusCode: 200, 235 | body: '{"path":"/vX/vY/test-register/sub2","route":"/test-register/sub2","method":"GET"}', 236 | isBase64Encoded: false, 237 | }); 238 | }); // end it 239 | 240 | it("New prefix", async function () { 241 | let _event = Object.assign({}, event, { path: "/v2/test-register" }); 242 | let result = await new Promise((r) => 243 | api.run(_event, {}, (e, res) => { 244 | r(res); 245 | }) 246 | ); 247 | expect(result).toEqual({ 248 | multiValueHeaders: { "content-type": ["application/json"] }, 249 | statusCode: 200, 250 | body: '{"path":"/v2/test-register","route":"/test-register","method":"GET"}', 251 | isBase64Encoded: false, 252 | }); 253 | }); // end it 254 | 255 | it("New prefix (nested)", async function () { 256 | let _event = Object.assign({}, event, { path: "/v2/test-register/sub1" }); 257 | let result = await new Promise((r) => 258 | api.run(_event, {}, (e, res) => { 259 | r(res); 260 | }) 261 | ); 262 | expect(result).toEqual({ 263 | multiValueHeaders: { "content-type": ["application/json"] }, 264 | statusCode: 200, 265 | body: '{"path":"/v2/test-register/sub1","route":"/test-register/sub1","method":"GET"}', 266 | isBase64Encoded: false, 267 | }); 268 | }); // end it 269 | 270 | it("New prefix (nested w/ param)", async function () { 271 | let _event = Object.assign({}, event, { 272 | path: "/v2/test-register/TEST/test", 273 | }); 274 | let result = await new Promise((r) => 275 | api.run(_event, {}, (e, res) => { 276 | r(res); 277 | }) 278 | ); 279 | expect(result).toEqual({ 280 | multiValueHeaders: { "content-type": ["application/json"] }, 281 | statusCode: 200, 282 | body: '{"path":"/v2/test-register/TEST/test","route":"/test-register/:param1/test","method":"GET","params":{"param1":"TEST"}}', 283 | isBase64Encoded: false, 284 | }); 285 | }); // end it 286 | 287 | it("No options/no prefix", async function () { 288 | let _event = Object.assign({}, event, { 289 | path: "/test-register-no-options", 290 | }); 291 | let result = await new Promise((r) => 292 | api.run(_event, {}, (e, res) => { 293 | r(res); 294 | }) 295 | ); 296 | expect(result).toEqual({ 297 | multiValueHeaders: { "content-type": ["application/json"] }, 298 | statusCode: 200, 299 | body: '{"path":"/test-register-no-options","route":"/test-register-no-options","method":"GET"}', 300 | isBase64Encoded: false, 301 | }); 302 | }); // end it 303 | 304 | it("Base path w/ multiple unprefixed registers", async function () { 305 | const api = require("../index")({ 306 | base: "base-path", 307 | }); 308 | api.register( 309 | (api) => { 310 | api.get("/foo", async () => {}); 311 | }, 312 | { prefix: "fuz" } 313 | ); 314 | api.register((api) => { 315 | api.get("/bar", async () => {}); 316 | }); 317 | api.register((api) => { 318 | api.get("/baz", async () => {}); 319 | }); 320 | 321 | expect(api.routes()).toEqual([ 322 | ["GET", "/base-path/fuz/foo", ["unnamed"]], 323 | ["GET", "/base-path/bar", ["unnamed"]], 324 | ["GET", "/base-path/baz", ["unnamed"]], 325 | ]); 326 | }); // end it 327 | }); // end ROUTE tests 328 | -------------------------------------------------------------------------------- /lib/request.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /** 3 | * Lightweight web framework for your serverless applications 4 | * @author Jeremy Daly 5 | * @license MIT 6 | */ 7 | 8 | const QS = require('querystring'); // Require the querystring library 9 | const UTILS = require('./utils'); // Require utils library 10 | const LOGGER = require('./logger'); // Require logger library 11 | const { RouteError, MethodError } = require('./errors'); // Require custom errors 12 | 13 | class REQUEST { 14 | // Create the constructor function. 15 | constructor(app) { 16 | // Record start time 17 | this._start = Date.now(); 18 | 19 | // Create a reference to the app 20 | this.app = app; 21 | 22 | // Flag cold starts 23 | this.coldStart = app._requestCount === 0 ? true : false; 24 | 25 | // Increment the requests counter 26 | this.requestCount = ++app._requestCount; 27 | 28 | // Init the handler 29 | this._handler; 30 | 31 | // Init the execution stack 32 | this._stack; 33 | 34 | // Expose Namespaces 35 | this.namespace = this.ns = app._app; 36 | 37 | // Set the version 38 | this.version = app._version; 39 | 40 | // Init the params 41 | this.params = {}; 42 | 43 | // Init headers 44 | this.headers = {}; 45 | 46 | // Init multi-value support flag 47 | this._multiValueSupport = null; 48 | 49 | // Init log helpers (message,custom) and create app reference 50 | app.log = this.log = Object.keys(app._logLevels).reduce( 51 | (acc, lvl) => 52 | Object.assign(acc, { 53 | [lvl]: (m, c) => this.logger(lvl, m, this, this.context, c), 54 | }), 55 | {} 56 | ); 57 | 58 | // Init _logs array for storage 59 | this._logs = []; 60 | } // end constructor 61 | 62 | // Parse the request 63 | async parseRequest() { 64 | // Set the payload version 65 | this.payloadVersion = this.app._event.version 66 | ? this.app._event.version 67 | : null; 68 | 69 | // Detect multi-value support 70 | this._multiValueSupport = 'multiValueHeaders' in this.app._event; 71 | 72 | // Set the method 73 | this.method = this.app._event.httpMethod 74 | ? this.app._event.httpMethod.toUpperCase() 75 | : this.app._event.requestContext && this.app._event.requestContext.http 76 | ? this.app._event.requestContext.http.method.toUpperCase() 77 | : 'GET'; 78 | 79 | // Set the path 80 | this.path = 81 | this.payloadVersion === '2.0' 82 | ? this.app._event.rawPath 83 | : this.app._event.path; 84 | 85 | // Set the query parameters (backfill for ALB) 86 | this.query = Object.assign( 87 | {}, 88 | this.app._event.queryStringParameters, 89 | 'queryStringParameters' in this.app._event 90 | ? {} // do nothing 91 | : Object.keys( 92 | Object.assign({}, this.app._event.multiValueQueryStringParameters) 93 | ).reduce( 94 | (qs, key) => 95 | Object.assign( 96 | qs, // get the last value of the array 97 | { 98 | [key]: decodeURIComponent( 99 | this.app._event.multiValueQueryStringParameters[key].slice( 100 | -1 101 | )[0] 102 | ), 103 | } 104 | ), 105 | {} 106 | ) 107 | ); 108 | 109 | // Set the multi-value query parameters (simulate if no multi-value support) 110 | this.multiValueQuery = Object.assign( 111 | {}, 112 | this._multiValueSupport 113 | ? {} 114 | : Object.keys(this.query).reduce( 115 | (qs, key) => 116 | Object.assign(qs, { [key]: this.query[key].split(',') }), 117 | {} 118 | ), 119 | this.app._event.multiValueQueryStringParameters 120 | ); 121 | 122 | // Set the raw headers (normalize multi-values) 123 | // per https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 124 | this.rawHeaders = 125 | this._multiValueSupport && this.app._event.multiValueHeaders !== null 126 | ? Object.keys(this.app._event.multiValueHeaders).reduce( 127 | (headers, key) => 128 | Object.assign(headers, { 129 | [key]: UTILS.fromArray(this.app._event.multiValueHeaders[key]), 130 | }), 131 | {} 132 | ) 133 | : this.app._event.headers || {}; 134 | 135 | // Set the headers to lowercase 136 | this.headers = Object.keys(this.rawHeaders).reduce( 137 | (acc, header) => 138 | Object.assign(acc, { [header.toLowerCase()]: this.rawHeaders[header] }), 139 | {} 140 | ); 141 | 142 | this.multiValueHeaders = this._multiValueSupport 143 | ? this.app._event.multiValueHeaders 144 | : Object.keys(this.headers).reduce( 145 | (headers, key) => 146 | Object.assign(headers, { 147 | [key.toLowerCase()]: this.headers[key].split(','), 148 | }), 149 | {} 150 | ); 151 | 152 | // Extract user agent 153 | this.userAgent = this.headers['user-agent']; 154 | 155 | // Get cookies from event 156 | let cookies = this.app._event.cookies 157 | ? this.app._event.cookies 158 | : this.headers.cookie 159 | ? this.headers.cookie.split(';') 160 | : []; 161 | 162 | // Set and parse cookies 163 | this.cookies = cookies.reduce((acc, cookie) => { 164 | cookie = cookie.trim().split('='); 165 | return Object.assign(acc, { 166 | [cookie[0]]: UTILS.parseBody(decodeURIComponent(cookie[1])), 167 | }); 168 | }, {}); 169 | 170 | // Attempt to parse the auth 171 | this.auth = UTILS.parseAuth(this.headers.authorization); 172 | 173 | // Set the requestContext 174 | this.requestContext = this.app._event.requestContext || {}; 175 | 176 | // Extract IP (w/ sourceIp fallback) 177 | this.ip = 178 | (this.headers['x-forwarded-for'] && 179 | this.headers['x-forwarded-for'].split(',')[0].trim()) || 180 | (this.requestContext['identity'] && 181 | this.requestContext['identity']['sourceIp'] && 182 | this.requestContext['identity']['sourceIp'].split(',')[0].trim()); 183 | 184 | // Assign the requesting interface 185 | this.interface = this.requestContext.elb ? 'alb' : 'apigateway'; 186 | 187 | // Set the pathParameters 188 | this.pathParameters = this.app._event.pathParameters || {}; 189 | 190 | // Set the stageVariables 191 | this.stageVariables = this.app._event.stageVariables || {}; 192 | 193 | // Set the isBase64Encoded 194 | this.isBase64Encoded = this.app._event.isBase64Encoded || false; 195 | 196 | // Add context 197 | this.context = 198 | this.app.context && typeof this.app.context === 'object' 199 | ? this.app.context 200 | : {}; 201 | 202 | // Parse id from context 203 | this.id = this.context.awsRequestId ? this.context.awsRequestId : null; 204 | 205 | // Determine client type 206 | this.clientType = 207 | this.headers['cloudfront-is-desktop-viewer'] === 'true' 208 | ? 'desktop' 209 | : this.headers['cloudfront-is-mobile-viewer'] === 'true' 210 | ? 'mobile' 211 | : this.headers['cloudfront-is-smarttv-viewer'] === 'true' 212 | ? 'tv' 213 | : this.headers['cloudfront-is-tablet-viewer'] === 'true' 214 | ? 'tablet' 215 | : 'unknown'; 216 | 217 | // Parse country 218 | this.clientCountry = this.headers['cloudfront-viewer-country'] 219 | ? this.headers['cloudfront-viewer-country'].toUpperCase() 220 | : 'unknown'; 221 | 222 | // Capture the raw body 223 | this.rawBody = this.app._event.body; 224 | 225 | // Set the body (decode it if base64 encoded) 226 | this.body = this.app._event.isBase64Encoded 227 | ? Buffer.from(this.app._event.body || '', 'base64').toString() 228 | : this.app._event.body; 229 | 230 | // Set the body 231 | if ( 232 | this.headers['content-type'] && 233 | this.headers['content-type'].includes('application/x-www-form-urlencoded') 234 | ) { 235 | this.body = QS.parse(this.body); 236 | } else if (typeof this.body === 'object') { 237 | // Do nothing 238 | } else { 239 | this.body = UTILS.parseBody(this.body); 240 | } 241 | 242 | // Init the stack reporter 243 | this.stack = null; 244 | 245 | // Extract path from event (strip querystring just in case) 246 | let path = UTILS.parsePath(this.path); 247 | 248 | // Init the route 249 | this.route = null; 250 | 251 | // Create a local routes reference 252 | let routes = this.app._routes; 253 | 254 | // Init wildcard 255 | let wc = []; 256 | 257 | // Loop the routes and see if this matches 258 | for (let i = 0; i < path.length; i++) { 259 | // Capture wildcard routes 260 | if (routes['ROUTES'] && routes['ROUTES']['*']) { 261 | wc.push(routes['ROUTES']['*']); 262 | } 263 | 264 | // Traverse routes 265 | if (routes['ROUTES'] && routes['ROUTES'][path[i]]) { 266 | routes = routes['ROUTES'][path[i]]; 267 | } else if (routes['ROUTES'] && routes['ROUTES']['__VAR__']) { 268 | routes = routes['ROUTES']['__VAR__']; 269 | } else if ( 270 | wc[wc.length - 1] && 271 | wc[wc.length - 1]['METHODS'] && 272 | // && (wc[wc.length-1]['METHODS'][this.method] || wc[wc.length-1]['METHODS']['ANY']) 273 | ((this.method !== 'OPTIONS' && 274 | Object.keys(wc[wc.length - 1]['METHODS']).toString() !== 'OPTIONS') || 275 | this.validWildcard(wc, this.method)) 276 | ) { 277 | routes = wc[wc.length - 1]; 278 | } else { 279 | this.app._errorStatus = 404; 280 | throw new RouteError('Route not found', '/' + path.join('/')); 281 | } 282 | } // end for loop 283 | 284 | // Grab the deepest wildcard path 285 | let wildcard = wc.pop(); 286 | 287 | // Select ROUTE if exist for method, default ANY, apply wildcards, alias HEAD requests 288 | let route = 289 | routes['METHODS'] && routes['METHODS'][this.method] 290 | ? routes['METHODS'][this.method] 291 | : routes['METHODS'] && routes['METHODS']['ANY'] 292 | ? routes['METHODS']['ANY'] 293 | : wildcard && wildcard['METHODS'] && wildcard['METHODS'][this.method] 294 | ? wildcard['METHODS'][this.method] 295 | : wildcard && wildcard['METHODS'] && wildcard['METHODS']['ANY'] 296 | ? wildcard['METHODS']['ANY'] 297 | : this.method === 'HEAD' && 298 | routes['METHODS'] && 299 | routes['METHODS']['GET'] 300 | ? routes['METHODS']['GET'] 301 | : undefined; 302 | 303 | // Check for the requested method 304 | if (route) { 305 | // Assign path parameters 306 | for (let x in route.vars) { 307 | route.vars[x].map((y) => (this.params[y] = path[x])); 308 | } // end for 309 | 310 | // Set the route used 311 | this.route = route.route; 312 | 313 | // Set the execution stack 314 | // this._stack = route.inherited.concat(route.stack); 315 | this._stack = route.stack; 316 | 317 | // Set the stack reporter 318 | this.stack = this._stack.map((x) => 319 | x.name.trim() !== '' ? x.name : 'unnamed' 320 | ); 321 | } else { 322 | this.app._errorStatus = 405; 323 | throw new MethodError( 324 | 'Method not allowed', 325 | this.method, 326 | '/' + path.join('/') 327 | ); 328 | } 329 | 330 | // Reference to sample rule 331 | this._sampleRule = {}; 332 | 333 | // Enable sampling 334 | this._sample = LOGGER.sampler(this.app, this); 335 | } // end parseRequest 336 | 337 | // Main logger 338 | logger(...args) { 339 | this.app._logger.level !== 'none' && 340 | this.app._logLevels[args[0]] >= 341 | this.app._logLevels[ 342 | this._sample ? this._sample : this.app._logger.level 343 | ] && 344 | this._logs.push(this.app._logger.log(...args)); 345 | } 346 | 347 | // Recursive wildcard function 348 | validWildcard(wc) { 349 | return ( 350 | Object.keys(wc[wc.length - 1]['METHODS']).length > 1 || 351 | (wc.length > 1 && this.validWildcard(wc.slice(0, -1))) 352 | ); 353 | } 354 | } // end REQUEST class 355 | 356 | // Export the response object 357 | module.exports = REQUEST; 358 | -------------------------------------------------------------------------------- /__tests__/download.unit.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const delay = ms => new Promise(res => setTimeout(res, ms)) 4 | 5 | const fs = require('fs') // Require Node.js file system 6 | 7 | // Require Sinon.js library 8 | const sinon = require('sinon') 9 | 10 | const S3 = require('../lib/s3-service') // Init S3 Service 11 | 12 | // Init API instance 13 | const api = require('../index')({ version: 'v1.0', mimeTypes: { test: 'text/test' } }) 14 | 15 | let event = { 16 | httpMethod: 'get', 17 | path: '/', 18 | body: {}, 19 | multiValueHeaders: { 20 | 'content-type': ['application/json'] 21 | } 22 | } 23 | 24 | /******************************************************************************/ 25 | /*** DEFINE TEST ROUTES ***/ 26 | /******************************************************************************/ 27 | 28 | api.get('/download/badpath', function(req,res) { 29 | res.download() 30 | }) 31 | 32 | api.get('/download', function(req,res) { 33 | res.download('./test-missing.txt') 34 | }) 35 | 36 | api.get('/download/err', function(req,res) { 37 | res.download('./test-missing.txt', err => { 38 | if (err) { 39 | res.error(404,'There was an error accessing the requested file') 40 | } 41 | }) 42 | }) 43 | 44 | api.get('/download/test', function(req,res) { 45 | res.download('__tests__/test.txt' + (req.query.test ? req.query.test : ''), err => { 46 | 47 | // Return a promise 48 | return delay(100).then((x) => { 49 | if (err) { 50 | // set custom error code and message on error 51 | res.error(501,'Custom File Error') 52 | } else { 53 | // else set custom response code 54 | res.status(201) 55 | } 56 | }) 57 | 58 | }) 59 | }) 60 | 61 | api.get('/download/buffer', function(req,res) { 62 | res.download(fs.readFileSync('__tests__/test.txt'), req.query.filename ? req.query.filename : undefined) 63 | }) 64 | 65 | api.get('/download/headers', function(req,res) { 66 | res.download('__tests__/test.txt', { 67 | headers: { 'x-test': 'test', 'x-timestamp': 1 } 68 | }) 69 | }) 70 | 71 | api.get('/download/headers-private', function(req,res) { 72 | res.download('__tests__/test.txt', { 73 | headers: { 'x-test': 'test', 'x-timestamp': 1 }, 74 | private: true 75 | }) 76 | }) 77 | 78 | api.get('/download/all', function(req,res) { 79 | res.download('__tests__/test.txt', 'test-file.txt', { private: true, maxAge: 3600000 }, err => { res.header('x-callback','true') }) 80 | }) 81 | 82 | api.get('/download/no-options', function(req,res) { 83 | res.download('__tests__/test.txt', 'test-file.txt', err => { res.header('x-callback','true') }) 84 | }) 85 | 86 | // S3 file 87 | api.get('/download/s3', function(req,res) { 88 | 89 | stub.withArgs({Bucket: 'my-test-bucket', Key: 'test.txt'}).returns({ 90 | promise: () => { return { 91 | AcceptRanges: 'bytes', 92 | LastModified: new Date('2018-04-01T13:32:58.000Z'), 93 | ContentLength: 23, 94 | ETag: '"ae771fbbba6a74eeeb77754355831713"', 95 | ContentType: 'text/plain', 96 | Metadata: {}, 97 | Body: Buffer.from('Test file for sendFile\n') 98 | }} 99 | }) 100 | 101 | res.download('s3://my-test-bucket/test.txt') 102 | }) 103 | 104 | api.get('/download/s3path', function(req,res) { 105 | 106 | stub.withArgs({Bucket: 'my-test-bucket', Key: 'test/test.txt'}).returns({ 107 | promise: () => { return { 108 | AcceptRanges: 'bytes', 109 | LastModified: new Date('2018-04-01T13:32:58.000Z'), 110 | ContentLength: 23, 111 | ETag: '"ae771fbbba6a74eeeb77754355831713"', 112 | ContentType: 'text/plain', 113 | Metadata: {}, 114 | Body: Buffer.from('Test file for sendFile\n') 115 | }} 116 | }) 117 | 118 | res.download('s3://my-test-bucket/test/test.txt') 119 | }) 120 | 121 | api.get('/download/s3missing', function(req,res) { 122 | 123 | stub.withArgs({Bucket: 'my-test-bucket', Key: 'file-does-not-exist.txt'}) 124 | .throws(new Error("NoSuchKey: The specified key does not exist.")) 125 | 126 | res.download('s3://my-test-bucket/file-does-not-exist.txt') 127 | }) 128 | 129 | 130 | // Error Middleware 131 | api.use(function(err,req,res,next) { 132 | res.header('x-error','true') 133 | next() 134 | }) 135 | 136 | /******************************************************************************/ 137 | /*** BEGIN TESTS ***/ 138 | /******************************************************************************/ 139 | 140 | let stub 141 | 142 | describe('Download Tests:', function() { 143 | 144 | beforeEach(function() { 145 | // Stub getObjectAsync 146 | stub = sinon.stub(S3,'getObject') 147 | }) 148 | 149 | it('Bad path', async function() { 150 | let _event = Object.assign({},event,{ path: '/download/badpath' }) 151 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 152 | expect(result).toEqual({ multiValueHeaders: { 'content-type': ['application/json'], 'x-error': ['true'] }, statusCode: 500, body: '{"error":"Invalid file"}', isBase64Encoded: false }) 153 | }) // end it 154 | 155 | it('Missing file', async function() { 156 | let _event = Object.assign({},event,{ path: '/download' }) 157 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 158 | expect(result).toEqual({ multiValueHeaders: { 'content-type': ['application/json'], 'x-error': ['true'] }, statusCode: 500, body: '{"error":"No such file"}', isBase64Encoded: false }) 159 | }) // end it 160 | 161 | it('Missing file with custom catch', async function() { 162 | let _event = Object.assign({},event,{ path: '/download/err' }) 163 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 164 | expect(result).toEqual({ multiValueHeaders: { 'content-type': ['application/json'], 'x-error': ['true'] }, statusCode: 404, body: '{"error":"There was an error accessing the requested file"}', isBase64Encoded: false }) 165 | }) // end it 166 | 167 | it('Text file w/ callback override (promise)', async function() { 168 | let _event = Object.assign({},event,{ path: '/download/test' }) 169 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 170 | expect(result).toEqual({ 171 | multiValueHeaders: { 172 | 'content-type': ['text/plain'], 173 | 'cache-control': ['max-age=0'], 174 | 'expires': result.multiValueHeaders.expires, 175 | 'last-modified': result.multiValueHeaders['last-modified'], 176 | 'content-disposition': ['attachment; filename="test.txt"'] 177 | }, 178 | statusCode: 201, body: 'VGVzdCBmaWxlIGZvciBzZW5kRmlsZQo=', isBase64Encoded: true 179 | }) 180 | }) // end it 181 | 182 | it('Text file error w/ callback override (promise)', async function() { 183 | let _event = Object.assign({},event,{ path: '/download/test', queryStringParameters: { test: 'x' } }) 184 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 185 | expect(result).toEqual({ multiValueHeaders: { 'content-type': ['application/json'], 'x-error': ['true'] }, statusCode: 501, body: '{"error":"Custom File Error"}', isBase64Encoded: false }) 186 | }) // end it 187 | 188 | it('Buffer Input (no filename)', async function() { 189 | let _event = Object.assign({},event,{ path: '/download/buffer' }) 190 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 191 | expect(result).toEqual({ 192 | multiValueHeaders: { 193 | 'content-type': ['application/json'], 194 | 'cache-control': ['max-age=0'], 195 | 'expires': result.multiValueHeaders.expires, 196 | 'last-modified': result.multiValueHeaders['last-modified'], 197 | 'content-disposition': ['attachment'] 198 | }, statusCode: 200, body: 'VGVzdCBmaWxlIGZvciBzZW5kRmlsZQo=', isBase64Encoded: true 199 | }) 200 | }) // end it 201 | 202 | it('Buffer Input (w/ filename)', async function() { 203 | let _event = Object.assign({},event,{ path: '/download/buffer', queryStringParameters: { filename: 'test.txt' } }) 204 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 205 | expect(result).toEqual({ 206 | multiValueHeaders: { 207 | 'content-type': ['text/plain'], 208 | 'cache-control': ['max-age=0'], 209 | 'expires': result.multiValueHeaders.expires, 210 | 'last-modified': result.multiValueHeaders['last-modified'], 211 | 'content-disposition': ['attachment; filename="test.txt"'] 212 | }, statusCode: 200, body: 'VGVzdCBmaWxlIGZvciBzZW5kRmlsZQo=', isBase64Encoded: true 213 | }) 214 | }) // end it 215 | 216 | 217 | it('Text file w/ headers', async function() { 218 | let _event = Object.assign({},event,{ path: '/download/headers' }) 219 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 220 | expect(result).toEqual({ 221 | multiValueHeaders: { 222 | 'content-type': ['text/plain'], 223 | 'x-test': ['test'], 224 | 'x-timestamp': [1], 225 | 'cache-control': ['max-age=0'], 226 | 'expires': result.multiValueHeaders.expires, 227 | 'last-modified': result.multiValueHeaders['last-modified'], 228 | 'content-disposition': ['attachment; filename="test.txt"'] 229 | }, statusCode: 200, body: 'VGVzdCBmaWxlIGZvciBzZW5kRmlsZQo=', isBase64Encoded: true 230 | }) 231 | }) // end it 232 | 233 | 234 | it('Text file w/ filename, options, and callback', async function() { 235 | let _event = Object.assign({},event,{ path: '/download/all' }) 236 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 237 | expect(result).toEqual({ 238 | multiValueHeaders: { 239 | 'content-type': ['text/plain'], 240 | 'x-callback': ['true'], 241 | 'cache-control': ['private, max-age=3600'], 242 | 'expires': result.multiValueHeaders.expires, 243 | 'last-modified': result.multiValueHeaders['last-modified'], 244 | 'content-disposition': ['attachment; filename="test-file.txt"'] 245 | }, statusCode: 200, body: 'VGVzdCBmaWxlIGZvciBzZW5kRmlsZQo=', isBase64Encoded: true 246 | }) 247 | }) // end it 248 | 249 | 250 | it('Text file w/ filename and callback (no options)', async function() { 251 | let _event = Object.assign({},event,{ path: '/download/no-options' }) 252 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 253 | expect(result).toEqual({ 254 | multiValueHeaders: { 255 | 'content-type': ['text/plain'], 256 | 'x-callback': ['true'], 257 | 'cache-control': ['max-age=0'], 258 | 'expires': result.multiValueHeaders.expires, 259 | 'last-modified': result.multiValueHeaders['last-modified'], 260 | 'content-disposition': ['attachment; filename="test-file.txt"'] 261 | }, statusCode: 200, body: 'VGVzdCBmaWxlIGZvciBzZW5kRmlsZQo=', isBase64Encoded: true 262 | }) 263 | }) // end it 264 | 265 | 266 | it('S3 file', async function() { 267 | let _event = Object.assign({},event,{ path: '/download/s3' }) 268 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 269 | expect(result).toEqual({ 270 | multiValueHeaders: { 271 | 'content-type': ['text/plain'], 272 | 'cache-control': ['max-age=0'], 273 | 'content-disposition': ['attachment; filename="test.txt"'], 274 | 'expires': result.multiValueHeaders['expires'], 275 | 'etag': ['"ae771fbbba6a74eeeb77754355831713"'], 276 | 'last-modified': result.multiValueHeaders['last-modified'] 277 | }, statusCode: 200, body: 'VGVzdCBmaWxlIGZvciBzZW5kRmlsZQo=', isBase64Encoded: true 278 | }) 279 | }) // end it 280 | 281 | it('S3 file w/ nested path', async function() { 282 | let _event = Object.assign({},event,{ path: '/download/s3path' }) 283 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 284 | expect(result).toEqual({ 285 | multiValueHeaders: { 286 | 'content-type': ['text/plain'], 287 | 'cache-control': ['max-age=0'], 288 | 'content-disposition': ['attachment; filename="test.txt"'], 289 | 'expires': result.multiValueHeaders['expires'], 290 | 'etag': ['"ae771fbbba6a74eeeb77754355831713"'], 291 | 'last-modified': result.multiValueHeaders['last-modified'] 292 | }, statusCode: 200, body: 'VGVzdCBmaWxlIGZvciBzZW5kRmlsZQo=', isBase64Encoded: true 293 | }) 294 | }) // end it 295 | 296 | it('S3 file error', async function() { 297 | let _event = Object.assign({},event,{ path: '/download/s3missing' }) 298 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 299 | expect(result).toEqual({ 300 | multiValueHeaders: { 301 | 'content-type': ['application/json'], 302 | 'x-error': ['true'] 303 | }, statusCode: 500, body: '{"error":"NoSuchKey: The specified key does not exist."}', isBase64Encoded: false 304 | }) 305 | }) // end it 306 | 307 | afterEach(function() { 308 | stub.restore() 309 | }) 310 | 311 | }) // end download tests 312 | -------------------------------------------------------------------------------- /__tests__/requests.unit.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Init API instance 4 | const api = require('../index')({ version: 'v1.0' }) 5 | 6 | /******************************************************************************/ 7 | /*** DEFINE TEST ROUTES ***/ 8 | /******************************************************************************/ 9 | api.get('/test/hello', function(req,res) { 10 | let request = Object.assign(req,{app:null}) 11 | res.cookie('test','value') 12 | res.cookie('test2','value2') 13 | res.status(200).json({ request }) 14 | }) 15 | 16 | api.get('/test/201', function(req,res) { 17 | let request = Object.assign(req,{app:null}) 18 | res.status(201).json({ request }) 19 | }) 20 | 21 | 22 | 23 | /******************************************************************************/ 24 | /*** BEGIN TESTS ***/ 25 | /******************************************************************************/ 26 | 27 | describe('Request Tests:', function() { 28 | 29 | describe('API Gateway Proxy Event v1', function() { 30 | it('Standard event', async function() { 31 | let _event = require('./sample-event-apigateway-v1.json') 32 | let _context = require('./sample-context-apigateway1.json') 33 | let result = await new Promise(r => api.run(_event,_context,(e,res) => { r(res) })) 34 | let body = JSON.parse(result.body) 35 | // console.log(body); 36 | // console.log(body.request.multiValueHeaders); 37 | expect(result.multiValueHeaders).toEqual({ 'content-type': ['application/json'], 'set-cookie': ['test=value; Path=/','test2=value2; Path=/'] }) 38 | expect(body).toHaveProperty('request') 39 | expect(body.request.id).toBeDefined() 40 | expect(body.request.interface).toBe('apigateway') 41 | expect(body.request.userAgent).toBe('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36 OPR/39.0.2256.48') 42 | expect(body.request).toHaveProperty('requestContext') 43 | expect(body.request.ip).toBe('192.168.100.1') 44 | expect(body.request.pathParameters).toEqual({ "proxy": "hello" }) 45 | expect(body.request.stageVariables).toEqual({ "stageVarName": "stageVarValue" }) 46 | expect(body.request.isBase64Encoded).toBe(false) 47 | expect(body.request.clientType).toBe('desktop') 48 | expect(body.request.clientCountry).toBe('US') 49 | expect(body.request.route).toBe('/test/hello') 50 | expect(body.request.query.qs1).toBe('foo') 51 | expect(body.request.query.qs2).toBe('bar') 52 | expect(body.request.multiValueQuery.qs2).toEqual(['foo','bar']) 53 | expect(body.request.multiValueQuery.qs3).toEqual(['bat','baz']) 54 | expect(body.request.headers['test-header']).toBe('val1,val2') 55 | expect(body.request.multiValueHeaders['test-header']).toEqual(['val1','val2']) 56 | }) 57 | 58 | it('Missing X-Forwarded-For (sourceIp fallback)', async function() { 59 | let _event = require('./sample-event-apigateway-v1.json') 60 | let _context = require('./sample-context-apigateway1.json') 61 | delete _event.headers['X-Forwarded-For'] // remove the header 62 | delete _event.multiValueHeaders['x-forwarded-for'] // remove the header 63 | let result = await new Promise(r => api.run(_event,_context,(e,res) => { r(res) })) 64 | let body = JSON.parse(result.body) 65 | expect(result.multiValueHeaders).toEqual({ 'content-type': ['application/json'], 'set-cookie': ['test=value; Path=/','test2=value2; Path=/'] }) 66 | expect(body).toHaveProperty('request') 67 | expect(body.request.id).toBeDefined() 68 | expect(body.request.interface).toBe('apigateway') 69 | expect(body.request.userAgent).toBe('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36 OPR/39.0.2256.48') 70 | expect(body.request).toHaveProperty('requestContext') 71 | expect(body.request.ip).toBe('192.168.100.12') 72 | expect(body.request.pathParameters).toEqual({ "proxy": "hello" }) 73 | expect(body.request.stageVariables).toEqual({ "stageVarName": "stageVarValue" }) 74 | expect(body.request.isBase64Encoded).toBe(false) 75 | expect(body.request.clientType).toBe('desktop') 76 | expect(body.request.clientCountry).toBe('US') 77 | expect(body.request.route).toBe('/test/hello') 78 | expect(body.request.query.qs1).toBe('foo') 79 | expect(body.request.query.qs2).toBe('bar') 80 | expect(body.request.multiValueQuery.qs2).toEqual(['foo','bar']) 81 | expect(body.request.multiValueQuery.qs3).toEqual(['bat','baz']) 82 | expect(body.request.headers['test-header']).toBe('val1,val2') 83 | expect(body.request.multiValueHeaders['test-header']).toEqual(['val1','val2']) 84 | // console.log(body); 85 | }) 86 | }) 87 | 88 | describe('API Gateway Proxy Event v2', function() { 89 | it('Standard event', async function() { 90 | let _event = require('./sample-event-apigateway-v2.json') 91 | let _context = require('./sample-context-apigateway1.json') 92 | let result = await new Promise(r => api.run(_event,_context,(e,res) => { r(res) })) 93 | let body = JSON.parse(result.body) 94 | // console.log(result); 95 | // console.log(body.request.multiValueHeaders); 96 | expect(result.cookies).toEqual(['test=value; Path=/','test2=value2; Path=/']) 97 | expect(body).toHaveProperty('request') 98 | expect(body.request.id).toBeDefined() 99 | expect(body.request.interface).toBe('apigateway') 100 | expect(body.request.userAgent).toBe('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36 OPR/39.0.2256.48') 101 | expect(body.request).toHaveProperty('requestContext') 102 | expect(body.request.ip).toBe('192.168.100.1') 103 | expect(body.request.pathParameters).toEqual({ "proxy": "hello" }) 104 | expect(body.request.stageVariables).toEqual({ "stageVarName": "stageVarValue" }) 105 | expect(body.request.isBase64Encoded).toBe(false) 106 | expect(body.request.clientType).toBe('desktop') 107 | expect(body.request.clientCountry).toBe('US') 108 | expect(body.request.route).toBe('/test/hello') 109 | expect(body.request.query.qs1).toBe('foo') 110 | expect(body.request.query.qs2).toBe('foo,bar') 111 | expect(body.request.multiValueQuery.qs2).toEqual(['foo','bar']) 112 | expect(body.request.multiValueQuery.qs3).toEqual(['bat','baz']) 113 | expect(body.request.headers['test-header']).toBe('val1,val2') 114 | expect(body.request.multiValueHeaders['test-header']).toEqual(['val1','val2']) 115 | expect(body.request.cookies).toEqual({ cookie1: 'test', cookie2: 123 }) 116 | }) 117 | }) 118 | 119 | describe('ALB Event', function() { 120 | it('Standard event', async function() { 121 | let _event = require('./sample-event-alb1.json') 122 | let _context = require('./sample-context-alb1.json') 123 | let result = await new Promise(r => api.run(_event,_context,(e,res) => { r(res) })) 124 | let body = JSON.parse(result.body) 125 | // console.log(JSON.stringify(result,null,2)); 126 | expect(result.headers).toEqual({ 'content-type': 'application/json', 'set-cookie': 'test2=value2; Path=/' }) 127 | expect(body).toHaveProperty('request') 128 | expect(result.statusDescription).toBe('200 OK') 129 | expect(body.request.id).toBeDefined() 130 | expect(body.request.interface).toBe('alb') 131 | expect(body.request.userAgent).toBe('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36 OPR/39.0.2256.48') 132 | expect(body.request).toHaveProperty('requestContext') 133 | expect(body.request.ip).toBe('192.168.100.1') 134 | expect(body.request.isBase64Encoded).toBe(true) 135 | expect(body.request.clientType).toBe('unknown') 136 | expect(body.request.clientCountry).toBe('unknown') 137 | expect(body.request.route).toBe('/test/hello') 138 | expect(body.request.query.qs1).toBe('foo') 139 | expect(body.request.multiValueQuery.qs1).toEqual(['foo']) 140 | }) 141 | 142 | 143 | it('With multi-value support', async function() { 144 | let _event = require('./sample-event-alb2.json') 145 | let _context = require('./sample-context-alb1.json') 146 | let result = await new Promise(r => api.run(_event,_context,(e,res) => { r(res) })) 147 | let body = JSON.parse(result.body) 148 | // console.log(JSON.stringify(result,null,2)); 149 | expect(result.multiValueHeaders).toEqual({ 'content-type': ['application/json'], 'set-cookie': ['test=value; Path=/','test2=value2; Path=/'] }) 150 | expect(body).toHaveProperty('request') 151 | expect(result.statusDescription).toBe('200 OK') 152 | expect(body.request.id).toBeDefined() 153 | expect(body.request.interface).toBe('alb') 154 | expect(body.request.userAgent).toBe('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36 OPR/39.0.2256.48') 155 | expect(body.request).toHaveProperty('requestContext') 156 | expect(body.request.ip).toBe('192.168.100.1') 157 | expect(body.request.isBase64Encoded).toBe(true) 158 | expect(body.request.clientType).toBe('unknown') 159 | expect(body.request.clientCountry).toBe('unknown') 160 | expect(body.request.route).toBe('/test/hello') 161 | expect(body.request.query.qs1).toBe('foo') 162 | expect(body.request.multiValueQuery.qs1).toEqual(['foo']) 163 | expect(body.request.multiValueQuery.qs2).toEqual(['foo','bar']) 164 | expect(body.request.multiValueQuery.qs3).toEqual(['foo','bar','bat']) 165 | expect(body.request.headers['test-header']).toBe('val1,val2') 166 | expect(body.request.multiValueHeaders['test-header']).toEqual(['val1','val2']) 167 | }) 168 | 169 | 170 | it('Alternate status code', async function() { 171 | let _event = Object.assign(require('./sample-event-alb2.json'),{ path: '/test/201' }) 172 | let _context = require('./sample-context-alb1.json') 173 | let result = await new Promise(r => api.run(_event,_context,(e,res) => { r(res) })) 174 | let body = JSON.parse(result.body) 175 | // console.log(JSON.stringify(result,null,2)); 176 | expect(result.multiValueHeaders).toEqual({ 'content-type': ['application/json'] }) 177 | expect(result.statusDescription).toBe('201 Created') 178 | expect(body).toHaveProperty('request') 179 | expect(body.request.id).toBeDefined() 180 | expect(body.request.interface).toBe('alb') 181 | expect(body.request.userAgent).toBe('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36 OPR/39.0.2256.48') 182 | expect(body.request).toHaveProperty('requestContext') 183 | expect(body.request.ip).toBe('192.168.100.1') 184 | expect(body.request.isBase64Encoded).toBe(true) 185 | expect(body.request.clientType).toBe('unknown') 186 | expect(body.request.clientCountry).toBe('unknown') 187 | expect(body.request.route).toBe('/test/201') 188 | expect(body.request.query.qs1).toBe('foo') 189 | expect(body.request.multiValueQuery.qs1).toEqual(['foo']) 190 | expect(body.request.multiValueQuery.qs2).toEqual(['foo','bar']) 191 | expect(body.request.multiValueQuery.qs3).toEqual(['foo','bar','bat']) 192 | expect(body.request.headers['test-header']).toBe('val1,val2') 193 | expect(body.request.multiValueHeaders['test-header']).toEqual(['val1','val2']) 194 | }) 195 | 196 | }) 197 | 198 | describe('API Gateway Console Test', function() { 199 | // See: https://docs.aws.amazon.com/apigateway/latest/developerguide/how-to-test-method.html 200 | it('Standard event w/o multiValueHeaders', async function() { 201 | let _event = require('./sample-event-consoletest1.json') 202 | let _context = require('./sample-context-apigateway1.json') 203 | let result = await new Promise(r => api.run(_event,_context,(e,res) => { r(res) })) 204 | let body = JSON.parse(result.body) 205 | // console.log(body); 206 | // console.log(body.request.multiValueHeaders); 207 | expect(body).toHaveProperty('request') 208 | expect(body.request.id).toBeDefined() 209 | expect(body.request.interface).toBe('apigateway') 210 | expect(body.request).toHaveProperty('requestContext') 211 | expect(body.request.ip).toBe('test-invoke-source-ip') 212 | expect(body.request.pathParameters).toEqual({ "proxy": "test/hello" }) 213 | expect(body.request.stageVariables).toEqual({}) 214 | expect(body.request.isBase64Encoded).toBe(false) 215 | expect(body.request.clientType).toBe('unknown') 216 | expect(body.request.clientCountry).toBe('unknown') 217 | expect(body.request.route).toBe('/test/hello') 218 | expect(body.request.query).toEqual({}) 219 | expect(body.request.multiValueQuery).toEqual({}) 220 | expect(body.request.headers).toEqual({}) 221 | // NOTE: body.request.multiValueHeaders is null in this case 222 | }) 223 | 224 | }) 225 | 226 | }) // end Request tests 227 | -------------------------------------------------------------------------------- /__tests__/cookies.unit.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Init API instance 4 | const api = require('../index')({ version: 'v1.0' }) 5 | 6 | // NOTE: Set test to true 7 | api._test = true; 8 | 9 | let event = { 10 | httpMethod: 'get', 11 | path: '/test', 12 | body: {}, 13 | multiValueHeaders: { 14 | 'content-type': ['application/json'] 15 | } 16 | } 17 | 18 | /******************************************************************************/ 19 | /*** DEFINE TEST ROUTES ***/ 20 | /******************************************************************************/ 21 | 22 | api.get('/cookie', function(req,res) { 23 | res.cookie('test','value').send({}) 24 | }) 25 | 26 | api.get('/cookieMultiple', function(req,res) { 27 | res.cookie('test','value').cookie('test2','value2').send({}) 28 | }) 29 | 30 | api.get('/cookieEncoded', function(req,res) { 31 | res.cookie('test','http:// [] foo;bar').send({}) 32 | }) 33 | 34 | api.get('/cookieObject', function(req,res) { 35 | res.cookie('test',{ foo: "bar" }).send({}) 36 | }) 37 | 38 | api.get('/cookieNonString', function(req,res) { 39 | res.cookie(123,'value').send({}) 40 | }) 41 | 42 | api.get('/cookieExpire', function(req,res) { 43 | res.cookie('test','value', { expires: new Date('January 1, 2019 00:00:00 GMT') }).send({}) 44 | }) 45 | 46 | api.get('/cookieMaxAge', function(req,res) { 47 | res.cookie('test','value', { maxAge: 60*60*1000 }).send({}) 48 | }) 49 | 50 | api.get('/cookieDomain', function(req,res) { 51 | res.cookie('test','value', { 52 | domain: 'test.com', 53 | expires: new Date('January 1, 2019 00:00:00 GMT') 54 | }).send({}) 55 | }) 56 | 57 | api.get('/cookieHttpOnly', function(req,res) { 58 | res.cookie('test','value', { 59 | domain: 'test.com', 60 | httpOnly: true, 61 | expires: new Date('January 1, 2019 00:00:00 GMT') 62 | }).send({}) 63 | }) 64 | 65 | api.get('/cookieSecure', function(req,res) { 66 | res.cookie('test','value', { 67 | domain: 'test.com', 68 | secure: true, 69 | expires: new Date('January 1, 2019 00:00:00 GMT') 70 | }).send({}) 71 | }) 72 | 73 | api.get('/cookiePath', function(req,res) { 74 | res.cookie('test','value', { 75 | domain: 'test.com', 76 | secure: true, 77 | path: '/test', 78 | expires: new Date('January 1, 2019 00:00:00 GMT') 79 | }).send({}) 80 | }) 81 | 82 | api.get('/cookieSameSiteTrue', function(req,res) { 83 | res.cookie('test','value', { 84 | domain: 'test.com', 85 | sameSite: true, 86 | expires: new Date('January 1, 2019 00:00:00 GMT') 87 | }).send({}) 88 | }) 89 | 90 | api.get('/cookieSameSiteFalse', function(req,res) { 91 | res.cookie('test','value', { 92 | domain: 'test.com', 93 | sameSite: false, 94 | expires: new Date('January 1, 2019 00:00:00 GMT') 95 | }).send({}) 96 | }) 97 | 98 | api.get('/cookieSameSiteString', function(req,res) { 99 | res.cookie('test','value', { 100 | domain: 'test.com', 101 | sameSite: 'Test', 102 | expires: new Date('January 1, 2019 00:00:00 GMT') 103 | }).send({}) 104 | }) 105 | 106 | api.get('/cookieParse', function(req,res) { 107 | res.send({ cookies: req.cookies }) 108 | }) 109 | 110 | api.get('/cookieClear', function(req,res) { 111 | res.clearCookie('test').send({}) 112 | }) 113 | 114 | api.get('/cookieClearOptions', function(req,res) { 115 | res.clearCookie('test', { domain: 'test.com', httpOnly: true, secure: true }).send({}) 116 | }) 117 | 118 | /******************************************************************************/ 119 | /*** BEGIN TESTS ***/ 120 | /******************************************************************************/ 121 | 122 | describe('Cookie Tests:', function() { 123 | 124 | describe("Set", function() { 125 | it('Basic Session Cookie', async function() { 126 | let _event = Object.assign({},event,{ path: '/cookie' }) 127 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 128 | expect(result).toEqual({ 129 | multiValueHeaders: { 130 | 'content-type': ['application/json'], 131 | 'set-cookie': ['test=value; Path=/'] 132 | }, statusCode: 200, body: '{}', isBase64Encoded: false 133 | }) 134 | }) // end it 135 | 136 | it('Basic Session Cookie (multi-header)', async function() { 137 | let _event = Object.assign({},event,{ path: '/cookieMultiple' }) 138 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 139 | expect(result).toEqual({ 140 | multiValueHeaders: { 141 | 'content-type': ['application/json'], 142 | 'set-cookie': ['test=value; Path=/','test2=value2; Path=/'] 143 | }, statusCode: 200, body: '{}', isBase64Encoded: false 144 | }) 145 | }) // end it 146 | 147 | it('Basic Session Cookie (encoded value)', async function() { 148 | let _event = Object.assign({},event,{ path: '/cookieEncoded' }) 149 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 150 | expect(result).toEqual({ 151 | multiValueHeaders: { 152 | 'content-type': ['application/json'], 153 | 'set-cookie': ['test=http%3A%2F%2F%20%5B%5D%20foo%3Bbar; Path=/'] 154 | }, statusCode: 200, body: '{}', isBase64Encoded: false 155 | }) 156 | }) // end it 157 | 158 | 159 | it('Basic Session Cookie (object value)', async function() { 160 | let _event = Object.assign({},event,{ path: '/cookieObject' }) 161 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 162 | expect(result).toEqual({ 163 | multiValueHeaders: { 164 | 'content-type': ['application/json'], 165 | 'set-cookie': ['test=%7B%22foo%22%3A%22bar%22%7D; Path=/'] 166 | }, statusCode: 200, body: '{}', isBase64Encoded: false 167 | }) 168 | }) // end it 169 | 170 | 171 | it('Basic Session Cookie (non-string name)', async function() { 172 | let _event = Object.assign({},event,{ path: '/cookieNonString' }) 173 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 174 | expect(result).toEqual({ 175 | multiValueHeaders: { 176 | 'content-type': ['application/json'], 177 | 'set-cookie': ['123=value; Path=/'] 178 | }, statusCode: 200, body: '{}', isBase64Encoded: false 179 | }) 180 | }) // end it 181 | 182 | 183 | it('Permanent Cookie (set expires)', async function() { 184 | let _event = Object.assign({},event,{ path: '/cookieExpire' }) 185 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 186 | expect(result).toEqual({ 187 | multiValueHeaders: { 188 | 'content-type': ['application/json'], 189 | 'set-cookie': ['test=value; Expires=Tue, 01 Jan 2019 00:00:00 GMT; Path=/'] 190 | }, statusCode: 200, body: '{}', isBase64Encoded: false 191 | }) 192 | }) // end it 193 | 194 | it('Permanent Cookie (set maxAge)', async function() { 195 | let _event = Object.assign({},event,{ path: '/cookieMaxAge' }) 196 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 197 | expect(result).toEqual({ 198 | multiValueHeaders: { 199 | 'content-type': ['application/json'], 200 | 'set-cookie': ['test=value; MaxAge=3600; Expires='+ new Date(Date.now()+3600000).toUTCString() + '; Path=/'] 201 | }, statusCode: 200, body: '{}', isBase64Encoded: false 202 | }) 203 | }) // end it 204 | 205 | it('Permanent Cookie (set domain)', async function() { 206 | let _event = Object.assign({},event,{ path: '/cookieDomain' }) 207 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 208 | expect(result).toEqual({ 209 | multiValueHeaders: { 210 | 'content-type': ['application/json'], 211 | 'set-cookie': ['test=value; Domain=test.com; Expires=Tue, 01 Jan 2019 00:00:00 GMT; Path=/'] 212 | }, statusCode: 200, body: '{}', isBase64Encoded: false 213 | }) 214 | }) // end it 215 | 216 | it('Permanent Cookie (set httpOnly)', async function() { 217 | let _event = Object.assign({},event,{ path: '/cookieHttpOnly' }) 218 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 219 | expect(result).toEqual({ 220 | multiValueHeaders: { 221 | 'content-type': ['application/json'], 222 | 'set-cookie': ['test=value; Domain=test.com; Expires=Tue, 01 Jan 2019 00:00:00 GMT; HttpOnly; Path=/'] 223 | }, statusCode: 200, body: '{}', isBase64Encoded: false 224 | }) 225 | }) // end it 226 | 227 | it('Permanent Cookie (set secure)', async function() { 228 | let _event = Object.assign({},event,{ path: '/cookieSecure' }) 229 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 230 | expect(result).toEqual({ 231 | multiValueHeaders: { 232 | 'content-type': ['application/json'], 233 | 'set-cookie': ['test=value; Domain=test.com; Expires=Tue, 01 Jan 2019 00:00:00 GMT; Path=/; Secure'] 234 | }, statusCode: 200, body: '{}', isBase64Encoded: false 235 | }) 236 | }) // end it 237 | 238 | it('Permanent Cookie (set path)', async function() { 239 | let _event = Object.assign({},event,{ path: '/cookiePath' }) 240 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 241 | expect(result).toEqual({ 242 | multiValueHeaders: { 243 | 'content-type': ['application/json'], 244 | 'set-cookie': ['test=value; Domain=test.com; Expires=Tue, 01 Jan 2019 00:00:00 GMT; Path=/test; Secure'] 245 | }, statusCode: 200, body: '{}', isBase64Encoded: false 246 | }) 247 | }) // end it 248 | 249 | it('Permanent Cookie (set sameSite - true)', async function() { 250 | let _event = Object.assign({},event,{ path: '/cookieSameSiteTrue' }) 251 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 252 | expect(result).toEqual({ 253 | multiValueHeaders: { 254 | 'content-type': ['application/json'], 255 | 'set-cookie': ['test=value; Domain=test.com; Expires=Tue, 01 Jan 2019 00:00:00 GMT; Path=/; SameSite=Strict'] 256 | }, statusCode: 200, body: '{}', isBase64Encoded: false 257 | }) 258 | }) // end it 259 | 260 | it('Permanent Cookie (set sameSite - false)', async function() { 261 | let _event = Object.assign({},event,{ path: '/cookieSameSiteFalse' }) 262 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 263 | expect(result).toEqual({ 264 | multiValueHeaders: { 265 | 'content-type': ['application/json'], 266 | 'set-cookie': ['test=value; Domain=test.com; Expires=Tue, 01 Jan 2019 00:00:00 GMT; Path=/; SameSite=Lax'] 267 | }, statusCode: 200, body: '{}', isBase64Encoded: false 268 | }) 269 | }) // end it 270 | 271 | it('Permanent Cookie (set sameSite - string)', async function() { 272 | let _event = Object.assign({},event,{ path: '/cookieSameSiteString' }) 273 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 274 | expect(result).toEqual({ 275 | multiValueHeaders: { 276 | 'content-type': ['application/json'], 277 | 'set-cookie': ['test=value; Domain=test.com; Expires=Tue, 01 Jan 2019 00:00:00 GMT; Path=/; SameSite=Test'] 278 | }, statusCode: 200, body: '{}', isBase64Encoded: false 279 | }) 280 | }) // end it 281 | 282 | }) // end set tests 283 | 284 | 285 | describe("Parse", function() { 286 | 287 | it('Parse single cookie', async function() { 288 | let _event = Object.assign({},event,{ 289 | path: '/cookieParse', 290 | multiValueHeaders: { 291 | cookie: ["test=some%20value"] 292 | } 293 | }) 294 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 295 | expect(result).toEqual({ 296 | multiValueHeaders: { 297 | 'content-type': ['application/json'], 298 | }, statusCode: 200, body: '{"cookies":{"test":"some value"}}', isBase64Encoded: false 299 | }) 300 | }) // end it 301 | 302 | it('Parse & decode two cookies', async function() { 303 | let _event = Object.assign({},event,{ 304 | path: '/cookieParse', 305 | multiValueHeaders: { 306 | cookie: ["test=some%20value; test2=%7B%22foo%22%3A%22bar%22%7D"] 307 | } 308 | }) 309 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 310 | expect(result).toEqual({ 311 | multiValueHeaders: { 312 | 'content-type': ['application/json'], 313 | }, statusCode: 200, body: '{\"cookies\":{\"test\":\"some value\",\"test2\":{\"foo\":\"bar\"}}}', isBase64Encoded: false 314 | }) 315 | }) // end it 316 | 317 | 318 | it('Parse & decode multiple cookies', async function() { 319 | let _event = Object.assign({},event,{ 320 | path: '/cookieParse', 321 | multiValueHeaders: { 322 | cookie: ["test=some%20value; test2=%7B%22foo%22%3A%22bar%22%7D; test3=domain"] 323 | } 324 | }) 325 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 326 | expect(result).toEqual({ 327 | multiValueHeaders: { 328 | 'content-type': ['application/json'], 329 | }, statusCode: 200, body: '{\"cookies\":{\"test\":\"some value\",\"test2\":{\"foo\":\"bar\"},\"test3\":\"domain\"}}', isBase64Encoded: false 330 | }) 331 | }) // end it 332 | 333 | }) // end parse tests 334 | 335 | describe("Clear", function() { 336 | 337 | it('Clear cookie (no options)', async function() { 338 | let _event = Object.assign({},event,{ 339 | path: '/cookieClear' 340 | }) 341 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 342 | expect(result).toEqual({ 343 | multiValueHeaders: { 344 | 'content-type': ['application/json'], 345 | 'set-cookie': ['test=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; MaxAge=-1; Path=/'] 346 | }, statusCode: 200, body: '{}', isBase64Encoded: false 347 | }) 348 | }) // end it 349 | 350 | it('Clear cookie (w/ options)', async function() { 351 | let _event = Object.assign({},event,{ 352 | path: '/cookieClearOptions' 353 | }) 354 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 355 | expect(result).toEqual({ 356 | multiValueHeaders: { 357 | 'content-type': ['application/json'], 358 | 'set-cookie': ['test=; Domain=test.com; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; MaxAge=-1; Path=/; Secure'] 359 | }, statusCode: 200, body: '{}', isBase64Encoded: false 360 | }) 361 | }) // end it 362 | 363 | }) // end Clear tests 364 | 365 | }) // end COOKIE tests 366 | -------------------------------------------------------------------------------- /__tests__/sampling.unit.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const delay = ms => new Promise(res => setTimeout(res, ms)) 4 | 5 | let request = 1 6 | 7 | // Init API instance 8 | const api_default = require('../index')({ logger: { sampling: true } }) 9 | const api_rules = require('../index')({ 10 | logger: { 11 | access: 'never', 12 | sampling: { 13 | rules: [ 14 | { route: '/testNone', target: 0, rate: 0, period: 1 }, 15 | { route: '/testTarget', target: 3, rate: 0, period: 1 }, 16 | { route: '/testTargetRate', target: 0, rate: 0.2, period: 1 }, 17 | { route: '/testTargetMethod', target: 1, rate: 0.2, period: 1, method:'get,put' }, 18 | { route: '/testTargetMethod', target: 2, rate: 0.1, period: 2, method:['Post','DELETE'], level:'info' }, 19 | { route: '/testParam/:param', target: 10, level: 'debug' }, 20 | { route: '/testParam/:param/deep', target: 20, level: 'info' }, 21 | { route: '/testWildCard/*', target: 30, level: 'debug' }, 22 | { route: '/testWildCard/test', target: 40, level: 'debug', method: 'Any' }, 23 | { route: '/testWildCardMethod/*', target: 50, level: 'debug', method:'get' } 24 | ], 25 | target: 1, 26 | rate: 0.1, 27 | period: 1 28 | } 29 | } 30 | }) 31 | const api_basePathRules = require('../index')({ 32 | logger: { 33 | access: false, 34 | sampling: { 35 | rules: [ 36 | { route: '/', target: 0, rate: 0, period: 5, method: 'get' }, 37 | ], 38 | target: 1, 39 | rate: 0.1, 40 | period: 5 41 | } 42 | } 43 | }) 44 | 45 | 46 | 47 | // Define default event 48 | let event = { 49 | httpMethod: 'get', 50 | path: '/test', 51 | body: {}, 52 | multiValueHeaders: { 53 | 'content-type': ['application/json'], 54 | 'x-forwarded-for': ['12.34.56.78, 23.45.67.89'], 55 | 'User-Agent': ['user-agent-string'] 56 | } 57 | } 58 | 59 | // Default context 60 | let context = { 61 | awsRequestId: 'AWSREQID', 62 | functionName: 'testFunc', 63 | memoryLimitInMB: '2048', 64 | getRemainingTimeInMillis: () => 5000 65 | } 66 | 67 | 68 | /******************************************************************************/ 69 | /*** DEFINE TEST ROUTES ***/ 70 | /******************************************************************************/ 71 | 72 | api_rules.get('/', (req,res) => { 73 | req.log.trace('request #'+request++) 74 | res.send({ method: req.method, rule: req._sampleRule }) 75 | }) 76 | 77 | api_rules.get('/testNone', (req,res) => { 78 | req.log.trace('request #'+request++) 79 | res.send({ method: req.method, rule: req._sampleRule }) 80 | }) 81 | 82 | api_rules.get('/testTarget', (req,res) => { 83 | req.log.trace('request #'+request++) 84 | res.send({ method: req.method, rule: req._sampleRule }) 85 | }) 86 | 87 | api_rules.get('/testTargetRate', (req,res) => { 88 | req.log.trace('request #'+request++) 89 | res.send({ method: req.method, rule: req._sampleRule }) 90 | }) 91 | 92 | api_rules.get('/testTargetMethod', (req,res) => { 93 | res.send({ method: req.method, rule: req._sampleRule }) 94 | }) 95 | 96 | api_rules.post('/testTargetMethod', (req,res) => { 97 | res.send({ method: req.method, rule: req._sampleRule }) 98 | }) 99 | 100 | api_rules.get('/testParam/:param', (req,res) => { 101 | res.send({ method: req.method, rule: req._sampleRule }) 102 | }) 103 | 104 | api_rules.get('/testParam/:param/deep', (req,res) => { 105 | res.send({ method: req.method, rule: req._sampleRule }) 106 | }) 107 | 108 | api_rules.get('/testWildCard/test', (req,res) => { 109 | res.send({ method: req.method, rule: req._sampleRule }) 110 | }) 111 | 112 | api_rules.get('/testWildCard/other', (req,res) => { 113 | res.send({ method: req.method, rule: req._sampleRule }) 114 | }) 115 | 116 | api_rules.get('/testWildCard/other/deep', (req,res) => { 117 | res.send({ method: req.method, rule: req._sampleRule }) 118 | }) 119 | 120 | api_rules.get('/testWildCardMethod/other', (req,res) => { 121 | res.send({ method: req.method, rule: req._sampleRule }) 122 | }) 123 | 124 | 125 | 126 | 127 | /******************************************************************************/ 128 | /*** BEGIN TESTS ***/ 129 | /******************************************************************************/ 130 | 131 | describe('Sampling Tests:', function() { 132 | 133 | describe('Configurations:', function() { 134 | it('Invalid sampling config', async function() { 135 | let error_message 136 | try { 137 | const api_error = require('../index')({ version: 'v1.0', logger: { 138 | sampling: 'invalid' 139 | } }) 140 | } catch(e) { 141 | error_message = e.message 142 | } 143 | expect(error_message).toBe('Invalid sampler configuration') 144 | }) // end it 145 | 146 | 147 | it('Missing route in rules', async function() { 148 | let error_message 149 | try { 150 | const api_error = require('../index')({ version: 'v1.0', logger: { 151 | sampling: { 152 | rules: [ 153 | { route: '/testNone', target: 0, rate: 0, period: 5 }, 154 | { target: 0, rate: 0, period: 5 } 155 | ] 156 | } 157 | } }) 158 | } catch(e) { 159 | error_message = e.message 160 | } 161 | expect(error_message).toBe('Invalid route specified in rule') 162 | }) // end it 163 | 164 | 165 | it('Invalid route in rules', async function() { 166 | let error_message 167 | try { 168 | const api_error = require('../index')({ version: 'v1.0', logger: { 169 | sampling: { 170 | rules: [ 171 | { route: '/testNone', target: 0, rate: 0, period: 5 }, 172 | { route: 1, target: 0, rate: 0, period: 5 } 173 | ] 174 | } 175 | } }) 176 | } catch(e) { 177 | error_message = e.message 178 | } 179 | expect(error_message).toBe('Invalid route specified in rule') 180 | }) // end it 181 | 182 | }) 183 | 184 | describe('Rule Matching', function() { 185 | 186 | it('Match based on method (GET)', async function() { 187 | let _event = Object.assign({},event,{ path: '/testTargetMethod', queryStringParameters: { test: true } }) 188 | let result = await new Promise(r => api_rules.run(_event,context,(e,res) => { r(res) })) 189 | let data = JSON.parse(result.body) 190 | 191 | expect(data.method).toBe('GET') 192 | expect(data.rule.target).toBe(1) 193 | expect(data.rule.rate).toBe(0.2) 194 | expect(data.rule.period).toBe(1) 195 | expect(data.rule.level).toBe('trace') 196 | }) 197 | 198 | it('Match based on method (POST)', async function() { 199 | let _event = Object.assign({},event,{ httpMethod: 'post', path: '/testTargetMethod', queryStringParameters: { test: true } }) 200 | let result = await new Promise(r => api_rules.run(_event,context,(e,res) => { r(res) })) 201 | let data = JSON.parse(result.body) 202 | 203 | expect(data.method).toBe('POST') 204 | expect(data.rule.target).toBe(2) 205 | expect(data.rule.rate).toBe(0.1) 206 | expect(data.rule.period).toBe(2) 207 | expect(data.rule.level).toBe('info') 208 | }) 209 | 210 | it('Match parameterized path', async function() { 211 | let _event = Object.assign({},event,{ path: '/testParam/foo', queryStringParameters: { test: true } }) 212 | let result = await new Promise(r => api_rules.run(_event,context,(e,res) => { r(res) })) 213 | let data = JSON.parse(result.body) 214 | 215 | expect(data.method).toBe('GET') 216 | expect(data.rule.target).toBe(10) 217 | expect(data.rule.rate).toBe(0.1) 218 | expect(data.rule.period).toBe(60) 219 | expect(data.rule.level).toBe('debug') 220 | }) 221 | 222 | it('Match deep parameterized path', async function() { 223 | let _event = Object.assign({},event,{ path: '/testParam/foo/deep', queryStringParameters: { test: true } }) 224 | let result = await new Promise(r => api_rules.run(_event,context,(e,res) => { r(res) })) 225 | let data = JSON.parse(result.body) 226 | 227 | expect(data.method).toBe('GET') 228 | expect(data.rule.target).toBe(20) 229 | expect(data.rule.rate).toBe(0.1) 230 | expect(data.rule.period).toBe(60) 231 | expect(data.rule.level).toBe('info') 232 | }) 233 | 234 | it('Match wildcard route', async function() { 235 | let _event = Object.assign({},event,{ path: '/testWildCard/other', queryStringParameters: { test: true } }) 236 | let result = await new Promise(r => api_rules.run(_event,context,(e,res) => { r(res) })) 237 | let data = JSON.parse(result.body) 238 | 239 | expect(data.method).toBe('GET') 240 | expect(data.rule.target).toBe(30) 241 | expect(data.rule.rate).toBe(0.1) 242 | expect(data.rule.period).toBe(60) 243 | expect(data.rule.level).toBe('debug') 244 | }) 245 | 246 | it('Match static route (w/ wildcard at the same level)', async function() { 247 | let _event = Object.assign({},event,{ path: '/testWildCard/test', queryStringParameters: { test: true } }) 248 | let result = await new Promise(r => api_rules.run(_event,context,(e,res) => { r(res) })) 249 | let data = JSON.parse(result.body) 250 | 251 | expect(data.method).toBe('GET') 252 | expect(data.rule.target).toBe(40) 253 | expect(data.rule.rate).toBe(0.1) 254 | expect(data.rule.period).toBe(60) 255 | expect(data.rule.level).toBe('debug') 256 | }) 257 | 258 | it('Match deep wildcard route', async function() { 259 | let _event = Object.assign({},event,{ path: '/testWildCard/other/deep', queryStringParameters: { test: true } }) 260 | let result = await new Promise(r => api_rules.run(_event,context,(e,res) => { r(res) })) 261 | let data = JSON.parse(result.body) 262 | 263 | expect(data.method).toBe('GET') 264 | expect(data.rule.target).toBe(30) 265 | expect(data.rule.rate).toBe(0.1) 266 | expect(data.rule.period).toBe(60) 267 | expect(data.rule.level).toBe('debug') 268 | }) 269 | 270 | it('Match wildcard route (by method)', async function() { 271 | let _event = Object.assign({},event,{ path: '/testWildCardMethod/other', queryStringParameters: { test: true } }) 272 | let result = await new Promise(r => api_rules.run(_event,context,(e,res) => { r(res) })) 273 | let data = JSON.parse(result.body) 274 | 275 | expect(data.method).toBe('GET') 276 | expect(data.rule.target).toBe(50) 277 | expect(data.rule.rate).toBe(0.1) 278 | expect(data.rule.period).toBe(60) 279 | expect(data.rule.level).toBe('debug') 280 | }) 281 | 282 | it('Matches first rule in rules array', async () => { 283 | let _event = Object.assign({},event,{ path: '/testNone', queryStringParameters: { test: true } }) 284 | let result = await new Promise(r => api_rules.run(_event,context,(e,res) => { r(res) })) 285 | let data = JSON.parse(result.body) 286 | 287 | expect(data.method).toBe('GET') 288 | expect(data.rule.target).toBe(0) 289 | expect(data.rule.rate).toBe(0) 290 | expect(data.rule.period).toBe(1) 291 | expect(data.rule.level).toBe('trace') 292 | }) 293 | }) 294 | 295 | 296 | describe('Simulations', function() { 297 | 298 | // Create a temporary logger to capture the console.log 299 | let consoleLog = console.log 300 | let _log = [] 301 | // const logger = log => { try { _log.push(JSON.parse(log)) } catch(e) { _log.push(log) } } 302 | const logger = (...logs) => { 303 | let log 304 | try { log = JSON.parse(logs[0]) } catch(e) { } 305 | if (log && log.level) { _log.push(log) } else { console.info(...logs) } 306 | } 307 | 308 | it('Default route', async function() { 309 | // this.timeout(10000); 310 | // this.slow(10000); 311 | _log = [] // clear log 312 | request = 1 // reset requests 313 | api_rules._initTime = Date.now() // reset _initTime for the API 314 | 315 | // Set the number of simulated requests 316 | let requests = 100 317 | 318 | // Set the default event, init result, override logger, and start the counter 319 | let _event = Object.assign({},event,{ path: '/' }) 320 | let result 321 | console.log = logger 322 | let start = Date.now() 323 | 324 | for(let x = 0; x api_rules.run(_event,context,(e,res) => { r(res) })) 326 | await delay(20) 327 | } // end for loop 328 | 329 | // End the timer and restore console.log 330 | let end = Date.now() 331 | console.log = consoleLog 332 | 333 | let data = JSON.parse(result.body) 334 | let rules = data.rule 335 | 336 | let totalTime = end - start 337 | let totalFixed = Math.ceil(totalTime/(rules.period*1000)*rules.target) 338 | let totalRate = Math.ceil(requests*rules.rate) 339 | let deviation = Math.abs(((totalFixed+totalRate)/_log.length-1).toFixed(2)) 340 | 341 | expect(deviation).toBeLessThan(0.12) 342 | }) // end it 343 | 344 | 345 | 346 | it.skip('Fixed target only route', async function() { 347 | // this.timeout(10000); 348 | // this.slow(10000); 349 | _log = [] // clear log 350 | request = 1 // reset requests 351 | api_rules._initTime = Date.now() // reset _initTime for the API 352 | 353 | // Set the number of simulated requests 354 | let requests = 100 355 | 356 | // Set the default event, init result, override logger, and start the counter 357 | let _event = Object.assign({},event,{ path: '/testTarget' }) 358 | let result 359 | console.log = logger 360 | let start = Date.now() 361 | 362 | for(let x = 0; x api_rules.run(_event,context,(e,res) => { r(res) })) 364 | await delay(20) 365 | } // end for loop 366 | 367 | // End the timer and restore console.log 368 | let end = Date.now() 369 | console.log = consoleLog 370 | 371 | let data = JSON.parse(result.body) 372 | let rules = data.rule 373 | 374 | let totalTime = end - start 375 | let totalFixed = Math.ceil(totalTime/(rules.period*1000)*rules.target) 376 | let totalRate = Math.ceil(requests*rules.rate) 377 | let deviation = Math.abs(((totalFixed+totalRate)/_log.length-1).toFixed(2)) 378 | 379 | // console.log(_log.length,totalFixed,totalRate,deviation) 380 | expect(deviation).toBeLessThan(0.15) 381 | }) // end it 382 | 383 | 384 | 385 | it('Fixed rate only route', async function() { 386 | // this.timeout(10000); 387 | // this.slow(10000); 388 | _log = [] // clear log 389 | request = 1 // reset requests 390 | api_rules._initTime = Date.now() // reset _initTime for the API 391 | 392 | // Set the number of simulated requests 393 | let requests = 100 394 | 395 | // Set the default event, init result, override logger, and start the counter 396 | let _event = Object.assign({},event,{ path: '/testTargetRate' }) 397 | let result 398 | console.log = logger 399 | let start = Date.now() 400 | 401 | for(let x = 0; x api_rules.run(_event,context,(e,res) => { r(res) })) 403 | await delay(20) 404 | // await Promise.delay(20) 405 | } // end for loop 406 | 407 | // End the timer and restore console.log 408 | let end = Date.now() 409 | console.log = consoleLog 410 | 411 | let data = JSON.parse(result.body) 412 | let rules = data.rule 413 | 414 | let totalTime = end - start 415 | let totalFixed = Math.ceil(totalTime/(rules.period*1000)*rules.target) 416 | let totalRate = Math.ceil(requests*rules.rate) 417 | let deviation = Math.abs(((totalFixed+totalRate)/_log.length-1).toFixed(2)) 418 | 419 | // console.log(_log); 420 | // console.log(totalTime,_log.length,totalFixed,totalRate,deviation) 421 | expect(deviation).toBeLessThan(0.12) 422 | }) // end it 423 | 424 | 425 | 426 | }) // end simulations 427 | 428 | }) 429 | -------------------------------------------------------------------------------- /__tests__/sendFile.unit.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const delay = ms => new Promise(res => setTimeout(res, ms)) 4 | 5 | const fs = require('fs') // Require Node.js file system 6 | 7 | // Require Sinon.js library 8 | const sinon = require('sinon') 9 | 10 | const S3 = require('../lib/s3-service'); // Init S3 Service 11 | 12 | // Init API instance 13 | const api = require('../index')({ version: 'v1.0', mimeTypes: { test: 'text/test' }}) 14 | 15 | let event = { 16 | httpMethod: 'get', 17 | path: '/', 18 | body: {}, 19 | multiValueHeaders: { 20 | 'content-type': ['application/json'] 21 | } 22 | } 23 | 24 | /******************************************************************************/ 25 | /*** DEFINE TEST ROUTES ***/ 26 | /******************************************************************************/ 27 | 28 | api.get('/sendfile/badpath', function(req,res) { 29 | res.sendFile() 30 | }) 31 | 32 | api.get('/sendfile', function(req,res) { 33 | res.sendFile('./test-missing.txt') 34 | }) 35 | 36 | api.get('/sendfile/root', function(req,res) { 37 | res.sendFile('test.txt', { root: './__tests__/' }) 38 | }) 39 | 40 | api.get('/sendfile/err', function(req,res) { 41 | res.sendFile('./test-missing.txt', err => { 42 | if (err) { 43 | res.error(404,'There was an error accessing the requested file') 44 | } 45 | }) 46 | }) 47 | 48 | api.get('/sendfile/test', function(req,res) { 49 | res.sendFile('__tests__/test.txt' + (req.query.test ? req.query.test : ''), err => { 50 | // Return a promise 51 | return delay(100).then((x) => { 52 | if (err) { 53 | // set custom error code and message on error 54 | res.error(501,'Custom File Error') 55 | } else { 56 | // else set custom response code 57 | res.status(201) 58 | } 59 | }) 60 | 61 | }) 62 | }) 63 | 64 | api.get('/sendfile/error', function(req,res) { 65 | res.sendFile('__tests__/test.txtx', err => { 66 | 67 | // Return a promise 68 | return delay(100).then((x) => { 69 | if (err) { 70 | // log error 71 | return true 72 | } 73 | }) 74 | 75 | }) 76 | }) 77 | 78 | api.get('/sendfile/buffer', function(req,res) { 79 | res.sendFile(fs.readFileSync('__tests__/test.txt')) 80 | }) 81 | 82 | api.get('/sendfile/multiValueHeaders', function(req,res) { 83 | res.sendFile('__tests__/test.txt', { 84 | headers: { 'x-test': 'test', 'x-timestamp': 1 } 85 | }) 86 | }) 87 | 88 | api.get('/sendfile/headers', function(req,res) { 89 | res.sendFile('__tests__/test.txt', { 90 | headers: { 'x-test': 'test', 'x-timestamp': 1 }, 91 | private: false 92 | }) 93 | }) 94 | 95 | api.get('/sendfile/headers-private', function(req,res) { 96 | res.sendFile('__tests__/test.txt', { 97 | headers: { 'x-test': 'test', 'x-timestamp': 1 }, 98 | private: true 99 | }) 100 | }) 101 | 102 | api.get('/sendfile/last-modified', function(req,res) { 103 | res.sendFile('__tests__/test.txt', { 104 | lastModified: new Date('Fri, 1 Jan 2018 00:00:00 GMT') 105 | }) 106 | }) 107 | 108 | api.get('/sendfile/no-last-modified', function(req,res) { 109 | res.sendFile('__tests__/test.txt', { 110 | lastModified: false 111 | }) 112 | }) 113 | 114 | api.get('/sendfile/no-cache-control', function(req,res) { 115 | res.sendFile('__tests__/test.txt', { 116 | cacheControl: false 117 | }) 118 | }) 119 | 120 | api.get('/sendfile/custom-cache-control', function(req,res) { 121 | res.sendFile('__tests__/test.txt', { 122 | cacheControl: 'no-cache, no-store' 123 | }) 124 | }) 125 | 126 | // S3 file 127 | api.get('/sendfile/s3', function(req,res) { 128 | 129 | stub.withArgs({Bucket: 'my-test-bucket', Key: 'test.txt'}).returns({ 130 | promise: () => { return { 131 | AcceptRanges: 'bytes', 132 | LastModified: new Date('2018-04-01T13:32:58.000Z'), 133 | ContentLength: 23, 134 | ETag: '"ae771fbbba6a74eeeb77754355831713"', 135 | ContentType: 'text/plain', 136 | Metadata: {}, 137 | Body: Buffer.from('Test file for sendFile\n') 138 | }} 139 | }) 140 | 141 | res.sendFile('s3://my-test-bucket/test.txt') 142 | }) 143 | 144 | api.get('/sendfile/s3path', function(req,res) { 145 | 146 | stub.withArgs({Bucket: 'my-test-bucket', Key: 'test/test.txt'}).returns({ 147 | promise: () => { return { 148 | AcceptRanges: 'bytes', 149 | LastModified: new Date('2018-04-01T13:32:58.000Z'), 150 | ContentLength: 23, 151 | ETag: '"ae771fbbba6a74eeeb77754355831713"', 152 | ContentType: 'text/plain', 153 | Metadata: {}, 154 | Body: Buffer.from('Test file for sendFile\n') 155 | }} 156 | }) 157 | 158 | res.sendFile('s3://my-test-bucket/test/test.txt') 159 | }) 160 | 161 | api.get('/sendfile/s3missing', function(req,res) { 162 | 163 | stub.withArgs({Bucket: 'my-test-bucket', Key: 'file-does-not-exist.txt'}) 164 | .throws(new Error("NoSuchKey: The specified key does not exist.")) 165 | 166 | res.sendFile('s3://my-test-bucket/file-does-not-exist.txt') 167 | }) 168 | 169 | api.get('/sendfile/s3-bad-path', function(req,res) { 170 | res.sendFile('s3://my-test-bucket') 171 | }) 172 | 173 | // Error Middleware 174 | api.use(function(err,req,res,next) { 175 | // Set x-error header to test middleware execution 176 | res.header('x-error','true') 177 | next() 178 | }) 179 | 180 | /******************************************************************************/ 181 | /*** BEGIN TESTS ***/ 182 | /******************************************************************************/ 183 | 184 | let stub 185 | 186 | describe('SendFile Tests:', function() { 187 | let setConfigSpy; 188 | 189 | beforeEach(function() { 190 | // Stub getObjectAsync 191 | stub = sinon.stub(S3,'getObject') 192 | setConfigSpy = sinon.spy(S3, 'setConfig'); 193 | }) 194 | 195 | it('Bad path', async function() { 196 | let _event = Object.assign({},event,{ path: '/sendfile/badpath' }) 197 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 198 | expect(result).toEqual({ multiValueHeaders: { 'content-type': ['application/json'],'x-error': ['true'] }, statusCode: 500, body: '{"error":"Invalid file"}', isBase64Encoded: false }) 199 | }) // end it 200 | 201 | it('Missing file', async function() { 202 | let _event = Object.assign({},event,{ path: '/sendfile' }) 203 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 204 | expect(result).toEqual({ multiValueHeaders: { 'content-type': ['application/json'],'x-error': ['true'] }, statusCode: 500, body: '{"error":"No such file"}', isBase64Encoded: false }) 205 | }) // end it 206 | 207 | it('Missing file with custom catch', async function() { 208 | let _event = Object.assign({},event,{ path: '/sendfile/err' }) 209 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 210 | expect(result).toEqual({ multiValueHeaders: { 'content-type': ['application/json'],'x-error': ['true'] }, statusCode: 404, body: '{"error":"There was an error accessing the requested file"}', isBase64Encoded: false }) 211 | }) // end it 212 | 213 | it('Text file w/ callback override (promise)', async function() { 214 | let _event = Object.assign({},event,{ path: '/sendfile/test' }) 215 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 216 | expect(result).toEqual({ 217 | multiValueHeaders: { 218 | 'content-type': ['text/plain'], 219 | 'cache-control': ['max-age=0'], 220 | 'expires': result.multiValueHeaders.expires, 221 | 'last-modified': result.multiValueHeaders['last-modified'] 222 | }, 223 | statusCode: 201, body: 'VGVzdCBmaWxlIGZvciBzZW5kRmlsZQo=', isBase64Encoded: true 224 | }) 225 | }) // end it 226 | 227 | it('Text file error w/ callback override (promise)', async function() { 228 | let _event = Object.assign({},event,{ path: '/sendfile/test', queryStringParameters: { test: 'x' } }) 229 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 230 | expect(result).toEqual({ multiValueHeaders: { 'content-type': ['application/json'],'x-error': ['true'] }, statusCode: 501, body: '{"error":"Custom File Error"}', isBase64Encoded: false }) 231 | }) // end it 232 | 233 | it('Text file error w/ callback override (promise - no end)', async function() { 234 | let _event = Object.assign({},event,{ path: '/sendfile/error', queryStringParameters: { test: 'x' } }) 235 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 236 | expect(result).toEqual({ multiValueHeaders: { 'content-type': ['application/json'],'x-error': ['true'] }, statusCode: 500, body: result.body, isBase64Encoded: false }) 237 | }) // end it 238 | 239 | it('Buffer Input', async function() { 240 | let _event = Object.assign({},event,{ path: '/sendfile/buffer' }) 241 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 242 | expect(result).toEqual({ 243 | multiValueHeaders: { 244 | 'content-type': ['application/json'], 245 | 'cache-control': ['max-age=0'], 246 | 'expires': result.multiValueHeaders.expires, 247 | 'last-modified': result.multiValueHeaders['last-modified'] 248 | }, statusCode: 200, body: 'VGVzdCBmaWxlIGZvciBzZW5kRmlsZQo=', isBase64Encoded: true 249 | }) 250 | }) // end it 251 | 252 | it('Text file w/ headers', async function() { 253 | let _event = Object.assign({},event,{ path: '/sendfile/headers' }) 254 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 255 | expect(result).toEqual({ 256 | multiValueHeaders: { 257 | 'content-type': ['text/plain'], 258 | 'x-test': ['test'], 259 | 'x-timestamp': [1], 260 | 'cache-control': ['max-age=0'], 261 | 'expires': result.multiValueHeaders.expires, 262 | 'last-modified': result.multiValueHeaders['last-modified'] 263 | }, statusCode: 200, body: 'VGVzdCBmaWxlIGZvciBzZW5kRmlsZQo=', isBase64Encoded: true 264 | }) 265 | }) // end it 266 | 267 | it('Text file w/ root path', async function() { 268 | let _event = Object.assign({},event,{ path: '/sendfile/root' }) 269 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 270 | expect(result).toEqual({ 271 | multiValueHeaders: { 272 | 'content-type': ['text/plain'], 273 | 'cache-control': ['max-age=0'], 274 | 'expires': result.multiValueHeaders.expires, 275 | 'last-modified': result.multiValueHeaders['last-modified'] 276 | }, statusCode: 200, body: 'VGVzdCBmaWxlIGZvciBzZW5kRmlsZQo=', isBase64Encoded: true 277 | }) 278 | }) // end it 279 | 280 | it('Text file w/ headers (private cache)', async function() { 281 | let _event = Object.assign({},event,{ path: '/sendfile/headers-private' }) 282 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 283 | expect(result).toEqual({ 284 | multiValueHeaders: { 285 | 'content-type': ['text/plain'], 286 | 'x-test': ['test'], 287 | 'x-timestamp': [1], 288 | 'cache-control': ['private, max-age=0'], 289 | 'expires': result.multiValueHeaders.expires, 290 | 'last-modified': result.multiValueHeaders['last-modified'] 291 | }, statusCode: 200, body: 'VGVzdCBmaWxlIGZvciBzZW5kRmlsZQo=', isBase64Encoded: true 292 | }) 293 | }) // end it 294 | 295 | it('Text file custom Last-Modified', async function() { 296 | let _event = Object.assign({},event,{ path: '/sendfile/last-modified' }) 297 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 298 | expect(result).toEqual({ 299 | multiValueHeaders: { 300 | 'content-type': ['text/plain'], 301 | 'cache-control': ['max-age=0'], 302 | 'expires': result.multiValueHeaders.expires, 303 | 'last-modified': ['Mon, 01 Jan 2018 00:00:00 GMT'] 304 | }, statusCode: 200, body: 'VGVzdCBmaWxlIGZvciBzZW5kRmlsZQo=', isBase64Encoded: true 305 | }) 306 | }) // end it 307 | 308 | 309 | it('Text file no Last-Modified', async function() { 310 | let _event = Object.assign({},event,{ path: '/sendfile/no-last-modified' }) 311 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 312 | expect(result).toEqual({ 313 | multiValueHeaders: { 314 | 'content-type': ['text/plain'], 315 | 'cache-control': ['max-age=0'], 316 | 'expires': result.multiValueHeaders.expires 317 | }, statusCode: 200, body: 'VGVzdCBmaWxlIGZvciBzZW5kRmlsZQo=', isBase64Encoded: true 318 | }) 319 | }) // end it 320 | 321 | 322 | it('Text file no Cache-Control', async function() { 323 | let _event = Object.assign({},event,{ path: '/sendfile/no-cache-control' }) 324 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 325 | expect(result).toEqual({ 326 | multiValueHeaders: { 327 | 'content-type': ['text/plain'], 328 | 'last-modified': result.multiValueHeaders['last-modified'] 329 | }, statusCode: 200, body: 'VGVzdCBmaWxlIGZvciBzZW5kRmlsZQo=', isBase64Encoded: true 330 | }) 331 | }) // end it 332 | 333 | 334 | it('Text file custom Cache-Control', async function() { 335 | let _event = Object.assign({},event,{ path: '/sendfile/custom-cache-control' }) 336 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 337 | expect(result).toEqual({ 338 | multiValueHeaders: { 339 | 'content-type': ['text/plain'], 340 | 'cache-control': ['no-cache, no-store'], 341 | 'last-modified': result.multiValueHeaders['last-modified'] 342 | }, statusCode: 200, body: 'VGVzdCBmaWxlIGZvciBzZW5kRmlsZQo=', isBase64Encoded: true 343 | }) 344 | }) // end it 345 | 346 | 347 | it('S3 file', async function() { 348 | let _event = Object.assign({},event,{ path: '/sendfile/s3' }) 349 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 350 | sinon.assert.notCalled(setConfigSpy); 351 | expect(result).toEqual({ 352 | multiValueHeaders: { 353 | 'content-type': ['text/plain'], 354 | 'cache-control': ['max-age=0'], 355 | 'expires': result.multiValueHeaders['expires'], 356 | 'etag': ['"ae771fbbba6a74eeeb77754355831713"'], 357 | 'last-modified': result.multiValueHeaders['last-modified'] 358 | }, statusCode: 200, body: 'VGVzdCBmaWxlIGZvciBzZW5kRmlsZQo=', isBase64Encoded: true 359 | }) 360 | }) // end it 361 | 362 | it('S3 file w/ custom config',async function() { 363 | const s3Config = { 364 | endpoint: "http://test" 365 | } 366 | const apiWithConfig = require('../index')({ version: 'v1.0', mimeTypes: { test: 'text/test' }, s3Config}) 367 | sinon.assert.calledWith(setConfigSpy, s3Config); 368 | }) // end it 369 | 370 | it('S3 file w/ nested path', async function() { 371 | let _event = Object.assign({},event,{ path: '/sendfile/s3path' }) 372 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 373 | expect(result).toEqual({ 374 | multiValueHeaders: { 375 | 'content-type': ['text/plain'], 376 | 'cache-control': ['max-age=0'], 377 | 'expires': result.multiValueHeaders['expires'], 378 | 'etag': ['"ae771fbbba6a74eeeb77754355831713"'], 379 | 'last-modified': result.multiValueHeaders['last-modified'] 380 | }, statusCode: 200, body: 'VGVzdCBmaWxlIGZvciBzZW5kRmlsZQo=', isBase64Encoded: true 381 | }) 382 | }) // end it 383 | 384 | it('S3 file error',async function() { 385 | let _event = Object.assign({},event,{ path: '/sendfile/s3missing' }) 386 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 387 | expect(result).toEqual({ 388 | multiValueHeaders: { 389 | 'content-type': ['application/json'], 390 | 'x-error': ['true'] 391 | }, statusCode: 500, body: '{"error":"NoSuchKey: The specified key does not exist."}', isBase64Encoded: false 392 | }) 393 | }) // end it 394 | 395 | 396 | it('S3 bad path error',async function() { 397 | let _event = Object.assign({},event,{ path: '/sendfile/s3-bad-path' }) 398 | let result = await new Promise(r => api.run(_event,{},(e,res) => { r(res) })) 399 | expect(result).toEqual({ 400 | multiValueHeaders: { 401 | 'content-type': ['application/json'], 402 | 'x-error': ['true'] 403 | }, statusCode: 500, body: '{"error":"Invalid S3 path"}', isBase64Encoded: false 404 | }) 405 | }) // end it 406 | 407 | 408 | afterEach(function() { 409 | stub.restore() 410 | setConfigSpy.restore(); 411 | }) 412 | 413 | }) // end sendFile tests 414 | --------------------------------------------------------------------------------