diff --git a/CHANGELOG.md b/CHANGELOG.md index 28abbd34..8295a908 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,33 @@ Entries from `0.4.0` onward are generated automatically by `bin/release.sh` from ### New Features * feat: add marko/page-cache and marko/page-cache-file packages +* feat(core): add module-declared global middleware support via `globalMiddleware` key in `module.php` + +#### Module-declared global middleware (`marko/core`) + +Any module can now register global HTTP middleware by adding a `'globalMiddleware'` key to its `module.php`: + +```php title="module.php" + [ + // class-string form — default priority 100 + \App\Http\Middleware\MyMiddleware::class, + + // array form — explicit priority (lower = runs earlier) + ['class' => \App\Http\Middleware\EarlyMiddleware::class, 'priority' => 5], + ], +]; +``` + +**Priority semantics** --- entries are sorted by priority value ascending; a lower value runs earlier in the stack. The default priority is `100` when the `priority` key is omitted. Built-in framework middleware (page cache, session, layout) occupy the `10`–`30` range. + +**Deduplication** --- if the same middleware class appears in more than one source (vendor, module, or app), the highest-source-priority entry wins (`app > modules > vendor`). Within the same source, the entry with the lower priority value (runs earlier) is kept. + +**Upgrade note** --- Apps that subclass `Application` and override `discoverGlobalMiddleware()` will not pick up module-declared middleware until they update their override to delegate to `GlobalMiddlewareResolver`. If you have a custom `discoverGlobalMiddleware()`, call `parent::discoverGlobalMiddleware()` or inject and call `GlobalMiddlewareResolver::resolve()` directly. ## [0.5.0] - 2026-05-01 diff --git a/packages/core/src/Application.php b/packages/core/src/Application.php index 9b3caba7..2765db69 100644 --- a/packages/core/src/Application.php +++ b/packages/core/src/Application.php @@ -26,6 +26,7 @@ use Marko\Core\Exceptions\PluginException; use Marko\Core\Exceptions\PreferenceConflictException; use Marko\Core\Module\DependencyResolver; +use Marko\Core\Module\GlobalMiddlewareResolver; use Marko\Core\Module\ManifestParser; use Marko\Core\Module\ModuleDiscovery; use Marko\Core\Module\ModuleManifest; @@ -303,11 +304,6 @@ private function discoverCommands(): void $this->commandRunner = new CommandRunner($this->container, $this->commandRegistry); } - private const array GLOBAL_MIDDLEWARE = [ - 'Marko\\PageCache\\Middleware\\PageCacheMiddleware', - 'Marko\\Session\\Middleware\\SessionMiddleware', - 'Marko\\Layout\\Middleware\\LayoutMiddleware', - ]; /** * @throws RouteException|RouteConflictException|ReflectionException @@ -348,20 +344,12 @@ public function handleRequest(): void } /** - * Discover available global middleware classes. - * + * Discover available global middleware classes by merging module-declared * @return array> + * @throws ModuleException When a module-declared middleware class is invalid */ private function discoverGlobalMiddleware(): array { - $middleware = []; - - foreach (self::GLOBAL_MIDDLEWARE as $class) { - if (class_exists($class)) { - $middleware[] = $class; - } - } - - return $middleware; + return (new GlobalMiddlewareResolver())->resolve($this->modules); } } diff --git a/packages/core/src/Exceptions/ModuleException.php b/packages/core/src/Exceptions/ModuleException.php index 6188600e..0cec9159 100644 --- a/packages/core/src/Exceptions/ModuleException.php +++ b/packages/core/src/Exceptions/ModuleException.php @@ -27,4 +27,27 @@ public static function missingDependency( suggestion: "Install the missing package with: composer require $dependencyName", ); } + + public static function invalidMiddlewareClass( + string $moduleName, + string $className, + string $reason, + ): self { + return new self( + message: "Invalid globalMiddleware entry in module '$moduleName': $reason", + context: "While resolving global middleware for '$moduleName'", + suggestion: "Ensure '$className' exists and implements " . \Marko\Routing\Middleware\MiddlewareInterface::class, + ); + } + + public static function invalidMiddlewareEntry( + string $moduleName, + string $reason, + ): self { + return new self( + message: "Invalid globalMiddleware entry in module '$moduleName': $reason", + context: "While resolving global middleware for '$moduleName'", + suggestion: "Each entry must be a class-string or ['class' => SomeMiddleware::class, 'priority' => 10]", + ); + } } diff --git a/packages/core/src/Module/GlobalMiddlewareResolver.php b/packages/core/src/Module/GlobalMiddlewareResolver.php new file mode 100644 index 00000000..4adc7e17 --- /dev/null +++ b/packages/core/src/Module/GlobalMiddlewareResolver.php @@ -0,0 +1,131 @@ + modules > vendor). + */ +class GlobalMiddlewareResolver +{ + private const array SOURCE_PRIORITY = [ + 'vendor' => 0, + 'modules' => 1, + 'app' => 2, + ]; + + private const int DEFAULT_PRIORITY = 100; + + /** + * Resolve global middleware from module declarations. + * + * @param array $modules + * @return array> + * @throws ModuleException When a module-declared class does not exist, is missing the class key, or does not implement MiddlewareInterface + */ + public function resolve(array $modules): array + { + // Candidate map: class => [priority, sourcePriority] + // We keep the highest-source-priority entry; within same source, the lowest priority value. + /** @var array $candidates */ + $candidates = []; + + foreach ($modules as $module) { + $sourcePriority = self::SOURCE_PRIORITY[$module->source] ?? 0; + + foreach ($module->globalMiddleware as $entry) { + [$class, $priority] = $this->parseEntry($entry, $module->name); + + if (!class_exists($class)) { + throw ModuleException::invalidMiddlewareClass( + moduleName: $module->name, + className: $class, + reason: "Class '$class' does not exist", + ); + } + + if (!is_a($class, MiddlewareInterface::class, true)) { + throw ModuleException::invalidMiddlewareClass( + moduleName: $module->name, + className: $class, + reason: "Class '$class' does not implement " . MiddlewareInterface::class, + ); + } + + $this->addCandidate($candidates, $class, $priority, $sourcePriority); + } + } + + // Sort by priority ascending + uasort($candidates, fn (array $a, array $b) => $a['priority'] <=> $b['priority']); + + return array_keys($candidates); + } + + /** + * Parse a single globalMiddleware entry (flat string or array form). + * + * @param mixed $entry + * @return array{0: string, 1: int} + * @throws ModuleException When entry is invalid + */ + private function parseEntry( + mixed $entry, + string $moduleName, + ): array { + if (is_string($entry)) { + return [$entry, self::DEFAULT_PRIORITY]; + } + + if (!is_array($entry) || !isset($entry['class'])) { + throw ModuleException::invalidMiddlewareEntry( + moduleName: $moduleName, + reason: "Each globalMiddleware entry must be a class-string or an array with a 'class' key", + ); + } + + return [$entry['class'], $entry['priority'] ?? self::DEFAULT_PRIORITY]; + } + + /** + * Add or update a candidate entry applying deduplication rules. + * + * Rules: + * - Higher source priority always wins (app > modules > vendor) + * - Within the same source, keep the lowest priority value (runs earliest) + * + * @param array $candidates + */ + private function addCandidate( + array &$candidates, + string $class, + int $priority, + int $sourcePriority, + ): void { + if (!isset($candidates[$class])) { + $candidates[$class] = ['priority' => $priority, 'sourcePriority' => $sourcePriority]; + return; + } + + $existing = $candidates[$class]; + + if ($sourcePriority > $existing['sourcePriority']) { + // Higher source priority wins unconditionally + $candidates[$class] = ['priority' => $priority, 'sourcePriority' => $sourcePriority]; + return; + } + + if ($sourcePriority === $existing['sourcePriority'] && $priority < $existing['priority']) { + // Same source: keep the lower priority value (runs earlier) + $candidates[$class]['priority'] = $priority; + } + } +} diff --git a/packages/core/src/Module/ManifestParser.php b/packages/core/src/Module/ManifestParser.php index af39801d..f50397d6 100644 --- a/packages/core/src/Module/ManifestParser.php +++ b/packages/core/src/Module/ManifestParser.php @@ -40,6 +40,7 @@ public function parse( singletons: $moduleData['singletons'] ?? [], autoload: $composerData['autoload']['psr-4'] ?? [], boot: $moduleData['boot'] ?? null, + globalMiddleware: $moduleData['globalMiddleware'] ?? [], ); } diff --git a/packages/core/src/Module/ModuleDiscovery.php b/packages/core/src/Module/ModuleDiscovery.php index 48e75f5b..dce412c1 100644 --- a/packages/core/src/Module/ModuleDiscovery.php +++ b/packages/core/src/Module/ModuleDiscovery.php @@ -168,6 +168,7 @@ private function withPathAndSource( source: $source, autoload: $manifest->autoload, boot: $manifest->boot, + globalMiddleware: $manifest->globalMiddleware, ); } diff --git a/packages/core/src/Module/ModuleManifest.php b/packages/core/src/Module/ModuleManifest.php index f9755831..94202ad7 100644 --- a/packages/core/src/Module/ModuleManifest.php +++ b/packages/core/src/Module/ModuleManifest.php @@ -27,6 +27,7 @@ * @param string $source Discovery source: vendor, modules, or app * @param array $autoload PSR-4 autoload configuration from composer.json (namespace => path) * @param Closure|null $boot Boot callback to run after bindings are registered (from module.php). Parameters are auto-injected from the container — type-hint any registered dependency, including ContainerInterface. + * @param array $globalMiddleware Global HTTP middleware declared by this module (from module.php) */ public function __construct( public string $name, @@ -41,5 +42,6 @@ public function __construct( public string $source = '', public array $autoload = [], public ?Closure $boot = null, + public array $globalMiddleware = [], ) {} } diff --git a/packages/core/tests/Unit/ApplicationTest.php b/packages/core/tests/Unit/ApplicationTest.php index 08c42fe8..4dc0ea3e 100644 --- a/packages/core/tests/Unit/ApplicationTest.php +++ b/packages/core/tests/Unit/ApplicationTest.php @@ -2005,34 +2005,3 @@ public function run(string \$result): string appTestCleanupDirectory($baseDir); }); -it('includes SessionMiddleware in global middleware', function (): void { - $reflection = new ReflectionClass(Application::class); - $constant = $reflection->getReflectionConstant('GLOBAL_MIDDLEWARE'); - - expect($constant->getValue())->toContain('Marko\\Session\\Middleware\\SessionMiddleware'); -}); - -it('includes LayoutMiddleware in global middleware', function (): void { - $reflection = new ReflectionClass(Application::class); - $constant = $reflection->getReflectionConstant('GLOBAL_MIDDLEWARE'); - - expect($constant->getValue())->toContain('Marko\\Layout\\Middleware\\LayoutMiddleware'); -}); - -it('includes PageCacheMiddleware in global middleware', function (): void { - $reflection = new ReflectionClass(Application::class); - $constant = $reflection->getReflectionConstant('GLOBAL_MIDDLEWARE'); - - expect($constant->getValue())->toContain('Marko\\PageCache\\Middleware\\PageCacheMiddleware'); -}); - -it('lists PageCacheMiddleware before SessionMiddleware in global middleware order', function (): void { - $reflection = new ReflectionClass(Application::class); - $constant = $reflection->getReflectionConstant('GLOBAL_MIDDLEWARE'); - $middleware = $constant->getValue(); - - $pageCache = array_search('Marko\\PageCache\\Middleware\\PageCacheMiddleware', $middleware); - $session = array_search('Marko\\Session\\Middleware\\SessionMiddleware', $middleware); - - expect($pageCache)->toBeLessThan($session); -}); diff --git a/packages/core/tests/Unit/Module/GlobalMiddlewareResolverTest.php b/packages/core/tests/Unit/Module/GlobalMiddlewareResolverTest.php new file mode 100644 index 00000000..53f9210c --- /dev/null +++ b/packages/core/tests/Unit/Module/GlobalMiddlewareResolverTest.php @@ -0,0 +1,409 @@ +resolve([$module]); + + expect($result) + ->toContain('Acme\Mw\AlphaMiddleware') + ->toContain('Acme\Mw\BetaMiddleware'); +}); + +// --------------------------------------------------------------------------- +// Requirement 2: array form with class key and priority +// --------------------------------------------------------------------------- + +it('accepts globalMiddleware entries as array with class key and priority', function (): void { + makeMiddlewareClass('Acme\Mw\GammaMiddleware'); + + $module = makeModuleManifest( + name: 'acme/test', + source: 'vendor', + globalMiddleware: [ + ['class' => 'Acme\Mw\GammaMiddleware', 'priority' => 25], + ], + ); + + $resolver = new GlobalMiddlewareResolver(); + $result = $resolver->resolve([$module]); + + expect($result)->toContain('Acme\Mw\GammaMiddleware'); +}); + +// --------------------------------------------------------------------------- +// Requirement 3: default priority 100 for flat entries +// --------------------------------------------------------------------------- + +it('defaults missing priority to 100', function (): void { + makeMiddlewareClass('Acme\Mw\DeltaMiddleware'); + makeMiddlewareClass('Acme\Mw\EpsilonMiddleware'); + + // DeltaMiddleware is flat (default priority 100) + // EpsilonMiddleware has priority 50 — should come first + $module = makeModuleManifest( + name: 'acme/test', + source: 'vendor', + globalMiddleware: [ + 'Acme\Mw\DeltaMiddleware', + ['class' => 'Acme\Mw\EpsilonMiddleware', 'priority' => 50], + ], + ); + + $resolver = new GlobalMiddlewareResolver(); + $result = $resolver->resolve([$module]); + + $deltaIndex = array_search('Acme\Mw\DeltaMiddleware', $result); + $epsilonIndex = array_search('Acme\Mw\EpsilonMiddleware', $result); + + // EpsilonMiddleware (priority 50) should come before DeltaMiddleware (priority 100) + expect($epsilonIndex)->toBeLessThan($deltaIndex); +}); + +// --------------------------------------------------------------------------- +// Requirement 4: merges globalMiddleware from multiple modules +// --------------------------------------------------------------------------- + +it('merges globalMiddleware declarations from multiple modules', function (): void { + makeMiddlewareClass('Acme\Mw\ZetaMiddleware'); + makeMiddlewareClass('Acme\Mw\OmegaMiddleware'); + + $moduleA = makeModuleManifest( + name: 'acme/mod-a', + source: 'vendor', + globalMiddleware: ['Acme\Mw\ZetaMiddleware'], + ); + + $moduleB = makeModuleManifest( + name: 'acme/mod-b', + source: 'vendor', + globalMiddleware: ['Acme\Mw\OmegaMiddleware'], + ); + + $resolver = new GlobalMiddlewareResolver(); + $result = $resolver->resolve([$moduleA, $moduleB]); + + expect($result) + ->toContain('Acme\Mw\ZetaMiddleware') + ->toContain('Acme\Mw\OmegaMiddleware'); +}); + +// --------------------------------------------------------------------------- +// Requirement 5: sorts merged result by priority ascending +// --------------------------------------------------------------------------- + +it('sorts merged globalMiddleware by priority ascending', function (): void { + makeMiddlewareClass('Acme\Mw\EtaMiddleware'); // priority 30 + makeMiddlewareClass('Acme\Mw\ThetaMiddleware'); // priority 10 + makeMiddlewareClass('Acme\Mw\IotaMiddleware'); // priority 20 + + $moduleA = makeModuleManifest( + name: 'acme/mod-a', + source: 'vendor', + globalMiddleware: [ + ['class' => 'Acme\Mw\EtaMiddleware', 'priority' => 30], + ['class' => 'Acme\Mw\IotaMiddleware', 'priority' => 20], + ], + ); + + $moduleB = makeModuleManifest( + name: 'acme/mod-b', + source: 'vendor', + globalMiddleware: [ + ['class' => 'Acme\Mw\ThetaMiddleware', 'priority' => 10], + ], + ); + + $resolver = new GlobalMiddlewareResolver(); + $result = $resolver->resolve([$moduleA, $moduleB]); + + $thetaIndex = array_search('Acme\Mw\ThetaMiddleware', $result); + $iotaIndex = array_search('Acme\Mw\IotaMiddleware', $result); + $etaIndex = array_search('Acme\Mw\EtaMiddleware', $result); + + expect($thetaIndex)->toBeLessThan($iotaIndex) + ->and($iotaIndex)->toBeLessThan($etaIndex); +}); + +// --------------------------------------------------------------------------- +// Requirement 6: deduplication — app > modules > vendor source priority +// --------------------------------------------------------------------------- + +it('deduplicates globalMiddleware entries preferring app over modules over vendor source', function (): void { + makeMiddlewareClass('Acme\Mw\KappaMiddleware'); + + $vendorModule = makeModuleManifest( + name: 'acme/vendor-mod', + source: 'vendor', + globalMiddleware: [ + ['class' => 'Acme\Mw\KappaMiddleware', 'priority' => 10], + ], + ); + + $appModule = makeModuleManifest( + name: 'acme/app-mod', + source: 'app', + globalMiddleware: [ + ['class' => 'Acme\Mw\KappaMiddleware', 'priority' => 50], + ], + ); + + $resolver = new GlobalMiddlewareResolver(); + $result = $resolver->resolve([$vendorModule, $appModule]); + + // Should appear only once — app source wins + $count = count(array_filter($result, fn ($c) => $c === 'Acme\Mw\KappaMiddleware')); + expect($count)->toBe(1); + // App entry has priority 50 — but app source always wins regardless + expect($result)->toContain('Acme\Mw\KappaMiddleware'); +}); + +// --------------------------------------------------------------------------- +// Requirement 7: within same source, keep lowest priority value +// --------------------------------------------------------------------------- + +it('deduplicates globalMiddleware within the same source by keeping the lowest priority value', function (): void { + makeMiddlewareClass('Acme\Mw\LambdaMiddleware'); + makeMiddlewareClass('Acme\Mw\MuMiddleware'); + + $module1 = makeModuleManifest( + name: 'acme/mod-a', + source: 'vendor', + globalMiddleware: [ + ['class' => 'Acme\Mw\LambdaMiddleware', 'priority' => 80], + ['class' => 'Acme\Mw\MuMiddleware', 'priority' => 5], + ], + ); + + $module2 = makeModuleManifest( + name: 'acme/mod-b', + source: 'vendor', + globalMiddleware: [ + ['class' => 'Acme\Mw\LambdaMiddleware', 'priority' => 40], // lower → should win + ], + ); + + $resolver = new GlobalMiddlewareResolver(); + $result = $resolver->resolve([$module1, $module2]); + + // LambdaMiddleware should appear once, with priority 40 (comes before MuMiddleware priority 5? no) + $count = count(array_filter($result, fn ($c) => $c === 'Acme\Mw\LambdaMiddleware')); + expect($count)->toBe(1); + + $lambdaIndex = array_search('Acme\Mw\LambdaMiddleware', $result); + $muIndex = array_search('Acme\Mw\MuMiddleware', $result); + + // MuMiddleware has priority 5, LambdaMiddleware's kept priority is 40 → mu comes first + expect($muIndex)->toBeLessThan($lambdaIndex); +}); + +// --------------------------------------------------------------------------- +// Requirement 8: built-in priorities +// --------------------------------------------------------------------------- + +it('assigns priority 10 to PageCacheMiddleware 20 to SessionMiddleware 30 to LayoutMiddleware as module declarations', function (): void { + makeMiddlewareClass('Marko\PageCache\Middleware\PageCacheMiddleware'); + makeMiddlewareClass('Marko\Session\Middleware\SessionMiddleware'); + makeMiddlewareClass('Marko\Layout\Middleware\LayoutMiddleware'); + + $pageCacheModule = makeModuleManifest( + name: 'marko/page-cache', + source: 'vendor', + globalMiddleware: [['class' => 'Marko\PageCache\Middleware\PageCacheMiddleware', 'priority' => 10]], + ); + + $sessionModule = makeModuleManifest( + name: 'marko/session', + source: 'vendor', + globalMiddleware: [['class' => 'Marko\Session\Middleware\SessionMiddleware', 'priority' => 20]], + ); + + $layoutModule = makeModuleManifest( + name: 'marko/layout', + source: 'vendor', + globalMiddleware: [['class' => 'Marko\Layout\Middleware\LayoutMiddleware', 'priority' => 30]], + ); + + $resolver = new GlobalMiddlewareResolver(); + $result = $resolver->resolve([$pageCacheModule, $sessionModule, $layoutModule]); + + $pageCacheIndex = array_search('Marko\PageCache\Middleware\PageCacheMiddleware', $result); + $sessionIndex = array_search('Marko\Session\Middleware\SessionMiddleware', $result); + $layoutIndex = array_search('Marko\Layout\Middleware\LayoutMiddleware', $result); + + // All should be present and ordered: PageCache (10) < Session (20) < Layout (30) + expect($pageCacheIndex)->not->toBeFalse() + ->and($sessionIndex)->not->toBeFalse() + ->and($layoutIndex)->not->toBeFalse() + ->and($pageCacheIndex)->toBeLessThan($sessionIndex) + ->and($sessionIndex)->toBeLessThan($layoutIndex); +}); + +// --------------------------------------------------------------------------- +// Requirement 9: returns class-string array in priority order +// --------------------------------------------------------------------------- + +it('returns class-string array from discoverGlobalMiddleware in priority order', function (): void { + makeMiddlewareClass('Acme\Mw\NuMiddleware'); + makeMiddlewareClass('Acme\Mw\XiMiddleware'); + + $module = makeModuleManifest( + name: 'acme/test', + source: 'vendor', + globalMiddleware: [ + ['class' => 'Acme\Mw\NuMiddleware', 'priority' => 200], + ['class' => 'Acme\Mw\XiMiddleware', 'priority' => 5], + ], + ); + + $resolver = new GlobalMiddlewareResolver(); + $result = $resolver->resolve([$module]); + + expect($result)->toBeArray(); + foreach ($result as $entry) { + expect($entry)->toBeString(); + } + + $nuIndex = array_search('Acme\Mw\NuMiddleware', $result); + $xiIndex = array_search('Acme\Mw\XiMiddleware', $result); + + // XiMiddleware (priority 5) should come before NuMiddleware (priority 200) + expect($xiIndex)->toBeLessThan($nuIndex); +}); + +// --------------------------------------------------------------------------- +// Requirement 10: throws exception when module-declared class does not exist +// --------------------------------------------------------------------------- + +it('throws a clear exception with suggestion when a module-declared class does not exist', function (): void { + $module = makeModuleManifest( + name: 'acme/bad', + source: 'vendor', + globalMiddleware: ['Acme\NonExistent\GhostMiddleware'], + ); + + $resolver = new GlobalMiddlewareResolver(); + + expect(fn () => $resolver->resolve([$module])) + ->toThrow(ModuleException::class); +}); + +// --------------------------------------------------------------------------- +// Requirement 11: throws exception when array-form entry is missing class key +// --------------------------------------------------------------------------- + +it('throws a clear exception with suggestion when an array-form entry is missing the class key', function (): void { + $module = makeModuleManifest( + name: 'acme/bad', + source: 'vendor', + globalMiddleware: [['priority' => 10]], // missing 'class' key + ); + + $resolver = new GlobalMiddlewareResolver(); + + expect(fn () => $resolver->resolve([$module])) + ->toThrow(ModuleException::class); +}); + +// --------------------------------------------------------------------------- +// Requirement 12: throws exception when declared class doesn't implement MiddlewareInterface +// --------------------------------------------------------------------------- + +it('throws a clear exception with suggestion when a declared class does not implement MiddlewareInterface', function (): void { + // Create a class that exists but doesn't implement MiddlewareInterface + if (!class_exists('Acme\Mw\NotAMiddleware')) { + eval('namespace Acme\Mw; class NotAMiddleware {}'); + } + + $module = makeModuleManifest( + name: 'acme/bad', + source: 'vendor', + globalMiddleware: ['Acme\Mw\NotAMiddleware'], + ); + + $resolver = new GlobalMiddlewareResolver(); + + expect(fn () => $resolver->resolve([$module])) + ->toThrow(ModuleException::class); +}); + +// --------------------------------------------------------------------------- +// Requirement 13: returns empty array when modules have no globalMiddleware +// --------------------------------------------------------------------------- + +it('returns empty array when modules exist but none declare globalMiddleware', function (): void { + $module = makeModuleManifest(name: 'acme/no-mw', source: 'vendor'); + + $resolver = new GlobalMiddlewareResolver(); + $result = $resolver->resolve([$module]); + + expect($result)->toBe([]); +}); + +// --------------------------------------------------------------------------- +// Requirement 14: returns empty array when no modules are loaded +// --------------------------------------------------------------------------- + +it('returns empty array when no modules are loaded', function (): void { + $resolver = new GlobalMiddlewareResolver(); + $result = $resolver->resolve([]); + + expect($result)->toBe([]); +}); diff --git a/packages/core/tests/Unit/Module/ModuleDiscoveryTest.php b/packages/core/tests/Unit/Module/ModuleDiscoveryTest.php index 4e4e38b4..adac250f 100644 --- a/packages/core/tests/Unit/Module/ModuleDiscoveryTest.php +++ b/packages/core/tests/Unit/Module/ModuleDiscoveryTest.php @@ -611,3 +611,45 @@ function createTestModule( cleanupDirectory($tempDir); }); + +it('ModuleManifest exposes a globalMiddleware property defaulting to empty array', function (): void { + $manifest = new ModuleManifest( + name: 'acme/test', + version: '1.0.0', + ); + + expect($manifest->globalMiddleware) + ->toBeArray() + ->toBeEmpty(); +}); + +it('ManifestParser reads globalMiddleware from module.php and passes it to ModuleManifest', function (): void { + $tempDir = sys_get_temp_dir() . '/marko-test-' . bin2hex(random_bytes(8)); + mkdir($tempDir, 0755, true); + file_put_contents($tempDir . '/composer.json', json_encode(['name' => 'acme/test'])); + + $modulePhpContent = <<<'PHP' + [ + 'Acme\Middleware\FirstMiddleware', + ['class' => 'Acme\Middleware\SecondMiddleware', 'priority' => 25], + ], +]; +PHP; + file_put_contents($tempDir . '/module.php', $modulePhpContent); + + $parser = new ManifestParser(); + $manifest = $parser->parse($tempDir); + + expect($manifest->globalMiddleware) + ->toBeArray() + ->toHaveCount(2) + ->and($manifest->globalMiddleware[0])->toBe('Acme\Middleware\FirstMiddleware') + ->and($manifest->globalMiddleware[1])->toBe(['class' => 'Acme\Middleware\SecondMiddleware', 'priority' => 25]); + + cleanupDirectory($tempDir); +}); diff --git a/packages/layout/module.php b/packages/layout/module.php index 2e2e0443..62a1d474 100644 --- a/packages/layout/module.php +++ b/packages/layout/module.php @@ -8,6 +8,7 @@ use Marko\Layout\LayoutProcessor; use Marko\Layout\LayoutProcessorInterface; use Marko\Layout\LayoutResolver; +use Marko\Layout\Middleware\LayoutMiddleware; return [ 'bindings' => [ @@ -18,4 +19,7 @@ HandleResolver::class => HandleResolver::class, LayoutResolver::class => LayoutResolver::class, ], + 'globalMiddleware' => [ + ['class' => LayoutMiddleware::class, 'priority' => 30], + ], ]; diff --git a/packages/layout/tests/PackageStructureTest.php b/packages/layout/tests/PackageStructureTest.php index 315514ee..1f736f8a 100644 --- a/packages/layout/tests/PackageStructureTest.php +++ b/packages/layout/tests/PackageStructureTest.php @@ -65,3 +65,15 @@ expect($config)->toBeArray(); }); + +it('module.php declares LayoutMiddleware as globalMiddleware at priority 30', function (): void { + $module = require dirname(__DIR__) . '/module.php'; + + $entry = array_find( + $module['globalMiddleware'] ?? [], + fn (array $e) => ($e['class'] ?? '') === 'Marko\\Layout\\Middleware\\LayoutMiddleware', + ); + + expect($entry)->not->toBeNull() + ->and($entry['priority'])->toBe(30); +}); diff --git a/packages/page-cache/module.php b/packages/page-cache/module.php index 068e791a..22e36f14 100644 --- a/packages/page-cache/module.php +++ b/packages/page-cache/module.php @@ -4,6 +4,7 @@ use Marko\Core\Module\ModuleRepositoryInterface; use Marko\PageCache\Boot\IdentityBridgeValidator; +use Marko\PageCache\Middleware\PageCacheMiddleware; // Marko-specific configuration for this module. // Name and version come from composer.json. @@ -15,4 +16,7 @@ ): void { $validator->validate($modules->all()); }, + 'globalMiddleware' => [ + ['class' => PageCacheMiddleware::class, 'priority' => 10], + ], ]; diff --git a/packages/page-cache/tests/PackageStructureTest.php b/packages/page-cache/tests/PackageStructureTest.php index 44a24ae8..1c4a299c 100644 --- a/packages/page-cache/tests/PackageStructureTest.php +++ b/packages/page-cache/tests/PackageStructureTest.php @@ -55,3 +55,15 @@ expect($composer['require'])->toHaveKey('marko/page-cache') ->and($composer['require']['marko/page-cache'])->toBe('self.version'); }); + +it('module.php declares PageCacheMiddleware as globalMiddleware at priority 10', function (): void { + $module = require dirname(__DIR__) . '/module.php'; + + $entry = array_find( + $module['globalMiddleware'] ?? [], + fn (array $e) => ($e['class'] ?? '') === 'Marko\\PageCache\\Middleware\\PageCacheMiddleware', + ); + + expect($entry)->not->toBeNull() + ->and($entry['priority'])->toBe(10); +}); diff --git a/packages/session/module.php b/packages/session/module.php index 2f82225a..52fe6fed 100644 --- a/packages/session/module.php +++ b/packages/session/module.php @@ -3,10 +3,14 @@ declare(strict_types=1); use Marko\Session\Contracts\SessionInterface; +use Marko\Session\Middleware\SessionMiddleware; use Marko\Session\Session; return [ 'singletons' => [ SessionInterface::class => Session::class, ], + 'globalMiddleware' => [ + ['class' => SessionMiddleware::class, 'priority' => 20], + ], ]; diff --git a/packages/session/tests/PackageStructureTest.php b/packages/session/tests/PackageStructureTest.php index 44bd693e..a0fb9ea4 100644 --- a/packages/session/tests/PackageStructureTest.php +++ b/packages/session/tests/PackageStructureTest.php @@ -97,3 +97,15 @@ ->and($config)->toHaveKey('lifetime') ->and($config)->toHaveKey('cookie'); }); + +it('module.php declares SessionMiddleware as globalMiddleware at priority 20', function (): void { + $module = require dirname(__DIR__) . '/module.php'; + + $entry = array_find( + $module['globalMiddleware'] ?? [], + fn (array $e) => ($e['class'] ?? '') === 'Marko\\Session\\Middleware\\SessionMiddleware', + ); + + expect($entry)->not->toBeNull() + ->and($entry['priority'])->toBe(20); +});