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:
parent
6f9e0609f4
commit
a6e9c08499
13 changed files with 270 additions and 13 deletions
|
@ -50,6 +50,9 @@ class ContainerBuilder
|
|||
/** @var LoginManager */
|
||||
protected $login;
|
||||
|
||||
/** @var PluginManager */
|
||||
protected $pluginManager;
|
||||
|
||||
/** @var LoggerInterface */
|
||||
protected $logger;
|
||||
|
||||
|
@ -61,12 +64,14 @@ class ContainerBuilder
|
|||
SessionManager $session,
|
||||
CookieManager $cookieManager,
|
||||
LoginManager $login,
|
||||
PluginManager $pluginManager,
|
||||
LoggerInterface $logger
|
||||
) {
|
||||
$this->conf = $conf;
|
||||
$this->session = $session;
|
||||
$this->login = $login;
|
||||
$this->cookieManager = $cookieManager;
|
||||
$this->pluginManager = $pluginManager;
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
|
@ -78,12 +83,10 @@ class ContainerBuilder
|
|||
$container['sessionManager'] = $this->session;
|
||||
$container['cookieManager'] = $this->cookieManager;
|
||||
$container['loginManager'] = $this->login;
|
||||
$container['pluginManager'] = $this->pluginManager;
|
||||
$container['logger'] = $this->logger;
|
||||
$container['basePath'] = $this->basePath;
|
||||
|
||||
$container['plugins'] = function (ShaarliContainer $container): PluginManager {
|
||||
return new PluginManager($container->conf);
|
||||
};
|
||||
|
||||
$container['history'] = function (ShaarliContainer $container): History {
|
||||
return new History($container->conf->get('resource.history'));
|
||||
|
@ -113,14 +116,6 @@ class ContainerBuilder
|
|||
);
|
||||
};
|
||||
|
||||
$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 {
|
||||
return new FormatterFactory(
|
||||
$container->conf,
|
||||
|
|
|
@ -4,6 +4,7 @@ namespace Shaarli\Plugin;
|
|||
|
||||
use Shaarli\Config\ConfigManager;
|
||||
use Shaarli\Plugin\Exception\PluginFileNotFoundException;
|
||||
use Shaarli\Plugin\Exception\PluginInvalidRouteException;
|
||||
|
||||
/**
|
||||
* Class PluginManager
|
||||
|
@ -26,6 +27,14 @@ class PluginManager
|
|||
*/
|
||||
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.
|
||||
*/
|
||||
|
@ -86,6 +95,9 @@ class PluginManager
|
|||
$this->loadPlugin($dirs[$index], $plugin);
|
||||
} catch (PluginFileNotFoundException $e) {
|
||||
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 @@ class PluginManager
|
|||
}
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
|
@ -237,6 +265,14 @@ class PluginManager
|
|||
return $metaData;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array List of registered custom routes by plugins.
|
||||
*/
|
||||
public function getRegisteredRoutes(): array
|
||||
{
|
||||
return $this->registeredRoutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the list of encountered errors.
|
||||
*
|
||||
|
@ -246,4 +282,32 @@ class PluginManager
|
|||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
26
application/plugin/exception/PluginInvalidRouteException.php
Normal file
26
application/plugin/exception/PluginInvalidRouteException.php
Normal 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.';
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
Because Shaarli is a self-hosted tool, an instance can either be installed at the root directory, or under a subfolder.
|
||||
|
|
22
index.php
22
index.php
|
@ -31,6 +31,7 @@ use Psr\Log\LogLevel;
|
|||
use Shaarli\Config\ConfigManager;
|
||||
use Shaarli\Container\ContainerBuilder;
|
||||
use Shaarli\Languages;
|
||||
use Shaarli\Plugin\PluginManager;
|
||||
use Shaarli\Security\BanManager;
|
||||
use Shaarli\Security\CookieManager;
|
||||
use Shaarli\Security\LoginManager;
|
||||
|
@ -87,7 +88,17 @@ date_default_timezone_set($conf->get('general.timezone', 'UTC'));
|
|||
|
||||
$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();
|
||||
$app = new App($container);
|
||||
|
||||
|
@ -154,6 +165,15 @@ $app->group('/admin', function () {
|
|||
$this->get('/visibility/{visibility}', '\Shaarli\Front\Controller\Admin\SessionFilterController:visibility');
|
||||
})->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
|
||||
$app->group('/api/v1', function () {
|
||||
|
|
|
@ -18,5 +18,6 @@
|
|||
<rule ref="PSR1.Files.SideEffects.FoundWithSymbols">
|
||||
<!-- index.php bootstraps everything, so yes mixed symbols with side effects -->
|
||||
<exclude-pattern>index.php</exclude-pattern>
|
||||
<exclude-pattern>plugins/*</exclude-pattern>
|
||||
</rule>
|
||||
</ruleset>
|
||||
|
|
24
plugins/demo_plugin/DemoPluginController.php
Normal file
24
plugins/demo_plugin/DemoPluginController.php
Normal 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'));
|
||||
}
|
||||
}
|
|
@ -7,6 +7,8 @@
|
|||
* Can be used by plugin developers to make their own plugin.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/DemoPluginController.php';
|
||||
|
||||
/*
|
||||
* RENDER HEADER, INCLUDES, FOOTER
|
||||
*
|
||||
|
@ -60,6 +62,17 @@ function demo_plugin_init($conf)
|
|||
return $errors;
|
||||
}
|
||||
|
||||
function demo_plugin_register_routes(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'method' => 'GET',
|
||||
'route' => '/custom',
|
||||
'callable' => 'Shaarli\DemoPlugin\DemoPluginController:index',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook render_header.
|
||||
* Executed on every page render.
|
||||
|
@ -304,7 +317,11 @@ function hook_demo_plugin_render_editlink($data)
|
|||
function hook_demo_plugin_render_tools($data)
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
|
|
|
@ -120,4 +120,43 @@ class PluginManagerTest extends \Shaarli\TestCase
|
|||
$this->assertEquals('test plugin', $meta[self::$pluginName]['description']);
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -43,11 +43,15 @@ class ContainerBuilderTest extends TestCase
|
|||
/** @var CookieManager */
|
||||
protected $cookieManager;
|
||||
|
||||
/** @var PluginManager */
|
||||
protected $pluginManager;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->conf = new ConfigManager('tests/utils/config/configJson');
|
||||
$this->sessionManager = $this->createMock(SessionManager::class);
|
||||
$this->cookieManager = $this->createMock(CookieManager::class);
|
||||
$this->pluginManager = $this->createMock(PluginManager::class);
|
||||
|
||||
$this->loginManager = $this->createMock(LoginManager::class);
|
||||
$this->loginManager->method('isLoggedIn')->willReturn(true);
|
||||
|
@ -57,6 +61,7 @@ class ContainerBuilderTest extends TestCase
|
|||
$this->sessionManager,
|
||||
$this->cookieManager,
|
||||
$this->loginManager,
|
||||
$this->pluginManager,
|
||||
$this->createMock(LoggerInterface::class)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -27,3 +27,19 @@ function hook_test_error()
|
|||
{
|
||||
new Unknown();
|
||||
}
|
||||
|
||||
function test_register_routes(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'method' => 'GET',
|
||||
'route' => '/test',
|
||||
'callable' => 'getFunction',
|
||||
],
|
||||
[
|
||||
'method' => 'POST',
|
||||
'route' => '/custom',
|
||||
'callable' => 'postFunction',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
|
12
tests/plugins/test_route_invalid/test_route_invalid.php
Normal file
12
tests/plugins/test_route_invalid/test_route_invalid.php
Normal file
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
function test_route_invalid_register_routes(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'method' => 'GET',
|
||||
'route' => 'not a route',
|
||||
'callable' => 'getFunction',
|
||||
],
|
||||
];
|
||||
}
|
13
tpl/default/pluginscontent.html
Normal file
13
tpl/default/pluginscontent.html
Normal 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>
|
Loading…
Reference in a new issue