Skip to content

Commit

Permalink
Fix #1325 Added support for dynamic custom paths (#1785)
Browse files Browse the repository at this point in the history
  • Loading branch information
Jeffrey Baldwin authored Mar 31, 2023
1 parent 72a79e4 commit 4f9a106
Show file tree
Hide file tree
Showing 7 changed files with 314 additions and 19 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"@types/tsscmp": "^1.0.0",
"axios": "^0.27.2",
"express": "^4.16.4",
"path-to-regexp": "^6.2.1",
"please-upgrade-node": "^3.2.0",
"promise.allsettled": "^1.0.2",
"raw-body": "^2.3.3",
Expand Down
116 changes: 112 additions & 4 deletions src/receivers/HTTPReceiver.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { Logger, LogLevel } from '@slack/logger';
import { EventEmitter } from 'events';
import { InstallProvider } from '@slack/oauth';
import { IncomingMessage, ServerResponse } from 'http';
import { match } from 'path-to-regexp';
import { ParamsDictionary } from 'express-serve-static-core';
import { Override, mergeOverrides } from '../test-helpers';
import {
AppInitializationError,
Expand Down Expand Up @@ -485,6 +487,7 @@ describe('HTTPReceiver', function () {
it('should call custom route handler only if request matches route path and method', async function () {
const HTTPReceiver = await importHTTPReceiver();
const customRoutes = [{ path: '/test', method: ['get', 'POST'], handler: sinon.fake() }];
const matchRegex = match(customRoutes[0].path, { decode: decodeURIComponent });
const receiver = new HTTPReceiver({
clientSecret: 'my-client-secret',
signingSecret: 'secret',
Expand All @@ -495,14 +498,17 @@ describe('HTTPReceiver', function () {
const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse;

fakeReq.url = '/test';
const tempMatch = matchRegex(fakeReq.url);
if (!tempMatch) throw new Error('match failed');
const params : ParamsDictionary = tempMatch.params as ParamsDictionary;

fakeReq.method = 'GET';
receiver.requestListener(fakeReq, fakeRes);
assert(customRoutes[0].handler.calledWith(fakeReq, fakeRes));
assert(customRoutes[0].handler.calledWith({ ...fakeReq, params }, fakeRes));

fakeReq.method = 'POST';
receiver.requestListener(fakeReq, fakeRes);
assert(customRoutes[0].handler.calledWith(fakeReq, fakeRes));
assert(customRoutes[0].handler.calledWith({ ...fakeReq, params }, fakeRes));

fakeReq.method = 'UNHANDLED_METHOD';
assert.throws(() => receiver.requestListener(fakeReq, fakeRes), HTTPReceiverDeferredRequestError);
Expand All @@ -511,6 +517,7 @@ describe('HTTPReceiver', function () {
it('should call custom route handler only if request matches route path and method, ignoring query params', async function () {
const HTTPReceiver = await importHTTPReceiver();
const customRoutes = [{ path: '/test', method: ['get', 'POST'], handler: sinon.fake() }];
const matchRegex = match(customRoutes[0].path, { decode: decodeURIComponent });
const receiver = new HTTPReceiver({
clientSecret: 'my-client-secret',
signingSecret: 'secret',
Expand All @@ -521,14 +528,115 @@ describe('HTTPReceiver', function () {
const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse;

fakeReq.url = '/test?hello=world';
const tempMatch = matchRegex('/test');
if (!tempMatch) throw new Error('match failed');
const params : ParamsDictionary = tempMatch.params as ParamsDictionary;

fakeReq.method = 'GET';
receiver.requestListener(fakeReq, fakeRes);
assert(customRoutes[0].handler.calledWith(fakeReq, fakeRes));
assert(customRoutes[0].handler.calledWith({ ...fakeReq, params }, fakeRes));

fakeReq.method = 'POST';
receiver.requestListener(fakeReq, fakeRes);
assert(customRoutes[0].handler.calledWith(fakeReq, fakeRes));
assert(customRoutes[0].handler.calledWith({ ...fakeReq, params }, fakeRes));

fakeReq.method = 'UNHANDLED_METHOD';
assert.throws(() => receiver.requestListener(fakeReq, fakeRes), HTTPReceiverDeferredRequestError);
});

it('should call custom route handler only if request matches route path and method including params', async function () {
const HTTPReceiver = await importHTTPReceiver();
const customRoutes = [{ path: '/test/:id', method: ['get', 'POST'], handler: sinon.fake() }];
const matchRegex = match(customRoutes[0].path, { decode: decodeURIComponent });
const receiver = new HTTPReceiver({
clientSecret: 'my-client-secret',
signingSecret: 'secret',
customRoutes,
});

const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage;
const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse;

fakeReq.url = '/test/123';
const tempMatch = matchRegex(fakeReq.url);
if (!tempMatch) throw new Error('match failed');
const params : ParamsDictionary = tempMatch.params as ParamsDictionary;

fakeReq.method = 'GET';
receiver.requestListener(fakeReq, fakeRes);
assert(customRoutes[0].handler.calledWith({ ...fakeReq, params }, fakeRes));

fakeReq.method = 'POST';
receiver.requestListener(fakeReq, fakeRes);
assert(customRoutes[0].handler.calledWith({ ...fakeReq, params }, fakeRes));

fakeReq.method = 'UNHANDLED_METHOD';
assert.throws(() => receiver.requestListener(fakeReq, fakeRes), HTTPReceiverDeferredRequestError);
});

it('should call custom route handler only if request matches multiple route paths and method including params', async function () {
const HTTPReceiver = await importHTTPReceiver();
const customRoutes = [
{ path: '/test/123', method: ['get', 'POST'], handler: sinon.fake() },
{ path: '/test/:id', method: ['get', 'POST'], handler: sinon.fake() },
];
const matchRegex = match(customRoutes[0].path, { decode: decodeURIComponent });
const receiver = new HTTPReceiver({
clientSecret: 'my-client-secret',
signingSecret: 'secret',
customRoutes,
});

const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage;
const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse;

fakeReq.url = '/test/123';
const tempMatch = matchRegex(fakeReq.url);
if (!tempMatch) throw new Error('match failed');
const params : ParamsDictionary = tempMatch.params as ParamsDictionary;

fakeReq.method = 'GET';
receiver.requestListener(fakeReq, fakeRes);
assert(customRoutes[0].handler.calledWith({ ...fakeReq, params }, fakeRes));
assert(customRoutes[1].handler.notCalled);

fakeReq.method = 'POST';
receiver.requestListener(fakeReq, fakeRes);
assert(customRoutes[0].handler.calledWith({ ...fakeReq, params }, fakeRes));

fakeReq.method = 'UNHANDLED_METHOD';
assert.throws(() => receiver.requestListener(fakeReq, fakeRes), HTTPReceiverDeferredRequestError);
});

it('should call custom route handler only if request matches multiple route paths and method including params reverse order', async function () {
const HTTPReceiver = await importHTTPReceiver();
const customRoutes = [
{ path: '/test/:id', method: ['get', 'POST'], handler: sinon.fake() },
{ path: '/test/123', method: ['get', 'POST'], handler: sinon.fake() },
];
const matchRegex = match(customRoutes[0].path, { decode: decodeURIComponent });
const receiver = new HTTPReceiver({
clientSecret: 'my-client-secret',
signingSecret: 'secret',
customRoutes,
});

const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage;
const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse;

fakeReq.url = '/test/123';
const tempMatch = matchRegex(fakeReq.url);
if (!tempMatch) throw new Error('match failed');
const params : ParamsDictionary = tempMatch.params as ParamsDictionary;

fakeReq.method = 'GET';
receiver.requestListener(fakeReq, fakeRes);
assert(customRoutes[0].handler.calledWith({ ...fakeReq, params }, fakeRes));
assert(customRoutes[1].handler.notCalled);

fakeReq.method = 'POST';
receiver.requestListener(fakeReq, fakeRes);
assert(customRoutes[0].handler.calledWith({ ...fakeReq, params }, fakeRes));

fakeReq.method = 'UNHANDLED_METHOD';
assert.throws(() => receiver.requestListener(fakeReq, fakeRes), HTTPReceiverDeferredRequestError);
Expand Down
22 changes: 19 additions & 3 deletions src/receivers/HTTPReceiver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { ListenOptions } from 'net';
import { Logger, ConsoleLogger, LogLevel } from '@slack/logger';
import { InstallProvider, CallbackOptions, InstallProviderOptions, InstallURLOptions, InstallPathOptions } from '@slack/oauth';
import { URL } from 'url';

import { match } from 'path-to-regexp';
import { ParamsDictionary } from 'express-serve-static-core';
import { ParamsIncomingMessage } from './ParamsIncomingMessage';
import { verifyRedirectOpts } from './verify-redirect-opts';
import App from '../App';
import { Receiver, ReceiverEvent } from '../types';
Expand Down Expand Up @@ -392,8 +394,22 @@ export default class HTTPReceiver implements Receiver {

// Handle custom routes
if (Object.keys(this.routes).length) {
const match = this.routes[path] && this.routes[path][method] !== undefined;
if (match) { return this.routes[path][method](req, res); }
// Check if the request matches any of the custom routes
let pathMatch : string | boolean = false;
let params : ParamsDictionary = {};
Object.keys(this.routes).forEach((route) => {
if (pathMatch) return;
const matchRegex = match(route, { decode: decodeURIComponent });
const tempMatch = matchRegex(path);
if (tempMatch) {
pathMatch = route;
params = tempMatch.params as ParamsDictionary;
}
});
const urlMatch = pathMatch && this.routes[pathMatch][method] !== undefined;
if (urlMatch && pathMatch) {
return this.routes[pathMatch][method]({ ...req, params } as ParamsIncomingMessage, res);
}
}

// If the request did not match the previous conditions, an error is thrown. The error can be caught by
Expand Down
13 changes: 13 additions & 0 deletions src/receivers/ParamsIncomingMessage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { IncomingMessage } from 'http';
import { ParamsDictionary } from 'express-serve-static-core';

export interface ParamsIncomingMessage extends IncomingMessage {
/**
* **Only valid for requests with path parameters.**
*
* The path parameters of the request. For example, if the request URL is
* `/users/123`, and the route definition is `/users/:id`
* then `request.params` will be `{ id: '123' }`.
*/
params?: ParamsDictionary;
}
Loading

0 comments on commit 4f9a106

Please sign in to comment.