diff --git a/src/exception/constant.ts b/src/exception/constant.ts new file mode 100644 index 0000000..a684e6a --- /dev/null +++ b/src/exception/constant.ts @@ -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'); diff --git a/src/exception/decorator.ts b/src/exception/decorator.ts new file mode 100644 index 0000000..5a00b01 --- /dev/null +++ b/src/exception/decorator.ts @@ -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): 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); + }; +}; \ No newline at end of file diff --git a/src/exception/impl.ts b/src/exception/impl.ts index a1bf7d2..c115f8f 100644 --- a/src/exception/impl.ts +++ b/src/exception/impl.ts @@ -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'; @@ -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; diff --git a/src/exception/index.ts b/src/exception/index.ts index 4cabdf4..3a4c3aa 100644 --- a/src/exception/index.ts +++ b/src/exception/index.ts @@ -1 +1,5 @@ +export * from './constant'; +export * from './decorator'; export * from './impl'; +export * from './types'; +export * from './utils'; diff --git a/src/exception/types.ts b/src/exception/types.ts index af9d930..e771945 100644 --- a/src/exception/types.ts +++ b/src/exception/types.ts @@ -1,4 +1,14 @@ +import { Constructable } from '@artus/injection'; +import { ArtusStdError } from './impl'; + export interface ExceptionItem { desc: string | Record; detailUrl?: string; -} \ No newline at end of file +} + +export type ExceptionIdentifier = string|symbol|Constructable; +export type ExceptionFilterMapType = Map>; + +export interface ExceptionFilterType { + catch(err: Error|ArtusStdError): void | Promise; +} diff --git a/src/exception/utils.ts b/src/exception/utils.ts new file mode 100644 index 0000000..539786e --- /dev/null +++ b/src/exception/utils.ts @@ -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; + // 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; +}; diff --git a/src/loader/impl/exception_filter.ts b/src/loader/impl/exception_filter.ts new file mode 100644 index 0000000..cc1a591 --- /dev/null +++ b/src/loader/impl/exception_filter.ts @@ -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[]; + 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; diff --git a/src/loader/impl/index.ts b/src/loader/impl/index.ts index 18d66ee..ec0e867 100644 --- a/src/loader/impl/index.ts +++ b/src/loader/impl/index.ts @@ -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'; @@ -11,6 +12,7 @@ export { ModuleLoader, ConfigLoader, ExceptionLoader, + ExceptionFilterLoader, LifecycleLoader, PluginMetaLoader, PluginConfigLoader, diff --git a/src/loader/impl/module.ts b/src/loader/impl/module.ts index 32d4b75..860ce55 100644 --- a/src/loader/impl/module.ts +++ b/src/loader/impl/module.ts @@ -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; diff --git a/src/trigger/index.ts b/src/trigger/index.ts index ee3dfb0..e8b78e6 100644 --- a/src/trigger/index.ts +++ b/src/trigger/index.ts @@ -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 }) @@ -12,6 +13,7 @@ export default class Trigger implements TriggerType { constructor() { this.pipeline = new Pipeline(); + this.pipeline.use(exceptionFilterMiddleware); } async use(middleware: MiddlewareInput): Promise { diff --git a/test/exception.test.ts b/test/exception.test.ts index 5349b53..19a2dcd 100644 --- a/test/exception.test.ts +++ b/test/exception.test.ts @@ -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', () => { diff --git a/test/exception_filter.test.ts b/test/exception_filter.test.ts new file mode 100644 index 0000000..ae57bef --- /dev/null +++ b/test/exception_filter.test.ts @@ -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 = 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; + } + }); +}); diff --git a/test/fixtures/exception_filter/bootstrap.ts b/test/fixtures/exception_filter/bootstrap.ts new file mode 100644 index 0000000..465ace1 --- /dev/null +++ b/test/fixtures/exception_filter/bootstrap.ts @@ -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, +}; diff --git a/test/fixtures/exception_filter/error.ts b/test/fixtures/exception_filter/error.ts new file mode 100644 index 0000000..3519ab3 --- /dev/null +++ b/test/fixtures/exception_filter/error.ts @@ -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'; +} diff --git a/test/fixtures/exception_filter/exception.json b/test/fixtures/exception_filter/exception.json new file mode 100644 index 0000000..6805c7b --- /dev/null +++ b/test/fixtures/exception_filter/exception.json @@ -0,0 +1,9 @@ +{ + "APP:TEST_ERROR": { + "desc": "这是一个测试用的错误", + "detailUrl": "https://github.com/artusjs" + }, + "APP:WRAPPED_ERROR": { + "desc": "这个错误将会被自定义类包装" + } +} \ No newline at end of file diff --git a/test/fixtures/exception_filter/filter.ts b/test/fixtures/exception_filter/filter.ts new file mode 100644 index 0000000..7aa8fb7 --- /dev/null +++ b/test/fixtures/exception_filter/filter.ts @@ -0,0 +1,43 @@ +import { ArtusStdError, Catch, Inject } from '../../../src'; +import { ExceptionFilterType } from '../../../src/exception/types'; +import { TestCustomError, TestWrappedError } from './error'; + +@Catch() +export class TestDefaultExceptionHandler implements ExceptionFilterType { + @Inject('mock_exception_set') + mockSet: Set; + + async catch(err: Error) { + this.mockSet.add(err.name); + } +} + +@Catch('APP:TEST_ERROR') +export class TestAppCodeExceptionHandler implements ExceptionFilterType { + @Inject('mock_exception_set') + mockSet: Set; + + async catch(err: ArtusStdError) { + this.mockSet.add(err.code); + } +} + +@Catch(TestWrappedError) +export class TestWrappedExceptionHandler implements ExceptionFilterType { + @Inject('mock_exception_set') + mockSet: Set; + + async catch(err: TestWrappedError) { + this.mockSet.add(err.code); + } +} + +@Catch(TestCustomError) +export class TestCustomExceptionHandler implements ExceptionFilterType { + @Inject('mock_exception_set') + mockSet: Set; + + async catch(err: TestCustomError) { + this.mockSet.add(err.name); + } +} diff --git a/test/fixtures/exception_filter/package.json b/test/fixtures/exception_filter/package.json new file mode 100644 index 0000000..dd7aad6 --- /dev/null +++ b/test/fixtures/exception_filter/package.json @@ -0,0 +1,4 @@ +{ + "name": "demo-fixture", + "main": "boostrap.ts" +} diff --git a/test/fixtures/exception_filter/tsconfig.json b/test/fixtures/exception_filter/tsconfig.json new file mode 100644 index 0000000..2aa8781 --- /dev/null +++ b/test/fixtures/exception_filter/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "experimentalDecorators": true + }, + "rootDir": "./", + "exclude": [] +} \ No newline at end of file diff --git a/test/fixtures/exception_invalid_filter/bootstrap.ts b/test/fixtures/exception_invalid_filter/bootstrap.ts new file mode 100644 index 0000000..8bb2b60 --- /dev/null +++ b/test/fixtures/exception_invalid_filter/bootstrap.ts @@ -0,0 +1,32 @@ +import path from 'path'; +import { ArtusApplication } from '../../../src'; + +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: [ + 'TestInvalidFilter', + ], + }, + source: 'app', + }, + ], + }); + await app.run(); + return app; +} + +export { + main, +}; diff --git a/test/fixtures/exception_invalid_filter/filter.ts b/test/fixtures/exception_invalid_filter/filter.ts new file mode 100644 index 0000000..461b2ad --- /dev/null +++ b/test/fixtures/exception_invalid_filter/filter.ts @@ -0,0 +1,2 @@ +export class TestInvalidFilter { +} diff --git a/test/fixtures/exception_invalid_filter/package.json b/test/fixtures/exception_invalid_filter/package.json new file mode 100644 index 0000000..dd7aad6 --- /dev/null +++ b/test/fixtures/exception_invalid_filter/package.json @@ -0,0 +1,4 @@ +{ + "name": "demo-fixture", + "main": "boostrap.ts" +} diff --git a/test/fixtures/exception_invalid_filter/tsconfig.json b/test/fixtures/exception_invalid_filter/tsconfig.json new file mode 100644 index 0000000..2aa8781 --- /dev/null +++ b/test/fixtures/exception_invalid_filter/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "experimentalDecorators": true + }, + "rootDir": "./", + "exclude": [] +} \ No newline at end of file