Skip to content

Commit

Permalink
Merge pull request #3 from Giftbit/MultiValueSupport
Browse files Browse the repository at this point in the history
Multi value support
  • Loading branch information
pushplay committed Oct 22, 2018
2 parents 04a237d + 5a0859a commit 516a71f
Show file tree
Hide file tree
Showing 10 changed files with 272 additions and 331 deletions.
446 changes: 141 additions & 305 deletions package-lock.json

Large diffs are not rendered by default.

20 changes: 10 additions & 10 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "cassava",
"version": "2.2.2",
"version": "2.3.0",
"description": "AWS API Gateway Router",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down Expand Up @@ -37,22 +37,22 @@
},
"homepage": "https://github.com/Giftbit/cassava#readme",
"devDependencies": {
"@types/aws-lambda": "8.10.8",
"@types/chai": "^4.1.4",
"@types/aws-lambda": "^8.10.13",
"@types/chai": "^4.1.6",
"@types/cookie": "^0.3.1",
"@types/mocha": "^5.2.5",
"@types/negotiator": "^0.6.1",
"@types/node": "^10.5.2",
"@types/uuid": "^3.4.2",
"chai": "^4.1.2",
"gh-pages": "^1.2.0",
"@types/node": "^10.12.0",
"@types/uuid": "^3.4.4",
"chai": "^4.2.0",
"gh-pages": "^2.0.1",
"mocha": "^5.2.0",
"rimraf": "^2.6.2",
"touch": "^3.1.0",
"ts-node": "^7.0.0",
"ts-node": "^7.0.1",
"tslint": "^5.11.0",
"typedoc": "^0.11.1",
"typescript": "^2.9.2"
"typedoc": "^0.13.0",
"typescript": "^3.1.3"
},
"dependencies": {
"cookie": "^0.3.1",
Expand Down
14 changes: 12 additions & 2 deletions src/ProxyEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,25 @@ export interface ProxyEvent {
httpMethod: string;

/**
* All headers of the request.
* All headers of the request with only their first value.
*/
headers: { [key: string]: string } | null;

/**
* The parsed URI query parameters.
* All headers of the request including all values.
*/
multiValueHeaders: { [key: string]: string[] } | null;

/**
* The parsed URI query parameters with only their first value.
*/
queryStringParameters: { [key: string]: string } | null;

/**
* The parsed URI query parameters including all values.
*/
multiValueQueryStringParameters: { [key: string]: string[] } | null;

/**
* The parsed URI path parameters.
*/
Expand Down
7 changes: 7 additions & 0 deletions src/Router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,12 @@ export class Router {

r.context = evt.context;
r.headers = evt.headers || {};
r.multiValueHeaders = evt.multiValueHeaders || {};
r.httpMethod = evt.httpMethod;
r.meta = {};
r.path = this.proxyPathToRouterPath(evt.path);
r.queryStringParameters = evt.queryStringParameters || {};
r.multiValueQueryStringParameters = evt.multiValueQueryStringParameters || {};
r.pathParameters = evt.pathParameters || {};
r.stageVariables = evt.stageVariables || {};

Expand All @@ -165,6 +167,11 @@ export class Router {
r.headersLowerCase[headerKey.toLowerCase()] = r.headers[headerKey];
}

r.multiValueHeadersLowerCase = {};
for (const headerKey of Object.keys(r.multiValueHeaders)) {
r.multiValueHeadersLowerCase[headerKey.toLowerCase()] = r.multiValueHeaders[headerKey];
}

if (typeof evt.body === "string" && (!r.headersLowerCase["content-type"] || /(application|text)\/(x-)?json/.test(r.headersLowerCase["content-type"]))) {
try {
if ((evt as ProxyEvent).isBase64Encoded) {
Expand Down
23 changes: 20 additions & 3 deletions src/RouterEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,29 @@ export class RouterEvent {
body: any;

/**
* All headers of the request. They are stored here in their original
* All headers of the request with only the first value. They are stored here in their original
* form but the spec requires that header keys are treated as case-insensitive.
* Use `headersLowerCase` for easier retrieval.
*/
headers: { [key: string]: string };

/**
* All headers of the request with keys in lower case.
* All headers of the request with only the first value, keys in lower case.
*/
headersLowerCase: { [key: string]: string };

/**
* All headers of the request including all values. They are stored here in their original
* form but the spec requires that header keys are treated as case-insensitive.
* Use `multiValueHeadersLowerCase` for easier retrieval.
*/
multiValueHeaders: { [key: string]: string[] };

/**
* All headers of the request including all values, keys in lower case.
*/
multiValueHeadersLowerCase: { [key: string]: string[] };

/**
* GET, POST, PUT, etc...
*/
Expand All @@ -78,10 +90,15 @@ export class RouterEvent {
pathParameters: { [key: string]: string };

/**
* The parsed URI query parameters.
* The parsed URI query parameters with only their first value.
*/
queryStringParameters: { [key: string]: string };

/**
* The parsed URI query parameters including all values.
*/
multiValueQueryStringParameters: { [key: string]: string[] };

/**
* Configuration attributes associated with a deployment stage of an API.
*/
Expand Down
10 changes: 10 additions & 0 deletions src/routes/LoggingRoute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ describe("LoggingRoute", () => {
Accept: "text/plain",
Host: "www.example.com",
Origin: "http://www.example.com"
},
multiValueHeaders: {
Accept: ["text/plain"],
Host: ["www.example.com"],
Origin: ["http://www.example.com"]
}
}));
chai.assert.equal(msgs.length, 2);
Expand All @@ -70,6 +75,11 @@ describe("LoggingRoute", () => {
Accept: "text/plain",
Host: "www.example.com",
Origin: "http://www.example.com"
},
multiValueHeaders: {
Accept: ["text/plain"],
Host: ["www.example.com"],
Origin: ["http://www.example.com"]
}
}));
chai.assert.equal(msgs.length, 2);
Expand Down
25 changes: 19 additions & 6 deletions src/routes/LoggingRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ export class LoggingRoute implements Route {
}

requestToString(evt: RouterEvent): string {
let msg = `${evt.httpMethod} ${evt.path}${this.queryMapToString(evt.queryStringParameters)}`;
let msg = `${evt.httpMethod} ${evt.path}${this.queryMapToString(evt.multiValueQueryStringParameters || evt.queryStringParameters)}`;
if (!this.options.hideRequestBody && evt.body != null) {
msg += ` reqbody=${JSON.stringify(evt.body)}`;
}
if (this.options.logRequestHeaders) {
msg += ` reqheaders={${this.headersToString(evt.headers, this.options.logRequestHeaders)}}`;
msg += ` reqheaders={${this.headersToString(evt.multiValueHeaders || evt.headers, this.options.logRequestHeaders)}}`;
}
return msg;
}
Expand All @@ -46,25 +46,38 @@ export class LoggingRoute implements Route {
return msg;
}

queryMapToString(queryStringParameters: { [key: string]: string } | null): string {
queryMapToString(queryStringParameters: { [key: string]: string | string[] } | null): string {
if (!queryStringParameters) {
return "";
}
const keys = Object.keys(queryStringParameters);
if (!keys.length) {
return "";
}
return "?" + keys.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(queryStringParameters[key])}`).join("&");

return "?" + keys.map(key => {
const value = queryStringParameters[key];
if (Array.isArray(value)) {
return value.map(value => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join("&");
}
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
}).join("&");
}

headersToString(headers: { [key: string]: string }, headersFilter: true | string[]): string {
headersToString(headers: { [key: string]: string | string[] }, headersFilter: true | string[]): string {
let keys = Object.keys(headers);
if (Array.isArray(headersFilter)) {
const filterLowerCase = headersFilter.map(h => h.toLowerCase());
keys = keys.filter(key => filterLowerCase ? filterLowerCase.indexOf(key.toLowerCase()) !== -1 : true);
}

return keys.map(key => `${key}=${headers[key]}`).join(", ");
return keys.map(key => {
const value = headers[key];
if (Array.isArray(value)) {
return value.map(value => `${key}=${value}`).join(", ");
}
return `${key}=${value}`;
}).join(", ");
}

log(msg: string): void {
Expand Down
4 changes: 2 additions & 2 deletions src/routes/ProxyRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ export class ProxyRoute implements Route {
headers: {...evt.headers, ...this.config.additionalHeaders},
path: this.parsedDest.path + evt.path.substr(this.config.srcPath.length)
};
if (evt.queryStringParameters) {
const q = querystring.stringify(evt.queryStringParameters);
if (evt.multiValueQueryStringParameters) {
const q = querystring.stringify(evt.multiValueQueryStringParameters);
if (q.length) {
reqArgs.path += "?" + q;
}
Expand Down
27 changes: 27 additions & 0 deletions src/testing/createTestProxyEvent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ describe("createTestProxyEvent", () => {
chai.assert.equal(evt.context.httpMethod, "HEAD");
});

it("generates null queryStringParams where there is no query string", () => {
const evt = createTestProxyEvent("https://www.example.com");

chai.assert.isNull(evt.queryStringParameters);
chai.assert.isNull(evt.multiValueQueryStringParameters);
});

it("correctly parses and decodes the query string", () => {
const evt = createTestProxyEvent("https://www.example.com/search/?q=a%2B-b&d=2017-06-29T18%3A58%3A56.832Z&exp=a%20%26%26%20(b%20%7C%7C%20c)%20%3D%3D%20d");

Expand All @@ -28,9 +35,29 @@ describe("createTestProxyEvent", () => {
d: "2017-06-29T18:58:56.832Z",
exp: "a && (b || c) == d"
});
chai.assert.deepEqual(evt.multiValueQueryStringParameters, {
q: ["a+-b"],
d: ["2017-06-29T18:58:56.832Z"],
exp: ["a && (b || c) == d"]
});
chai.assert.equal(evt.context.httpMethod, "GET");
});

it("correctly generates multiValueQueryStringParameters", async () => {
const evt = createTestProxyEvent("https://www.example.com/foo?a=1&a=2&a=3&b=four");

chai.assert.equal(evt.httpMethod, "GET");
chai.assert.equal(evt.path, "/foo");
chai.assert.deepEqual(evt.queryStringParameters, {
a: "3",
b: "four"
});
chai.assert.deepEqual(evt.multiValueQueryStringParameters, {
a: ["1", "2", "3"],
b: ["four"]
});
});

it("generates a different requestId each time", () => {
const evt1 = createTestProxyEvent();
const evt2 = createTestProxyEvent();
Expand Down
27 changes: 24 additions & 3 deletions src/testing/createTestProxyEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ const defaultTestProxyEvent: ProxyEvent = {
path: "/",
httpMethod: "GET",
headers: null,
multiValueHeaders: null,
queryStringParameters: null,
multiValueQueryStringParameters: null,
pathParameters: null,
stageVariables: null,
context: {
Expand Down Expand Up @@ -53,6 +55,7 @@ const defaultTestProxyEvent: ProxyEvent = {
*/
export function createTestProxyEvent(url: string = "/", method: string = "GET", overrides: Partial<ProxyEvent> = {}): ProxyEvent {
const heavyUrl = new URL(url, "https://example.org/");
const mixedQueryStringParams: { [key: string]: string | string[] } = heavyUrl.search ? querystring.parse(heavyUrl.search.substring(1)) : null;

return {
...defaultTestProxyEvent,
Expand All @@ -65,16 +68,17 @@ export function createTestProxyEvent(url: string = "/", method: string = "GET",
},
httpMethod: method,
path: heavyUrl.pathname,
queryStringParameters: deduplicateQueryStringParameters(heavyUrl.search ? querystring.parse(heavyUrl.search.substring(1)) : null)
queryStringParameters: deduplicateQueryStringParameters(mixedQueryStringParams),
multiValueQueryStringParameters: normalizeMMultiValueQueryStringParameters(mixedQueryStringParams)
};
}

/**
* Turn a query params object that allows for duplicate query string values into one that doesn't.
* see: https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-known-issues.html
* Empirically API Gateway takes the last version on duplicates.
*/
function deduplicateQueryStringParameters(params: { [key: string]: string | string[] }): { [key: string]: string } {
if (!params) {
if (params == null) {
return null;
}

Expand All @@ -89,3 +93,20 @@ function deduplicateQueryStringParameters(params: { [key: string]: string | stri
}
return queryStringParameters;
}

function normalizeMMultiValueQueryStringParameters(params: { [key: string]: string | string[] }): { [key: string]: string[] } {
if (params == null) {
return null;
}

const multiValueQueryStringParameters: { [key: string]: string[] } = {};
for (const key in params) {
const value = params[key];
if (Array.isArray(value)) {
multiValueQueryStringParameters[key] = value;
} else {
multiValueQueryStringParameters[key] = [value];
}
}
return multiValueQueryStringParameters;
}

0 comments on commit 516a71f

Please sign in to comment.