From ad700c61b8c2df0d70c6ad920ac5fa761d56dd84 Mon Sep 17 00:00:00 2001 From: ChengLei Shao Date: Sun, 6 Mar 2022 12:07:56 +0800 Subject: [PATCH] Plugin error handler (#222) * feat: validation error with i18n * feat: plugin-error-handler * del: error-handle.test.ts * feat: field name with i18n * fix: yarn build --- .../__tests__/create-with-expection.test.ts | 41 ------ packages/plugin-error-handler/package.json | 21 +++ .../src/__tests__/render-error.test.ts | 120 ++++++++++++++++++ .../src/error-handler.ts | 4 - .../plugin-error-handler/src/locale/en_US.ts | 4 + .../plugin-error-handler/src/locale/zh_CN.ts | 4 + packages/plugin-error-handler/src/server.ts | 50 ++++++++ .../plugin-error-handler/tsconfig.build.json | 9 ++ packages/plugin-error-handler/tsconfig.json | 5 + .../server/src/__tests__/error-handle.test.ts | 48 ------- packages/server/src/application.ts | 5 - packages/server/src/helper.ts | 36 ++---- 12 files changed, 223 insertions(+), 124 deletions(-) delete mode 100644 packages/actions/src/__tests__/create-with-expection.test.ts create mode 100644 packages/plugin-error-handler/package.json create mode 100644 packages/plugin-error-handler/src/__tests__/render-error.test.ts rename packages/{server => plugin-error-handler}/src/error-handler.ts (90%) create mode 100644 packages/plugin-error-handler/src/locale/en_US.ts create mode 100644 packages/plugin-error-handler/src/locale/zh_CN.ts create mode 100644 packages/plugin-error-handler/src/server.ts create mode 100644 packages/plugin-error-handler/tsconfig.build.json create mode 100644 packages/plugin-error-handler/tsconfig.json delete mode 100644 packages/server/src/__tests__/error-handle.test.ts diff --git a/packages/actions/src/__tests__/create-with-expection.test.ts b/packages/actions/src/__tests__/create-with-expection.test.ts deleted file mode 100644 index 77473bacd..000000000 --- a/packages/actions/src/__tests__/create-with-expection.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { mockServer } from '@nocobase/test'; - -describe('create with exception', () => { - it('should handle validationError', async () => { - const app = mockServer(); - - app.collection({ - name: 'users', - fields: [ - { - name: 'name', - type: 'string', - allowNull: false, - }, - ], - }); - - await app.loadAndInstall(); - - const response = await app - .agent() - .resource('users') - .create({ - values: { - title: 't1', - }, - }); - - expect(response.statusCode).toEqual(400); - - expect(response.body).toEqual({ - errors: [ - { - message: 'users.name cannot be null', - }, - ], - }); - - await app.destroy(); - }); -}); diff --git a/packages/plugin-error-handler/package.json b/packages/plugin-error-handler/package.json new file mode 100644 index 000000000..03ee29132 --- /dev/null +++ b/packages/plugin-error-handler/package.json @@ -0,0 +1,21 @@ +{ + "name": "@nocobase/plugin-error-handler", + "version": "0.6.0-alpha.0", + "description": "", + "license": "MIT", + "main": "./lib/index.js", + "types": "./lib/index.d.ts", + "scripts": { + "build": "rimraf -rf lib esm dist && npm run build:cjs && npm run build:esm", + "build:cjs": "tsc --project tsconfig.build.json", + "build:esm": "tsc --project tsconfig.build.json --module es2015 --outDir esm" + }, + "dependencies": { + "@nocobase/server": "^0.6.0-alpha.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nocobase/nocobase.git", + "directory": "packages/plugin-error-handler" + } +} diff --git a/packages/plugin-error-handler/src/__tests__/render-error.test.ts b/packages/plugin-error-handler/src/__tests__/render-error.test.ts new file mode 100644 index 000000000..e1a8a0612 --- /dev/null +++ b/packages/plugin-error-handler/src/__tests__/render-error.test.ts @@ -0,0 +1,120 @@ +import { MockServer, mockServer } from '@nocobase/test'; +import { PluginErrorHandler } from '../server'; +describe('create with exception', () => { + let app: MockServer; + beforeEach(async () => { + app = mockServer(); + app.plugin(PluginErrorHandler); + }); + + afterEach(async () => { + await app.destroy(); + }); + + it('should handle not null error', async () => { + app.collection({ + name: 'users', + fields: [ + { + name: 'name', + type: 'string', + allowNull: false, + }, + ], + }); + + await app.loadAndInstall(); + + const response = await app + .agent() + .resource('users') + .create({ + values: { + title: 't1', + }, + }); + + expect(response.statusCode).toEqual(400); + + expect(response.body).toEqual({ + errors: [ + { + message: 'name cannot be null', + }, + ], + }); + }); + + it('should handle unique error', async () => { + app.collection({ + name: 'users', + fields: [ + { + name: 'name', + type: 'string', + unique: true, + }, + ], + }); + + await app.loadAndInstall(); + + await app + .agent() + .resource('users') + .create({ + values: { + name: 'u1', + }, + }); + + const response = await app + .agent() + .resource('users') + .create({ + values: { + name: 'u1', + }, + }); + + expect(response.statusCode).toEqual(400); + + expect(response.body).toEqual({ + errors: [ + { + message: 'name must be unique', + }, + ], + }); + }); + + it('should render error with field title', async () => { + app.collection({ + name: 'users', + fields: [ + { + name: 'name', + type: 'string', + allowNull: false, + uiSchema: { + title: '{{t("UserName")}}', + }, + }, + ], + }); + + await app.loadAndInstall(); + + const response = await app.agent().resource('users').create({}); + + expect(response.statusCode).toEqual(400); + + expect(response.body).toEqual({ + errors: [ + { + message: 'UserName cannot be null', + }, + ], + }); + }); +}); diff --git a/packages/server/src/error-handler.ts b/packages/plugin-error-handler/src/error-handler.ts similarity index 90% rename from packages/server/src/error-handler.ts rename to packages/plugin-error-handler/src/error-handler.ts index c62b99a3a..291bfacc7 100644 --- a/packages/server/src/error-handler.ts +++ b/packages/plugin-error-handler/src/error-handler.ts @@ -1,10 +1,6 @@ -import Application from './application'; - export class ErrorHandler { handlers = []; - constructor(app: Application) {} - register(guard: (err) => boolean, render: (err, ctx) => void) { this.handlers.push({ guard, diff --git a/packages/plugin-error-handler/src/locale/en_US.ts b/packages/plugin-error-handler/src/locale/en_US.ts new file mode 100644 index 000000000..7ff61f96e --- /dev/null +++ b/packages/plugin-error-handler/src/locale/en_US.ts @@ -0,0 +1,4 @@ +export default { + 'unique violation': '{{field}} must be unique', + 'notNull Violation': '{{field}} cannot be null', +}; diff --git a/packages/plugin-error-handler/src/locale/zh_CN.ts b/packages/plugin-error-handler/src/locale/zh_CN.ts new file mode 100644 index 000000000..e8c3f2a5b --- /dev/null +++ b/packages/plugin-error-handler/src/locale/zh_CN.ts @@ -0,0 +1,4 @@ +export default { + 'unique violation': '{{field}} 字段值是唯一的', + 'notNull Violation': '{{field}} 字段不能为空', +}; diff --git a/packages/plugin-error-handler/src/server.ts b/packages/plugin-error-handler/src/server.ts new file mode 100644 index 000000000..d3f5362c5 --- /dev/null +++ b/packages/plugin-error-handler/src/server.ts @@ -0,0 +1,50 @@ +import { Plugin } from '@nocobase/server'; +import { ErrorHandler } from './error-handler'; +import { BaseError } from 'sequelize'; +import zhCN from './locale/zh_CN'; +import enUS from './locale/en_US'; +import lodash from 'lodash'; +import { compile } from '@formily/json-schema/lib/compiler'; + +export class PluginErrorHandler extends Plugin { + errorHandler: ErrorHandler = new ErrorHandler(); + i18nNs: string = 'error-handler'; + + beforeLoad() { + this.registerSequelizeValidationErrorHandler(); + } + + registerSequelizeValidationErrorHandler() { + const findFieldTitle = (instance, path, tFunc) => { + const model = instance.constructor; + const collection = this.db.modelCollection.get(model); + const field = collection.getField(path); + const fieldOptions = compile(field.options, { t: tFunc }); + const title = lodash.get(fieldOptions, 'uiSchema.title', path); + return title; + }; + + this.errorHandler.register( + (err) => err?.errors?.length && err instanceof BaseError, + (err, ctx) => { + ctx.body = { + errors: err.errors.map((err) => { + return { + message: ctx.i18n.t(err.type, { + ns: this.i18nNs, + field: findFieldTitle(err.instance, err.path, ctx.i18n.t), + }), + }; + }), + }; + ctx.status = 400; + }, + ); + } + async load() { + this.app.i18n.addResources('zh-CN', this.i18nNs, zhCN); + this.app.i18n.addResources('en-US', this.i18nNs, enUS); + + this.app.middleware.unshift(this.errorHandler.middleware()); + } +} diff --git a/packages/plugin-error-handler/tsconfig.build.json b/packages/plugin-error-handler/tsconfig.build.json new file mode 100644 index 000000000..0db344533 --- /dev/null +++ b/packages/plugin-error-handler/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "outDir": "./lib", + "declaration": true + }, + "include": ["./src/**/*.ts", "./src/**/*.tsx"], + "exclude": ["./src/__tests__/*", "./esm/*", "./lib/*"] +} diff --git a/packages/plugin-error-handler/tsconfig.json b/packages/plugin-error-handler/tsconfig.json new file mode 100644 index 000000000..67a583a32 --- /dev/null +++ b/packages/plugin-error-handler/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["./src/**/*.ts", "./src/**/*.tsx"], + "exclude": ["./esm/*", "./lib/*"] +} diff --git a/packages/server/src/__tests__/error-handle.test.ts b/packages/server/src/__tests__/error-handle.test.ts deleted file mode 100644 index 0b993f600..000000000 --- a/packages/server/src/__tests__/error-handle.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { mockServer } from '@nocobase/test'; - -describe('error handle', () => { - it('should handle error with default handler', async () => { - const app = mockServer(); - - app.use(async () => { - throw new Error('some thing went wrong'); - }); - - const response = await app.agent().post('/'); - - expect(response.statusCode).toEqual(500); - expect(response.body.errors[0].message).toEqual('some thing went wrong'); - }); - - it('should handle error by custom handler', async () => { - class CustomError extends Error { - constructor(message, errors) { - super(message); - this.name = 'CustomError'; - } - } - - const app = mockServer(); - - app.errorHandler.register( - (err) => { - return err.name == 'CustomError'; - }, - (err, ctx) => { - ctx.body = { - message: 'hello', - }; - ctx.status = 422; - }, - ); - - app.use(async () => { - throw new CustomError('some thing went wrong', []); - }); - - const response = await app.agent().post('/'); - - expect(response.statusCode).toEqual(422); - expect(response.body).toEqual({ message: 'hello' }); - }); -}); diff --git a/packages/server/src/application.ts b/packages/server/src/application.ts index da017414f..0ce33811b 100644 --- a/packages/server/src/application.ts +++ b/packages/server/src/application.ts @@ -12,7 +12,6 @@ import { createACL } from './acl'; import { createCli, createDatabase, createI18n, createResourcer, registerMiddlewares } from './helper'; import { Plugin } from './plugin'; import { PluginManager, InstallOptions } from './plugin-manager'; -import { ErrorHandler } from './error-handler'; export interface ResourcerOptions { prefix?: string; @@ -85,8 +84,6 @@ export class Application exten public readonly acl: ACL; - public readonly errorHandler: ErrorHandler; - protected plugins = new Map(); public listenServer: Server; @@ -104,8 +101,6 @@ export class Application exten app: this, }); - this.errorHandler = new ErrorHandler(this); - registerMiddlewares(this, options); if (options.registerActions !== false) { registerActions(this); diff --git a/packages/server/src/helper.ts b/packages/server/src/helper.ts index 2e8abd6f5..b026e2244 100644 --- a/packages/server/src/helper.ts +++ b/packages/server/src/helper.ts @@ -5,11 +5,20 @@ import { Command } from 'commander'; import i18next from 'i18next'; import { DefaultContext, DefaultState } from 'koa'; import bodyParser from 'koa-bodyparser'; -import { BaseError } from 'sequelize'; import Application, { ApplicationOptions } from './application'; import { dataWrapping } from './middlewares/data-wrapping'; import { table2resource } from './middlewares/table2resource'; +export function createI18n(options: ApplicationOptions) { + const instance = i18next.createInstance(); + instance.init({ + lng: 'en-US', + resources: {}, + ...options.i18n, + }); + return instance; +} + export function createDatabase(options: ApplicationOptions) { if (options.database instanceof Database) { return options.database; @@ -22,16 +31,6 @@ export function createResourcer(options: ApplicationOptions) { return new Resourcer({ ...options.resourcer }); } -export function createI18n(options: ApplicationOptions) { - const instance = i18next.createInstance(); - instance.init({ - lng: 'en-US', - resources: {}, - ...options.i18n, - }); - return instance; -} - export function createCli(app: Application, options: ApplicationOptions): Command { const cli = new Command(); @@ -94,22 +93,7 @@ export function createCli(app: Application, options: ApplicationOptions): Comman return cli; } -function registerErrorHandler(app: Application) { - app.errorHandler.register( - (err) => err?.errors?.length && err instanceof BaseError, - (err, ctx) => { - ctx.body = { - errors: err.errors.map((err) => ({ message: err.message })), - }; - ctx.status = 400; - }, - ); - app.use(app.errorHandler.middleware()); -} - export function registerMiddlewares(app: Application, options: ApplicationOptions) { - registerErrorHandler(app); - if (options.bodyParser !== false) { app.use( bodyParser({