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
This commit is contained in:
ChengLei Shao 2022-03-06 12:07:56 +08:00 committed by GitHub
parent f9018cabda
commit ad700c61b8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 223 additions and 124 deletions

View File

@ -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();
});
});

View File

@ -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"
}
}

View File

@ -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',
},
],
});
});
});

View File

@ -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,

View File

@ -0,0 +1,4 @@
export default {
'unique violation': '{{field}} must be unique',
'notNull Violation': '{{field}} cannot be null',
};

View File

@ -0,0 +1,4 @@
export default {
'unique violation': '{{field}} 字段值是唯一的',
'notNull Violation': '{{field}} 字段不能为空',
};

View File

@ -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());
}
}

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.build.json",
"compilerOptions": {
"outDir": "./lib",
"declaration": true
},
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
"exclude": ["./src/__tests__/*", "./esm/*", "./lib/*"]
}

View File

@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.json",
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
"exclude": ["./esm/*", "./lib/*"]
}

View File

@ -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' });
});
});

View File

@ -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<StateT = DefaultState, ContextT = DefaultContext> exten
public readonly acl: ACL;
public readonly errorHandler: ErrorHandler;
protected plugins = new Map<string, Plugin>();
public listenServer: Server;
@ -104,8 +101,6 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
app: this,
});
this.errorHandler = new ErrorHandler(this);
registerMiddlewares(this, options);
if (options.registerActions !== false) {
registerActions(this);

View File

@ -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({