refactor: improve server application

This commit is contained in:
chenos 2021-09-09 13:05:33 +08:00
parent edbe1ecb67
commit 3fa9e59093
9 changed files with 335 additions and 411 deletions

View File

@ -0,0 +1,123 @@
import supertest from 'supertest';
import { Application } from '../application';
describe('application', () => {
let app: Application;
let agent;
beforeEach(() => {
app = new Application({
database: {
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
host: process.env.DB_HOST,
port: process.env.DB_PORT as any,
dialect: process.env.DB_DIALECT as any,
dialectOptions: {
charset: 'utf8mb4',
collate: 'utf8mb4_unicode_ci',
},
},
resourcer: {
prefix: '/api',
},
dataWrapping: false,
});
app.resourcer.registerActionHandlers({
list: async (ctx, next) => {
ctx.body = [1, 2];
await next();
},
get: async (ctx, next) => {
ctx.body = [3, 4];
await next();
},
'foo2s.bar2s:list': async (ctx, next) => {
ctx.body = [5, 6];
await next();
},
});
agent = supertest.agent(app.callback());
});
afterEach(async () => {
return app.database.close();
});
it('resourcer.define', async () => {
app.resourcer.define({
name: 'test',
});
const response = await agent.get('/api/test');
expect(response.body).toEqual([1, 2]);
});
it('resourcer.define', async () => {
app.resourcer.define({
type: 'hasMany',
name: 'test.abc',
});
const response = await agent.get('/api/test/1/abc');
expect(response.body).toEqual([1, 2]);
});
it('db.table', async () => {
app.database.table({
name: 'tests',
});
const response = await agent.get('/api/tests');
expect(response.body).toEqual([1, 2]);
});
it('db.association', async () => {
app.database.table({
name: 'bars',
});
app.database.table({
name: 'foos',
fields: [
{
type: 'hasMany',
name: 'bars',
}
],
});
const response = await agent.get('/api/foos/1/bars');
expect(response.body).toEqual([1, 2]);
});
it('db.middleware', async () => {
const index = app.middleware.findIndex(m => m.name === 'table2resource');
app.middleware.splice(index, 0, async (ctx, next) => {
app.database.table({
name: 'tests',
});
await next();
});
const response = await agent.get('/api/tests');
expect(response.body).toEqual([1, 2]);
});
it('db.middleware', async () => {
const index = app.middleware.findIndex(m => m.name === 'table2resource');
app.middleware.splice(index, 0, async (ctx, next) => {
app.database.table({
name: 'bars',
});
app.database.table({
name: 'foos',
fields: [
{
type: 'hasMany',
name: 'bars',
}
],
});
await next();
});
console.log(app.middleware);
const response = await agent.get('/api/foos/1/bars');
expect(response.body).toEqual([1, 2]);
});
});

View File

@ -0,0 +1,57 @@
import supertest from 'supertest';
import { Application } from '../application';
describe('application', () => {
let app: Application;
let agent;
beforeEach(() => {
app = new Application({
database: {
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
host: process.env.DB_HOST,
port: process.env.DB_PORT as any,
dialect: process.env.DB_DIALECT as any,
dialectOptions: {
charset: 'utf8mb4',
collate: 'utf8mb4_unicode_ci',
},
},
resourcer: {
prefix: '/api',
},
dataWrapping: true,
});
app.resourcer.registerActionHandlers({
list: async (ctx, next) => {
ctx.body = [1, 2];
await next();
},
get: async (ctx, next) => {
ctx.body = [3, 4];
await next();
},
'foo2s.bar2s:list': async (ctx, next) => {
ctx.body = [5, 6];
await next();
},
});
agent = supertest.agent(app.callback());
});
afterEach(async () => {
return app.database.close();
});
it('resourcer.define', async () => {
app.resourcer.define({
name: 'test',
});
const response = await agent.get('/api/test');
expect(response.body).toEqual({
data: [1, 2],
});
});
});

View File

@ -1,105 +0,0 @@
import Koa from 'koa';
import supertest from 'supertest';
import http from 'http';
import Resourcer from '@nocobase/resourcer';
import Database from '@nocobase/database';
import middleware from '../middleware';
describe('middleware', () => {
let app: Koa;
let resourcer: Resourcer;
let database: Database;
let agent;
beforeAll(() => {
app = new Koa();
resourcer = new Resourcer();
resourcer.registerActionHandlers({
list: async (ctx, next) => {
ctx.body = [1, 2];
await next();
},
get: async (ctx, next) => {
ctx.body = [3, 4];
await next();
},
'foo2s.bar2s:list': async (ctx, next) => {
ctx.body = [5, 6];
await next();
},
});
database = new Database({
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
host: process.env.DB_HOST,
port: process.env.DB_PORT as any,
dialect: process.env.DB_DIALECT as any,
dialectOptions: {
charset: 'utf8mb4',
collate: 'utf8mb4_unicode_ci',
},
});
agent = supertest.agent(app.callback());
app.use(middleware({
prefix: '/api',
database,
resourcer,
}));
});
it('shound work', async () => {
database.table({
name: 'tests',
});
const response = await agent.get('/api/tests');
expect(response.body).toEqual([1, 2]);
});
it('shound work', async () => {
database.table({
name: 'foos',
fields: [
{
type: 'hasmany',
name: 'bars',
}
]
});
database.table({
name: 'bars',
fields: [
{
type: 'belongsTo',
name: 'foo',
},
],
});
let response = await agent.get('/api/foos/1/bars');
expect(response.body).toEqual([1, 2]);
response = await agent.get('/api/bars/1/foo');
expect(response.body).toEqual([3, 4]);
});
it('shound work', async () => {
database.table({
name: 'foo2s',
fields: [
{
type: 'belongsToMany',
name: 'bar2s',
}
]
});
database.table({
name: 'bar2s',
fields: [
{
type: 'belongsToMany',
name: 'foo2s',
},
],
});
let response = await agent.get('/api/foo2s/1/bar2s');
expect(response.body).toEqual([5, 6]);
response = await agent.get('/api/bar2s/1/foo2s');
expect(response.body).toEqual([1, 2]);
});
});

View File

@ -1,19 +1,24 @@
import Koa from 'koa'; import Koa from 'koa';
import cors from '@koa/cors';
import bodyParser from 'koa-bodyparser';
import { Command } from 'commander';
import Database, { DatabaseOptions } from '@nocobase/database'; import Database, { DatabaseOptions } from '@nocobase/database';
import Resourcer from '@nocobase/resourcer'; import Resourcer from '@nocobase/resourcer';
import { Command } from 'commander'; import { dataWrapping, table2resource } from './middlewares';
import { actions, middlewares as m } from '@nocobase/actions';
import cors from '@koa/cors'; export interface ResourcerOptions {
import { dbResourceRouter } from './middlewares'; prefix?: string;
import bodyParser from 'koa-bodyparser'; }
export interface ApplicationOptions { export interface ApplicationOptions {
database: DatabaseOptions; database?: DatabaseOptions;
resourcer?: any; resourcer?: ResourcerOptions;
bodyParser?: any;
cors?: any;
dataWrapping?: boolean;
} }
export class Application extends Koa { export class Application extends Koa {
public readonly database: Database; public readonly database: Database;
public readonly resourcer: Resourcer; public readonly resourcer: Resourcer;
@ -24,31 +29,37 @@ export class Application extends Koa {
constructor(options: ApplicationOptions) { constructor(options: ApplicationOptions) {
super(); super();
this.database = new Database(options.database); this.database = new Database(options.database);
this.resourcer = new Resourcer(); this.resourcer = new Resourcer({ ...options.resourcer });
this.cli = new Command(); this.cli = new Command();
this.use(bodyParser()); this.use(
this.use(cors({ bodyParser({
exposeHeaders: ['content-disposition'], ...options.bodyParser,
})); }),
);
this.resourcer.registerActionHandlers({ ...actions.common, ...actions.associate }); this.use(
cors({
exposeHeaders: ['content-disposition'],
...options.cors,
}),
);
this.use(async (ctx, next) => { this.use(async (ctx, next) => {
ctx.db = this.database; ctx.db = this.database;
ctx.database = this.database; ctx.database = this.database;
ctx.resourcer = this.resourcer;
await next(); await next();
}); });
this.resourcer.use(m.associated); if (options.dataWrapping !== false) {
this.use(m.dataWrapping); this.use(dataWrapping);
}
this.use(dbResourceRouter({ this.use(table2resource);
database: this.database, this.use(this.resourcer.restApiMiddleware());
resourcer: this.resourcer,
...(options.resourcer || {}),
}));
this.cli this.cli
.command('db sync') .command('db sync')
@ -65,7 +76,7 @@ export class Application extends Koa {
.action(async (...args) => { .action(async (...args) => {
const cli = args.pop(); const cli = args.pop();
await this.emitAsync('db.init'); await this.emitAsync('db.init');
await this.database.close(); await this.destroy();
}); });
this.cli this.cli
@ -110,12 +121,7 @@ export class Application extends Koa {
cb = cb.apply(this, args); cb = cb.apply(this, args);
} }
if ( if (cb && (cb instanceof Promise || typeof cb.then === 'function')) {
cb && (
cb instanceof Promise ||
typeof cb.then === 'function'
)
) {
return cb; return cb;
} }
@ -128,7 +134,9 @@ export class Application extends Koa {
callbacks = callbacks.slice().filter(Boolean); callbacks = callbacks.slice().filter(Boolean);
await callbacks.reduce((prev, next) => { await callbacks.reduce((prev, next) => {
return prev.then((res) => { return prev.then((res) => {
return run(next).then((result) => Promise.resolve(res.concat(result))); return run(next).then((result) =>
Promise.resolve(res.concat(result)),
);
}); });
}, Promise.resolve([])); }, Promise.resolve([]));
} }
@ -153,11 +161,6 @@ export class Application extends Koa {
} }
} }
getPluginInstance(key: string) {
const plugin = this.plugins.get(key);
return plugin && plugin.instance;
}
async loadPlugins() { async loadPlugins() {
await this.emitAsync('plugins.beforeLoad'); await this.emitAsync('plugins.beforeLoad');
const allPlugins = this.plugins.values(); const allPlugins = this.plugins.values();
@ -171,15 +174,26 @@ export class Application extends Koa {
return this.cli.parseAsync(argv); return this.cli.parseAsync(argv);
} }
protected async loadPlugin({ entry, options = {} }: { entry: string | Function, options: any }) { async destroy() {
await this.database.close()
}
protected async loadPlugin({
entry,
options = {},
}: {
entry: string | Function;
options: any;
}) {
let main: any; let main: any;
if (typeof entry === 'function') { if (typeof entry === 'function') {
main = entry; main = entry;
} else if (typeof entry === 'string') { } else if (typeof entry === 'string') {
const pathname = `${entry}/${__filename.endsWith('.ts') ? 'src' : 'lib'}/server`; const pathname = `${entry}/${__filename.endsWith('.ts') ? 'src' : 'lib'
}/server`;
main = require(pathname).default; main = require(pathname).default;
} }
return main && await main.call(this, options); return main && (await main.call(this, options));
} }
} }

View File

@ -1,135 +0,0 @@
import compose from 'koa-compose';
import pathToRegexp from 'path-to-regexp';
import Resourcer, { getNameByParams, KoaMiddlewareOptions, parseRequest, parseQuery, ResourcerContext, ResourceType } from '@nocobase/resourcer';
import Database, { BELONGSTO, BELONGSTOMANY, HASMANY, HASONE } from '@nocobase/database';
interface MiddlewareOptions extends KoaMiddlewareOptions {
resourcer?: Resourcer;
database?: Database;
}
/**
* database + resourcer
*
* @param options
*/
export function middleware(options: MiddlewareOptions = {}) {
const {
prefix,
database,
resourcer,
accessors,
paramsKey = 'params',
nameRule = getNameByParams,
} = options;
return async (ctx: ResourcerContext, next: () => Promise<any>) => {
ctx.resourcer = resourcer;
let params = parseRequest({
path: ctx.request.path,
method: ctx.request.method,
}, {
prefix,
accessors,
});
if (!params) {
return next();
}
try {
const resourceName = nameRule(params);
// 如果资源名称未被定义
if (!resourcer.isDefined(resourceName)) {
const [tableName, fieldName] = resourceName.split('.');
const Collection = database.getModel('collections');
// 检查资源对应的表名是否已经定义
if (!database.isDefined(tableName) && Collection) {
// 未定义则尝试通过 collection 表来加载
await Collection.load({
where: {
name: tableName,
},
});
}
// 如果经过加载后是已经定义的表
if (database.isDefined(tableName)) {
const table = database.getTable(tableName);
const field = table.getField(fieldName) as BELONGSTO | HASMANY | BELONGSTOMANY | HASONE;
if (!fieldName || field) {
let resourceType: ResourceType = 'single';
let actions = {};
if (field) {
if (field instanceof HASONE) {
resourceType = 'hasOne';
} else if (field instanceof HASMANY) {
resourceType = 'hasMany';
} else if (field instanceof BELONGSTO) {
resourceType = 'belongsTo';
} else if (field instanceof BELONGSTOMANY) {
resourceType = 'belongsToMany';
}
if (field.options.actions) {
actions = field.options.actions;
}
} else {
const items = table.getOptions('actions') || [];
for (const item of (items as any[])) {
actions[item.name] = item;
}
}
resourcer.define({
type: resourceType,
name: resourceName,
actions,
});
}
}
}
const resource = resourcer.getResource(resourceName);
// 为关系资源时,暂时需要再执行一遍 parseRequest
if (resource.options.type !== 'single') {
params = parseRequest({
path: ctx.request.path,
method: ctx.request.method,
type: resource.options.type,
}, {
prefix,
accessors,
});
if (!params) {
return next();
}
}
// console.log(resource);
// action 需要 clone 之后再赋给 ctx
ctx.action = resourcer.getAction(resourceName, params.actionName).clone();
ctx.action.setContext(ctx);
// 自带 query 处理的不太给力,需要用 qs 转一下
const query = parseQuery(ctx.request.querystring);
// 兼容 ctx.params 的处理,之后的版本里会去掉
ctx[paramsKey] = {
table: params.resourceName,
tableKey: params.resourceKey,
relatedTable: params.associatedName,
relatedKey: params.resourceKey,
action: params.actionName,
};
if (pathToRegexp('/resourcer/{:associatedName.}?:resourceName{\\::actionName}').test(ctx.request.path)) {
await ctx.action.mergeParams({
...query,
...params,
...ctx.request.body,
});
} else {
await ctx.action.mergeParams({
...query,
...params,
values: ctx.request.body,
});
}
return compose(ctx.action.getHandlers())(ctx, next);
} catch (error) {
console.log(error);
return next();
}
}
}
export default middleware;

View File

@ -0,0 +1,30 @@
import { Action } from '@nocobase/resourcer';
export async function dataWrapping(ctx, next) {
await next();
if (ctx.withoutDataWrapping) {
return;
}
if (!ctx?.action?.params) {
return;
}
if (ctx.body instanceof Buffer) {
return;
}
if (!ctx.body) {
ctx.body = {};
}
const { rows, ...meta } = ctx.body;
if (rows) {
ctx.body = {
data: rows,
meta,
};
} else {
ctx.body = {
data: ctx.body,
};
}
}
export default dataWrapping;

View File

@ -1,133 +0,0 @@
import compose from 'koa-compose';
import pathToRegexp from 'path-to-regexp';
import Resourcer, { getNameByParams, KoaMiddlewareOptions, parseRequest, parseQuery, ResourcerContext, ResourceType } from '@nocobase/resourcer';
import Database, { BELONGSTO, BELONGSTOMANY, HASMANY, HASONE } from '@nocobase/database';
interface MiddlewareOptions extends KoaMiddlewareOptions {
resourcer?: Resourcer;
database?: Database;
}
/**
* database + resourcer
*
* @param options
*/
export function dbResourceRouter(options: MiddlewareOptions = {}) {
const {
prefix,
database,
resourcer,
accessors,
paramsKey = 'params',
nameRule = getNameByParams,
} = options;
return async (ctx: ResourcerContext, next: () => Promise<any>) => {
ctx.resourcer = resourcer;
let params = parseRequest({
path: ctx.request.path,
method: ctx.request.method,
}, {
prefix,
accessors,
});
if (!params) {
return next();
}
try {
const resourceName = nameRule(params);
// 如果资源名称未被定义
if (!resourcer.isDefined(resourceName)) {
const [tableName, fieldName] = resourceName.split('.');
const Collection = database.getModel('collections');
// 检查资源对应的表名是否已经定义
if (!database.isDefined(tableName) && Collection) {
// 未定义则尝试通过 collection 表来加载
await Collection.load({
where: {
name: tableName,
},
});
}
// 如果经过加载后是已经定义的表
if (database.isDefined(tableName)) {
const table = database.getTable(tableName);
const field = table.getField(fieldName) as BELONGSTO | HASMANY | BELONGSTOMANY | HASONE;
if (!fieldName || field) {
let resourceType: ResourceType = 'single';
let actions = {};
if (field) {
if (field instanceof HASONE) {
resourceType = 'hasOne';
} else if (field instanceof HASMANY) {
resourceType = 'hasMany';
} else if (field instanceof BELONGSTO) {
resourceType = 'belongsTo';
} else if (field instanceof BELONGSTOMANY) {
resourceType = 'belongsToMany';
}
if (field.options.actions) {
actions = field.options.actions;
}
} else {
const items = table.getOptions('actions') || [];
for (const item of (items as any[])) {
actions[item.name] = item;
}
}
resourcer.define({
type: resourceType,
name: resourceName,
actions,
});
}
}
}
const resource = resourcer.getResource(resourceName);
// 为关系资源时,暂时需要再执行一遍 parseRequest
if (resource.options.type !== 'single') {
params = parseRequest({
path: ctx.request.path,
method: ctx.request.method,
type: resource.options.type,
}, {
prefix,
accessors,
});
if (!params) {
return next();
}
}
// console.log(resource);
// action 需要 clone 之后再赋给 ctx
ctx.action = resourcer.getAction(resourceName, params.actionName).clone();
ctx.action.setContext(ctx);
// 自带 query 处理的不太给力,需要用 qs 转一下
const query = parseQuery(ctx.request.querystring);
// 兼容 ctx.params 的处理,之后的版本里会去掉
ctx[paramsKey] = {
table: params.resourceName,
tableKey: params.resourceKey,
relatedTable: params.associatedName,
relatedKey: params.resourceKey,
action: params.actionName,
};
if (pathToRegexp('/resourcer/{:associatedName.}?:resourceName{\\::actionName}').test(ctx.request.path)) {
await ctx.action.mergeParams({
...query,
...params,
...ctx.request.body,
});
} else {
await ctx.action.mergeParams({
...query,
...params,
values: ctx.request.body,
});
}
return compose(ctx.action.getHandlers())(ctx, next);
} catch (error) {
console.log(error);
return next();
}
}
}

View File

@ -1,4 +1,5 @@
export * from './action-params'; export * from './action-params';
export * from './app-dist-serve'; export * from './app-dist-serve';
export * from './db-resource-router';
export * from './demo-blacklisted-actions'; export * from './demo-blacklisted-actions';
export * from './table2resource';
export * from './data-wrapping';

View File

@ -0,0 +1,72 @@
import {
getNameByParams,
parseRequest,
ResourcerContext,
ResourceType,
} from '@nocobase/resourcer';
import { BELONGSTO, BELONGSTOMANY, HASMANY, HASONE } from '@nocobase/database';
export async function table2resource(
ctx: ResourcerContext,
next: () => Promise<any>,
) {
const resourcer = ctx.resourcer;
const database = ctx.database;
let params = parseRequest(
{
path: ctx.request.path,
method: ctx.request.method,
},
{
prefix: resourcer.options.prefix,
accessors: resourcer.options.accessors,
},
);
if (!params) {
return next();
}
const resourceName = getNameByParams(params);
// 如果资源名称未被定义
if (resourcer.isDefined(resourceName)) {
return next();
}
const [tableName, fieldName] = resourceName.split('.');
// 如果经过加载后是已经定义的表
if (!database.isDefined(tableName)) {
return next();
}
const table = database.getTable(tableName);
const field = table.getField(fieldName) as
| BELONGSTO
| HASMANY
| BELONGSTOMANY
| HASONE;
if (!fieldName || field) {
let resourceType: ResourceType = 'single';
let actions = {};
if (field) {
if (field instanceof HASONE) {
resourceType = 'hasOne';
} else if (field instanceof HASMANY) {
resourceType = 'hasMany';
} else if (field instanceof BELONGSTO) {
resourceType = 'belongsTo';
} else if (field instanceof BELONGSTOMANY) {
resourceType = 'belongsToMany';
}
if (field.options.actions) {
actions = field.options.actions || {};
}
} else {
actions = table.getOptions('actions') || {};
}
resourcer.define({
type: resourceType,
name: resourceName,
actions,
});
}
return next();
}
export default table2resource;