diff --git a/CHANGELOG.md b/CHANGELOG.md index f705fe9d..6b794c02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,13 @@ The format is based on Keep a Changelog. ### Added - Added `Request::expectsJson()`: true when the `Accept` header lists a JSON media type (`application/json` or a `+json` type, parameters and case ignored), or for an AJAX request that accepts any content type (#581) -- Added boot stage lifecycle events: stages may declare `BEFORE`/`AFTER` event names and the boot `Pipeline` dispatches them with the `AppContext` as a named payload. The environment, config, error handler, HTTP, and modules stages declare `boot..before` and `boot..after` (#536) +- Added lifecycle events for the web and console runtime: the runtime stages declare `web.preflight`, `web.route`, `web.not_found`, `web.prepare`, `web.dispatch`, `web.send` and `console.command`, each with `.before` and `.after`, and the `Pipeline` dispatches them with the runtime context as a named `context` payload. A stage that does not run dispatches nothing, a stage that throws dispatches no `.after`, and the closing `web.send` events also follow an OPTIONS or not found response. Boot stages declare no events. `console.command.after` fires only with a Symfony application that does not exit after the command (#536, #538) - Added adapter-based Lang provider support with `DeepL` and `Google Translate` adapters plus shared remote request/caching infrastructure (#533) ### Changed -- **BREAKING:** Reworked the app boot and runtime into pipelines of stages that the adapters run in explicit steps (#537, #575, #576, #579, #583, #589): +- **BREAKING:** Reworked the app boot and runtime into pipelines of stages that the adapters run in explicit steps (#537, #538, #575, #576, #579, #583, #589): - `AppInterface` gains `boot()`. The adapter constructors only store the context, and `AppFactory` calls `boot()` right after creating the adapter, so `AppFactory::create(...)->start()` is unchanged. Code that creates an adapter directly must call `boot()` before `start()`. - - Removed the `AppAdapter` base class and the `WebAppTrait` and `ConsoleAppTrait` traits: `WebAppAdapter` and `ConsoleAppAdapter` implement `AppInterface` directly and moved to `Quantum\App\Adapters\Web` and `Quantum\App\Adapters\Console`. The boot stages moved to `Quantum\App\Stages\Boot`. Code that goes through `AppFactory` is unaffected; code that references these classes directly must update its imports. + - Removed the `AppAdapter` base class and the `WebAppTrait` and `ConsoleAppTrait` traits: `WebAppAdapter` and `ConsoleAppAdapter` implement `AppInterface` directly and moved to `Quantum\App\Adapters\Web` and `Quantum\App\Adapters\Console`. The stages are grouped by the adapter that runs them: the boot stages both adapters run in `Quantum\App\Stages\Boot`, the others in `Quantum\App\Adapters\Web\Stages\Boot`, `...\Web\Stages\Runtime`, `...\Console\Stages\Boot` and `...\Console\Stages\Runtime`. Code that goes through `AppFactory` is unaffected; code that references these classes directly must update its imports. - Replaced `BootPipeline` and `BootStageInterface` with one generic `Quantum\App\Pipeline`, which runs `StageInterface` stages in order and dispatches the `BEFORE`/`AFTER` events they declare. The context type is a PHPDoc template checked by PHPStan: a stage states it with `@implements StageInterface` (or `WebContext`, `ConsoleContext`) and takes `process(AppContext $context)`. A stage that calls `stop()` on the context ends the run; an optional closing stage, the second constructor argument, runs last, also after a stop, but not after a stage throws. - `AppContext` keeps `getBaseDir()`, `getContainer()`, `getEnvironment()` and `getConfig()`, and carries the state of a run: `stop()`, `isStopped()`, `getExitCode()` and `setExitCode()`. Removed `AppContext::getRequest()`, `getResponse()` and `getRoutes()`: use `WebContext::getRequest()` and `WebContext::getRoutes()`, and `response()` for the shared response. - The new `WebContext` and `ConsoleContext` extend `AppContext` with the same base directory and container. `WebContext` reads the request, the routes and the view cache from the container and stores the response to send; `ConsoleContext` reads the Symfony `Application`, the input and the output from the container. @@ -26,7 +26,7 @@ The format is based on Keep a Changelog. - `hook()` is now `event()`; `on()` and `fire()` are now `listen()` and `dispatch()`, and no aliases remain. Listeners receive one named payload array, which is empty when none is given. - Event names no longer need to be registered, so `shared/config/hooks.php` is no longer read; dispatching a name without listeners does nothing and an empty name throws `EventException`. - Listeners stay registered after `dispatch()` instead of being removed after the first call. - - Listeners can be registered from app helper files, which load before config and before any boot event. + - Listeners can be registered from app helper files, which load before the config and before any runtime event. - Removed the unused `hooks_dir()` helper and renamed the debugger tab constant `Debugger::HOOKS` to `Debugger::EVENTS`; the debugbar tab is now `events` and lists only events that have listeners. - **BREAKING:** Removed `Quantum\Loader` and moved its responsibilities to their owning packages (#535): - Moved `Setup` to `Quantum\Config\Setup`; required config failures now use `ConfigException`, and optional imports use `Config::importIfExists()`. diff --git a/src/App/Adapters/Console/ConsoleAppAdapter.php b/src/App/Adapters/Console/ConsoleAppAdapter.php index 9ca781b6..40fb4ab7 100644 --- a/src/App/Adapters/Console/ConsoleAppAdapter.php +++ b/src/App/Adapters/Console/ConsoleAppAdapter.php @@ -10,15 +10,15 @@ namespace Quantum\App\Adapters\Console; -use Quantum\App\Stages\Boot\SetupConsoleApplicationStage; -use Quantum\App\Stages\Boot\RegisterCoreCommandsStage; -use Quantum\App\Stages\Boot\RegisterAppCommandsStage; -use Quantum\App\Stages\Boot\ResolveEnvironmentStage; +use Quantum\App\Adapters\Console\Stages\Boot\SetupConsoleApplicationStage; +use Quantum\App\Adapters\Console\Stages\Boot\RegisterCoreCommandsStage; +use Quantum\App\Adapters\Console\Stages\Boot\RegisterAppCommandsStage; +use Quantum\App\Adapters\Console\Stages\Boot\ResolveEnvironmentStage; +use Quantum\App\Adapters\Console\Stages\Runtime\RunCommandStage; +use Quantum\App\Adapters\Console\Stages\Boot\InitConsoleStage; use Quantum\App\Stages\Boot\SetupErrorHandlerStage; use Quantum\App\Stages\Boot\LoadEnvironmentStage; -use Quantum\App\Stages\Console\RunCommandStage; use Quantum\App\Stages\Boot\LoadAppConfigStage; -use Quantum\App\Stages\Boot\InitConsoleStage; use Quantum\App\Stages\Boot\LoadHelpersStage; use Quantum\App\Contracts\AppInterface; use Quantum\App\AppContext; diff --git a/src/App/Stages/Boot/InitConsoleStage.php b/src/App/Adapters/Console/Stages/Boot/InitConsoleStage.php similarity index 96% rename from src/App/Stages/Boot/InitConsoleStage.php rename to src/App/Adapters/Console/Stages/Boot/InitConsoleStage.php index 4c026a4c..78b93738 100644 --- a/src/App/Stages/Boot/InitConsoleStage.php +++ b/src/App/Adapters/Console/Stages/Boot/InitConsoleStage.php @@ -8,7 +8,7 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages\Boot; +namespace Quantum\App\Adapters\Console\Stages\Boot; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\ConsoleOutput; diff --git a/src/App/Stages/Boot/RegisterAppCommandsStage.php b/src/App/Adapters/Console/Stages/Boot/RegisterAppCommandsStage.php similarity index 95% rename from src/App/Stages/Boot/RegisterAppCommandsStage.php rename to src/App/Adapters/Console/Stages/Boot/RegisterAppCommandsStage.php index f9e6efc2..12c1fbb1 100644 --- a/src/App/Stages/Boot/RegisterAppCommandsStage.php +++ b/src/App/Adapters/Console/Stages/Boot/RegisterAppCommandsStage.php @@ -8,7 +8,7 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages\Boot; +namespace Quantum\App\Adapters\Console\Stages\Boot; use Symfony\Component\Console\Application; use Quantum\App\Contracts\StageInterface; diff --git a/src/App/Stages/Boot/RegisterCoreCommandsStage.php b/src/App/Adapters/Console/Stages/Boot/RegisterCoreCommandsStage.php similarity index 96% rename from src/App/Stages/Boot/RegisterCoreCommandsStage.php rename to src/App/Adapters/Console/Stages/Boot/RegisterCoreCommandsStage.php index ce2b047f..7399fac7 100644 --- a/src/App/Stages/Boot/RegisterCoreCommandsStage.php +++ b/src/App/Adapters/Console/Stages/Boot/RegisterCoreCommandsStage.php @@ -8,7 +8,7 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages\Boot; +namespace Quantum\App\Adapters\Console\Stages\Boot; use Symfony\Component\Console\Application; use Quantum\App\Contracts\StageInterface; diff --git a/src/App/Stages/Boot/ResolveEnvironmentStage.php b/src/App/Adapters/Console/Stages/Boot/ResolveEnvironmentStage.php similarity index 97% rename from src/App/Stages/Boot/ResolveEnvironmentStage.php rename to src/App/Adapters/Console/Stages/Boot/ResolveEnvironmentStage.php index 7a286ee5..bde36151 100644 --- a/src/App/Stages/Boot/ResolveEnvironmentStage.php +++ b/src/App/Adapters/Console/Stages/Boot/ResolveEnvironmentStage.php @@ -8,7 +8,7 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages\Boot; +namespace Quantum\App\Adapters\Console\Stages\Boot; use Symfony\Component\Console\Input\InputInterface; use Quantum\Environment\Exceptions\EnvException; diff --git a/src/App/Stages/Boot/SetupConsoleApplicationStage.php b/src/App/Adapters/Console/Stages/Boot/SetupConsoleApplicationStage.php similarity index 93% rename from src/App/Stages/Boot/SetupConsoleApplicationStage.php rename to src/App/Adapters/Console/Stages/Boot/SetupConsoleApplicationStage.php index 6a566f74..2dff6831 100644 --- a/src/App/Stages/Boot/SetupConsoleApplicationStage.php +++ b/src/App/Adapters/Console/Stages/Boot/SetupConsoleApplicationStage.php @@ -8,7 +8,7 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages\Boot; +namespace Quantum\App\Adapters\Console\Stages\Boot; use Symfony\Component\Console\Application; use Quantum\App\Contracts\StageInterface; diff --git a/src/App/Stages/Console/RunCommandStage.php b/src/App/Adapters/Console/Stages/Runtime/RunCommandStage.php similarity index 86% rename from src/App/Stages/Console/RunCommandStage.php rename to src/App/Adapters/Console/Stages/Runtime/RunCommandStage.php index 3904f278..17329cd6 100644 --- a/src/App/Stages/Console/RunCommandStage.php +++ b/src/App/Adapters/Console/Stages/Runtime/RunCommandStage.php @@ -8,7 +8,7 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages\Console; +namespace Quantum\App\Adapters\Console\Stages\Runtime; use Quantum\App\Adapters\Console\ConsoleContext; use Quantum\App\Contracts\StageInterface; @@ -25,6 +25,10 @@ */ class RunCommandStage implements StageInterface { + public const BEFORE = 'console.command.before'; + + public const AFTER = 'console.command.after'; + /** * @param ConsoleContext $context * @throws Exception diff --git a/src/App/Stages/Boot/InitDebuggerStage.php b/src/App/Adapters/Web/Stages/Boot/InitDebuggerStage.php similarity index 90% rename from src/App/Stages/Boot/InitDebuggerStage.php rename to src/App/Adapters/Web/Stages/Boot/InitDebuggerStage.php index 3da72583..3c84e6f6 100644 --- a/src/App/Stages/Boot/InitDebuggerStage.php +++ b/src/App/Adapters/Web/Stages/Boot/InitDebuggerStage.php @@ -8,7 +8,7 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages\Boot; +namespace Quantum\App\Adapters\Web\Stages\Boot; use Quantum\App\Contracts\StageInterface; use Quantum\App\AppContext; diff --git a/src/App/Stages/Boot/InitHttpStage.php b/src/App/Adapters/Web/Stages/Boot/InitHttpStage.php similarity index 84% rename from src/App/Stages/Boot/InitHttpStage.php rename to src/App/Adapters/Web/Stages/Boot/InitHttpStage.php index 8eebd363..5dbb61cc 100644 --- a/src/App/Stages/Boot/InitHttpStage.php +++ b/src/App/Adapters/Web/Stages/Boot/InitHttpStage.php @@ -8,7 +8,7 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages\Boot; +namespace Quantum\App\Adapters\Web\Stages\Boot; use Quantum\App\Contracts\StageInterface; use Quantum\App\AppContext; @@ -23,10 +23,6 @@ */ class InitHttpStage implements StageInterface { - public const BEFORE = 'boot.http.before'; - - public const AFTER = 'boot.http.after'; - public function process(AppContext $context): void { if (!Di::isRegistered(Request::class)) { diff --git a/src/App/Stages/Boot/LoadModulesStage.php b/src/App/Adapters/Web/Stages/Boot/LoadModulesStage.php similarity index 90% rename from src/App/Stages/Boot/LoadModulesStage.php rename to src/App/Adapters/Web/Stages/Boot/LoadModulesStage.php index 6d2bb86a..e053bd4b 100644 --- a/src/App/Stages/Boot/LoadModulesStage.php +++ b/src/App/Adapters/Web/Stages/Boot/LoadModulesStage.php @@ -8,7 +8,7 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages\Boot; +namespace Quantum\App\Adapters\Web\Stages\Boot; use Quantum\Module\Exceptions\ModuleException; use Quantum\Router\Exceptions\RouteException; @@ -28,10 +28,6 @@ */ class LoadModulesStage implements StageInterface { - public const BEFORE = 'boot.modules.before'; - - public const AFTER = 'boot.modules.after'; - /** * @throws ModuleException|RouteException|DiException|ReflectionException */ diff --git a/src/App/Stages/Web/DispatchRequestStage.php b/src/App/Adapters/Web/Stages/Runtime/DispatchRequestStage.php similarity index 91% rename from src/App/Stages/Web/DispatchRequestStage.php rename to src/App/Adapters/Web/Stages/Runtime/DispatchRequestStage.php index 9c3303aa..ec27c1ae 100644 --- a/src/App/Stages/Web/DispatchRequestStage.php +++ b/src/App/Adapters/Web/Stages/Runtime/DispatchRequestStage.php @@ -8,7 +8,7 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages\Web; +namespace Quantum\App\Adapters\Web\Stages\Runtime; use Quantum\Middleware\Exceptions\MiddlewareException; use Quantum\App\Contracts\StageInterface; @@ -33,6 +33,10 @@ */ class DispatchRequestStage implements StageInterface { + public const BEFORE = 'web.dispatch.before'; + + public const AFTER = 'web.dispatch.after'; + /** * @param WebContext $context * @throws MiddlewareException|BaseException|DiException diff --git a/src/App/Stages/Web/HandlePreflightStage.php b/src/App/Adapters/Web/Stages/Runtime/HandlePreflightStage.php similarity index 86% rename from src/App/Stages/Web/HandlePreflightStage.php rename to src/App/Adapters/Web/Stages/Runtime/HandlePreflightStage.php index ccf00da7..a259681e 100644 --- a/src/App/Stages/Web/HandlePreflightStage.php +++ b/src/App/Adapters/Web/Stages/Runtime/HandlePreflightStage.php @@ -8,7 +8,7 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages\Web; +namespace Quantum\App\Adapters\Web\Stages\Runtime; use Quantum\App\Contracts\StageInterface; use Quantum\App\Adapters\Web\WebContext; @@ -23,6 +23,10 @@ */ class HandlePreflightStage implements StageInterface { + public const BEFORE = 'web.preflight.before'; + + public const AFTER = 'web.preflight.after'; + /** * Answers an OPTIONS request with an empty response and stops the remaining stages * @param WebContext $context diff --git a/src/App/Stages/Web/HandleRouteNotFoundStage.php b/src/App/Adapters/Web/Stages/Runtime/HandleRouteNotFoundStage.php similarity index 87% rename from src/App/Stages/Web/HandleRouteNotFoundStage.php rename to src/App/Adapters/Web/Stages/Runtime/HandleRouteNotFoundStage.php index f7d27c8a..02cd28f5 100644 --- a/src/App/Stages/Web/HandleRouteNotFoundStage.php +++ b/src/App/Adapters/Web/Stages/Runtime/HandleRouteNotFoundStage.php @@ -8,7 +8,7 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages\Web; +namespace Quantum\App\Adapters\Web\Stages\Runtime; use Quantum\Config\Exceptions\ConfigException; use Quantum\App\Contracts\StageInterface; @@ -25,6 +25,10 @@ */ class HandleRouteNotFoundStage implements StageInterface { + public const BEFORE = 'web.not_found.before'; + + public const AFTER = 'web.not_found.after'; + /** * Answers a request that matched no route with the page not found response and stops the remaining stages * @param WebContext $context diff --git a/src/App/Stages/Web/PrepareRequestStage.php b/src/App/Adapters/Web/Stages/Runtime/PrepareRequestStage.php similarity index 92% rename from src/App/Stages/Web/PrepareRequestStage.php rename to src/App/Adapters/Web/Stages/Runtime/PrepareRequestStage.php index f19989ca..bb55dcab 100644 --- a/src/App/Stages/Web/PrepareRequestStage.php +++ b/src/App/Adapters/Web/Stages/Runtime/PrepareRequestStage.php @@ -8,7 +8,7 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages\Web; +namespace Quantum\App\Adapters\Web\Stages\Runtime; use Quantum\Config\Exceptions\ConfigException; use Quantum\Lang\Exceptions\LangException; @@ -32,6 +32,10 @@ */ class PrepareRequestStage implements StageInterface { + public const BEFORE = 'web.prepare.before'; + + public const AFTER = 'web.prepare.after'; + /** * @param WebContext $context * @throws LangException|ConfigException|DiException|ReflectionException diff --git a/src/App/Stages/Web/ResolveRouteStage.php b/src/App/Adapters/Web/Stages/Runtime/ResolveRouteStage.php similarity index 87% rename from src/App/Stages/Web/ResolveRouteStage.php rename to src/App/Adapters/Web/Stages/Runtime/ResolveRouteStage.php index a261877c..d940556b 100644 --- a/src/App/Stages/Web/ResolveRouteStage.php +++ b/src/App/Adapters/Web/Stages/Runtime/ResolveRouteStage.php @@ -8,7 +8,7 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages\Web; +namespace Quantum\App\Adapters\Web\Stages\Runtime; use Quantum\Router\Exceptions\RouteException; use Quantum\App\Contracts\StageInterface; @@ -25,6 +25,10 @@ */ class ResolveRouteStage implements StageInterface { + public const BEFORE = 'web.route.before'; + + public const AFTER = 'web.route.after'; + /** * Finds the route for the current request and stores it on the request, or null when nothing matches * @param WebContext $context diff --git a/src/App/Stages/Web/SendResponseStage.php b/src/App/Adapters/Web/Stages/Runtime/SendResponseStage.php similarity index 93% rename from src/App/Stages/Web/SendResponseStage.php rename to src/App/Adapters/Web/Stages/Runtime/SendResponseStage.php index 66c06d6c..c62f308e 100644 --- a/src/App/Stages/Web/SendResponseStage.php +++ b/src/App/Adapters/Web/Stages/Runtime/SendResponseStage.php @@ -8,7 +8,7 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages\Web; +namespace Quantum\App\Adapters\Web\Stages\Runtime; use Quantum\Config\Exceptions\ConfigException; use Quantum\App\Contracts\StageInterface; @@ -34,6 +34,10 @@ */ class SendResponseStage implements StageInterface { + public const BEFORE = 'web.send.before'; + + public const AFTER = 'web.send.after'; + /** * @param WebContext $context * @throws ConfigException|DiException|ReflectionException|Exception diff --git a/src/App/Adapters/Web/WebAppAdapter.php b/src/App/Adapters/Web/WebAppAdapter.php index ee94c4ba..747c7191 100644 --- a/src/App/Adapters/Web/WebAppAdapter.php +++ b/src/App/Adapters/Web/WebAppAdapter.php @@ -10,19 +10,19 @@ namespace Quantum\App\Adapters\Web; -use Quantum\App\Stages\Web\HandleRouteNotFoundStage; +use Quantum\App\Adapters\Web\Stages\Runtime\HandleRouteNotFoundStage; +use Quantum\App\Adapters\Web\Stages\Runtime\HandlePreflightStage; +use Quantum\App\Adapters\Web\Stages\Runtime\DispatchRequestStage; +use Quantum\App\Adapters\Web\Stages\Runtime\PrepareRequestStage; +use Quantum\App\Adapters\Web\Stages\Runtime\SendResponseStage; +use Quantum\App\Adapters\Web\Stages\Runtime\ResolveRouteStage; +use Quantum\App\Adapters\Web\Stages\Boot\InitDebuggerStage; +use Quantum\App\Adapters\Web\Stages\Boot\LoadModulesStage; +use Quantum\App\Adapters\Web\Stages\Boot\InitHttpStage; use Quantum\App\Stages\Boot\SetupErrorHandlerStage; use Quantum\App\Stages\Boot\LoadEnvironmentStage; -use Quantum\App\Stages\Web\HandlePreflightStage; -use Quantum\App\Stages\Web\DispatchRequestStage; -use Quantum\App\Stages\Web\PrepareRequestStage; use Quantum\App\Stages\Boot\LoadAppConfigStage; -use Quantum\App\Stages\Boot\InitDebuggerStage; -use Quantum\App\Stages\Web\SendResponseStage; -use Quantum\App\Stages\Web\ResolveRouteStage; -use Quantum\App\Stages\Boot\LoadModulesStage; use Quantum\App\Stages\Boot\LoadHelpersStage; -use Quantum\App\Stages\Boot\InitHttpStage; use Quantum\App\Contracts\AppInterface; use Quantum\App\Enums\ExitCode; use Quantum\App\AppContext; diff --git a/src/App/Pipeline.php b/src/App/Pipeline.php index 5c86ec09..9818ff5c 100644 --- a/src/App/Pipeline.php +++ b/src/App/Pipeline.php @@ -85,7 +85,7 @@ private function runStage(StageInterface $stage, AppContext $context): void /** * Dispatches the event a stage declares for the given point, if any. - * Skipped while the event() helper is not loaded yet, since nothing can listen then. + * Only runtime stages declare events, and they run after the helpers are loaded. * @param StageInterface $stage * @param TContext $context */ @@ -93,7 +93,7 @@ private function dispatchStageEvent(StageInterface $stage, string $point, AppCon { $constant = $stage::class . '::' . $point; - if (!defined($constant) || !function_exists('event')) { + if (!defined($constant)) { return; } diff --git a/src/App/Stages/Boot/LoadAppConfigStage.php b/src/App/Stages/Boot/LoadAppConfigStage.php index 82873094..e07959d1 100644 --- a/src/App/Stages/Boot/LoadAppConfigStage.php +++ b/src/App/Stages/Boot/LoadAppConfigStage.php @@ -24,10 +24,6 @@ */ class LoadAppConfigStage implements StageInterface { - public const BEFORE = 'boot.config.before'; - - public const AFTER = 'boot.config.after'; - /** * @throws ConfigException|DiException|ReflectionException */ diff --git a/src/App/Stages/Boot/LoadEnvironmentStage.php b/src/App/Stages/Boot/LoadEnvironmentStage.php index 81fb8701..2fd3f4c3 100644 --- a/src/App/Stages/Boot/LoadEnvironmentStage.php +++ b/src/App/Stages/Boot/LoadEnvironmentStage.php @@ -25,10 +25,6 @@ */ class LoadEnvironmentStage implements StageInterface { - public const BEFORE = 'boot.environment.before'; - - public const AFTER = 'boot.environment.after'; - /** * @throws EnvException|DiException|BaseException */ diff --git a/src/App/Stages/Boot/SetupErrorHandlerStage.php b/src/App/Stages/Boot/SetupErrorHandlerStage.php index c146a90a..c38e6c89 100644 --- a/src/App/Stages/Boot/SetupErrorHandlerStage.php +++ b/src/App/Stages/Boot/SetupErrorHandlerStage.php @@ -27,10 +27,6 @@ */ class SetupErrorHandlerStage implements StageInterface { - public const BEFORE = 'boot.error_handler.before'; - - public const AFTER = 'boot.error_handler.after'; - /** * @throws ConfigException|DiException|BaseException|ReflectionException */ diff --git a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php index 4b75af4f..c10d39e4 100644 --- a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php +++ b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php @@ -5,6 +5,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Quantum\App\Adapters\Console\ConsoleAppAdapter; use Symfony\Component\Console\Input\InputInterface; +use Quantum\App\Adapters\Console\ConsoleContext; use Quantum\Environment\Exceptions\EnvException; use Quantum\Tests\Helpers\NonExitingApplication; use Symfony\Component\Console\Input\ArgvInput; @@ -195,6 +196,50 @@ public function testConsoleAppAdapterBootsAllStagesForCoreEnvCommandWhenTheEnvFi $this->assertTrue(config()->has('app')); } + public function testConsoleAppAdapterStartDispatchesTheCommandEventsAroundTheCommandWithTheConsoleContext(): void + { + $_SERVER['argv'] = ['qt', 'list', '--quiet']; + + $adapter = $this->createAdapter(new NonExitingApplication('Qt Console Application', 'test')); + + $events = []; + + foreach (['console.command.before', 'console.command.after'] as $name) { + event()->listen($name, function (array $payload) use (&$events, $name): void { + $events[] = [$name, get_class($payload['context'])]; + }); + } + + $this->assertSame(0, $adapter->start()); + + $this->assertSame([ + ['console.command.before', ConsoleContext::class], + ['console.command.after', ConsoleContext::class], + ], $events); + } + + public function testConsoleAppAdapterStartDispatchesNoAfterEventWhenTheCommandIsUnknown(): void + { + $_SERVER['argv'] = ['qt', 'unknown:command', '--quiet']; + + $adapter = $this->createAdapter(); + + $events = []; + + foreach (['console.command.before', 'console.command.after'] as $name) { + event()->listen($name, function () use (&$events, $name): void { + $events[] = $name; + }); + } + + try { + $adapter->start(); + $this->fail('Expected the unknown command to be rejected.'); + } catch (Exception $exception) { + $this->assertSame(['console.command.before'], $events); + } + } + public function testConsoleAppAdapterConstructorDoesNotBoot(): void { $_SERVER['argv'] = ['qt', 'list', '--quiet']; diff --git a/tests/Unit/App/Stages/Boot/InitConsoleStageTest.php b/tests/Unit/App/Adapters/Console/Stages/Boot/InitConsoleStageTest.php similarity index 95% rename from tests/Unit/App/Stages/Boot/InitConsoleStageTest.php rename to tests/Unit/App/Adapters/Console/Stages/Boot/InitConsoleStageTest.php index 9e86981a..bf7104a2 100644 --- a/tests/Unit/App/Stages/Boot/InitConsoleStageTest.php +++ b/tests/Unit/App/Adapters/Console/Stages/Boot/InitConsoleStageTest.php @@ -1,14 +1,14 @@ assertNotNull(request()->getUri()); } + public function testWebAppAdapterStartDispatchesTheEventsOfEveryRuntimeStageForAMatchedRoute(): void + { + request()->create('GET', '/test/am/tests'); + + $events = $this->recordRuntimeEvents(); + + ob_start(); + $this->webAppAdapter->start(); + ob_end_clean(); + + $this->assertSame([ + 'web.preflight.before', 'web.preflight.after', + 'web.route.before', 'web.route.after', + 'web.not_found.before', 'web.not_found.after', + 'web.prepare.before', 'web.prepare.after', + 'web.dispatch.before', 'web.dispatch.after', + 'web.send.before', 'web.send.after', + ], $events->getArrayCopy()); + } + + public function testWebAppAdapterStartDispatchesOnlyThePreflightAndSendEventsForAnOptionsRequest(): void + { + request()->create('OPTIONS', '/test/am/tests'); + + $events = $this->recordRuntimeEvents(); + + ob_start(); + $this->webAppAdapter->start(); + ob_end_clean(); + + $this->assertSame([ + 'web.preflight.before', 'web.preflight.after', + 'web.send.before', 'web.send.after', + ], $events->getArrayCopy()); + } + + public function testWebAppAdapterStartDispatchesNoPrepareOrDispatchEventsWhenNoRouteMatches(): void + { + request()->create('GET', '/non-existing-uri'); + + $events = $this->recordRuntimeEvents(); + + ob_start(); + $this->webAppAdapter->start(); + ob_end_clean(); + + $this->assertSame([ + 'web.preflight.before', 'web.preflight.after', + 'web.route.before', 'web.route.after', + 'web.not_found.before', 'web.not_found.after', + 'web.send.before', 'web.send.after', + ], $events->getArrayCopy()); + } + + public function testWebAppAdapterStartDispatchesNoAfterEventAndNoSendEventsWhenAStageThrows(): void + { + request()->create('GET', '/test/Test/5'); + + $events = $this->recordRuntimeEvents(); + + ob_start(); + + try { + $this->webAppAdapter->start(); + $this->fail('Expected the dispatcher to fail for a missing controller action.'); + } catch (RouteException $exception) { + $this->assertSame([ + 'web.preflight.before', 'web.preflight.after', + 'web.route.before', 'web.route.after', + 'web.not_found.before', 'web.not_found.after', + 'web.prepare.before', 'web.prepare.after', + 'web.dispatch.before', + ], $events->getArrayCopy()); + } finally { + ob_end_clean(); + } + } + + public function testWebAppAdapterStartPassesTheWebContextToTheRuntimeEvents(): void + { + request()->create('GET', '/test/am/tests'); + + $contexts = []; + + foreach (['web.preflight.before', 'web.send.after'] as $name) { + event()->listen($name, function (array $payload) use (&$contexts): void { + $contexts[] = $payload['context']; + }); + } + + ob_start(); + $this->webAppAdapter->start(); + ob_end_clean(); + + $this->assertCount(2, $contexts); + $this->assertInstanceOf(WebContext::class, $contexts[0]); + $this->assertSame($contexts[0], $contexts[1]); + } + + public function testWebAppAdapterStartDispatchesTheSendAfterEventOnceTheRequestIsCleared(): void + { + request()->create('GET', '/test/am/tests'); + + $uriSeenAfterSend = 'not called'; + + event()->listen('web.send.after', function () use (&$uriSeenAfterSend): void { + $uriSeenAfterSend = request()->getUri(); + }); + + ob_start(); + $this->webAppAdapter->start(); + ob_end_clean(); + + $this->assertNull($uriSeenAfterSend); + } + public function testWebAppAdapterConstructorDoesNotBoot(): void { $this->clearAppContext(); @@ -268,15 +386,13 @@ public function testWebAppAdapterBootRunsTheBootStages(): void $this->assertTrue(config()->has('app')); } - public function testWebAppAdapterBootFiresAppHelperListenerAtModulesBeforeEvent(): void + public function testWebAppAdapterStartFiresAppHelperListenerAtPreflightBeforeEvent(): void { $this->withTemporaryAppHelper(function (): void { - $context = $this->createContext(); - - (new WebAppAdapter($context))->boot(); + $this->startWithAppHelperListener(); - $this->assertCount(1, $GLOBALS['bootEventPayloads']); - $this->assertSame($context, $GLOBALS['bootEventPayloads'][0]['context']); + $this->assertCount(1, $GLOBALS['helperEventPayloads']); + $this->assertInstanceOf(WebContext::class, $GLOBALS['helperEventPayloads'][0]['context']); }); } @@ -285,7 +401,7 @@ public function testWebAppAdapterBootRegistersAppHelperListenerBeforeAppConfigIs $this->withTemporaryAppHelper(function (): void { (new WebAppAdapter($this->createContext()))->boot(); - $this->assertFalse($GLOBALS['bootEventConfigLoadedAtRegistration']); + $this->assertFalse($GLOBALS['helperConfigLoadedAtRegistration']); $this->assertTrue(config()->has('app')); }); } @@ -293,15 +409,51 @@ public function testWebAppAdapterBootRegistersAppHelperListenerBeforeAppConfigIs public function testWebAppAdapterSecondBootDoesNotRegisterAppHelperListenerAgain(): void { $this->withTemporaryAppHelper(function (): void { - (new WebAppAdapter($this->createContext()))->boot(); - $this->assertCount(1, $GLOBALS['bootEventPayloads']); + $this->startWithAppHelperListener(); + $this->assertCount(1, $GLOBALS['helperEventPayloads']); // Helper files load with require_once, so a new container does not get the listener again. - (new WebAppAdapter($this->createContext()))->boot(); - $this->assertCount(1, $GLOBALS['bootEventPayloads']); + $this->startWithAppHelperListener(); + $this->assertCount(1, $GLOBALS['helperEventPayloads']); }); } + /** + * Boots a new adapter, which loads the temporary app helper, and starts it for a matched route + */ + private function startWithAppHelperListener(): void + { + $adapter = new WebAppAdapter($this->createContext()); + $adapter->boot(); + + request()->create('GET', '/test/am/tests'); + + ob_start(); + $adapter->start(); + ob_end_clean(); + } + + /** + * Listens to the before and after events of every web runtime stage and records their names in order + * @return ArrayObject + */ + private function recordRuntimeEvents(): ArrayObject + { + $events = new ArrayObject(); + + foreach (['preflight', 'route', 'not_found', 'prepare', 'dispatch', 'send'] as $stage) { + foreach (['before', 'after'] as $point) { + $name = 'web.' . $stage . '.' . $point; + + event()->listen($name, function () use ($events, $name): void { + $events[] = $name; + }); + } + } + + return $events; + } + private function withTemporaryAppHelper(callable $test): void { $dir = PROJECT_ROOT . DS . 'helpers'; @@ -311,10 +463,10 @@ private function withTemporaryAppHelper(callable $test): void $helper = <<<'PHP' has('app'); +$GLOBALS['helperConfigLoadedAtRegistration'] = config()->has('app'); -event()->listen('boot.modules.before', function (array $payload): void { - $GLOBALS['bootEventPayloads'][] = $payload; +event()->listen('web.preflight.before', function (array $payload): void { + $GLOBALS['helperEventPayloads'][] = $payload; }); PHP; @@ -333,7 +485,7 @@ private function withTemporaryAppHelper(callable $test): void if ($createdDir && is_dir($dir)) { rmdir($dir); } - unset($GLOBALS['bootEventPayloads'], $GLOBALS['bootEventConfigLoadedAtRegistration']); + unset($GLOBALS['helperEventPayloads'], $GLOBALS['helperConfigLoadedAtRegistration']); } } } diff --git a/tests/Unit/App/PipelineTest.php b/tests/Unit/App/PipelineTest.php index ffac2687..b1cb3586 100644 --- a/tests/Unit/App/PipelineTest.php +++ b/tests/Unit/App/PipelineTest.php @@ -2,16 +2,15 @@ namespace Quantum\Tests\Unit\App; -use Quantum\App\Stages\Boot\SetupConsoleApplicationStage; -use Quantum\App\Stages\Boot\SetupErrorHandlerStage; -use Quantum\App\Stages\Boot\LoadEnvironmentStage; +use Quantum\App\Adapters\Web\Stages\Runtime\HandleRouteNotFoundStage; +use Quantum\App\Adapters\Web\Stages\Runtime\HandlePreflightStage; +use Quantum\App\Adapters\Web\Stages\Runtime\DispatchRequestStage; +use Quantum\App\Adapters\Console\Stages\Runtime\RunCommandStage; +use Quantum\App\Adapters\Web\Stages\Runtime\PrepareRequestStage; +use Quantum\App\Adapters\Web\Stages\Runtime\SendResponseStage; +use Quantum\App\Adapters\Web\Stages\Runtime\ResolveRouteStage; use Quantum\App\Adapters\Console\ConsoleContext; -use Quantum\App\Stages\Boot\LoadAppConfigStage; -use Quantum\App\Stages\Boot\InitDebuggerStage; -use Quantum\App\Stages\Boot\LoadModulesStage; -use Quantum\App\Stages\Boot\InitConsoleStage; use Quantum\App\Stages\Boot\LoadHelpersStage; -use Quantum\App\Stages\Boot\InitHttpStage; use Quantum\App\Contracts\StageInterface; use Quantum\App\Adapters\Web\WebContext; use Quantum\Tests\Unit\AppTestCase; @@ -195,23 +194,6 @@ public function testPipelinePropagatesAnExceptionWithoutTheAfterEvent(): void $this->assertFalse($after); } - /** - * @runInSeparateProcess - * @preserveGlobalState disabled - */ - public function testPipelineSkipsStageEventsWhileEventHelperIsNotLoaded(): void - { - $this->assertFalse(function_exists('event')); - - $processed = false; - - (new Pipeline([$this->createFirstEventStage(function () use (&$processed): void { - $processed = true; - })]))->run($this->context); - - $this->assertTrue($processed); - } - public function testPipelineEndsTheRunWhenAStageStopsTheContext(): void { $log = []; @@ -361,29 +343,52 @@ public function testPipelineCarriesTheExitCodeOfAConsoleContextBetweenStages(): $this->assertSame(ExitCode::INVALID, $exitCodeSeenBySecondStage); } - public function testPipelineStagesDeclareBootEventNames(): void + public function testPipelineRuntimeStagesDeclareTheirEventNames(): void { - $this->assertSame('boot.environment.before', LoadEnvironmentStage::BEFORE); - $this->assertSame('boot.environment.after', LoadEnvironmentStage::AFTER); + $this->assertSame('web.preflight.before', HandlePreflightStage::BEFORE); + $this->assertSame('web.preflight.after', HandlePreflightStage::AFTER); + + $this->assertSame('web.route.before', ResolveRouteStage::BEFORE); + $this->assertSame('web.route.after', ResolveRouteStage::AFTER); - $this->assertSame('boot.config.before', LoadAppConfigStage::BEFORE); - $this->assertSame('boot.config.after', LoadAppConfigStage::AFTER); + $this->assertSame('web.not_found.before', HandleRouteNotFoundStage::BEFORE); + $this->assertSame('web.not_found.after', HandleRouteNotFoundStage::AFTER); - $this->assertSame('boot.error_handler.before', SetupErrorHandlerStage::BEFORE); - $this->assertSame('boot.error_handler.after', SetupErrorHandlerStage::AFTER); + $this->assertSame('web.prepare.before', PrepareRequestStage::BEFORE); + $this->assertSame('web.prepare.after', PrepareRequestStage::AFTER); - $this->assertSame('boot.http.before', InitHttpStage::BEFORE); - $this->assertSame('boot.http.after', InitHttpStage::AFTER); + $this->assertSame('web.dispatch.before', DispatchRequestStage::BEFORE); + $this->assertSame('web.dispatch.after', DispatchRequestStage::AFTER); - $this->assertSame('boot.modules.before', LoadModulesStage::BEFORE); - $this->assertSame('boot.modules.after', LoadModulesStage::AFTER); + $this->assertSame('web.send.before', SendResponseStage::BEFORE); + $this->assertSame('web.send.after', SendResponseStage::AFTER); + + $this->assertSame('console.command.before', RunCommandStage::BEFORE); + $this->assertSame('console.command.after', RunCommandStage::AFTER); } - public function testLoadHelpersInitConsoleInitDebuggerAndSetupConsoleApplicationStagesDeclareNoEvents(): void + public function testPipelineBootStagesDeclareNoEvents(): void { - foreach ([LoadHelpersStage::class, InitConsoleStage::class, InitDebuggerStage::class, SetupConsoleApplicationStage::class] as $stage) { - $this->assertFalse(defined($stage . '::BEFORE'), $stage . ' must not declare BEFORE'); - $this->assertFalse(defined($stage . '::AFTER'), $stage . ' must not declare AFTER'); + $app = dirname(__DIR__, 3) . DS . 'src' . DS . 'App'; + + $bootFolders = [ + 'Quantum\\App\\Stages\\Boot\\' => $app . DS . 'Stages' . DS . 'Boot', + 'Quantum\\App\\Adapters\\Web\\Stages\\Boot\\' => $app . DS . 'Adapters' . DS . 'Web' . DS . 'Stages' . DS . 'Boot', + 'Quantum\\App\\Adapters\\Console\\Stages\\Boot\\' => $app . DS . 'Adapters' . DS . 'Console' . DS . 'Stages' . DS . 'Boot', + ]; + + foreach ($bootFolders as $namespace => $folder) { + $files = glob($folder . DS . '*Stage.php'); + + $this->assertNotEmpty($files, 'No boot stages found in ' . $folder); + + foreach ($files as $file) { + $stage = $namespace . basename($file, '.php'); + + $this->assertTrue(class_exists($stage), $stage . ' must exist'); + $this->assertFalse(defined($stage . '::BEFORE'), $stage . ' must not declare BEFORE'); + $this->assertFalse(defined($stage . '::AFTER'), $stage . ' must not declare AFTER'); + } } }