]> git.immae.eu Git - github/shaarli/Shaarli.git/commitdiff
Plugin system: allow plugins to provide custom routes 1645/head
authorArthurHoaro <arthur@hoa.ro>
Tue, 27 Oct 2020 18:23:45 +0000 (19:23 +0100)
committerArthurHoaro <arthur@hoa.ro>
Sun, 15 Nov 2020 11:41:43 +0000 (12:41 +0100)
  - 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

13 files changed:
application/container/ContainerBuilder.php
application/plugin/PluginManager.php
application/plugin/exception/PluginInvalidRouteException.php [new file with mode: 0644]
doc/md/dev/Plugin-system.md
index.php
phpcs.xml
plugins/demo_plugin/DemoPluginController.php [new file with mode: 0644]
plugins/demo_plugin/demo_plugin.php
tests/PluginManagerTest.php
tests/container/ContainerBuilderTest.php
tests/plugins/test/test.php
tests/plugins/test_route_invalid/test_route_invalid.php [new file with mode: 0644]
tpl/default/pluginscontent.html [new file with mode: 0644]

index f0234eca2f92a4bcca74ea99b915c18d4d2e64f4..6d69a880f4fb0694e762b42a38df432c445bca4a 100644 (file)
@@ -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,
index 3ea55728cc4c9b6af22d88da6f92e533b270069c..7fc0cb047db70d1ca33d8f72529b57ff598b579e 100644 (file)
@@ -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;
+    }
 }
diff --git a/application/plugin/exception/PluginInvalidRouteException.php b/application/plugin/exception/PluginInvalidRouteException.php
new file mode 100644 (file)
index 0000000..6ba9bc4
--- /dev/null
@@ -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.';
+    }
+}
index f09fadc2925db027873cd2d788ac6a239a6ffa68..79654011b49f910aeb380a805736f812fd6e2490 100644 (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.
 
+### 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.
index 1eb7659af859a2a785a5f913c6be5b6b5f80c966..862c53efa5d6716ba6faef19b0adf073266a5587 100644 (file)
--- a/index.php
+++ b/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 () {
index c559e35da97bb4d8ec7afb229c7845fab983767f..9bdc872092a0d3aff71bb1652db2148f3cc1790f 100644 (file)
--- a/phpcs.xml
+++ b/phpcs.xml
@@ -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>
diff --git a/plugins/demo_plugin/DemoPluginController.php b/plugins/demo_plugin/DemoPluginController.php
new file mode 100644 (file)
index 0000000..b8ace9c
--- /dev/null
@@ -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'));
+    }
+}
index 22d27b6827f306ad3a3230ffd8361cd543a5bd6b..15cfc2c51cd4889cc0d38f821e0ab856806a4167 100644 (file)
@@ -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;
 }
index efef5e8746ed2b165d4902a1877a550cc007b462..8947f6791831cec961c9c84a73e644a7c197ee7f 100644 (file)
@@ -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);
+    }
 }
index 3d43c34470d098dd69a18e5a78c874187c0ca83d..04d4ef014878c75d18da09e841b20451d410a508 100644 (file)
@@ -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)
         );
     }
index 03be4f4e8c997bd9eb875ad1f42a65bf4d294eb7..34cd339e1a8cb84409f689d3a48f67dbb5124744 100644 (file)
@@ -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',
+        ],
+    ];
+}
diff --git a/tests/plugins/test_route_invalid/test_route_invalid.php b/tests/plugins/test_route_invalid/test_route_invalid.php
new file mode 100644 (file)
index 0000000..0c5a510
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+function test_route_invalid_register_routes(): array
+{
+    return [
+        [
+            'method' => 'GET',
+            'route' => 'not a route',
+            'callable' => 'getFunction',
+        ],
+    ];
+}
diff --git a/tpl/default/pluginscontent.html b/tpl/default/pluginscontent.html
new file mode 100644 (file)
index 0000000..1e4f6b8
--- /dev/null
@@ -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>