Files
breadcrumb-the-shire/lib/App/Module/ModuleRegistry.php

702 lines
26 KiB
PHP
Raw Normal View History

<?php
namespace MintyPHP\App\Module;
use Composer\Autoload\ClassLoader;
use RuntimeException;
/**
* Loads, validates and merges module manifests.
*
* Modules are discovered from a configurable directory and activated via an
* allow-list (config/modules.php + APP_ENABLED_MODULES env variable).
*
* The registry is immutable after construction all contributions are merged
* at boot time and available through typed getters.
*/
final class ModuleRegistry
{
/** @var array<string, ModuleManifest> keyed by module id */
private array $modules = [];
/** @var array<int, array{path: string, target: string, module_id: string, public?: bool}> merged routes */
private array $mergedRoutes = [];
/** @var list<string> merged public paths */
private array $mergedPublicPaths = [];
/** @var list<class-string> merged container registrar FQCNs */
private array $mergedContainerRegistrars = [];
/** @var array<string, list<mixed>> merged UI slots keyed by slot name */
private array $mergedUiSlots = [];
/** @var list<class-string> merged search resource provider FQCNs */
private array $mergedSearchResources = [];
/** @var array<string, list<string>> merged asset groups */
private array $mergedAssetGroups = [];
/** @var list<class-string> merged layout context provider FQCNs */
private array $mergedLayoutContextProviders = [];
/** @var list<class-string> merged session provider FQCNs */
private array $mergedSessionProviders = [];
/**
* @var list<array{
* key: string,
* description: string,
* active: int,
* is_system: int
* }> merged permissions
*/
private array $mergedPermissions = [];
/** @var list<class-string> merged authorization policy FQCNs */
private array $mergedAuthorizationPolicies = [];
/**
* @var list<array{
* job_key: string,
* handler: string,
* label: string,
* description: string,
* default_enabled: int,
* default_timezone: string,
* default_schedule_type: string,
* default_schedule_interval: int,
* default_schedule_time: ?string,
* default_schedule_weekdays_csv: ?string,
* default_catchup_once: int,
* allowed_schedule_types: list<string>,
* module_id: string
* }>
*/
private array $mergedSchedulerJobs = [];
/** @var array<string, string> route target => owning module id */
private array $routeTargetOwners = [];
/** @var array<string, string> route path => owning module id */
private array $routePathOwners = [];
/** @var array<string, string> permission key => owning module id */
private array $permissionOwners = [];
/** @var array<string, string> scheduler job key => owning module id */
private array $schedulerJobOwners = [];
/** @var array<string, list<array{class: class-string, method: string}>> merged event listeners keyed by event name */
private array $mergedEventListeners = [];
/**
* Boot the registry from a modules directory and activation config.
*
* @param string $modulesDir Absolute path to the modules/ directory
* @param list<string> $enabledModuleIds Module IDs to activate (from config + env)
*/
public static function boot(string $modulesDir, array $enabledModuleIds): self
{
$registry = new self();
if (!is_dir($modulesDir)) {
return $registry;
}
// Discover and load manifests for enabled modules
$manifests = [];
foreach ($enabledModuleIds as $moduleId) {
$moduleId = trim($moduleId);
if ($moduleId === '') {
continue;
}
$modulePath = rtrim($modulesDir, '/') . '/' . $moduleId;
$manifestFile = $modulePath . '/module.php';
if (!is_file($manifestFile)) {
throw new RuntimeException(
"Module '{$moduleId}' is enabled but manifest not found at: {$manifestFile}"
);
}
$raw = require $manifestFile;
if (!is_array($raw)) {
throw new RuntimeException(
"Module manifest at '{$manifestFile}' must return an array."
);
}
ModuleManifestSchemaValidator::assertValid($raw, $moduleId, $manifestFile);
$manifest = ModuleManifest::fromArray($raw, $modulePath);
if ($manifest->id !== $moduleId) {
throw new RuntimeException(
"Module directory '{$moduleId}' does not match manifest id '{$manifest->id}'."
);
}
$manifests[] = $manifest;
}
// Sort deterministically: load_order ASC, then id ASC
usort($manifests, static function (ModuleManifest $a, ModuleManifest $b): int {
return $a->loadOrder <=> $b->loadOrder ?: strcmp($a->id, $b->id);
});
// Register module lib dirs with Composer's PSR-4 autoloader so that
// module classes (MintyPHP\Module\<Name>\*) resolve without a custom loader.
self::registerModuleAutoloading($manifests);
// Preload core + module translations into I18n before any t() call.
$coreI18nDir = rtrim($modulesDir, '/') . '/../i18n';
if (is_dir($coreI18nDir)) {
ModuleI18nLoader::preload($manifests, $coreI18nDir);
}
// Register and merge
foreach ($manifests as $manifest) {
$registry->registerModule($manifest);
}
$registry->validateDependencies();
$registry->finalizeMergedContributions();
return $registry;
}
/**
* Resolve enabled module IDs from config array + ENV override.
*
* @param array{enabled_modules?: list<string>} $config From config/modules.php
* @return list<string>
*/
public static function resolveEnabledModules(array $config): array
{
$fromConfig = $config['enabled_modules'] ?? [];
$fromEnvRaw = getenv('APP_ENABLED_MODULES');
// Semantics:
// - not set => use config/modules.php
// - set '' => explicit "no modules"
// - set list => exact list from env
if ($fromEnvRaw !== false) {
$fromEnv = trim((string) $fromEnvRaw);
$fromConfig = $fromEnv === ''
? []
: array_map('trim', explode(',', $fromEnv));
}
return array_values(array_unique(array_filter($fromConfig, static fn (string $id): bool => trim($id) !== '')));
}
/**
* Register module lib/ directories with Composer's ClassLoader.
*
* @param list<ModuleManifest> $manifests
*/
private static function registerModuleAutoloading(array $manifests): void
{
$composerLoader = null;
foreach (spl_autoload_functions() as $autoloader) {
if (is_array($autoloader) && $autoloader[0] instanceof ClassLoader) {
$composerLoader = $autoloader[0];
break;
}
}
if ($composerLoader === null) {
return;
}
foreach ($manifests as $manifest) {
$libDir = rtrim($manifest->basePath, '/') . '/lib';
if (is_dir($libDir)) {
$composerLoader->addPsr4('MintyPHP\\', [$libDir]);
}
}
}
private function registerModule(ModuleManifest $manifest): void
{
if (isset($this->modules[$manifest->id])) {
throw new RuntimeException(
"Duplicate module id: '{$manifest->id}' is already registered."
);
}
$this->modules[$manifest->id] = $manifest;
// — Merge routes (fail-fast on collision) ——————————————
foreach ($manifest->routes as $rawRoute) {
$route = $this->normalizeRoute($rawRoute, $manifest->id);
$path = $route['path'];
$target = $route['target'];
if (isset($this->routeTargetOwners[$target]) && $this->routeTargetOwners[$target] !== $manifest->id) {
$owner = $this->routeTargetOwners[$target];
throw new RuntimeException(
"Route target conflict: '{$target}' from module '{$manifest->id}' collides with module '{$owner}'."
);
}
if (isset($this->routePathOwners[$path])) {
$owner = $this->routePathOwners[$path];
throw new RuntimeException(
"Route path conflict: '{$path}' from module '{$manifest->id}' collides with module '{$owner}'."
);
}
$this->routeTargetOwners[$target] = $manifest->id;
$this->routePathOwners[$path] = $manifest->id;
$this->mergedRoutes[] = $route;
if (!empty($route['public'])) {
$this->mergedPublicPaths[] = $path;
}
}
// — Merge public paths ———————————————————————————
array_push($this->mergedPublicPaths, ...$manifest->publicPaths);
// — Merge container registrars ————————————————————
array_push($this->mergedContainerRegistrars, ...$manifest->containerRegistrars);
// — Merge UI slots (fail-fast on duplicate keys within same slot) ——
foreach ($manifest->uiSlots as $slotName => $contributions) {
if (!isset($this->mergedUiSlots[$slotName])) {
$this->mergedUiSlots[$slotName] = [];
}
$contributionList = is_array($contributions) ? $contributions : [$contributions];
foreach ($contributionList as $contribution) {
$normalizedContribution = $this->normalizeUiSlotContribution($slotName, $contribution, $manifest->id, $manifest->basePath);
$key = (string) $normalizedContribution['key'];
foreach ($this->mergedUiSlots[$slotName] as $existing) {
$existingKey = is_array($existing) ? ($existing['key'] ?? null) : null;
if ($existingKey === $key) {
throw new RuntimeException(
"UI slot conflict: slot '{$slotName}' key '{$key}' from module '{$manifest->id}' already exists."
);
}
}
$this->mergedUiSlots[$slotName][] = $normalizedContribution;
}
}
// — Merge search resources (fail-fast on duplicate provider) ——
foreach ($manifest->searchResources as $provider) {
if (!is_string($provider) || trim($provider) === '') {
throw new RuntimeException(
"Search resource provider in module '{$manifest->id}' must be a non-empty class-string."
);
}
if (in_array($provider, $this->mergedSearchResources, true)) {
throw new RuntimeException(
"Search resource conflict: provider '{$provider}' from module '{$manifest->id}' is already registered."
);
}
$this->mergedSearchResources[] = $provider;
}
// — Merge asset groups ————————————————————————————
foreach ($manifest->assetGroups as $groupName => $files) {
if (isset($this->mergedAssetGroups[$groupName])) {
throw new RuntimeException(
"Asset group conflict: group '{$groupName}' from module '{$manifest->id}' already exists."
);
}
$this->mergedAssetGroups[$groupName] = $files;
}
// — Merge permissions (fail-fast on duplicate) ———————
foreach ($manifest->permissions as $permission) {
$permissionKey = trim((string) $permission['key']);
if ($permissionKey === '') {
throw new RuntimeException(
"Permission in module '{$manifest->id}' must define non-empty 'key'."
);
}
if (isset($this->permissionOwners[$permissionKey])) {
$owner = $this->permissionOwners[$permissionKey];
throw new RuntimeException(
"Permission conflict: '{$permissionKey}' from module '{$manifest->id}' is already registered by module '{$owner}'."
);
}
$this->permissionOwners[$permissionKey] = $manifest->id;
$this->mergedPermissions[] = [
'key' => $permissionKey,
'description' => trim((string) $permission['description']),
'active' => (int) $permission['active'],
'is_system' => (int) $permission['is_system'],
];
}
// — Merge layout context providers ————————————————
foreach ($manifest->layoutContextProviders as $providerClass) {
if (!is_string($providerClass) || trim($providerClass) === '') {
throw new RuntimeException(
"Layout context provider in module '{$manifest->id}' must be a non-empty class-string."
);
}
$this->mergedLayoutContextProviders[] = $providerClass;
}
// — Merge session providers ——————————————————————
foreach ($manifest->sessionProviders as $providerClass) {
if (!is_string($providerClass) || trim($providerClass) === '') {
throw new RuntimeException(
"Session provider in module '{$manifest->id}' must be a non-empty class-string."
);
}
$this->mergedSessionProviders[] = $providerClass;
}
// — Merge authorization policies (fail-fast on duplicate) ———
foreach ($manifest->authorizationPolicies as $policyClass) {
if (!is_string($policyClass) || trim($policyClass) === '') {
throw new RuntimeException(
"Authorization policy in module '{$manifest->id}' must be a non-empty class-string."
);
}
if (in_array($policyClass, $this->mergedAuthorizationPolicies, true)) {
throw new RuntimeException(
"Authorization policy conflict: '{$policyClass}' from module '{$manifest->id}' is already registered."
);
}
$this->mergedAuthorizationPolicies[] = $policyClass;
}
// — Merge event listeners ——————————————————————————
foreach ($manifest->eventListeners as $eventName => $handlers) {
if (!isset($this->mergedEventListeners[$eventName])) {
$this->mergedEventListeners[$eventName] = [];
}
array_push($this->mergedEventListeners[$eventName], ...$handlers);
}
// — Merge scheduler jobs —————————————————————————
foreach ($manifest->schedulerJobs as $schedulerJob) {
$jobKey = trim((string) $schedulerJob['job_key']);
if ($jobKey === '') {
throw new RuntimeException(
"Scheduler job in module '{$manifest->id}' must define non-empty 'job_key'."
);
}
if (isset($this->schedulerJobOwners[$jobKey])) {
$owner = $this->schedulerJobOwners[$jobKey];
throw new RuntimeException(
"Scheduler job conflict: '{$jobKey}' from module '{$manifest->id}' is already registered by module '{$owner}'."
);
}
$this->schedulerJobOwners[$jobKey] = $manifest->id;
$this->mergedSchedulerJobs[] = [
'job_key' => $jobKey,
'handler' => trim((string) $schedulerJob['handler']),
'label' => trim((string) $schedulerJob['label']),
'description' => trim((string) $schedulerJob['description']),
'default_enabled' => (int) $schedulerJob['default_enabled'],
'default_timezone' => trim((string) $schedulerJob['default_timezone']),
'default_schedule_type' => trim((string) $schedulerJob['default_schedule_type']),
'default_schedule_interval' => (int) $schedulerJob['default_schedule_interval'],
'default_schedule_time' => $schedulerJob['default_schedule_time'],
'default_schedule_weekdays_csv' => $schedulerJob['default_schedule_weekdays_csv'],
'default_catchup_once' => (int) $schedulerJob['default_catchup_once'],
'allowed_schedule_types' => array_values($schedulerJob['allowed_schedule_types']),
'module_id' => $manifest->id,
];
}
}
private function validateDependencies(): void
{
$enabledIds = array_keys($this->modules);
// Check all required modules are enabled
foreach ($this->modules as $manifest) {
foreach ($manifest->requires as $requiredId) {
if (!isset($this->modules[$requiredId])) {
throw new RuntimeException(
"Module '{$manifest->id}' requires module '{$requiredId}' which is not enabled."
);
}
}
}
// Check for circular dependencies (DFS cycle detection)
$visited = [];
$inStack = [];
$visit = function (string $moduleId) use (&$visit, &$visited, &$inStack): void {
if (isset($inStack[$moduleId])) {
throw new RuntimeException(
"Circular module dependency detected involving module '{$moduleId}'."
);
}
if (isset($visited[$moduleId])) {
return;
}
$inStack[$moduleId] = true;
if (isset($this->modules[$moduleId])) {
foreach ($this->modules[$moduleId]->requires as $requiredId) {
$visit($requiredId);
}
}
unset($inStack[$moduleId]);
$visited[$moduleId] = true;
};
foreach ($enabledIds as $moduleId) {
$visit($moduleId);
}
}
private function finalizeMergedContributions(): void
{
$this->mergedPublicPaths = array_values(array_unique(array_filter(
$this->mergedPublicPaths,
static fn (mixed $path): bool => trim((string) $path) !== ''
)));
foreach ($this->mergedUiSlots as $slotName => $items) {
usort($items, static function (array $a, array $b): int {
$orderCmp = ((int) ($a['order'] ?? 100)) <=> ((int) ($b['order'] ?? 100));
if ($orderCmp !== 0) {
return $orderCmp;
}
$moduleCmp = strcmp((string) ($a['__module_id'] ?? ''), (string) ($b['__module_id'] ?? ''));
if ($moduleCmp !== 0) {
return $moduleCmp;
}
return strcmp((string) ($a['key'] ?? ''), (string) ($b['key'] ?? ''));
});
foreach ($items as &$item) {
unset($item['__module_id']);
}
unset($item);
$this->mergedUiSlots[$slotName] = $items;
}
}
/**
* @return array{path: string, target: string, module_id: string, public?: bool}
*/
private function normalizeRoute(mixed $rawRoute, string $moduleId): array
{
if (!is_array($rawRoute)) {
throw new RuntimeException("Route entries in module '{$moduleId}' must be arrays.");
}
$path = trim((string) ($rawRoute['path'] ?? ''));
$target = trim((string) ($rawRoute['target'] ?? ''));
if ($path === '' || $target === '') {
throw new RuntimeException("Routes in module '{$moduleId}' must define non-empty 'path' and 'target'.");
}
$normalized = [
'path' => $path,
'target' => $target,
'module_id' => $moduleId,
];
if (array_key_exists('public', $rawRoute)) {
$normalized['public'] = (bool) $rawRoute['public'];
}
return $normalized;
}
/**
* @return array<string, mixed>
*/
private function normalizeUiSlotContribution(string $slotName, mixed $rawContribution, string $moduleId, string $basePath): array
{
if (!is_array($rawContribution)) {
throw new RuntimeException(
"UI slot contribution for slot '{$slotName}' in module '{$moduleId}' must be an array."
);
}
$key = trim((string) ($rawContribution['key'] ?? ''));
if ($key === '') {
throw new RuntimeException(
"UI slot contribution for slot '{$slotName}' in module '{$moduleId}' must define a non-empty 'key'."
);
}
// Resolve relative template paths to absolute using the module's basePath.
foreach (['panel_template', 'template'] as $templateKey) {
if (!isset($rawContribution[$templateKey])) {
continue;
}
$templatePath = trim((string) $rawContribution[$templateKey]);
if ($templatePath !== '' && !str_starts_with($templatePath, '/')) {
$rawContribution[$templateKey] = rtrim($basePath, '/') . '/' . $templatePath;
}
}
$normalized = $rawContribution;
$normalized['key'] = $key;
$normalized['order'] = (int) ($rawContribution['order'] ?? 100);
$normalized['__module_id'] = $moduleId;
$normalized['module_id'] = $moduleId;
return $normalized;
}
// ─── Getters ────────────────────────────────────────────────────────
/** @return array<string, ModuleManifest> */
public function getModules(): array
{
return $this->modules;
}
public function hasModule(string $id): bool
{
return isset($this->modules[$id]);
}
public function getModule(string $id): ModuleManifest
{
if (!isset($this->modules[$id])) {
throw new RuntimeException("Module '{$id}' is not registered.");
}
return $this->modules[$id];
}
/** @return array<int, array{path: string, target: string, module_id: string, public?: bool}> */
public function getRoutes(): array
{
return $this->mergedRoutes;
}
/** @return list<string> */
public function getPublicPaths(): array
{
return $this->mergedPublicPaths;
}
/** @return list<class-string> */
public function getContainerRegistrars(): array
{
return $this->mergedContainerRegistrars;
}
/** @return array<string, list<mixed>> */
public function getUiSlots(): array
{
return $this->mergedUiSlots;
}
/**
* @return list<mixed> Contributions for a specific slot
*/
public function getSlotContributions(string $slotName): array
{
return $this->mergedUiSlots[$slotName] ?? [];
}
/** @return list<class-string> */
public function getSearchResources(): array
{
return $this->mergedSearchResources;
}
/** @return array<string, list<string>> */
public function getAssetGroups(): array
{
return $this->mergedAssetGroups;
}
/** @return list<class-string> */
public function getLayoutContextProviders(): array
{
return $this->mergedLayoutContextProviders;
}
/** @return list<class-string> */
public function getSessionProviders(): array
{
return $this->mergedSessionProviders;
}
/**
* @return list<array{key: string, description: string, active: int, is_system: int}>
*/
public function getPermissions(): array
{
return $this->mergedPermissions;
}
/** @return list<string> */
public function getPermissionKeys(): array
{
return array_values(array_map(
static fn (array $permission): string => (string) $permission['key'],
$this->mergedPermissions
));
}
/** @return list<class-string> */
public function getAuthorizationPolicies(): array
{
return $this->mergedAuthorizationPolicies;
}
/**
* Collect unique ability strings from all module UI slot contributions.
*
* Used by UiAccessService to resolve module abilities into the layout auth map.
* Each ability maps to itself (key = ability, value = ability).
*
* @return array<string, string>
*/
public function getModuleUiAbilities(): array
{
$abilities = [];
foreach ($this->mergedUiSlots as $items) {
foreach ($items as $item) {
$ability = trim((string) ($item['permission'] ?? ''));
if ($ability !== '') {
$abilities[$ability] = $ability;
}
}
}
return $abilities;
}
/**
* @return list<array{
* job_key: string,
* handler: string,
* label: string,
* description: string,
* default_enabled: int,
* default_timezone: string,
* default_schedule_type: string,
* default_schedule_interval: int,
* default_schedule_time: ?string,
* default_schedule_weekdays_csv: ?string,
* default_catchup_once: int,
* allowed_schedule_types: list<string>,
* module_id: string
* }>
*/
public function getSchedulerJobs(): array
{
return $this->mergedSchedulerJobs;
}
/** @return array<string, list<array{class: class-string, method: string}>> */
public function getEventListeners(): array
{
return $this->mergedEventListeners;
}
}