diff --git a/module/VuFind/config/module.config.php b/module/VuFind/config/module.config.php index 51d656e6fb73..7c06b56ee38f 100644 --- a/module/VuFind/config/module.config.php +++ b/module/VuFind/config/module.config.php @@ -450,6 +450,171 @@ // This section contains all VuFind-specific settings (i.e. configurations // unrelated to specific Laminas components). 'vufind' => [ + // This section contains all action specific configuration that gets applied to actions before they're executed. + // + // The configuration is an array of associative arrays of configuration entries. Each entry is identified by its + // key so that any specific entry can be overridden in other modules. + // + // Note: Each module should use a module specific prefix in their own entries to avoid any unintentional clashes + // between modules. A good practice is to prefix each config entry key with lowercase module name followed by + // an underscore and the actual identifier (e.g. 'vufindadmin_admin'). + // + // Valid keys for each configuration entry: + // - actionIds An array of action identifiers or prefixes the configuration applies to. This can + // be a simple string in format 'category/action' (all lowercase) or an array for + // matching the beginning of the action identifier (again all lowercase): + // [ + // 'type' => 'prefix', + // 'prefix' => 'category/', + // ], + // + // - accessPermission Set access permission (string|false|null, see AccessPermissionInterface) + // - accessDeniedBehavior Set behavior when access is denied (string|null, see AccessPermissionInterface) + // - backendId Set search backend identifier (string) + // - defaultTab Set default tab (string|null) + // - fallbackDefaultTab Set fallback default tab (string; empty string to use Site/defaultRecordTab from + // config) + // - poweredBy Set "Powered by" displayed in page footer + 'action_config' => [ + // EDS: + 'vufind_eds_record' => [ + 'actionIds' => [ + 'edsrecord', + [ + 'type' => 'prefix', + 'prefix' => 'edsrecord/', + ], + ], + 'accessPermission' => 'access.EDSModule', + 'backendId' => 'EDS', + 'fallbackDefaultTab' => 'Description', + ], + + // EIT: + 'vufind_eit_record' => [ + 'actionIds' => [ + 'eitrecord', + [ + 'type' => 'prefix', + 'prefix' => 'eitrecord/', + ], + ], + 'accessPermission' => 'access.EITModule', + 'backendId' => 'EIT', + 'fallbackDefaultTab' => 'Description', + ], + + // EPF: + 'vufind_epf_record' => [ + 'actionIds' => [ + 'epfrecord', + [ + 'type' => 'prefix', + 'prefix' => 'epfrecord/', + ], + ], + 'accessPermission' => 'access.EPFModule', + 'backendId' => 'EPF', + ], + + // Record, Collection (Default backend): + 'vufind_record' => [ + 'actionIds' => [ + 'collection', + [ + 'type' => 'prefix', + 'prefix' => 'collection/', + ], + 'missingrecord', + 'missingrecord/home', + 'record', + [ + 'type' => 'prefix', + 'prefix' => 'record/', + ], + ], + 'backendId' => DEFAULT_SEARCH_BACKEND, + 'fallbackDefaultTab' => '', + ], + + // Primo: + 'vufind_primo_record' => [ + 'actionIds' => [ + 'primorecord', + [ + 'type' => 'prefix', + 'prefix' => 'primorecord/', + ], + ], + 'accessPermission' => 'access.PrimoModule', + 'backendId' => 'Primo', + 'fallbackDefaultTab' => 'Description', + ], + + // ProquestFSG: + 'vufind_proquestfsg_record' => [ + 'actionIds' => [ + 'proquestfsgrecord', + [ + 'type' => 'prefix', + 'prefix' => 'proquestfsgrecord/', + ], + ], + 'backendId' => 'ProQuestFSG', + 'checkEnabled' => true, + ], + + // Search2Record, Search2Collection: + 'vufind_search2_record' => [ + 'actionIds' => [ + 'search2collection', + [ + 'type' => 'prefix', + 'prefix' => 'search2collection/', + ], + 'search2record', + [ + 'type' => 'prefix', + 'prefix' => 'search2record/', + ], + ], + 'backendId' => 'Search2', + 'fallbackDefaultTab' => 'Description', + ], + + // Summon: + 'vufind_summon_record' => [ + 'actionIds' => [ + 'summonrecord', + [ + 'type' => 'prefix', + 'prefix' => 'summonrecord/', + ], + ], + 'backendId' => 'Summon', + 'fallbackDefaultTab' => 'Description', + 'poweredBy' => 'Powered by Summon™ from Serials Solutions, a division of ProQuest.', + ], + + // WorldCat2 and legacy WorldCat actions: + 'vufind_worldcat2_record' => [ + 'actionIds' => [ + // Legacy WorldCat actions: + 'worldcatrecord', + [ + 'type' => 'prefix', + 'prefix' => 'worldcatrecord/', + ], + // Current WorldCat2 actions: + 'worldcat2record', + [ + 'type' => 'prefix', + 'prefix' => 'worldcat2record/', + ], + ], + 'backendId' => 'WorldCat2', + ], + ], // The config reader is a special service manager for loading .ini files: 'config_reader' => [ /* see VuFind\Config\PluginManager for defaults */ ], // This section contains service manager configurations for all VuFind diff --git a/module/VuFind/src/VuFind/Action/AccessPermissionInterface.php b/module/VuFind/src/VuFind/Action/AccessPermissionInterface.php index 4e560d9b6ed4..3743ab1306bf 100644 --- a/module/VuFind/src/VuFind/Action/AccessPermissionInterface.php +++ b/module/VuFind/src/VuFind/Action/AccessPermissionInterface.php @@ -38,7 +38,7 @@ * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License * @link https://vufind.org Main Page */ -interface AccessPermissionInterface +interface AccessPermissionInterface extends ActionConfigInterface { /** * Get access permission. diff --git a/module/VuFind/src/VuFind/Action/ActionConfigManager.php b/module/VuFind/src/VuFind/Action/ActionConfigManager.php new file mode 100644 index 000000000000..95c66aa9f9f7 --- /dev/null +++ b/module/VuFind/src/VuFind/Action/ActionConfigManager.php @@ -0,0 +1,192 @@ +. + * + * @category VuFind + * @package Action + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:plugins:hierarchy_components Wiki + */ + +namespace VuFind\Action; + +use Laminas\Router\RouteMatch; +use VuFind\Exception\ConfigException; +use VuFind\ServiceManager\Factory\Autowire; +use VuFind\View\GlobalsContainer; + +use function is_string; + +/** + * Action configuration manager. + * + * @category VuFind + * @package Action + * @author Ere Maijala + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:plugins:hierarchy_components Wiki + */ +class ActionConfigManager +{ + /** + * Constructor. + * + * @param GlobalsContainer $globalsContainer Global data container + * @param array $config VuFind configuration + * @param array $appConfig Application config + */ + public function __construct( + protected GlobalsContainer $globalsContainer, + #[Autowire(config: 'config')] + protected array $config, + #[Autowire(service: 'Config')] + protected array $appConfig, + ) { + } + + /** + * Apply configuration to the action. + * + * @param ActionInterface $action Action + * @param ?RouteMatch $routeMatch Route match + * @param ?string $actionIdentifier Action identifier to use (alternative to one determined from RouteMatch) + * + * @return void + */ + public function applyActionConfig( + ActionInterface $action, + ?RouteMatch $routeMatch = null, + ?string $actionIdentifier = null, + ): void { + if ((!$routeMatch && !$actionIdentifier) || !($action instanceof ActionConfigInterface)) { + return; + } + + if (!$actionIdentifier) { + // Try to use lowercase controller-action or just action if available, with route name as a fallback: + if ($actionName = $routeMatch->getParam('action')) { + if ($categoryName = $routeMatch->getParam('category') ?? $routeMatch->getParam('controller')) { + $actionIdentifier = $categoryName . '/' . $actionName; + } else { + $actionIdentifier = $actionName; + } + } else { + $actionIdentifier = $routeMatch->getMatchedRouteName(); + } + } + $actionIdentifier = strtolower($actionIdentifier); + foreach ($this->appConfig['vufind']['action_config'] ?? [] as $currentConfig) { + if ($this->actionIdentifierMatchesConfig($actionIdentifier, $currentConfig)) { + // Apply configuration: + foreach ($currentConfig as $key => $value) { + switch ($key) { + case 'actionIds': + // This is used for identifying the actions to apply the configs on, so no need for further + // processing here. + break; + case 'accessPermission': + case 'accessDeniedBehavior': + if (!($action instanceof AccessPermissionInterface)) { + throw new ConfigException( + $action::class . ' (action ' . $actionIdentifier . ')' + . " does not implement AccessPermissionInterface for $key configuration" + ); + } + if ('accessDeniedBehavior' === $key) { + $action->setAccessDeniedBehavior($value); + } else { + $action->setAccessPermission($value); + } + break; + case 'backendId': + if (!($action instanceof BackendIdInterface)) { + throw new ConfigException( + $action::class . ' (action ' . $actionIdentifier . ')' + . " does not implement BackendIdInterface for $key configuration" + ); + } + $action->setBackendId($value); + break; + case 'defaultTab': + case 'fallbackDefaultTab': + if (!($action instanceof DefaultTabInterface)) { + throw new ConfigException( + $action::class . ' (action ' . $actionIdentifier . ')' + . " does not implement DefaultTabInterface for $key configuration" + ); + } + if ('fallbackDefaultTab' === $key) { + if ('' === $value) { + // Load default tab setting: + if (!($value = $this->config['Site']['defaultRecordTab'] ?? null)) { + break; + } + } + $action->setFallbackDefaultTab($value); + } else { + $action->setDefaultTab($value); + } + break; + case 'poweredBy': + $this->globalsContainer['poweredBy'] = $value; + break; + default: + throw new ConfigException( + $action::class . ' (action ' . $actionIdentifier . "): Invalid configuration key $key" + ); + } + } + break; + } + } + } + + /** + * Check if action identifier matches the given config. + * + * @param string $actionIdentifier Action identifier + * @param array $config Config entry + * + * @return bool + */ + protected function actionIdentifierMatchesConfig(string $actionIdentifier, array $config): bool + { + foreach ($config['actionIds'] as $actionId) { + if (is_string($actionId)) { + if ($actionIdentifier === $actionId) { + return true; + } + } else { + switch ($actionId['type']) { + case 'prefix': + if (str_starts_with($actionIdentifier, $actionId['prefix'])) { + return true; + } + break; + default: + throw new ConfigException(('Invalid actionIds entry: ' . var_export($actionId, true))); + } + } + } + return false; + } +} diff --git a/module/VuFind/src/VuFind/Action/ActionDispatchListener.php b/module/VuFind/src/VuFind/Action/ActionDispatchListener.php index e67780e6c169..7e76b2867ec6 100644 --- a/module/VuFind/src/VuFind/Action/ActionDispatchListener.php +++ b/module/VuFind/src/VuFind/Action/ActionDispatchListener.php @@ -36,18 +36,14 @@ use Laminas\Mvc\MvcEvent; use Laminas\Psr7Bridge\Psr7Response; use Laminas\Psr7Bridge\Psr7ServerRequest; -use Laminas\Router\RouteMatch; use Psr\Http\Message\ResponseInterface; use Throwable; -use VuFind\Exception\ConfigException; use VuFind\Http\RouteHelper; use VuFind\ServiceManager\Factory\Autowire; use VuFind\View\GlobalsContainer; -use function is_string; - /** - * Copyright (C) The National Library of Finland 2026. + * Action dispatch listener. * * @category VuFind * @package Action @@ -57,165 +53,20 @@ */ class ActionDispatchListener { - /** - * Route-specific action configuration. - * - * The configuration is an array of associative arrays of configuration entries. - * - * Valid keys for each configuration entry: - * - routes An array of route names the configuration applies to - * - accessPermission Set access permission (string|false|null, see AccessPermissionInterface) - * - accessDeniedBehavior Set behavior when access is denied (string|null, see AccessPermissionInterface) - * - backendId Set search backend identifier (string) - * - defaultTab Set default tab (string|null) - * - fallbackDefaultTab Set fallback default tab (string; empty string to use Site/defaultRecordTab from config) - * - poweredBy Set "Powered by" displayed in page footer - * - * @var array - */ - protected array $actionConfig = [ - // EDS: - [ - 'routes' => [ - 'edsrecord', - [ - 'type' => 'prefix', - 'prefix' => 'edsrecord-', - ], - ], - 'accessPermission' => 'access.EDSModule', - 'backendId' => 'EDS', - 'fallbackDefaultTab' => 'Description', - ], - // EIT: - [ - 'routes' => [ - 'eitrecord', - [ - 'type' => 'prefix', - 'prefix' => 'eitrecord-', - ], - ], - 'accessPermission' => 'access.EITModule', - 'backendId' => 'EIT', - 'fallbackDefaultTab' => 'Description', - ], - [ - 'routes' => [ - 'epfrecord', - [ - 'type' => 'prefix', - 'prefix' => 'epfrecord-', - ], - ], - 'accessPermission' => 'access.EPFModule', - 'backendId' => 'EPF', - ], - // Record, Collection (Default backend): - [ - 'routes' => [ - 'collection', - [ - 'type' => 'prefix', - 'prefix' => 'collection-', - ], - 'missingrecord', - 'missingrecord-home', - 'record', - [ - 'type' => 'prefix', - 'prefix' => 'record-', - ], - ], - 'backendId' => DEFAULT_SEARCH_BACKEND, - 'fallbackDefaultTab' => '', - ], - // Primo: - [ - 'routes' => [ - 'primorecord', - [ - 'type' => 'prefix', - 'prefix' => 'primorecord-', - ], - ], - 'accessPermission' => 'access.PrimoModule', - 'backendId' => 'Primo', - 'fallbackDefaultTab' => 'Description', - ], - // ProquestFSG: - [ - 'routes' => [ - 'proquestfsgrecord', - [ - 'type' => 'prefix', - 'prefix' => 'proquestfsgrecord-', - ], - ], - 'backendId' => 'ProQuestFSG', - ], - // Search2, Search2Collection: - [ - 'routes' => [ - 'search2collection', - [ - 'type' => 'prefix', - 'prefix' => 'search2collection-', - ], - 'search2record', - [ - 'type' => 'prefix', - 'prefix' => 'search2record-', - ], - ], - 'backendId' => 'Search2', - 'fallbackDefaultTab' => 'Description', - ], - // Summon: - [ - 'routes' => [ - 'summonrecord', - [ - 'type' => 'prefix', - 'prefix' => 'summonrecord-', - ], - ], - 'backendId' => 'Summon', - 'fallbackDefaultTab' => 'Description', - 'poweredBy' => 'Powered by Summon™ from Serials Solutions, a division of ProQuest.', - ], - // WorldCat2 and legacy WorldCat routes: - [ - 'routes' => [ - // Legacy WorldCat routes: - 'worldcatrecord', - [ - 'type' => 'prefix', - 'prefix' => 'worldcatrecord-', - ], - // Current WorldCat2 routes: - 'worldcat2record', - [ - 'type' => 'prefix', - 'prefix' => 'worldcat2record-', - ], - ], - 'backendId' => 'WorldCat2', - ], - ]; - /** * Constructor. * - * @param PluginManager $actionPluginManager Action plugin manager - * @param RouteHelper $routeHelper Route helper - * @param GlobalsContainer $globalsContainer Global data container - * @param array $config VuFind configuration + * @param PluginManager $actionPluginManager Action plugin manager + * @param RouteHelper $routeHelper Route helper + * @param GlobalsContainer $globalsContainer Global data container + * @param ActionConfigManager $actionConfigManager Action configuration manager + * @param array $config VuFind configuration */ public function __construct( protected PluginManager $actionPluginManager, protected RouteHelper $routeHelper, protected GlobalsContainer $globalsContainer, + protected ActionConfigManager $actionConfigManager, #[Autowire(config: 'config')] protected array $config, ) { @@ -257,7 +108,7 @@ public function onDispatch(MvcEvent $e) $action = $this->actionPluginManager->get($id); $routeMatch = $e->getRouteMatch(); - $this->applyRouteBasedConfig($routeMatch, $action); + $this->actionConfigManager->applyActionConfig($action, $routeMatch); $request = Psr7ServerRequest::fromLaminas($e->getRequest()) ->withAttribute('action-id', $id) @@ -319,115 +170,4 @@ protected function updateLaminasResponse( } return $laminasResponse; } - - /** - * Apply route-based configuration to the action. - * - * @param ?RouteMatch $routeMatch Route match - * @param ActionInterface $action Action - * - * @return void - */ - protected function applyRouteBasedConfig( - ?RouteMatch $routeMatch, - ActionInterface $action - ): void { - if (!$routeMatch || !($action instanceof ActionConfigInterface)) { - return; - } - - $routeName = $routeMatch->getMatchedRouteName(); - foreach ($this->actionConfig as $currentConfig) { - if ($this->routeNameMatchesConfig($routeName, $currentConfig)) { - // Apply configuration: - foreach ($currentConfig as $key => $value) { - switch ($key) { - case 'routes': - break; - case 'accessPermission': - case 'accessDeniedBehavior': - if (!($action instanceof AccessPermissionInterface)) { - throw new ConfigException( - $action::class . ' (route ' . $routeName . ')' - . " does not implement AccessPermissionInterface for $key configuration" - ); - } - if ('accessDeniedBehavior' === $key) { - $action->setAccessDeniedBehavior($value); - } else { - $action->setAccessPermission($value); - } - break; - case 'backendId': - if (!($action instanceof BackendIdInterface)) { - throw new ConfigException( - $action::class . ' (route ' . $routeName . ')' - . " does not implement BackendIdInterface for $key configuration" - ); - } - $action->setBackendId($value); - break; - case 'defaultTab': - case 'fallbackDefaultTab': - if (!($action instanceof DefaultTabInterface)) { - throw new ConfigException( - $action::class . ' (route ' . $routeName . ')' - . " does not implement DefaultTabInterface for $key configuration" - ); - } - if ('fallbackDefaultTab' === $key) { - if ('' === $value) { - // Load default tab setting: - if (!($value = $this->config['Site']['defaultRecordTab'] ?? null)) { - break; - } - } - $action->setFallbackDefaultTab($value); - } else { - $action->setDefaultTab($value); - } - break; - case 'poweredBy': - $this->globalsContainer['poweredBy'] = $value; - break; - default: - throw new ConfigException( - $action::class . ' (route ' . $routeName . "): Invalid configuration key $key" - ); - } - } - break; - } - } - } - - /** - * Check if route name matches the given config. - * - * @param string $routeName Route name - * @param array $config Route-based config entry - * - * @return bool - */ - protected function routeNameMatchesConfig(string $routeName, array $config): bool - { - foreach ($config['routes'] as $route) { - if (is_string($route)) { - if ($routeName === $route) { - return true; - } - } else { - switch ($route['type']) { - case 'prefix': - if (str_starts_with($routeName, $route['prefix'])) { - return true; - } - break; - default: - throw new ConfigException(('Invalid routes entry: ' . var_export($route, true))); - } - } - } - return false; - } } diff --git a/module/VuFind/src/VuFind/ActionHelper/ForwardHelper.php b/module/VuFind/src/VuFind/ActionHelper/ForwardHelper.php index 8fdcf42dc7b3..abc739c545d9 100644 --- a/module/VuFind/src/VuFind/ActionHelper/ForwardHelper.php +++ b/module/VuFind/src/VuFind/ActionHelper/ForwardHelper.php @@ -32,6 +32,7 @@ use Laminas\Router\RouteMatch; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use VuFind\Action\ActionConfigManager; use VuFind\Action\PluginManager as ActionPluginManager; use VuFind\ServiceManager\Factory\Autowire; @@ -50,10 +51,12 @@ class ForwardHelper implements HelperInterface * Constructor. * * @param ActionPluginManager $actionPluginManager Action plugin manager + * @param ActionConfigManager $actionConfigManager Action configuration manager */ #[Autowire] public function __construct( protected ActionPluginManager $actionPluginManager, + protected ActionConfigManager $actionConfigManager, ) { } @@ -75,6 +78,7 @@ public function forwardTo( throw new \InvalidArgumentException("Unknown action '$actionId'"); } $action = $this->actionPluginManager->get($actionId); + $this->actionConfigManager->applyActionConfig($action, actionIdentifier: $actionId); return $action( $request->withAttribute('action-id', $actionId), $response diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/ActionHelper/ForwardHelperTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/ActionHelper/ForwardHelperTest.php index 316fb677ed49..916e6a558c49 100644 --- a/module/VuFind/tests/unit-tests/src/VuFindTest/ActionHelper/ForwardHelperTest.php +++ b/module/VuFind/tests/unit-tests/src/VuFindTest/ActionHelper/ForwardHelperTest.php @@ -33,6 +33,7 @@ use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use VuFind\Action\ActionConfigManager; use VuFind\Action\ActionInterface; use VuFind\ActionHelper\ForwardHelper; use VuFind\ActionHelper\PluginManager as HelperPluginManager; @@ -61,7 +62,7 @@ public function testInvalidAction(): void $this->expectExceptionMessage("Unknown action 'foo'"); $mockPluginManager = $this->createMock(\VuFind\Action\PluginManager::class); $mockPluginManager->expects($this->once())->method('has')->with('foo')->willReturn(false); - $helper = new ForwardHelper($mockPluginManager); + $helper = new ForwardHelper($mockPluginManager, $this->createMock(ActionConfigManager::class)); $helper->forwardTo( $this->createMock(ServerRequestInterface::class), $this->createMock(ResponseInterface::class), @@ -148,7 +149,7 @@ public function __invoke(ServerRequestInterface $request, ResponseInterface $res $mockPluginManager = $this->createMock(\VuFind\Action\PluginManager::class); $mockPluginManager->expects($this->once())->method('has')->with('foo')->willReturn(true); $mockPluginManager->expects($this->once())->method('get')->with('foo')->willReturn($action); - $helper = new ForwardHelper($mockPluginManager); + $helper = new ForwardHelper($mockPluginManager, $this->createMock(ActionConfigManager::class)); $this->assertEquals($response, $helper->forwardTo($request, $response, 'foo')); } }