Skip to content

Commit

Permalink
Merge pull request #14184 from nestjs/feat/include-error-cause-ws
Browse files Browse the repository at this point in the history
feat(websockets): include exception cause to associate error with req
  • Loading branch information
kamilmysliwiec authored Nov 22, 2024
2 parents b04895a + f80984f commit 43cf572
Show file tree
Hide file tree
Showing 3 changed files with 165 additions and 46 deletions.
19 changes: 13 additions & 6 deletions integration/websockets/e2e/error-gateway.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,27 @@ describe('ErrorGateway', () => {
const testingModule = await Test.createTestingModule({
providers: [ErrorGateway],
}).compile();
app = await testingModule.createNestApplication();

app = testingModule.createNestApplication();
await app.listen(3000);
});

it(`should handle error`, async () => {
const ws = io('http://localhost:8080');
ws.emit('push', {
test: 'test',
});
const pattern = 'push';
const data = { test: 'test' };

ws.emit(pattern, data);

await new Promise<void>(resolve =>
ws.on('exception', data => {
expect(data).to.be.eql({
ws.on('exception', error => {
expect(error).to.be.eql({
status: 'error',
message: 'test',
cause: {
pattern,
data,
},
});
resolve();
}),
Expand Down
84 changes: 71 additions & 13 deletions packages/websockets/exceptions/base-ws-exception-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,48 +8,106 @@ import { isObject } from '@nestjs/common/utils/shared.utils';
import { MESSAGES } from '@nestjs/core/constants';
import { WsException } from '../errors/ws-exception';

export interface ErrorPayload<Cause = { pattern: string; data: unknown }> {
/**
* Error message identifier.
*/
status: 'error';
/**
* Error message.
*/
message: string;
/**
* Message that caused the exception.
*/
cause?: Cause;
}

interface BaseWsExceptionFilterOptions {
/**
* When true, the data that caused the exception will be included in the response.
* This is useful when you want to provide additional context to the client, or
* when you need to associate the error with a specific request.
* @default true
*/
includeCause?: boolean;

/**
* A factory function that can be used to control the shape of the "cause" object.
* This is useful when you need a custom structure for the cause object.
* @default (pattern, data) => ({ pattern, data })
*/
causeFactory?: (pattern: string, data: unknown) => Record<string, any>;
}

/**
* @publicApi
*/
export class BaseWsExceptionFilter<TError = any>
implements WsExceptionFilter<TError>
{
private static readonly logger = new Logger('WsExceptionsHandler');
protected static readonly logger = new Logger('WsExceptionsHandler');

constructor(protected readonly options: BaseWsExceptionFilterOptions = {}) {
this.options.includeCause = this.options.includeCause ?? true;
this.options.causeFactory =
this.options.causeFactory ?? ((pattern, data) => ({ pattern, data }));
}

public catch(exception: TError, host: ArgumentsHost) {
const client = host.switchToWs().getClient();
this.handleError(client, exception);
const pattern = host.switchToWs().getPattern();
const data = host.switchToWs().getData();
this.handleError(client, exception, {
pattern,
data,
});
}

public handleError<TClient extends { emit: Function }>(
client: TClient,
exception: TError,
cause: ErrorPayload['cause'],
) {
if (!(exception instanceof WsException)) {
return this.handleUnknownError(exception, client);
return this.handleUnknownError(exception, client, cause);
}

const status = 'error';
const result = exception.getError();
const message = isObject(result)
? result
: {
status,
message: result,
};

client.emit('exception', message);

if (isObject(result)) {
return client.emit('exception', result);
}

const payload: ErrorPayload<unknown> = {
status,
message: result,
};

if (this.options?.includeCause) {
payload.cause = this.options.causeFactory(cause.pattern, cause.data);
}

client.emit('exception', payload);
}

public handleUnknownError<TClient extends { emit: Function }>(
exception: TError,
client: TClient,
data: ErrorPayload['cause'],
) {
const status = 'error';
client.emit('exception', {
const payload: ErrorPayload<unknown> = {
status,
message: MESSAGES.UNKNOWN_EXCEPTION_MESSAGE,
});
};

if (this.options?.includeCause) {
payload.cause = this.options.causeFactory(data.pattern, data.data);
}

client.emit('exception', payload);

if (!(exception instanceof IntrinsicException)) {
const logger = BaseWsExceptionFilter.logger;
Expand Down
108 changes: 81 additions & 27 deletions packages/websockets/test/exceptions/ws-exceptions-handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,55 +7,109 @@ import { WsExceptionsHandler } from '../../exceptions/ws-exceptions-handler';
describe('WsExceptionsHandler', () => {
let handler: WsExceptionsHandler;
let emitStub: sinon.SinonStub;
let client;
let client: {
emit: sinon.SinonStub;
};
let pattern: string;
let data: unknown;
let executionContextHost: ExecutionContextHost;

beforeEach(() => {
handler = new WsExceptionsHandler();
emitStub = sinon.stub();
client = {
emit: emitStub,
};
pattern = 'test';
data = { foo: 'bar' };
executionContextHost = new ExecutionContextHost([client, data, pattern]);

client.emit.returns(client);
});

describe('handle', () => {
it('should method emit expected status code message when exception is unknown', () => {
handler.handle(new Error(), new ExecutionContextHost([client]));
expect(
emitStub.calledWith('exception', {
status: 'error',
message: 'Internal server error',
}),
).to.be.true;
describe('when "includeCause" is set to true (default)', () => {
it('should method emit expected status code message when exception is unknown', () => {
handler.handle(new Error(), executionContextHost);
expect(
emitStub.calledWith('exception', {
status: 'error',
message: 'Internal server error',
cause: {
pattern,
data,
},
}),
).to.be.true;
});
describe('when exception is instance of WsException', () => {
it('should method emit expected status and json object', () => {
const message = {
custom: 'Unauthorized',
};
handler.handle(new WsException(message), executionContextHost);
expect(emitStub.calledWith('exception', message)).to.be.true;
});
it('should method emit expected status and transform message to json', () => {
const message = 'Unauthorized';

handler.handle(new WsException(message), executionContextHost);
console.log(emitStub.getCall(0).args);
expect(
emitStub.calledWith('exception', {
message,
status: 'error',
cause: {
pattern,
data,
},
}),
).to.be.true;
});
});
});
describe('when exception is instance of WsException', () => {
it('should method emit expected status and json object', () => {
const message = {
custom: 'Unauthorized',
};
handler.handle(
new WsException(message),
new ExecutionContextHost([client]),
);
expect(emitStub.calledWith('exception', message)).to.be.true;

describe('when "includeCause" is set to false', () => {
beforeEach(() => {
handler = new WsExceptionsHandler({ includeCause: false });
});
it('should method emit expected status and transform message to json', () => {
const message = 'Unauthorized';

it('should method emit expected status code message when exception is unknown', () => {
handler.handle(
new WsException(message),
new ExecutionContextHost([client]),
new Error(),
new ExecutionContextHost([client, pattern, data]),
);
expect(emitStub.calledWith('exception', { message, status: 'error' }))
.to.be.true;
expect(
emitStub.calledWith('exception', {
status: 'error',
message: 'Internal server error',
}),
).to.be.true;
});
describe('when exception is instance of WsException', () => {
it('should method emit expected status and json object', () => {
const message = {
custom: 'Unauthorized',
};
handler.handle(new WsException(message), executionContextHost);
expect(emitStub.calledWith('exception', message)).to.be.true;
});
it('should method emit expected status and transform message to json', () => {
const message = 'Unauthorized';

handler.handle(new WsException(message), executionContextHost);
expect(emitStub.calledWith('exception', { message, status: 'error' }))
.to.be.true;
});
});
});

describe('when "invokeCustomFilters" returns true', () => {
beforeEach(() => {
sinon.stub(handler, 'invokeCustomFilters').returns(true);
});
it('should not call `emit`', () => {
handler.handle(new WsException(''), new ExecutionContextHost([client]));
handler.handle(new WsException(''), executionContextHost);
expect(emitStub.notCalled).to.be.true;
});
});
Expand All @@ -77,7 +131,7 @@ describe('WsExceptionsHandler', () => {
});
});
describe('when filters array is not empty', () => {
let filters, funcSpy;
let filters: any[], funcSpy: sinon.SinonSpy;
class TestException {}

beforeEach(() => {
Expand Down

0 comments on commit 43cf572

Please sign in to comment.