Skip to content

Commit

Permalink
feat(exception): add exception filter implemention (#209)
Browse files Browse the repository at this point in the history
* feat(exception): add exception filter implemention

* feat(exception): error message

* chore(exception): error message typo

* test(exception): add invalid filter case

* feat(exception): exception filterr default duplicated check
  • Loading branch information
noahziheng authored Oct 19, 2022
1 parent f7e615b commit 274cd6f
Show file tree
Hide file tree
Showing 22 changed files with 417 additions and 3 deletions.
3 changes: 3 additions & 0 deletions src/exception/constant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const EXCEPTION_FILTER_METADATA_KEY = 'exception_filter_meta';
export const EXCEPTION_FILTER_MAP_INJECT_ID = Symbol.for('exception_filter_map');
export const EXCEPTION_FILTER_DEFAULT_SYMBOL = Symbol.for('exception_filter_default');
13 changes: 13 additions & 0 deletions src/exception/decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Constructable, Injectable } from '@artus/injection';
import { HOOK_FILE_LOADER } from '../constant';
import { EXCEPTION_FILTER_DEFAULT_SYMBOL, EXCEPTION_FILTER_METADATA_KEY } from './constant';

export const Catch = (targetErr?: string|Constructable<Error>): ClassDecorator => {
return (target: Function) => {
Reflect.defineMetadata(EXCEPTION_FILTER_METADATA_KEY, {
targetErr: targetErr ?? EXCEPTION_FILTER_DEFAULT_SYMBOL,
}, target);
Reflect.defineMetadata(HOOK_FILE_LOADER, { loader: 'exception_filter' }, target);
Injectable()(target);
};
};
21 changes: 20 additions & 1 deletion src/exception/impl.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
import { Middleware } from '@artus/pipeline';
import { ARTUS_EXCEPTION_DEFAULT_LOCALE } from '../constant';
import { ExceptionItem } from './types';
import { matchExceptionFilter } from './utils';

export const exceptionFilterMiddleware: Middleware = async (ctx, next) => {
try {
await next();
} catch (err) {
const filter = matchExceptionFilter(err, ctx.container);
if (filter) {
await filter.catch(err);
}
throw err;
}
};

export class ArtusStdError extends Error {
name = 'ArtusStdError';
Expand All @@ -16,9 +30,14 @@ export class ArtusStdError extends Error {
}

constructor (code: string) {
super(`[${code}] This is Artus standard error, Please check on https://github.com/artusjs/error-code`);
super(`[${code}] This is Artus standard error, Please check on https://github.com/artusjs/spec/blob/master/documentation/core/6.exception.md`); // default message
this._code = code;
}

public get message(): string {
const { code, desc, detailUrl } = this;
return `[${code}] ${desc}${detailUrl ? ', Please check on ' + detailUrl : ''}`;
}

public get code(): string {
return this._code;
Expand Down
4 changes: 4 additions & 0 deletions src/exception/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
export * from './constant';
export * from './decorator';
export * from './impl';
export * from './types';
export * from './utils';
12 changes: 11 additions & 1 deletion src/exception/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
import { Constructable } from '@artus/injection';
import { ArtusStdError } from './impl';

export interface ExceptionItem {
desc: string | Record<string, string>;
detailUrl?: string;
}
}

export type ExceptionIdentifier = string|symbol|Constructable<Error>;
export type ExceptionFilterMapType = Map<ExceptionIdentifier, Constructable<ExceptionFilterType>>;

export interface ExceptionFilterType {
catch(err: Error|ArtusStdError): void | Promise<void>;
}
38 changes: 38 additions & 0 deletions src/exception/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@

import { Constructable, Container } from '@artus/injection';
import { EXCEPTION_FILTER_DEFAULT_SYMBOL, EXCEPTION_FILTER_MAP_INJECT_ID } from './constant';
import { ArtusStdError } from './impl';
import { ExceptionFilterMapType, ExceptionFilterType } from './types';

export const matchExceptionFilter = (err: Error, container: Container): ExceptionFilterType | null => {
const filterMap: ExceptionFilterMapType = container.get(EXCEPTION_FILTER_MAP_INJECT_ID, {
noThrow: true,
});
if (!filterMap) {
return null;
}
let targetFilterClazz: Constructable<ExceptionFilterType>;
// handle ArtusStdError with code simply
if (err instanceof ArtusStdError) {
targetFilterClazz = filterMap.get(err.code);
}
if (!targetFilterClazz) {
// handler other Exception by Clazz
for (const errorClazz of filterMap.keys()) {
if (typeof errorClazz === 'string' || typeof errorClazz === 'symbol') {
continue;
}
if (err instanceof errorClazz) {
targetFilterClazz = filterMap.get(errorClazz);
break;
}
}
}
if (!targetFilterClazz && filterMap.has(EXCEPTION_FILTER_DEFAULT_SYMBOL)) {
// handle default ExceptionFilter
targetFilterClazz = filterMap.get(EXCEPTION_FILTER_DEFAULT_SYMBOL);
}

// return the instance of exception filter
return targetFilterClazz ? container.get(targetFilterClazz) : null;
};
62 changes: 62 additions & 0 deletions src/loader/impl/exception_filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { DefineLoader } from '../decorator';
import { ManifestItem } from '../types';
import ModuleLoader from './module';
import { ArtusStdError, EXCEPTION_FILTER_DEFAULT_SYMBOL, EXCEPTION_FILTER_MAP_INJECT_ID, EXCEPTION_FILTER_METADATA_KEY } from '../../exception';
import { Constructable } from '@artus/injection';
import { ExceptionFilterMapType, ExceptionFilterType, ExceptionIdentifier } from '../../exception/types';

@DefineLoader('exception_filter')
class ExceptionFilterLoader extends ModuleLoader {
async load(item: ManifestItem) {
// Get or Init Exception Filter Map
let filterMap: ExceptionFilterMapType = this.container.get(EXCEPTION_FILTER_MAP_INJECT_ID, {
noThrow: true,
});
if (!filterMap) {
filterMap = new Map();
this.container.set({
id: EXCEPTION_FILTER_MAP_INJECT_ID,
value: filterMap,
});
}

const clazzList = await super.load(item) as Constructable<ExceptionFilterType>[];
for (let i = 0; i < clazzList.length; i++) {
const filterClazz = clazzList[i];
const filterMeta: {
targetErr: ExceptionIdentifier
} = Reflect.getOwnMetadata(EXCEPTION_FILTER_METADATA_KEY, filterClazz);

if (!filterMeta) {
throw new Error(`invalid ExceptionFilter ${filterClazz.name}`);
}

let { targetErr } = filterMeta;
if (filterMap.has(targetErr)) {
// check duplicated
if (targetErr === EXCEPTION_FILTER_DEFAULT_SYMBOL) {
throw new Error('the Default ExceptionFilter is duplicated');
}
let targetErrName = targetErr;
if (typeof targetErr !== 'string' && typeof targetErr !== 'symbol') {
targetErrName = targetErr.name || targetErr;
}
throw new Error(`the ExceptionFilter for ${String(targetErrName)} is duplicated`);
}

if (
typeof targetErr !== 'string' && typeof targetErr !== 'symbol' && // Decorate with a error class
Object.prototype.isPrototypeOf.call(ArtusStdError.prototype, targetErr.prototype) && // the class extends ArtusStdError
typeof targetErr['code'] === 'string' // Have static property `code` defined by string
) {
// Custom Exception Class use Error Code for simplied match
targetErr = targetErr['code'] as string;
}

filterMap.set(targetErr, filterClazz);
}
return clazzList;
}
}

export default ExceptionFilterLoader;
2 changes: 2 additions & 0 deletions src/loader/impl/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import ModuleLoader from './module';
import ConfigLoader from './config';
import ExceptionLoader from './exception';
import ExceptionFilterLoader from './exception_filter';
import LifecycleLoader from './lifecycle';
import PluginMetaLoader from './plugin_meta';
import PluginConfigLoader from './plugin_config';
Expand All @@ -11,6 +12,7 @@ export {
ModuleLoader,
ConfigLoader,
ExceptionLoader,
ExceptionFilterLoader,
LifecycleLoader,
PluginMetaLoader,
PluginConfigLoader,
Expand Down
2 changes: 1 addition & 1 deletion src/loader/impl/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { SHOULD_OVERWRITE_VALUE } from '../../constant';

@DefineLoader('module')
class ModuleLoader implements Loader {
private container: Container;
protected container: Container;

constructor(container) {
this.container = container;
Expand Down
2 changes: 2 additions & 0 deletions src/trigger/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ExecutionContainer, Inject, Injectable, ScopeEnum } from '@artus/injection';
import { Input, Context, MiddlewareInput, Pipeline, Output } from '@artus/pipeline';
import { ArtusInjectEnum } from '../constant';
import { exceptionFilterMiddleware } from '../exception';
import { Application, TriggerType } from '../types';

@Injectable({ scope: ScopeEnum.SINGLETON })
Expand All @@ -12,6 +13,7 @@ export default class Trigger implements TriggerType {

constructor() {
this.pipeline = new Pipeline();
this.pipeline.use(exceptionFilterMiddleware);
}

async use(middleware: MiddlewareInput): Promise<void> {
Expand Down
9 changes: 9 additions & 0 deletions test/exception.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ describe('test/app.test.ts', () => {
assert(error.code === errorCode);
assert(error.desc === exceptionItem.desc);
assert(error.detailUrl === exceptionItem.detailUrl);

try {
throw new ArtusStdError('UNKNWON_CODE');
} catch (error) {
assert(error instanceof ArtusStdError);
assert(error.code === 'UNKNWON_CODE');
assert(error.desc === 'Unknown Error');
assert(error.detailUrl === undefined);
}
});

describe('register error code and throw, with i18n', () => {
Expand Down
63 changes: 63 additions & 0 deletions test/exception_filter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import 'reflect-metadata';
import { ArtusApplication, ArtusStdError, Trigger } from '../src';
import { Input } from '@artus/pipeline';

describe('test/exception_filter.test.ts', () => {
it('a standard exception catch logic with no filter', async () => {
try {
const app = new ArtusApplication();
const trigger = app.container.get(Trigger);
trigger.use(() => {
throw new ArtusStdError('TEST');
});
const ctx = await trigger.initContext();
try {
await trigger.startPipeline(ctx);
} catch (error) {
expect(error).toBeInstanceOf(ArtusStdError);
}
} catch (error) {
throw error;
}
});
it('exception should pass their filter', async () => {
try {
const {
main,
} = await import('./fixtures/exception_filter/bootstrap');

const app = await main();
const trigger = app.container.get(Trigger);
const mockSet: Set<string> = app.container.get('mock_exception_set');
for (const [inputTarget, exceptedVal] of [
['default', 'Error'],
['custom', 'TestCustomError'],
['wrapped', 'APP:WRAPPED_ERROR'],
['APP:TEST_ERROR', 'APP:TEST_ERROR'],
]) {
const input = new Input();
input.params = {
target: inputTarget,
};
const ctx = await trigger.initContext(input);
try {
await trigger.startPipeline(ctx);
} catch (error) {}
expect(mockSet.has(exceptedVal)).toBeTruthy();
}
} catch (error) {
throw error;
}
});
it('should throw error then filter is invalid', async () => {
try {
const {
main,
} = await import('./fixtures/exception_invalid_filter/bootstrap');

expect(() => main()).rejects.toThrow(new Error(`invalid ExceptionFilter TestInvalidFilter`));
} catch (error) {
throw error;
}
});
});
65 changes: 65 additions & 0 deletions test/fixtures/exception_filter/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Context } from '@artus/pipeline';
import path from 'path';
import { ArtusApplication, ArtusStdError, Trigger } from '../../../src';
import { TestCustomError, TestWrappedError } from './error';

async function main() {
const app = new ArtusApplication();
app.container.set({
id: 'mock_exception_set',
value: new Set(),
});
await app.load({
items: [
{
path: path.resolve(__dirname, './filter'),
extname: '.ts',
filename: 'filter.ts',
loader: 'exception_filter',
loaderState: {
exportNames: [
'TestDefaultExceptionHandler',
'TestAppCodeExceptionHandler',
'TestWrappedExceptionHandler',
'TestCustomExceptionHandler',
],
},
source: 'app',
},
{
path: path.resolve(__dirname, '../../../exception.json'),
extname: '.json',
filename: 'exception.json',
loader: 'exception',
source: 'app',
},
{
path: path.resolve(__dirname, './exception.json'),
extname: '.json',
filename: 'exception.json',
loader: 'exception',
source: 'app',
},
],
});
const trigger = app.container.get(Trigger);
trigger.use((ctx: Context) => {
const target = ctx.input.params.target;
switch(target) {
case 'default':
throw new Error('default error');
case 'custom':
throw new TestCustomError();
case 'wrapped':
throw new TestWrappedError();
default:
throw new ArtusStdError(target);
}
});
await app.run();
return app;
}

export {
main,
};
14 changes: 14 additions & 0 deletions test/fixtures/exception_filter/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ArtusStdError } from '../../../src';

export class TestWrappedError extends ArtusStdError {
static code = 'APP:WRAPPED_ERROR';
name = 'TestWrappedError';

constructor() {
super(TestWrappedError.code);
}
}

export class TestCustomError extends Error {
name = 'TestCustomError';
}
9 changes: 9 additions & 0 deletions test/fixtures/exception_filter/exception.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"APP:TEST_ERROR": {
"desc": "这是一个测试用的错误",
"detailUrl": "https://github.com/artusjs"
},
"APP:WRAPPED_ERROR": {
"desc": "这个错误将会被自定义类包装"
}
}
Loading

0 comments on commit 274cd6f

Please sign in to comment.