diff --git a/README.md b/README.md index f9fee9e..e1b6c94 100644 --- a/README.md +++ b/README.md @@ -390,8 +390,9 @@ The `REQUEST` object contains a parsed and normalized request from API Gateway. - `path`: The path passed in by the request including the `base` and any `prefix` assigned to routes - `query`: Querystring parameters parsed into an object - `multiValueQuery`: Querystring parameters with multiple values parsed into an object with array values -- `headers`: An object containing the request headers (properties converted to lowercase for HTTP/2, see [rfc7540 8.1.2. HTTP Header Fields](https://tools.ietf.org/html/rfc7540)) +- `headers`: An object containing the request headers (properties converted to lowercase for HTTP/2, see [rfc7540 8.1.2. HTTP Header Fields](https://tools.ietf.org/html/rfc7540)). Note that multi-value headers are concatenated with a comma per [rfc2616 4.2. Message Headers](https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2). - `rawHeaders`: An object containing the original request headers (property case preserved) +- `multiValueHeaders`: An object containing header values as multi-value arrays - `body`: The body of the request. If the `isBase64Encoded` flag is `true`, it will be decoded automatically. - If the `content-type` header is `application/json`, it will attempt to parse the request using `JSON.parse()` - If the `content-type` header is `application/x-www-form-urlencoded`, it will attempt to parse a URL encoded string using `querystring` diff --git a/lib/request.js b/lib/request.js index 44511d9..da51246 100644 --- a/lib/request.js +++ b/lib/request.js @@ -82,15 +82,21 @@ class REQUEST { .reduce((qs,key) => Object.assign(qs, { [key]: [this.query[key]] }), {}), this.app._event.multiValueQueryStringParameters) - // Set the raw headers - this.rawHeaders = this.app._event.headers || {} - // this.rawHeaders = this._multiValueSupport ? this.app._event.multiValueHeaders - // : this.app._event.headers + // Set the raw headers (normalize multi-values) + // per https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 + this.rawHeaders = this._multiValueSupport ? + Object.keys(this.app._event.multiValueHeaders).reduce((headers,key) => + Object.assign(headers,{ [key]: UTILS.fromArray(this.app._event.multiValueHeaders[key]) }),{}) + : this.app._event.headers || {} // Set the headers to lowercase this.headers = Object.keys(this.rawHeaders).reduce((acc,header) => Object.assign(acc,{[header.toLowerCase()]:this.rawHeaders[header]}), {}) + this.multiValueHeaders = this._multiValueSupport ? this.app._event.multiValueHeaders + : Object.keys(this.headers).reduce((headers,key) => + Object.assign(headers,{ [key.toLowerCase()]: [this.headers[key]] }),{}) + // Extract user agent this.userAgent = this.headers['user-agent'] diff --git a/lib/utils.js b/lib/utils.js index 7bd4270..8071760 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -134,3 +134,7 @@ exports.deepMerge = (a,b) => { this.deepMerge(a[key],b[key]) : Object.assign(a,b) ) return a } + +// Concats values from an array to ',' separated string +exports.fromArray = val => + val && val instanceof Array ? val.toString() : undefined diff --git a/test/requests.js b/test/requests.js index b3619e2..357b8a6 100644 --- a/test/requests.js +++ b/test/requests.js @@ -29,6 +29,7 @@ describe('Request Tests:', function() { let result = await new Promise(r => api.run(_event,_context,(e,res) => { r(res) })) let body = JSON.parse(result.body) // console.log(body); + // console.log(body.request.multiValueHeaders); expect(result.headers).to.deep.equal({ 'content-type': 'application/json' }) expect(body).to.have.property('request') expect(body.request.id).is.not.null @@ -46,12 +47,15 @@ describe('Request Tests:', function() { expect(body.request.query.qs2).to.equal('bar') expect(body.request.multiValueQuery.qs2).to.deep.equal(['foo','bar']) expect(body.request.multiValueQuery.qs3).to.deep.equal(['bat','baz']) + expect(body.request.headers['test-header']).to.equal('val1,val2') + expect(body.request.multiValueHeaders['test-header']).to.deep.equal(['val1','val2']) }) it('Missing X-Forwarded-For (sourceIp fallback)', async function() { let _event = require('./sample-event-apigateway1.json') let _context = require('./sample-context-apigateway1.json') delete _event.headers['X-Forwarded-For'] // remove the header + delete _event.multiValueHeaders['x-forwarded-for'] // remove the header let result = await new Promise(r => api.run(_event,_context,(e,res) => { r(res) })) let body = JSON.parse(result.body) expect(result.headers).to.deep.equal({ 'content-type': 'application/json' }) @@ -71,6 +75,8 @@ describe('Request Tests:', function() { expect(body.request.query.qs2).to.equal('bar') expect(body.request.multiValueQuery.qs2).to.deep.equal(['foo','bar']) expect(body.request.multiValueQuery.qs3).to.deep.equal(['bat','baz']) + expect(body.request.headers['test-header']).to.equal('val1,val2') + expect(body.request.multiValueHeaders['test-header']).to.deep.equal(['val1','val2']) // console.log(body); }) }) @@ -94,12 +100,40 @@ describe('Request Tests:', function() { expect(body.request.clientCountry).to.equal('unknown') expect(body.request.route).to.equal('/test/hello') expect(body.request.query.qs1).to.equal('foo') + expect(body.request.multiValueQuery.qs1).to.deep.equal(['foo']) + console.log(body.request.multiValueHeaders) // expect(body.request.query.qs2).to.equal('bar') // expect(body.request.multiValueQuery.qs2).to.deep.equal(['foo','bar']) // expect(body.request.multiValueQuery.qs3).to.deep.equal(['bat','baz']) }) + + it('With multi-value support', async function() { + let _event = require('./sample-event-alb2.json') + let _context = require('./sample-context-alb1.json') + let result = await new Promise(r => api.run(_event,_context,(e,res) => { r(res) })) + let body = JSON.parse(result.body) + // console.log(body); + expect(result.headers).to.deep.equal({ 'content-type': 'application/json' }) + expect(body).to.have.property('request') + expect(body.request.id).is.not.null + expect(body.request.interface).to.equal('alb') + expect(body.request.userAgent).to.equal('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') + expect(body.request).to.have.property('requestContext') + expect(body.request.ip).to.equal('192.168.100.1') + expect(body.request.isBase64Encoded).to.equal(true) + expect(body.request.clientType).to.equal('unknown') + expect(body.request.clientCountry).to.equal('unknown') + expect(body.request.route).to.equal('/test/hello') + expect(body.request.query.qs1).to.equal('foo') + expect(body.request.multiValueQuery.qs1).to.deep.equal(['foo']) + expect(body.request.multiValueQuery.qs2).to.deep.equal(['foo','bar']) + expect(body.request.multiValueQuery.qs3).to.deep.equal(['foo','bar','bat']) + expect(body.request.headers['test-header']).to.equal('val1,val2') + expect(body.request.multiValueHeaders['test-header']).to.deep.equal(['val1','val2']) + }) + }) }) // end Request tests diff --git a/test/sample-event-alb2.json b/test/sample-event-alb2.json new file mode 100644 index 0000000..5ac3384 --- /dev/null +++ b/test/sample-event-alb2.json @@ -0,0 +1,29 @@ +{ + "requestContext": { + "elb": { + "targetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:XXXXXXXXXX:targetgroup/Test-ALB-Lambda/XXXXXXX" + } + }, + "httpMethod": "GET", + "path": "/test/hello", + "multiValueQueryStringParameters": { + "qs1": [ "foo" ], + "qs2": [ "foo", "bar" ], + "qs3": [ "foo", "bar", "bat" ] + }, + "multiValueHeaders": { + "accept": ["text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"], + "accept-encoding": ["br, gzip, deflate"], + "accept-language": ["en-us"], + "cookie": [""], + "host": ["wt6mne2s9k.execute-api.us-west-2.amazonaws.com"], + "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"], + "x-amzn-trace-id": ["Root=1-5c1db69f-XXXXXXXXXXX"], + "x-forwarded-for": ["192.168.100.1"], + "x-forwarded-port": ["443"], + "x-forwarded-proto": ["https"], + "test-header": ["val1","val2"] + }, + "body": "", + "isBase64Encoded": true +} diff --git a/test/sample-event-apigateway1.json b/test/sample-event-apigateway1.json index cad04ef..bafa651 100644 --- a/test/sample-event-apigateway1.json +++ b/test/sample-event-apigateway1.json @@ -19,6 +19,26 @@ "X-Forwarded-Port": "443", "X-Forwarded-Proto": "https" }, + "multiValueHeaders": { + "accept": ["text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"], + "accept-encoding": ["gzip, deflate, lzma, sdch, br"], + "accept-language": ["en-US,en;q=0.8"], + "cloudfront-forwarded-proto": ["https"], + "cloudfront-is-desktop-viewer": ["true"], + "cloudfront-is-mobile-viewer": ["false"], + "cloudfront-is-smarttv-viewer": ["false"], + "cloudfront-is-tablet-viewer": ["false"], + "cloudfront-viewer-country": ["US"], + "host": ["wt6mne2s9k.execute-api.us-west-2.amazonaws.com"], + "upgrade-insecure-requests": ["1"], + "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"], + "via": ["1.1 fb7cca60f0ecd82ce07790c9c5eef16c.cloudfront.net (CloudFront)"], + "x-amz-cf-id": ["nBsWBOrSHMgnaROZJK1wGCZ9PcRcSpq_oSXZNQwQ10OTZL4cimZo3g=="], + "x-forwarded-for": ["192.168.100.1, 192.168.1.1"], + "x-forwarded-port": ["443"], + "x-forwarded-proto": ["https"], + "test-header": ["val1","val2"] + }, "pathParameters": { "proxy": "hello" }, @@ -52,7 +72,7 @@ }, "multiValueQueryStringParameters": { "qs2": [ "foo", "bar" ], - "qs3": [ "bat", "baz" ] + "qs3": [ "bat", "baz" ] }, "stageVariables": { "stageVarName": "stageVarValue"