Skip to content

Commit

Permalink
Add Support for Custom HTTP Routes (#1114)
Browse files Browse the repository at this point in the history
  • Loading branch information
misscoded authored Sep 21, 2021
1 parent ee18b1c commit 7ada220
Show file tree
Hide file tree
Showing 7 changed files with 262 additions and 55 deletions.
4 changes: 4 additions & 0 deletions src/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ const tokenUsage = 'Apps used in one workspace should be initialized with a toke
export interface AppOptions {
signingSecret?: HTTPReceiverOptions['signingSecret'];
endpoints?: HTTPReceiverOptions['endpoints'];
customRoutes?: HTTPReceiverOptions['customRoutes'];
processBeforeResponse?: HTTPReceiverOptions['processBeforeResponse'];
signatureVerification?: HTTPReceiverOptions['signatureVerification'];
clientId?: HTTPReceiverOptions['clientId'];
Expand Down Expand Up @@ -211,6 +212,7 @@ export default class App {
public constructor({
signingSecret = undefined,
endpoints = undefined,
customRoutes = undefined,
agent = undefined,
clientTls = undefined,
receiver = undefined,
Expand Down Expand Up @@ -341,6 +343,7 @@ export default class App {
logger,
logLevel: this.logLevel,
installerOptions: this.installerOptions,
customRoutes,
});
} else if (signatureVerification && signingSecret === undefined) {
// No custom receiver
Expand All @@ -354,6 +357,7 @@ export default class App {
this.receiver = new HTTPReceiver({
signingSecret: signingSecret || '',
endpoints,
customRoutes,
processBeforeResponse,
signatureVerification,
clientId,
Expand Down
6 changes: 6 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export enum ErrorCode {

ContextMissingPropertyError = 'slack_bolt_context_missing_property_error',

CustomRouteInitializationError = 'slack_bolt_custom_route_initialization_error',

ReceiverMultipleAckError = 'slack_bolt_receiver_ack_multiple_error',
ReceiverAuthenticityError = 'slack_bolt_receiver_authenticity_error',
ReceiverInconsistentStateError = 'slack_bolt_receiver_inconsistent_state_error',
Expand Down Expand Up @@ -80,6 +82,10 @@ export class ContextMissingPropertyError extends Error implements CodedError {
}
}

export class CustomRouteInitializationError extends Error implements CodedError {
public code = ErrorCode.CustomRouteInitializationError;
}

export class ReceiverMultipleAckError extends Error implements CodedError {
public code = ErrorCode.ReceiverMultipleAckError;

Expand Down
56 changes: 50 additions & 6 deletions src/receivers/HTTPReceiver.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { EventEmitter } from 'events';
import { InstallProvider } from '@slack/oauth';
import { IncomingMessage, ServerResponse } from 'http';
import { Override, mergeOverrides } from '../test-helpers';
import { HTTPReceiverDeferredRequestError } from '../errors';
import { CustomRouteInitializationError, HTTPReceiverDeferredRequestError } from '../errors';

/* Testing Harness */

Expand Down Expand Up @@ -291,7 +291,46 @@ describe('HTTPReceiver', function () {
await receiver.requestListener(fakeReq, fakeRes);
assert(installProviderStub.handleCallback.calledWith(fakeReq, fakeRes, callbackOptions));
});
it('should throw if a request comes into neither the install path nor the redirect URI path', async 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 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';
fakeReq.headers = { host: 'localhost' };

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

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

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

it("should throw an error if customRoutes don't have the required keys", async function () {
const HTTPReceiver = await importHTTPReceiver();
const customRoutes = [{ path: '/test' }] as any;

assert.throws(() => new HTTPReceiver({
clientSecret: 'my-client-secret',
signingSecret: 'secret',
customRoutes,
}), CustomRouteInitializationError);
});

it("should throw if request doesn't match install path, redirect URI path, or custom routes", async function () {
// Arrange
const installProviderStub = sinon.createStubInstance(InstallProvider);
const overrides = mergeOverrides(
Expand All @@ -303,6 +342,8 @@ describe('HTTPReceiver', function () {
const metadata = 'this is bat country';
const scopes = ['channels:read'];
const userScopes = ['chat:write'];
const customRoutes = [{ path: '/nope', method: 'POST', handler: sinon.fake() }];

const receiver = new HTTPReceiver({
logger: noopLogger,
clientId: 'my-clientId',
Expand All @@ -317,18 +358,21 @@ describe('HTTPReceiver', function () {
metadata,
userScopes,
},
customRoutes,
});

assert.isNotNull(receiver);
receiver.installer = installProviderStub as unknown as InstallProvider;

const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage;
fakeReq.url = '/nope';
fakeReq.headers = { host: 'localhost' };
fakeReq.method = 'GET';

const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse;
const writeHead = sinon.fake();
const end = sinon.fake();
fakeRes.writeHead = writeHead;
fakeRes.end = end;
fakeRes.writeHead = sinon.fake();
fakeRes.end = sinon.fake();

assert.throws(() => receiver.requestListener(fakeReq, fakeRes), HTTPReceiverDeferredRequestError);
});
});
Expand Down
18 changes: 16 additions & 2 deletions src/receivers/HTTPReceiver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
ErrorCode,
CodedError,
} from '../errors';
import { CustomRoute, prepareRoutes, ReceiverRoutes } from './custom-routes';

// Option keys for tls.createServer() and tls.createSecureContext(), exclusive of those for http.createServer()
const httpsOptionKeys = [
Expand Down Expand Up @@ -58,6 +59,7 @@ const missingServerErrorDescription = 'The receiver cannot be started because pr
export interface HTTPReceiverOptions {
signingSecret: string;
endpoints?: string | string[];
customRoutes?: CustomRoute[];
logger?: Logger;
logLevel?: LogLevel;
processBeforeResponse?: boolean;
Expand All @@ -69,7 +71,6 @@ export interface HTTPReceiverOptions {
scopes?: InstallURLOptions['scopes'];
installerOptions?: HTTPReceiverInstallerOptions;
}

export interface HTTPReceiverInstallerOptions {
installPath?: string;
directInstall?: boolean; // see https://api.slack.com/start/distributing/directory#direct_install
Expand All @@ -90,6 +91,8 @@ export interface HTTPReceiverInstallerOptions {
export default class HTTPReceiver implements Receiver {
private endpoints: string[];

private routes: ReceiverRoutes;

private signingSecret: string;

private processBeforeResponse: boolean;
Expand Down Expand Up @@ -121,6 +124,7 @@ export default class HTTPReceiver implements Receiver {
public constructor({
signingSecret = '',
endpoints = ['/slack/events'],
customRoutes = [],
logger = undefined,
logLevel = LogLevel.INFO,
processBeforeResponse = false,
Expand All @@ -143,6 +147,7 @@ export default class HTTPReceiver implements Receiver {
return defaultLogger;
})();
this.endpoints = Array.isArray(endpoints) ? endpoints : [endpoints];
this.routes = prepareRoutes(customRoutes);

// Initialize InstallProvider when it's required options are provided
if (
Expand Down Expand Up @@ -302,19 +307,28 @@ export default class HTTPReceiver implements Receiver {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const [installPath, installRedirectUriPath] = [this.installPath!, this.installRedirectUriPath!];

// Visiting the installation endpoint
if (path === installPath) {
// Render installation path (containing Add to Slack button)
return this.handleInstallPathRequest(res);
}

// Installation has been initiated
if (path === installRedirectUriPath) {
// Handle OAuth callback request (to exchange authorization grant for a new access token)
return this.handleInstallRedirectRequest(req, res);
}
}

// 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); }
}

// If the request did not match the previous conditions, an error is thrown. The error can be caught by the
// the caller in order to defer to other routing logic (similar to calling `next()` in connect middleware).
throw new HTTPReceiverDeferredRequestError('Unhandled HTTP request', req, res);
throw new HTTPReceiverDeferredRequestError(`Unhandled HTTP request (${method}) made to ${path}`, req, res);
}

private handleIncomingEvent(req: IncomingMessage, res: ServerResponse) {
Expand Down
62 changes: 60 additions & 2 deletions src/receivers/SocketModeReceiver.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { IncomingMessage, ServerResponse } from 'http';
import { InstallProvider } from '@slack/oauth';
import { SocketModeClient } from '@slack/socket-mode';
import { Override, mergeOverrides } from '../test-helpers';
import { CustomRouteInitializationError } from '../errors';

// Fakes
class FakeServer extends EventEmitter {
Expand Down Expand Up @@ -150,6 +151,7 @@ describe('SocketModeReceiver', function () {
receiver.installer = installProviderStub as unknown as InstallProvider;
const fakeReq = {
url: '/heyo',
method: 'GET',
};
const fakeRes = null;
await this.listener(fakeReq, fakeRes);
Expand Down Expand Up @@ -191,6 +193,7 @@ describe('SocketModeReceiver', function () {
receiver.installer = installProviderStub as unknown as InstallProvider;
const fakeReq = {
url: '/hiya',
method: 'GET',
};
const fakeRes = {
writeHead: sinon.fake(),
Expand Down Expand Up @@ -232,6 +235,7 @@ describe('SocketModeReceiver', function () {
receiver.installer = installProviderStub as unknown as InstallProvider;
const fakeReq = {
url: '/hiya',
method: 'GET',
};
const fakeRes = {
writeHead: sinon.fake(),
Expand Down Expand Up @@ -275,6 +279,7 @@ describe('SocketModeReceiver', function () {
receiver.installer = installProviderStub as unknown as InstallProvider;
const fakeReq = {
url: '/hiya',
method: 'GET',
};
const fakeRes = {
writeHead: sinon.fake(),
Expand All @@ -286,7 +291,57 @@ describe('SocketModeReceiver', function () {
assert(fakeRes.writeHead.calledWith(302, sinon.match.object));
assert(fakeRes.end.called);
});
it('should return a 404 if a request comes into neither the install path nor the redirect URI path', async function () {

it('should call custom route handler only if request matches route path and method', async function () {
// Arrange
const installProviderStub = sinon.createStubInstance(InstallProvider);
const overrides = mergeOverrides(
withHttpCreateServer(this.fakeCreateServer),
withHttpsCreateServer(sinon.fake.throws('Should not be used.')),
);
const SocketModeReceiver = await importSocketModeReceiver(overrides);
const customRoutes = [{ path: '/test', method: ['get', 'POST'], handler: sinon.fake() }];

const receiver = new SocketModeReceiver({
appToken: 'my-secret',
customRoutes,
});
assert.isNotNull(receiver);
receiver.installer = installProviderStub as unknown as InstallProvider;

const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage;
const fakeRes = { writeHead: sinon.fake(), end: sinon.fake() };

fakeReq.url = '/test';
fakeReq.headers = { host: 'localhost' };

fakeReq.method = 'GET';
await this.listener(fakeReq, fakeRes);
assert(customRoutes[0].handler.calledWith(fakeReq, fakeRes));

fakeReq.method = 'POST';
await this.listener(fakeReq, fakeRes);
assert(customRoutes[0].handler.calledWith(fakeReq, fakeRes));

fakeReq.method = 'UNHANDLED_METHOD';
await this.listener(fakeReq, fakeRes);
assert(fakeRes.writeHead.calledWith(404, sinon.match.object));
assert(fakeRes.end.called);
});

it("should throw an error if customRoutes don't have the required keys", async function () {
// Arrange
const overrides = mergeOverrides(
withHttpCreateServer(this.fakeCreateServer),
withHttpsCreateServer(sinon.fake.throws('Should not be used.')),
);
const SocketModeReceiver = await importSocketModeReceiver(overrides);
const customRoutes = [{ handler: sinon.fake() }] as any;

assert.throws(() => new SocketModeReceiver({ appToken: 'my-secret', customRoutes }), CustomRouteInitializationError);
});

it('should return a 404 if a request passes the install path, redirect URI path and custom routes', async function () {
// Arrange
const installProviderStub = sinon.createStubInstance(InstallProvider);
const overrides = mergeOverrides(
Expand All @@ -298,13 +353,15 @@ describe('SocketModeReceiver', function () {
const metadata = 'this is bat country';
const scopes = ['channels:read'];
const userScopes = ['chat:write'];
const customRoutes = [{ path: '/test', method: ['get', 'POST'], handler: sinon.fake() }];
const receiver = new SocketModeReceiver({
appToken: 'my-secret',
logger: noopLogger,
clientId: 'my-clientId',
clientSecret: 'my-client-secret',
stateSecret: 'state-secret',
scopes,
customRoutes,
installerOptions: {
authVersion: 'v2',
installPath: '/hiya',
Expand All @@ -317,14 +374,15 @@ describe('SocketModeReceiver', function () {
receiver.installer = installProviderStub as unknown as InstallProvider;
const fakeReq = {
url: '/nope',
method: 'GET',
};
const fakeRes = {
writeHead: sinon.fake(),
end: sinon.fake(),
};
await this.listener(fakeReq, fakeRes);
assert(fakeRes.writeHead.calledWith(404, sinon.match.object));
assert(fakeRes.end.calledWith(sinon.match("route /nope doesn't exist!")));
assert(fakeRes.end.calledOnce);
});
});
describe('#start()', function () {
Expand Down
Loading

0 comments on commit 7ada220

Please sign in to comment.