Skip to content

Commit

Permalink
feat(Types): Add types for plugins as plugins (#5)
Browse files Browse the repository at this point in the history
Adds typed plugins. A typed plugin is plugin that references another plugin containing information about the type.
  • Loading branch information
e0ipso authored Oct 28, 2017
1 parent bb5abe4 commit 546bd83
Show file tree
Hide file tree
Showing 16 changed files with 336 additions and 19 deletions.
1 change: 1 addition & 0 deletions .emdaer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
link: 'http://npmjs.com/package/plugnplay'
- include: './docs/summary.md'
- include: './docs/quick.md'
- include: './docs/faq.md'
- include: './docs/plugins.md'
- include: './docs/options.md'
- contributors: true
Expand Down
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ manager.instantiate(config.get('loggerPlugin'), config.get('loggerOptions'))
});
```

## FAQ
##### Why not just node modules that you include with a `require`?
TBD
##### Can external modules provide plugins?
TBD
##### What's the impact on performance of the plugin auto discovery?
TBD

## Create a Plugin
A plugin is just a directory that contains a `plugnplay.yml` file and a loader. For instance:

Expand Down Expand Up @@ -91,6 +99,49 @@ module.exports = class FirstPluginLoader extends PluginLoaderBase {
This loader will export the `console.log` function as the `logger` property in the exported
functionality.

### Typed Plugins
A common use case is to have plugins that all conform to the same shape. For that we can use typed
plugins. A typed plugin is a plugin that has the `type` key populated. The `type` contains the id
of another plugin, this other plugin is the _type plugin_. Type plugins are regular plugins with the
only requirement that their loader extends from `PluginTypeLoaderBase`.

Look at the example plugins in the `test` folder: [fruit](/test/test_plugins/fruit) is the type for
[pear](/test/test_plugins/pear) (the typed plugin).

#### Fruit
##### plugnplay.yml
```yaml
id: fruit
name: Fruit
description: A type of delicious food.
loader: loader.js
```
```js
const { PluginTypeLoaderBase } = require('plugnplay');

module.exports = class FruitLoader extends PluginTypeLoaderBase {
/**
* @inheritDoc
*/
definePluginProperties() {
// This defines the names of the properties that plugins of this type will expose. If a plugin
// of this type doesn't expose any of these, an error is generated.
return ['isBerry', 'isGood', 'size'];
}
};
```

#### Pear
```yaml
id: pear
name: Pear
description: A kind of fruit
loader: loader.js
# The ID of the Fruit plugin.
type: fruit
```
## Options
```flow js
type PluginManagerConfig = {
Expand Down
7 changes: 7 additions & 0 deletions docs/faq.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
## FAQ
##### Why not just node modules that you include with a `require`?
TBD
##### Can external modules provide plugins?
TBD
##### What's the impact on performance of the plugin auto discovery?
TBD
43 changes: 43 additions & 0 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,46 @@ module.exports = class FirstPluginLoader extends PluginLoaderBase {

This loader will export the `console.log` function as the `logger` property in the exported
functionality.

### Typed Plugins
A common use case is to have plugins that all conform to the same shape. For that we can use typed
plugins. A typed plugin is a plugin that has the `type` key populated. The `type` contains the id
of another plugin, this other plugin is the _type plugin_. Type plugins are regular plugins with the
only requirement that their loader extends from `PluginTypeLoaderBase`.

Look at the example plugins in the `test` folder: [fruit](/test/test_plugins/fruit) is the type for
[pear](/test/test_plugins/pear) (the typed plugin).

#### Fruit
##### plugnplay.yml
```yaml
id: fruit
name: Fruit
description: A type of delicious food.
loader: loader.js
```
```js
const { PluginTypeLoaderBase } = require('plugnplay');

module.exports = class FruitLoader extends PluginTypeLoaderBase {
/**
* @inheritDoc
*/
definePluginProperties() {
// This defines the names of the properties that plugins of this type will expose. If a plugin
// of this type doesn't expose any of these, an error is generated.
return ['isBerry', 'isGood', 'size'];
}
};
```

#### Pear
```yaml
id: pear
name: Pear
description: A kind of fruit
loader: loader.js
# The ID of the Fruit plugin.
type: fruit
```
31 changes: 21 additions & 10 deletions src/PluginManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
PluginManagerConfig,
PluginManagerInterface,
PluginDescriptor,
PluginInstance,
} from '../types/common';

const { dirname } = require('path');
Expand All @@ -12,7 +13,6 @@ const fs = require('fs');
const _ = require('lodash');
const pify = require('pify');
const yaml = require('js-yaml');
const PluginLoaderBase = require('./PluginLoaderBase');
const PluginLoaderFactory = require('./PluginLoaderFactory');

const readFile = pify(fs.readFile);
Expand Down Expand Up @@ -100,7 +100,6 @@ class PluginManager implements PluginManagerInterface {
return null;
}
// Exclude docs without the required keys.
// TODO: Write a schema for the `plugnplay.yml` and validate the schema instead of manual testing.
return _.has(doc, 'id') && _.has(doc, 'loader') ? doc : null;
});
return docs.filter(_.identity);
Expand Down Expand Up @@ -131,17 +130,22 @@ class PluginManager implements PluginManagerInterface {
* @private
*/
_addDefaults(doc: Object, pluginPath: string): PluginDescriptor {
return Object.assign({}, {
const output = Object.assign({}, {
id: '',
dependencies: [],
_pluginPath: pluginPath,
}, doc);
if (typeof doc.type !== 'undefined') {
output.dependencies.push(doc.type);
}
output.dependencies = _.uniq(output.dependencies);
return output;
}

/**
* @inheritDoc
*/
instantiate(pluginId: string, options: Object = {}): Promise<Object> {
instantiate(pluginId: string, options: Object = {}): Promise<PluginInstance> {
return this.discover()
.then((descriptors) => {
const descriptor = descriptors
Expand All @@ -152,17 +156,17 @@ class PluginManager implements PluginManagerInterface {
throw new Error(msg);
}
const loader = PluginLoaderFactory.create(descriptor, this, pluginId);
if (loader instanceof PluginLoaderBase) {
if (typeof loader.export === 'function' && loader.constructor.prototype) {
// Get the object with the actual functionality.
return loader.export(options);
return { exports: loader.export(options), descriptor };
}
throw new Error(`Unable to find or execute the plugin loader for plugin "${pluginId}".`);
throw new Error(`Unable to find or execute the plugin loader for plugin "${pluginId}" (found ${loader.constructor.name}).`);
})
.then((exported) => {
if (!(exported instanceof Object)) {
.then((instance) => {
if (!(instance.exports instanceof Object)) {
throw new Error(`The plugin "${pluginId}" did not return an object after loading.`);
}
return exported;
return instance;
});
}

Expand Down Expand Up @@ -204,6 +208,13 @@ class PluginManager implements PluginManagerInterface {
}
});
}

/**
* @inheritDoc
*/
all(): Array<PluginDescriptor> {
return this.registeredDescriptors;
}
}

module.exports = PluginManager;
39 changes: 39 additions & 0 deletions src/PluginTypeLoaderBase.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// @flow

import type { PluginTypeLoaderInterface } from '../types/common';

const PluginLoaderBase = require('./PluginLoaderBase');

/**
* @classdesc
* Loader for plugins representing types.
* @class
* PluginTypeLoaderBase
*/
class PluginTypeLoaderBase extends PluginLoaderBase implements PluginTypeLoaderInterface {
/**
* @inheritDoc
*/
export(options: Object): Object {
return {
props: this.definePluginProperties(),
plugins: this.findPlugins(),
};
}

/**
* @inheritDoc
*/
definePluginProperties() {
throw new Error('You need to override this method in the actual plugin implementation.');
}

/**
* @inheritDoc
*/
findPlugins() {
return this.manager.all().filter(({ type }) => type === this.pluginId);
}
}

module.exports = PluginTypeLoaderBase;
2 changes: 2 additions & 0 deletions test/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const PluginLoaderBaseTest = require('./src/PluginLoaderBaseTest');
const PluginLoaderFactoryTest = require('./src/PluginLoaderFactoryTest');
const PluginManagerTest = require('./src/PluginManagerTest');
const PluginTypeLoaderBaseTest = require('./src/PluginTypeLoaderBaseTest');
const sinon = require('sinon');

module.exports = {
Expand Down Expand Up @@ -29,4 +30,5 @@ module.exports = {
PluginLoaderBaseTest,
PluginLoaderFactoryTest,
PluginManagerTest,
PluginTypeLoaderBaseTest,
};
55 changes: 47 additions & 8 deletions test/src/PluginManagerTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,20 @@ module.exports = {
const expected = [
{
id: 'avocado',
dependencies: ['mango'],
dependencies: ['mango', 'fruit'],
_pluginPath: './test/test_plugins/avocado',
name: 'Avocado',
description: 'The main ingredient for guacamole.',
loader: 'loader.js',
type: 'fruit',
},
{
id: 'fruit',
dependencies: [],
_pluginPath: './test/test_plugins/fruit',
name: 'Fruit',
description: 'A type of delicious food.',
loader: 'loader.js',
},
{
id: 'invalid_loader',
Expand All @@ -40,6 +49,15 @@ module.exports = {
description: 'It\'s an orange sweet oval.',
loader: 'loader.js',
},
{
id: 'pear',
dependencies: ['fruit'],
_pluginPath: './test/test_plugins/pear',
name: 'Pear',
description: 'A kind of fruit',
loader: 'loader.js',
type: 'fruit',
},
];
test.deepEqual(descriptors, expected);
this.manager.discover()
Expand Down Expand Up @@ -107,18 +125,27 @@ module.exports = {
])
.then((fruits) => {
test.deepEqual(fruits[0], {
sugarLevel: 'low',
color: '#33AA33',
size: 'medium',
exports: { sugarLevel: 'low', color: '#33AA33', size: 'medium' },
descriptor:
{
id: 'avocado',
dependencies: ['mango', 'fruit'],
_pluginPath: './test/test_plugins/avocado',
name: 'Avocado',
description: 'The main ingredient for guacamole.',
loader: 'loader.js',
type: 'fruit',
},
});
test.equal(fruits[1].color, 'green');
test.equal(fruits[1].exports.color, 'green');
test.done();
});
},
instantiateErrorNonObject(test) {
test.expect(1);
const manager = new PluginManager({ discovery: { rootPath: './test' } });
manager.instantiate('mango')
.then(() => test.done())
.catch((error) => {
test.equal(error.message, 'The plugin "mango" did not return an object after loading.');
test.done();
Expand All @@ -130,7 +157,7 @@ module.exports = {
.catch((error) => {
test.equal(
error.message,
'Unable to find plugin with ID: "fail". Available plugins are: avocado, invalid_loader, mango'
'Unable to find plugin with ID: "fail". Available plugins are: avocado, fruit, invalid_loader, mango, pear'
);
test.done();
});
Expand All @@ -141,7 +168,7 @@ module.exports = {
.catch((error) => {
test.equal(
error.message,
'Unable to find or execute the plugin loader for plugin "invalid_loader".'
'Unable to find or execute the plugin loader for plugin "invalid_loader" (found InvalidLoader).'
);
test.done();
});
Expand All @@ -168,9 +195,21 @@ module.exports = {
id: 'missing_deps',
loader: 'loader.js',
_pluginPath: 'foo',
dependencies: ['fail']
dependencies: ['fail'],
});
test.throws(() => this.manager.check('missing_deps'), 'Error');
test.done();
},
all(test) {
test.expect(1);
const descriptor = {
id: 'lorem',
dependencies: [],
loader: 'loader.js',
_pluginPath: 'foo',
};
this.manager.register(descriptor);
test.deepEqual(this.manager.all(), [descriptor]);
test.done();
}
};
Loading

0 comments on commit 546bd83

Please sign in to comment.