Plugin system: allow plugins to provide custom routes

- each route will be prefixed by `/plugin/<plugin_name>`
  - add a new template for plugins rendering
  - add a live example in the demo_plugin

Check out the "Plugin System" documentation for more detail.

Related to #143
This commit is contained in:
ArthurHoaro 2020-10-27 19:23:45 +01:00
parent 6f9e0609f4
commit a6e9c08499
13 changed files with 270 additions and 13 deletions

View file

@ -50,6 +50,9 @@ class ContainerBuilder
/** @var LoginManager */ /** @var LoginManager */
protected $login; protected $login;
/** @var PluginManager */
protected $pluginManager;
/** @var LoggerInterface */ /** @var LoggerInterface */
protected $logger; protected $logger;
@ -61,12 +64,14 @@ public function __construct(
SessionManager $session, SessionManager $session,
CookieManager $cookieManager, CookieManager $cookieManager,
LoginManager $login, LoginManager $login,
PluginManager $pluginManager,
LoggerInterface $logger LoggerInterface $logger
) { ) {
$this->conf = $conf; $this->conf = $conf;
$this->session = $session; $this->session = $session;
$this->login = $login; $this->login = $login;
$this->cookieManager = $cookieManager; $this->cookieManager = $cookieManager;
$this->pluginManager = $pluginManager;
$this->logger = $logger; $this->logger = $logger;
} }
@ -78,12 +83,10 @@ public function build(): ShaarliContainer
$container['sessionManager'] = $this->session; $container['sessionManager'] = $this->session;
$container['cookieManager'] = $this->cookieManager; $container['cookieManager'] = $this->cookieManager;
$container['loginManager'] = $this->login; $container['loginManager'] = $this->login;
$container['pluginManager'] = $this->pluginManager;
$container['logger'] = $this->logger; $container['logger'] = $this->logger;
$container['basePath'] = $this->basePath; $container['basePath'] = $this->basePath;
$container['plugins'] = function (ShaarliContainer $container): PluginManager {
return new PluginManager($container->conf);
};
$container['history'] = function (ShaarliContainer $container): History { $container['history'] = function (ShaarliContainer $container): History {
return new History($container->conf->get('resource.history')); return new History($container->conf->get('resource.history'));
@ -113,14 +116,6 @@ public function build(): ShaarliContainer
); );
}; };
$container['pluginManager'] = function (ShaarliContainer $container): PluginManager {
$pluginManager = new PluginManager($container->conf);
$pluginManager->load($container->conf->get('general.enabled_plugins'));
return $pluginManager;
};
$container['formatterFactory'] = function (ShaarliContainer $container): FormatterFactory { $container['formatterFactory'] = function (ShaarliContainer $container): FormatterFactory {
return new FormatterFactory( return new FormatterFactory(
$container->conf, $container->conf,

View file

@ -4,6 +4,7 @@
use Shaarli\Config\ConfigManager; use Shaarli\Config\ConfigManager;
use Shaarli\Plugin\Exception\PluginFileNotFoundException; use Shaarli\Plugin\Exception\PluginFileNotFoundException;
use Shaarli\Plugin\Exception\PluginInvalidRouteException;
/** /**
* Class PluginManager * Class PluginManager
@ -26,6 +27,14 @@ class PluginManager
*/ */
private $loadedPlugins = []; private $loadedPlugins = [];
/** @var array List of registered routes. Contains keys:
* - `method`: HTTP method, GET/POST/PUT/PATCH/DELETE
* - `route` (path): without prefix, e.g. `/up/{variable}`
* It will be later prefixed by `/plugin/<plugin name>/`.
* - `callable` string, function name or FQN class's method, e.g. `demo_plugin_custom_controller`.
*/
protected $registeredRoutes = [];
/** /**
* @var ConfigManager Configuration Manager instance. * @var ConfigManager Configuration Manager instance.
*/ */
@ -86,6 +95,9 @@ public function load($authorizedPlugins)
$this->loadPlugin($dirs[$index], $plugin); $this->loadPlugin($dirs[$index], $plugin);
} catch (PluginFileNotFoundException $e) { } catch (PluginFileNotFoundException $e) {
error_log($e->getMessage()); error_log($e->getMessage());
} catch (\Throwable $e) {
$error = $plugin . t(' [plugin incompatibility]: ') . $e->getMessage();
$this->errors = array_unique(array_merge($this->errors, [$error]));
} }
} }
} }
@ -166,6 +178,22 @@ private function loadPlugin($dir, $pluginName)
} }
} }
$registerRouteFunction = $pluginName . '_register_routes';
$routes = null;
if (function_exists($registerRouteFunction)) {
$routes = call_user_func($registerRouteFunction);
}
if ($routes !== null) {
foreach ($routes as $route) {
if (static::validateRouteRegistration($route)) {
$this->registeredRoutes[$pluginName][] = $route;
} else {
throw new PluginInvalidRouteException($pluginName);
}
}
}
$this->loadedPlugins[] = $pluginName; $this->loadedPlugins[] = $pluginName;
} }
@ -237,6 +265,14 @@ public function getPluginsMeta()
return $metaData; return $metaData;
} }
/**
* @return array List of registered custom routes by plugins.
*/
public function getRegisteredRoutes(): array
{
return $this->registeredRoutes;
}
/** /**
* Return the list of encountered errors. * Return the list of encountered errors.
* *
@ -246,4 +282,32 @@ public function getErrors()
{ {
return $this->errors; return $this->errors;
} }
/**
* Checks whether provided input is valid to register a new route.
* It must contain keys `method`, `route`, `callable` (all strings).
*
* @param string[] $input
*
* @return bool
*/
protected static function validateRouteRegistration(array $input): bool
{
if (
!array_key_exists('method', $input)
|| !in_array(strtoupper($input['method']), ['GET', 'PUT', 'PATCH', 'POST', 'DELETE'])
) {
return false;
}
if (!array_key_exists('route', $input) || !preg_match('#^[a-z\d/\.\-_]+$#', $input['route'])) {
return false;
}
if (!array_key_exists('callable', $input)) {
return false;
}
return true;
}
} }

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Shaarli\Plugin\Exception;
use Exception;
/**
* Class PluginFileNotFoundException
*
* Raise when plugin files can't be found.
*/
class PluginInvalidRouteException extends Exception
{
/**
* Construct exception with plugin name.
* Generate message.
*
* @param string $pluginName name of the plugin not found
*/
public function __construct()
{
$this->message = 'trying to register invalid route.';
}
}

View file

@ -139,6 +139,31 @@ Each file contain two keys:
> Note: In PHP, `parse_ini_file()` seems to want strings to be between by quotes `"` in the ini file. > Note: In PHP, `parse_ini_file()` seems to want strings to be between by quotes `"` in the ini file.
### Register plugin's routes
Shaarli lets you register custom Slim routes for your plugin.
To register a route, the plugin must include a function called `function <plugin_name>_register_routes(): array`.
This method must return an array of routes, each entry must contain the following keys:
- `method`: HTTP method, `GET/POST/PUT/PATCH/DELETE`
- `route` (path): without prefix, e.g. `/up/{variable}`
It will be later prefixed by `/plugin/<plugin name>/`.
- `callable` string, function name or FQN class's method to execute, e.g. `demo_plugin_custom_controller`.
Callable functions or methods must have `Slim\Http\Request` and `Slim\Http\Response` parameters
and return a `Slim\Http\Response`. We recommend creating a dedicated class and extend either
`ShaarliVisitorController` or `ShaarliAdminController` to use helper functions they provide.
A dedicated plugin template is available for rendering content: `pluginscontent.html` using `content` placeholder.
> **Warning**: plugins are not able to use RainTPL template engine for their content due to technical restrictions.
> RainTPL does not allow to register multiple template folders, so all HTML rendering must be done within plugin
> custom controller.
Check out the `demo_plugin` for a live example: `GET <shaarli_url>/plugin/demo_plugin/custom`.
### Understanding relative paths ### Understanding relative paths
Because Shaarli is a self-hosted tool, an instance can either be installed at the root directory, or under a subfolder. Because Shaarli is a self-hosted tool, an instance can either be installed at the root directory, or under a subfolder.

View file

@ -31,6 +31,7 @@
use Shaarli\Config\ConfigManager; use Shaarli\Config\ConfigManager;
use Shaarli\Container\ContainerBuilder; use Shaarli\Container\ContainerBuilder;
use Shaarli\Languages; use Shaarli\Languages;
use Shaarli\Plugin\PluginManager;
use Shaarli\Security\BanManager; use Shaarli\Security\BanManager;
use Shaarli\Security\CookieManager; use Shaarli\Security\CookieManager;
use Shaarli\Security\LoginManager; use Shaarli\Security\LoginManager;
@ -87,7 +88,17 @@
$loginManager->checkLoginState(client_ip_id($_SERVER)); $loginManager->checkLoginState(client_ip_id($_SERVER));
$containerBuilder = new ContainerBuilder($conf, $sessionManager, $cookieManager, $loginManager, $logger); $pluginManager = new PluginManager($conf);
$pluginManager->load($conf->get('general.enabled_plugins', []));
$containerBuilder = new ContainerBuilder(
$conf,
$sessionManager,
$cookieManager,
$loginManager,
$pluginManager,
$logger
);
$container = $containerBuilder->build(); $container = $containerBuilder->build();
$app = new App($container); $app = new App($container);
@ -154,6 +165,15 @@
$this->get('/visibility/{visibility}', '\Shaarli\Front\Controller\Admin\SessionFilterController:visibility'); $this->get('/visibility/{visibility}', '\Shaarli\Front\Controller\Admin\SessionFilterController:visibility');
})->add('\Shaarli\Front\ShaarliAdminMiddleware'); })->add('\Shaarli\Front\ShaarliAdminMiddleware');
$app->group('/plugin', function () use ($pluginManager) {
foreach ($pluginManager->getRegisteredRoutes() as $pluginName => $routes) {
$this->group('/' . $pluginName, function () use ($routes) {
foreach ($routes as $route) {
$this->{strtolower($route['method'])}('/' . ltrim($route['route'], '/'), $route['callable']);
}
});
}
})->add('\Shaarli\Front\ShaarliMiddleware');
// REST API routes // REST API routes
$app->group('/api/v1', function () { $app->group('/api/v1', function () {

View file

@ -18,5 +18,6 @@
<rule ref="PSR1.Files.SideEffects.FoundWithSymbols"> <rule ref="PSR1.Files.SideEffects.FoundWithSymbols">
<!-- index.php bootstraps everything, so yes mixed symbols with side effects --> <!-- index.php bootstraps everything, so yes mixed symbols with side effects -->
<exclude-pattern>index.php</exclude-pattern> <exclude-pattern>index.php</exclude-pattern>
<exclude-pattern>plugins/*</exclude-pattern>
</rule> </rule>
</ruleset> </ruleset>

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Shaarli\DemoPlugin;
use Shaarli\Front\Controller\Admin\ShaarliAdminController;
use Slim\Http\Request;
use Slim\Http\Response;
class DemoPluginController extends ShaarliAdminController
{
public function index(Request $request, Response $response): Response
{
$this->assignView(
'content',
'<div class="center">' .
'This is a demo page. I have access to Shaarli container, so I\'m free to do whatever I want here.' .
'</div>'
);
return $response->write($this->render('pluginscontent'));
}
}

View file

@ -7,6 +7,8 @@
* Can be used by plugin developers to make their own plugin. * Can be used by plugin developers to make their own plugin.
*/ */
require_once __DIR__ . '/DemoPluginController.php';
/* /*
* RENDER HEADER, INCLUDES, FOOTER * RENDER HEADER, INCLUDES, FOOTER
* *
@ -60,6 +62,17 @@ function demo_plugin_init($conf)
return $errors; return $errors;
} }
function demo_plugin_register_routes(): array
{
return [
[
'method' => 'GET',
'route' => '/custom',
'callable' => 'Shaarli\DemoPlugin\DemoPluginController:index',
],
];
}
/** /**
* Hook render_header. * Hook render_header.
* Executed on every page render. * Executed on every page render.
@ -304,7 +317,11 @@ function hook_demo_plugin_render_editlink($data)
function hook_demo_plugin_render_tools($data) function hook_demo_plugin_render_tools($data)
{ {
// field_plugin // field_plugin
$data['tools_plugin'][] = 'tools_plugin'; $data['tools_plugin'][] = '<div class="tools-item">
<a href="' . $data['_BASE_PATH_'] . '/plugin/demo_plugin/custom">
<span class="pure-button pure-u-lg-2-3 pure-u-3-4">Demo Plugin Custom Route</span>
</a>
</div>';
return $data; return $data;
} }

View file

@ -120,4 +120,43 @@ public function testGetPluginsMeta(): void
$this->assertEquals('test plugin', $meta[self::$pluginName]['description']); $this->assertEquals('test plugin', $meta[self::$pluginName]['description']);
$this->assertEquals($expectedParameters, $meta[self::$pluginName]['parameters']); $this->assertEquals($expectedParameters, $meta[self::$pluginName]['parameters']);
} }
/**
* Test plugin custom routes - note that there is no check on callable functions
*/
public function testRegisteredRoutes(): void
{
PluginManager::$PLUGINS_PATH = self::$pluginPath;
$this->pluginManager->load([self::$pluginName]);
$expectedParameters = [
[
'method' => 'GET',
'route' => '/test',
'callable' => 'getFunction',
],
[
'method' => 'POST',
'route' => '/custom',
'callable' => 'postFunction',
],
];
$meta = $this->pluginManager->getRegisteredRoutes();
static::assertSame($expectedParameters, $meta[self::$pluginName]);
}
/**
* Test plugin custom routes with invalid route
*/
public function testRegisteredRoutesInvalid(): void
{
$plugin = 'test_route_invalid';
$this->pluginManager->load([$plugin]);
$meta = $this->pluginManager->getRegisteredRoutes();
static::assertSame([], $meta);
$errors = $this->pluginManager->getErrors();
static::assertSame(['test_route_invalid [plugin incompatibility]: trying to register invalid route.'], $errors);
}
} }

View file

@ -43,11 +43,15 @@ class ContainerBuilderTest extends TestCase
/** @var CookieManager */ /** @var CookieManager */
protected $cookieManager; protected $cookieManager;
/** @var PluginManager */
protected $pluginManager;
public function setUp(): void public function setUp(): void
{ {
$this->conf = new ConfigManager('tests/utils/config/configJson'); $this->conf = new ConfigManager('tests/utils/config/configJson');
$this->sessionManager = $this->createMock(SessionManager::class); $this->sessionManager = $this->createMock(SessionManager::class);
$this->cookieManager = $this->createMock(CookieManager::class); $this->cookieManager = $this->createMock(CookieManager::class);
$this->pluginManager = $this->createMock(PluginManager::class);
$this->loginManager = $this->createMock(LoginManager::class); $this->loginManager = $this->createMock(LoginManager::class);
$this->loginManager->method('isLoggedIn')->willReturn(true); $this->loginManager->method('isLoggedIn')->willReturn(true);
@ -57,6 +61,7 @@ public function setUp(): void
$this->sessionManager, $this->sessionManager,
$this->cookieManager, $this->cookieManager,
$this->loginManager, $this->loginManager,
$this->pluginManager,
$this->createMock(LoggerInterface::class) $this->createMock(LoggerInterface::class)
); );
} }

View file

@ -27,3 +27,19 @@ function hook_test_error()
{ {
new Unknown(); new Unknown();
} }
function test_register_routes(): array
{
return [
[
'method' => 'GET',
'route' => '/test',
'callable' => 'getFunction',
],
[
'method' => 'POST',
'route' => '/custom',
'callable' => 'postFunction',
],
];
}

View file

@ -0,0 +1,12 @@
<?php
function test_route_invalid_register_routes(): array
{
return [
[
'method' => 'GET',
'route' => 'not a route',
'callable' => 'getFunction',
],
];
}

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html{if="$language !== 'auto'"} lang="{$language}"{/if}>
<head>
{include="includes"}
</head>
<body>
{include="page.header"}
{$content}
{include="page.footer"}
</body>
</html>