Application (#175)

* feat: getRepository

* getRepository return type

* export action

* add: acl

* feat: setResourceAction

* feat: action alias

* chore: code struct

* feat: removeResourceAction

* chore: file name

* ignorecase

* remove ACL

* feat: ACL

* feat: role toJSON

* using emit

* chore: test

* feat: plugin-acl

* feat: acl with predicate

* grant universal action test

* grant action test

* update resource action test

* revoke resource action

* usingActionsConfig switch

* plugin-ui-schema-storage

* remove global acl instance

* fix: collection manager with sqlite

* add own action listener

* add acl middleware

* add acl allowConfigure strategy option

* add plugin-acl allowConfigure

* change acl resourceName

* add acl middleware merge params

* bugfix

* append fields on acl action params

* acl middleware parse template

* fix: collection-manager migrate

* add acl association field test

* feat(plugin-acl): grant association field actions

* chore(plugin-acl): type name

* feat(plugin-acl): regrant actions on resource action update

* feat(plugin-acl): regrant action on field destroy

* fix(plugin-acl): test

* fix(plugin-acl): test run

* feat(plugin-acl): set default role

* feat(plugin-users): set user default role

* test(plugin-users): create user with role

* feat(plugin-users): create user with role

* feat(application): application hook

* feat(database): reconnect

* feat(database): application life cycle

* feat(database): sync with option

* feat(database): hook position

* feat(database): hook position

* feat(database): remove load in start

* fix(application): get plugin

* feat(test): loadAndInstall

* feat: improve code

* feat: improve code

* fix: listen options

* fix: bug

* test(database): add test case

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
ChengLei Shao 2022-01-30 11:11:36 +08:00 committed by GitHub
parent 7a7ab2ef41
commit 15950ece05
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 344 additions and 257 deletions

View File

@ -1,8 +1,25 @@
import { mockDatabase } from './index';
import path from 'path';
import { Model } from '..';
import { mockDatabase } from './index';
describe('database', () => {
test('close state', async () => {
const db = mockDatabase();
expect(db.closed()).toBeFalsy();
await db.close();
expect(db.closed()).toBeTruthy();
await db.reconnect();
expect(db.closed()).toBeFalsy();
});
test('reconnect', async () => {
const db = mockDatabase();
await db.sequelize.authenticate();
await db.close();
await db.reconnect();
await db.sequelize.authenticate();
});
test('get repository', async () => {
const db = mockDatabase();
db.collection({

View File

@ -1,18 +1,15 @@
import { Sequelize, ModelCtor, Model, Options, SyncOptions, Op, Utils } from 'sequelize';
import { EventEmitter } from 'events';
import { Collection, CollectionOptions, RepositoryType } from './collection';
import * as FieldTypes from './fields';
import { BaseFieldOptions, Field, FieldContext, FieldOptions, RelationField } from './fields';
import { applyMixins, AsyncEmitter } from '@nocobase/utils';
import merge from 'deepmerge';
import { ModelHook } from './model-hook';
import { EventEmitter } from 'events';
import { Model, ModelCtor, Op, Options, QueryInterfaceDropAllTablesOptions, Sequelize, SyncOptions, Utils } from 'sequelize';
import { Collection, CollectionOptions, RepositoryType } from './collection';
import { ImporterReader, ImportFileExtension } from './collection-importer';
import * as FieldTypes from './fields';
import { Field, FieldContext, RelationField } from './fields';
import { ModelHook } from './model-hook';
import extendOperators from './operators';
import { Repository } from './repository';
import { RelationRepository } from './relation-repository/relation-repository';
import { Repository } from './repository';
export interface MergeOptions extends merge.Options {}
@ -33,6 +30,10 @@ interface RegisterOperatorsContext {
field?: Field;
}
export interface CleanOptions extends QueryInterfaceDropAllTablesOptions {
drop?: boolean;
}
type OperatorFunc = (value: any, ctx?: RegisterOperatorsContext) => any;
export class Database extends EventEmitter implements AsyncEmitter {
@ -219,6 +220,29 @@ export class Database extends EventEmitter implements AsyncEmitter {
return result;
}
async clean(options: CleanOptions) {
const { drop, ...others } = options;
if (drop) {
await this.sequelize.getQueryInterface().dropAllTables(others);
}
}
async reconnect() {
// @ts-ignore
const ConnectionManager = this.sequelize.dialect.connectionManager.constructor;
// @ts-ignore
const connectionManager = new ConnectionManager(this.sequelize.dialect, this.sequelize);
// @ts-ignore
this.sequelize.dialect.connectionManager = connectionManager;
// @ts-ignore
this.sequelize.connectionManager = connectionManager;
}
closed() {
// @ts-ignore
return this.sequelize.connectionManager.pool._draining;
}
async close() {
return this.sequelize.close();
}

View File

@ -1,14 +1,15 @@
export * from './database';
export { Model, ModelCtor, SyncOptions } from 'sequelize';
export * from './collection';
export * from './repository';
export * from './database';
export { Database as default } from './database';
export * from './fields';
export * from './magic-attribute-model';
export * from './relation-repository/belongs-to-many-repository';
export * from './relation-repository/belongs-to-repository';
export * from './relation-repository/hasmany-repository';
export * from './relation-repository/single-relation-repository';
export * from './relation-repository/multiple-relation-repository';
export { Model, ModelCtor } from 'sequelize';
export * from './fields';
export * from './relation-repository/single-relation-repository';
export * from './repository';
export * from './update-associations';
export * from './magic-attribute-model';

View File

@ -19,7 +19,6 @@ export async function prepareApp() {
registerActions: true,
});
await app.cleanDb();
app.plugin(PluginUiSchema);
app.plugin(PluginCollectionManager);
@ -30,7 +29,7 @@ export async function prepareApp() {
});
app.plugin(PluginACL);
await app.loadAndSync();
await app.loadAndInstall();
return app;
}

View File

@ -2,7 +2,9 @@ import supertest from 'supertest';
import { Application } from '../application';
import { Plugin } from '../plugin';
class MyPlugin extends Plugin {}
class MyPlugin extends Plugin {
load() {}
}
describe('application', () => {
let app: Application;

View File

@ -0,0 +1,53 @@
import Application from '../application';
import { Plugin } from '../plugin';
describe('application life cycle', () => {
it('should start application', async () => {
const app = new Application({
database: {
dialect: 'sqlite',
storage: ':memory:',
},
});
const loadFn = jest.fn();
const installFn = jest.fn();
// register plugin
class TestPlugin extends Plugin {
beforeLoad() {}
getName() {
return 'Test';
}
load() {
loadFn();
this.app.on('beforeInstall', () => {
installFn();
});
}
}
app.plugin(TestPlugin);
await app.load();
expect(loadFn).toHaveBeenCalledTimes(1);
expect(installFn).toHaveBeenCalledTimes(0);
await app.install();
expect(installFn).toHaveBeenCalledTimes(1);
});
it('should listen application', async () => {
const app = new Application({
database: {
dialect: 'sqlite',
storage: ':memory:',
},
});
await app.start({ listen: { port: 13090 } });
expect(app.listenServer).not.toBeNull();
await app.stop();
expect(app.listenServer).toBeNull();
});
});

View File

@ -1,7 +1,7 @@
import supertest from 'supertest';
import { Application } from '../application';
import { Plugin } from '../plugin';
import path from 'path';
import Plugin1 from './plugins/plugin1';
import Plugin2 from './plugins/plugin2';
import Plugin3 from './plugins/plugin3';
describe('plugin', () => {
@ -29,101 +29,55 @@ describe('plugin', () => {
});
afterEach(async () => {
return app.db.close();
await app.destroy();
});
describe('define', () => {
it('plugin name', async () => {
const plugin = app.plugin(function abc() {});
expect(plugin).toBeInstanceOf(Plugin);
expect(plugin.getName()).toBe('abc');
});
it('should add plugin with options', async () => {
class MyPlugin extends Plugin {
load() {}
}
it('plugin name', async () => {
const plugin = app.plugin({
name: 'plugin-name2',
async load() {},
const plugin = app.plugin(MyPlugin, {
test: 'hello',
});
expect(plugin).toBeInstanceOf(Plugin);
expect(plugin.getName()).toBe('plugin-name2');
expect(plugin.options['test']).toEqual('hello');
});
it('plugin name', async () => {
const plugin = app.plugin({
name: 'plugin-name3',
load: function () {},
interface Options {
a?: string;
}
class MyPlugin extends Plugin<Options> {
load() {
this.options.a;
}
}
const plugin = app.plugin<Options>(MyPlugin, {
a: 'aa',
});
expect(plugin).toBeInstanceOf(Plugin);
expect(plugin.getName()).toBe('plugin-name3');
});
it('plugin name', async () => {
class MyPlugin extends Plugin {}
const plugin = app.plugin(MyPlugin);
plugin.setOptions({
a: 'a'
})
expect(plugin).toBeInstanceOf(MyPlugin);
expect(plugin.getName()).toBe('MyPlugin');
});
it('plugin name', async () => {
class MyPlugin extends Plugin {}
const plugin = app.plugin({
plugin: MyPlugin,
});
expect(plugin).toBeInstanceOf(MyPlugin);
expect(plugin.getName()).toBe('MyPlugin');
});
it('plugin name', async () => {
const plugin = app.plugin(path.resolve(__dirname, './plugins/plugin1'));
const plugin = app.plugin(Plugin1);
expect(plugin).toBeInstanceOf(Plugin);
expect(plugin.getName()).toBe('abc');
expect(plugin.getName()).toBe('Plugin1');
});
it('plugin name', async () => {
const plugin = app.plugin(path.resolve(__dirname, './plugins/plugin3'));
const plugin = app.plugin(Plugin3);
expect(plugin).toBeInstanceOf(Plugin3);
expect(plugin.getName()).toBe('Plugin3');
});
});
describe('load', () => {
it('plugin load', async () => {
app.plugin(function abc(this: Plugin) {
this.app.collection({
name: 'tests',
});
});
await app.load();
const Test = app.db.getCollection('tests');
expect(Test).toBeDefined();
});
it('plugin load', async () => {
app.plugin({
load() {
app.collection({
name: 'tests',
});
},
});
await app.load();
const Test = app.db.getCollection('tests');
expect(Test).toBeDefined();
});
it('plugin load', async () => {
app.plugin({
load: () => {
app.collection({
name: 'tests',
});
},
});
await app.load();
const Test = app.db.getCollection('tests');
expect(Test).toBeDefined();
});
it('plugin load', async () => {
app.plugin(
class MyPlugin extends Plugin {
@ -140,21 +94,21 @@ describe('plugin', () => {
});
it('plugin load', async () => {
app.plugin(path.resolve(__dirname, './plugins/plugin1'));
app.plugin(Plugin1);
await app.load();
const Test = app.db.getCollection('tests');
expect(Test).toBeDefined();
});
it('plugin load', async () => {
app.plugin(path.resolve(__dirname, './plugins/plugin2'));
app.plugin(Plugin2);
await app.load();
const Test = app.db.getCollection('tests');
expect(Test).toBeDefined();
});
it('plugin load', async () => {
app.plugin(path.resolve(__dirname, './plugins/plugin3'));
app.plugin(Plugin3);
await app.load();
const Test = app.db.getCollection('tests');
expect(Test).toBeDefined();

View File

@ -1,7 +1,9 @@
import { Plugin } from '../../plugin';
export default function abc(this: Plugin) {
this.app.collection({
name: 'tests',
});
export default class Plugin1 extends Plugin {
async load() {
this.app.collection({
name: 'tests',
});
}
}

View File

@ -1,9 +1,9 @@
import { IPlugin } from '../../plugin';
import { Plugin } from '../../plugin';
export default {
load() {
export default class Plugin2 extends Plugin {
async load() {
this.app.collection({
name: 'tests',
});
},
} as IPlugin;
}
}

View File

@ -1,11 +1,15 @@
import Koa from 'koa';
import { Command, CommandOptions } from 'commander';
import Database, { DatabaseOptions, CollectionOptions } from '@nocobase/database';
import Resourcer, { ResourceOptions } from '@nocobase/resourcer';
import { PluginType, Plugin, PluginOptions } from './plugin';
import { registerActions } from '@nocobase/actions';
import { createCli, createI18n, createDatabase, createResourcer, registerMiddlewares } from './helper';
import Database, { CleanOptions, CollectionOptions, DatabaseOptions, SyncOptions } from '@nocobase/database';
import Resourcer, { ResourceOptions } from '@nocobase/resourcer';
import { applyMixins, AsyncEmitter } from '@nocobase/utils';
import { Command, CommandOptions } from 'commander';
import { Server } from 'http';
import { i18n, InitOptions } from 'i18next';
import Koa from 'koa';
import { isBoolean } from 'lodash';
import { createCli, createDatabase, createI18n, createResourcer, registerMiddlewares } from './helper';
import { Plugin } from './plugin';
import { PluginManager } from './plugin-manager';
export interface ResourcerOptions {
prefix?: string;
@ -45,7 +49,31 @@ interface ActionsOptions {
resourceNames?: string[];
}
export class Application<StateT = DefaultState, ContextT = DefaultContext> extends Koa {
interface ListenOptions {
port?: number | undefined;
host?: string | undefined;
backlog?: number | undefined;
path?: string | undefined;
exclusive?: boolean | undefined;
readableAll?: boolean | undefined;
writableAll?: boolean | undefined;
/**
* @default false
*/
ipv6Only?: boolean | undefined;
signal?: AbortSignal | undefined;
}
interface StartOptions {
listen?: ListenOptions;
}
interface InstallOptions {
clean?: CleanOptions | boolean;
sync?: SyncOptions;
}
export class Application<StateT = DefaultState, ContextT = DefaultContext> extends Koa implements AsyncEmitter {
public readonly db: Database;
public readonly resourcer: Resourcer;
@ -54,8 +82,12 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
public readonly i18n: i18n;
public readonly pm: PluginManager;
protected plugins = new Map<string, Plugin>();
public listenServer: Server;
constructor(options: ApplicationOptions) {
super();
@ -64,12 +96,20 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
this.cli = createCli(this, options);
this.i18n = createI18n(options);
this.pm = new PluginManager({
app: this,
});
registerMiddlewares(this, options);
if (options.registerActions !== false) {
registerActions(this);
}
}
plugin<O = any>(pluginClass: any, options?: O): Plugin<O> {
return this.pm.add(pluginClass, options);
}
use<NewStateT = {}, NewContextT = {}>(
middleware: Koa.Middleware<StateT & NewStateT, ContextT & NewContextT>,
options?: MiddlewareOptions,
@ -98,110 +138,12 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
return (this.cli as any)._findCommand(name);
}
plugin(options?: PluginType | PluginOptions, ext?: PluginOptions): Plugin {
if (typeof options === 'string') {
return this.plugin(require(options).default, ext);
}
let instance: Plugin;
if (typeof options === 'function') {
try {
// @ts-ignore
instance = new options({
name: options.name,
...ext,
app: this,
});
if (!(instance instanceof Plugin)) {
throw new Error('plugin must be instanceof Plugin');
}
} catch (err) {
// console.log(err);
instance = new Plugin({
name: options.name,
...ext,
// @ts-ignore
load: options,
app: this,
});
}
} else if (typeof options === 'object') {
const plugin = options.plugin || Plugin;
instance = new plugin({
name: options.plugin ? plugin.name : undefined,
...options,
...ext,
app: this,
});
}
const name = instance.getName();
if (this.plugins.has(name)) {
throw new Error(`plugin name [${name}] is repeated`);
}
this.plugins.set(name, instance);
return instance;
}
async load() {
await this.emitAsync('plugins.beforeLoad');
for (const [name, plugin] of this.plugins) {
await this.emitAsync(`plugins.${name}.beforeLoad`);
await plugin.load();
await this.emitAsync(`plugins.${name}.afterLoad`);
}
await this.emitAsync('plugins.afterLoad');
await this.pm.load();
}
getPlugin<P extends Plugin>(name: string) {
return this.plugins.get(name) as P;
}
async emitAsync(event: string | symbol, ...args: any[]): Promise<boolean> {
// @ts-ignore
const events = this._events;
let callbacks = events[event];
if (!callbacks) {
return false;
}
// helper function to reuse as much code as possible
const run = (cb) => {
switch (args.length) {
// fast cases
case 0:
cb = cb.call(this);
break;
case 1:
cb = cb.call(this, args[0]);
break;
case 2:
cb = cb.call(this, args[0], args[1]);
break;
case 3:
cb = cb.call(this, args[0], args[1], args[2]);
break;
// slower
default:
cb = cb.apply(this, args);
}
if (cb && (cb instanceof Promise || typeof cb.then === 'function')) {
return cb;
}
return Promise.resolve(true);
};
if (typeof callbacks === 'function') {
await run(callbacks);
} else if (typeof callbacks === 'object') {
callbacks = callbacks.slice().filter(Boolean);
await callbacks.reduce((prev, next) => {
return prev.then((res) => {
return run(next).then((result) => Promise.resolve(res.concat(result)));
});
}, Promise.resolve([]));
}
return true;
return this.pm.get(name) as P;
}
async parse(argv = process.argv) {
@ -209,9 +151,73 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
return this.cli.parseAsync(argv);
}
async destroy() {
await this.db.close();
async start(options?: StartOptions) {
// reconnect database
if (this.db.closed()) {
await this.db.reconnect();
}
await this.emitAsync('beforeStart', this, options);
if (options?.listen?.port) {
const listen = () =>
new Promise((resolve) => {
const Server = this.listen(options?.listen, () => {
resolve(Server);
});
});
// @ts-ignore
this.listenServer = await listen();
}
await this.emitAsync('afterStart', this, options);
}
async stop() {
await this.emitAsync('beforeStop', this);
// close database connection
await this.db.close();
// close http server
if (this.listenServer) {
const closeServer = () =>
new Promise((resolve, reject) => {
this.listenServer.close((err) => {
if (err) {
return reject(err);
}
this.listenServer = null;
resolve(true);
});
});
await closeServer();
}
await this.emitAsync('afterStop', this);
}
async destroy() {
await this.emitAsync('beforeDestroy', this);
await this.stop();
await this.emitAsync('afterDestroy', this);
}
async install(options?: InstallOptions) {
if (options?.clean) {
await this.db.clean(isBoolean(options.clean) ? { drop: options.clean } : options.clean);
}
await this.db.sync(options?.sync);
await this.emitAsync('beforeInstall', this, options);
await this.emitAsync('afterInstall', this, options);
}
emitAsync: (event: string | symbol, ...args: any[]) => Promise<boolean>;
}
applyMixins(Application, [AsyncEmitter]);
export default Application;

View File

@ -86,7 +86,7 @@ export function createCli(app: Application, options: ApplicationOptions) {
const cli = args.pop();
const opts = cli.opts();
await app.emitAsync('beforeStart');
app.listen(opts.port || 3000);
await app.start(opts.port || 3000);
console.log(`http://localhost:${opts.port || 3000}/`);
});
return cli;

View File

@ -0,0 +1,49 @@
import Application from './application';
import { Plugin } from './plugin';
interface PluginManagerOptions {
app: Application;
}
export class PluginManager {
app: Application;
protected plugins = new Map<string, Plugin>();
constructor(options: PluginManagerOptions) {
this.app = options.app;
}
get(name: string) {
return this.plugins.get(name);
}
add<P = Plugin, O = any>(pluginClass: any, options?: O): P {
const instance = new pluginClass(this.app, options);
const name = instance.getName();
if (this.plugins.has(name)) {
throw new Error(`plugin name [${name}] `);
}
this.plugins.set(name, instance);
return instance;
}
async load() {
await this.app.emitAsync('beforeLoadAll');
for (const [name, plugin] of this.plugins) {
await plugin.beforeLoad();
}
for (const [name, plugin] of this.plugins) {
await this.app.emitAsync('beforeLoadPlugin', plugin);
await plugin.load();
await this.app.emitAsync('afterLoadPlugin', plugin);
}
await this.app.emitAsync('afterLoadAll');
}
}

View File

@ -1,14 +1,13 @@
import { uid } from '@nocobase/utils';
import { Database } from '@nocobase/database';
import { Application } from './application';
import _ from 'lodash';
export interface IPlugin {
install?: (this: Plugin) => void;
load?: (this: Plugin) => void;
export interface PluginInterface {
beforeLoad?: () => void;
load();
getName(): string;
}
export interface PluginOptions {
name?: string;
activate?: boolean;
displayName?: string;
description?: string;
@ -19,42 +18,28 @@ export interface PluginOptions {
[key: string]: any;
}
export type PluginFn = (this: Plugin) => void;
export type PluginType = typeof Plugin;
export type PluginType = string | PluginFn | typeof Plugin;
export class Plugin implements IPlugin {
options: PluginOptions = {};
export abstract class Plugin<O = any> implements PluginInterface {
options: O;
app: Application;
callbacks: IPlugin = {};
db: Database;
constructor(options?: PluginOptions & { app?: Application }) {
this.app = options?.app;
this.options = options;
this.callbacks = _.pick(options, ['load', 'activate']);
constructor(app: Application, options?: O) {
this.app = app;
this.db = app.db;
this.setOptions(options);
}
getName() {
return this.options.name || uid();
setOptions(options: O) {
this.options = options || ({} as any);
}
async activate() {
this.options.activate = true;
getName(): string {
return this.constructor.name;
}
async install() {
await this.call('install');
}
beforeLoad() {}
async call(name: string) {
if (!this.callbacks[name]) {
return;
}
const callback = this.callbacks[name].bind(this);
await callback();
}
async load() {
await this.call('load');
}
abstract load();
}

View File

@ -55,14 +55,9 @@ interface Resource {
}
export class MockServer extends Application {
async loadAndSync() {
async loadAndInstall() {
await this.load();
await this.db.sync({
force: false,
alter: {
drop: false,
},
});
await this.install({ clean: true });
}
async cleanDb() {