diff --git a/CHANGELOG.md b/CHANGELOG.md index 25956c6b..945d4cb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ The format is based on Keep a Changelog. - Added adapter-based Lang provider support with `DeepL` and `Google Translate` adapters plus shared remote request/caching infrastructure (#533) ### Changed +- **BREAKING:** The web and console contexts now extend `AppContext` (#579): + - `AppContext` keeps only what both the web and the console boot provide: `getBaseDir()`, `getContainer()`, `getEnvironment()` and `getConfig()`. Removed `AppContext::getRequest()`, `getResponse()` and `getRoutes()`, which only the web boot can serve: use `WebContext::getRequest()` and `WebContext::getRoutes()`, and `response()` for the shared response. + - `WebContext` and `ConsoleContext` extend `AppContext` and are built from it with the same base directory and container; `WebContext::getAppContext()` is removed. Like `ConsoleContext`, `WebContext` reads its services from the container (the request, the routes and the view cache) and the matched route from the request, and stores only what the run produces: the response that is sent and the stopped flag. `WebContext::setMatchedRoute()` and `setViewCache()` are removed. + - The web stages read the request, the routes and the config from the context instead of the global `request()` and `config()` helpers, as the console stages do. They are the same objects from the same container, so behavior is unchanged. - **BREAKING:** The console adapter constructor is now only the boot pipeline, like the web adapter (#576): - The Symfony `Application`, the input and the output are provided by the new `InitConsoleStage` through the container, and the new `SetupConsoleApplicationStage` names the application from `app.name` and `app.version` once the whole boot has succeeded (it stays `UNKNOWN` after a failed boot). `ConsoleContext` is built from the `AppContext` and the boot failure, and reads those objects from the container. Removed `ConsoleAppAdapter::createApplication()` (get the application with `Di::get(Application::class)`) and `ConsoleContext::getAppContext()`. - The adapter no longer makes the environment mutable for every command. `core:key` and the toolkit installer already do it themselves; a command outside the framework that calls `environment()->updateRow()` must call `setMutable(true)` first, or it gets `EnvException::environmentImmutable()`. @@ -18,7 +22,7 @@ The format is based on Keep a Changelog. - `ConsoleAppAdapter` always runs the full boot and, when it fails, keeps the failure instead of stopping. `RunCommandStage` re-throws that original failure only for a command that requires the environment, so `core:env`, `list`, and `help` still run and `core:env --yes` can repair a missing, empty, or malformed `.env`. The reduced boot for `core:env` is gone, and the Symfony `Application` is named `UNKNOWN` until a successful boot names it from the app config. - Added `CliCommand::$requiresEnvironment` (default `true`) with `CliCommand::requiresEnvironment()`; a command that works without the environment, like `EnvCommand`, sets it to `false`. `ConsoleContext` takes an optional boot failure and exposes it with `getBootFailure()`. - **BREAKING:** Refactored the web and console runtime into staged pipelines (#537): - - `WebAppAdapter::start()` now runs a `RequestPipeline` of the preflight, route resolution, not found, prepare, dispatch, and send stages with a `RequestContext`, and `ConsoleAppAdapter::start()` runs a `ConsolePipeline` of the register core commands, register app commands, and run stages with a `ConsoleContext`; the run stage rejects a missing or unknown command before running anything, and registration goes through the new `Quantum\Console\CommandRegistrar`. Behavior is unchanged, and runtime stage events are not dispatched yet. + - `WebAppAdapter::start()` now runs a `WebPipeline` of the preflight, route resolution, not found, prepare, dispatch, and send stages with a `WebContext`, and `ConsoleAppAdapter::start()` runs a `ConsolePipeline` of the register core commands, register app commands, and run stages with a `ConsoleContext`; the run stage rejects a missing or unknown command before running anything, and registration goes through the new `Quantum\Console\CommandRegistrar`. Behavior is unchanged, and runtime stage events are not dispatched yet. - Moved the web adapter, context, and pipeline to `Quantum\App\Adapters\Web`, the console ones to `Quantum\App\Adapters\Console`, and the boot stages 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` now implement `AppInterface` directly. - **BREAKING:** Replaced the `Quantum\Hook` package with `Quantum\Event` (#536): diff --git a/src/App/Adapters/Console/ConsoleAppAdapter.php b/src/App/Adapters/Console/ConsoleAppAdapter.php index 1f68f646..a51338c1 100644 --- a/src/App/Adapters/Console/ConsoleAppAdapter.php +++ b/src/App/Adapters/Console/ConsoleAppAdapter.php @@ -20,7 +20,6 @@ use Quantum\App\Stages\Boot\InitConsoleStage; use Quantum\App\Stages\Boot\LoadHelpersStage; use Quantum\App\Contracts\AppInterface; -use Quantum\App\Enums\ExitCode; use Quantum\App\BootPipeline; use Quantum\App\AppContext; use Throwable; @@ -79,6 +78,6 @@ public function start(): ?int throw $this->bootFailure ?? $exception; } - return $context->getExitCode() ?: ExitCode::SUCCESS; + return $context->getExitCode(); } } diff --git a/src/App/Adapters/Console/ConsoleContext.php b/src/App/Adapters/Console/ConsoleContext.php index 6a0b4faa..764d85f1 100644 --- a/src/App/Adapters/Console/ConsoleContext.php +++ b/src/App/Adapters/Console/ConsoleContext.php @@ -21,38 +21,37 @@ * Class ConsoleContext * * Carries the state of a single console run through the console stages. - * Unlike AppContext, which is immutable, the exit code is set by the stages. - * The Symfony application, input and output are read from the container of the AppContext. + * It is an AppContext (same base directory and container) whose exit code is set by the stages. + * The Symfony application, input and output are read from the container. * * @package Quantum\App */ -class ConsoleContext +class ConsoleContext extends AppContext { - private AppContext $appContext; - private ?Throwable $bootFailure; private int $exitCode = ExitCode::SUCCESS; public function __construct(AppContext $appContext, ?Throwable $bootFailure = null) { - $this->appContext = $appContext; + parent::__construct($appContext->getBaseDir(), $appContext->getContainer()); + $this->bootFailure = $bootFailure; } public function getApplication(): Application { - return $this->appContext->getContainer()->get(Application::class); + return $this->resolveFromContainer(Application::class); } public function getInput(): InputInterface { - return $this->appContext->getContainer()->get(InputInterface::class); + return $this->resolveFromContainer(InputInterface::class); } public function getOutput(): OutputInterface { - return $this->appContext->getContainer()->get(OutputInterface::class); + return $this->resolveFromContainer(OutputInterface::class); } /** diff --git a/src/App/Adapters/Web/WebAppAdapter.php b/src/App/Adapters/Web/WebAppAdapter.php index f975070d..366e55fc 100644 --- a/src/App/Adapters/Web/WebAppAdapter.php +++ b/src/App/Adapters/Web/WebAppAdapter.php @@ -10,20 +10,20 @@ namespace Quantum\App\Adapters\Web; -use Quantum\App\Stages\Request\HandleRouteNotFoundStage; -use Quantum\App\Stages\Request\HandlePreflightStage; -use Quantum\App\Stages\Request\DispatchRequestStage; -use Quantum\App\Stages\Request\PrepareRequestStage; -use Quantum\App\Stages\Request\SendResponseStage; -use Quantum\App\Stages\Request\ResolveRouteStage; +use Quantum\App\Stages\Web\HandleRouteNotFoundStage; 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\Contracts\AppInterface; +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\BootPipeline; use Quantum\App\AppContext; @@ -55,7 +55,7 @@ public function __construct(AppContext $context) public function start(): ?int { - $pipeline = new RequestPipeline([ + $pipeline = new WebPipeline([ new HandlePreflightStage(), new ResolveRouteStage(), new HandleRouteNotFoundStage(), @@ -64,7 +64,7 @@ public function start(): ?int new SendResponseStage(), ]); - $pipeline->run(new RequestContext($this->context)); + $pipeline->run(new WebContext($this->context)); return ExitCode::SUCCESS; } diff --git a/src/App/Adapters/Web/RequestContext.php b/src/App/Adapters/Web/WebContext.php similarity index 54% rename from src/App/Adapters/Web/RequestContext.php rename to src/App/Adapters/Web/WebContext.php index fa587a2b..cff8acff 100644 --- a/src/App/Adapters/Web/RequestContext.php +++ b/src/App/Adapters/Web/WebContext.php @@ -11,48 +11,46 @@ namespace Quantum\App\Adapters\Web; use Quantum\ResourceCache\ViewCache; +use Quantum\Router\RouteCollection; use Quantum\Router\MatchedRoute; use Quantum\App\AppContext; use Quantum\Http\Response; +use Quantum\Http\Request; /** - * Class RequestContext + * Class WebContext * - * Carries the state of a single web request through the request stages. - * Unlike AppContext, which is immutable, this object is mutated by the stages. + * Carries the state of a single web request through the web stages. + * It is an AppContext (same base directory and container) whose response and stopped flag are set by the stages; + * the response the stages set is the one that is sent. + * The request, the routes and the view cache are read from the container, and the matched route from the request. * * @package Quantum\App */ -class RequestContext +class WebContext extends AppContext { - private AppContext $appContext; - - private ?MatchedRoute $matchedRoute = null; - private ?Response $response = null; - private ?ViewCache $viewCache = null; - private bool $stopped = false; public function __construct(AppContext $appContext) { - $this->appContext = $appContext; + parent::__construct($appContext->getBaseDir(), $appContext->getContainer()); } - public function getAppContext(): AppContext + public function getRequest(): Request { - return $this->appContext; + return $this->resolveFromContainer(Request::class); } - public function getMatchedRoute(): ?MatchedRoute + public function getRoutes(): RouteCollection { - return $this->matchedRoute; + return $this->resolveFromContainer(RouteCollection::class); } - public function setMatchedRoute(?MatchedRoute $matchedRoute): void + public function getMatchedRoute(): ?MatchedRoute { - $this->matchedRoute = $matchedRoute; + return $this->getRequest()->getMatchedRoute(); } public function getResponse(): ?Response @@ -65,14 +63,9 @@ public function setResponse(Response $response): void $this->response = $response; } - public function getViewCache(): ?ViewCache - { - return $this->viewCache; - } - - public function setViewCache(ViewCache $viewCache): void + public function getViewCache(): ViewCache { - $this->viewCache = $viewCache; + return $this->resolveFromContainer(ViewCache::class); } /** diff --git a/src/App/Adapters/Web/RequestPipeline.php b/src/App/Adapters/Web/WebPipeline.php similarity index 57% rename from src/App/Adapters/Web/RequestPipeline.php rename to src/App/Adapters/Web/WebPipeline.php index fe56aabe..ed20529d 100644 --- a/src/App/Adapters/Web/RequestPipeline.php +++ b/src/App/Adapters/Web/WebPipeline.php @@ -10,34 +10,34 @@ namespace Quantum\App\Adapters\Web; -use Quantum\App\Contracts\RequestStageInterface; +use Quantum\App\Contracts\WebStageInterface; use InvalidArgumentException; /** - * Class RequestPipeline + * Class WebPipeline * - * Runs the web request stages in order. The pipeline does not skip stages by itself; - * a stage checks RequestContext::isStopped() when it must be skipped. + * Runs the web stages in order. The pipeline does not skip stages by itself; + * a stage checks WebContext::isStopped() when it must be skipped. * Stage lifecycle events are not dispatched here yet. * * @package Quantum\App */ -class RequestPipeline +class WebPipeline { /** - * @var RequestStageInterface[] + * @var WebStageInterface[] */ private array $stages; /** - * @param RequestStageInterface[] $stages + * @param WebStageInterface[] $stages */ public function __construct(array $stages = []) { foreach ($stages as $stage) { - if (!$stage instanceof RequestStageInterface) { + if (!$stage instanceof WebStageInterface) { throw new InvalidArgumentException( - 'All stages must implement ' . RequestStageInterface::class + 'All stages must implement ' . WebStageInterface::class ); } } @@ -45,7 +45,7 @@ public function __construct(array $stages = []) $this->stages = $stages; } - public function run(RequestContext $context): void + public function run(WebContext $context): void { foreach ($this->stages as $stage) { $stage->process($context); diff --git a/src/App/AppContext.php b/src/App/AppContext.php index 6e614d26..6897d63c 100644 --- a/src/App/AppContext.php +++ b/src/App/AppContext.php @@ -11,11 +11,8 @@ namespace Quantum\App; use Quantum\Environment\Environment; -use Quantum\Router\RouteCollection; use Quantum\Di\DiContainer; use Quantum\Config\Config; -use Quantum\Http\Response; -use Quantum\Http\Request; /** * Class AppContext @@ -53,27 +50,12 @@ public function getConfig(): Config return $this->resolveFromContainer(Config::class); } - public function getRequest(): Request - { - return $this->resolveFromContainer(Request::class); - } - - public function getResponse(): Response - { - return $this->resolveFromContainer(Response::class); - } - - public function getRoutes(): RouteCollection - { - return $this->resolveFromContainer(RouteCollection::class); - } - /** * @template T of object * @param class-string $class * @return T */ - private function resolveFromContainer(string $class) + protected function resolveFromContainer(string $class) { return $this->container->get($class); } diff --git a/src/App/Contracts/RequestStageInterface.php b/src/App/Contracts/WebStageInterface.php similarity index 53% rename from src/App/Contracts/RequestStageInterface.php rename to src/App/Contracts/WebStageInterface.php index 921529bf..88720bbb 100644 --- a/src/App/Contracts/RequestStageInterface.php +++ b/src/App/Contracts/WebStageInterface.php @@ -10,16 +10,16 @@ namespace Quantum\App\Contracts; -use Quantum\App\Adapters\Web\RequestContext; +use Quantum\App\Adapters\Web\WebContext; /** - * Interface RequestStageInterface + * Interface WebStageInterface * @package Quantum\App */ -interface RequestStageInterface +interface WebStageInterface { /** - * Processes a single request stage + * Processes a single web stage */ - public function process(RequestContext $context): void; + public function process(WebContext $context): void; } diff --git a/src/App/Stages/Request/ResolveRouteStage.php b/src/App/Stages/Request/ResolveRouteStage.php deleted file mode 100644 index 729ce8ec..00000000 --- a/src/App/Stages/Request/ResolveRouteStage.php +++ /dev/null @@ -1,46 +0,0 @@ -isStopped()) { - return; - } - - $matchedRoute = (new RouteFinder(Di::get(RouteCollection::class)))->find(request()); - - if ($matchedRoute === null) { - return; - } - - request()->setMatchedRoute($matchedRoute); - $context->setMatchedRoute($matchedRoute); - } -} diff --git a/src/App/Stages/Request/DispatchRequestStage.php b/src/App/Stages/Web/DispatchRequestStage.php similarity index 72% rename from src/App/Stages/Request/DispatchRequestStage.php rename to src/App/Stages/Web/DispatchRequestStage.php index 1781f562..9a06921d 100644 --- a/src/App/Stages/Request/DispatchRequestStage.php +++ b/src/App/Stages/Web/DispatchRequestStage.php @@ -8,14 +8,15 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages\Request; +namespace Quantum\App\Stages\Web; use Quantum\Middleware\Exceptions\MiddlewareException; -use Quantum\App\Contracts\RequestStageInterface; +use Quantum\App\Contracts\WebStageInterface; use Quantum\App\Exceptions\BaseException; use Quantum\Middleware\MiddlewareManager; +use Quantum\App\Adapters\Web\WebContext; +use Quantum\Di\Exceptions\DiException; use Quantum\Router\RouteDispatcher; -use Quantum\App\Adapters\Web\RequestContext; use Quantum\Http\Response; use Quantum\Http\Request; use RuntimeException; @@ -28,31 +29,28 @@ * * @package Quantum\App */ -class DispatchRequestStage implements RequestStageInterface +class DispatchRequestStage implements WebStageInterface { /** - * @throws MiddlewareException|BaseException + * @throws MiddlewareException|BaseException|DiException */ - public function process(RequestContext $context): void + public function process(WebContext $context): void { if ($context->isStopped()) { return; } $matchedRoute = $context->getMatchedRoute(); - $viewCache = $context->getViewCache(); if ($matchedRoute === null) { throw new RuntimeException('Matched route is not set.'); } - if ($viewCache === null) { - throw new RuntimeException('View cache is not set.'); - } + $viewCache = $context->getViewCache(); $terminal = fn (Request $request): Response => $viewCache->getCachedResponse($request->getUri() ?? '') ?? (new RouteDispatcher())->dispatch($matchedRoute, $request); - $context->setResponse((new MiddlewareManager($matchedRoute))->applyMiddlewares(request(), $terminal)); + $context->setResponse((new MiddlewareManager($matchedRoute))->applyMiddlewares($context->getRequest(), $terminal)); } } diff --git a/src/App/Stages/Request/HandlePreflightStage.php b/src/App/Stages/Web/HandlePreflightStage.php similarity index 58% rename from src/App/Stages/Request/HandlePreflightStage.php rename to src/App/Stages/Web/HandlePreflightStage.php index c99cdd85..c911b3d3 100644 --- a/src/App/Stages/Request/HandlePreflightStage.php +++ b/src/App/Stages/Web/HandlePreflightStage.php @@ -8,24 +8,26 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages\Request; +namespace Quantum\App\Stages\Web; -use Quantum\App\Contracts\RequestStageInterface; +use Quantum\App\Contracts\WebStageInterface; +use Quantum\App\Adapters\Web\WebContext; +use Quantum\Di\Exceptions\DiException; use Quantum\Http\Enums\StatusCode; -use Quantum\App\Adapters\Web\RequestContext; /** * Class HandlePreflightStage * @package Quantum\App */ -class HandlePreflightStage implements RequestStageInterface +class HandlePreflightStage implements WebStageInterface { /** * Answers an OPTIONS request with an empty response and stops the remaining stages + * @throws DiException */ - public function process(RequestContext $context): void + public function process(WebContext $context): void { - if (!request()->isMethod('OPTIONS')) { + if (!$context->getRequest()->isMethod('OPTIONS')) { return; } diff --git a/src/App/Stages/Request/HandleRouteNotFoundStage.php b/src/App/Stages/Web/HandleRouteNotFoundStage.php similarity index 75% rename from src/App/Stages/Request/HandleRouteNotFoundStage.php rename to src/App/Stages/Web/HandleRouteNotFoundStage.php index a405e11d..a3e7fad4 100644 --- a/src/App/Stages/Request/HandleRouteNotFoundStage.php +++ b/src/App/Stages/Web/HandleRouteNotFoundStage.php @@ -8,26 +8,26 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages\Request; +namespace Quantum\App\Stages\Web; -use Quantum\App\Contracts\RequestStageInterface; use Quantum\Config\Exceptions\ConfigException; +use Quantum\App\Contracts\WebStageInterface; use Quantum\App\Exceptions\BaseException; +use Quantum\App\Adapters\Web\WebContext; use Quantum\Di\Exceptions\DiException; -use Quantum\App\Adapters\Web\RequestContext; use ReflectionException; /** * Class HandleRouteNotFoundStage * @package Quantum\App */ -class HandleRouteNotFoundStage implements RequestStageInterface +class HandleRouteNotFoundStage implements WebStageInterface { /** * Answers a request that matched no route with the page not found response and stops the remaining stages * @throws ConfigException|BaseException|DiException|ReflectionException */ - public function process(RequestContext $context): void + public function process(WebContext $context): void { if ($context->isStopped() || $context->getMatchedRoute() !== null) { return; diff --git a/src/App/Stages/Request/PrepareRequestStage.php b/src/App/Stages/Web/PrepareRequestStage.php similarity index 71% rename from src/App/Stages/Request/PrepareRequestStage.php rename to src/App/Stages/Web/PrepareRequestStage.php index ddcfeabc..1eaabebf 100644 --- a/src/App/Stages/Request/PrepareRequestStage.php +++ b/src/App/Stages/Web/PrepareRequestStage.php @@ -8,18 +8,17 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages\Request; +namespace Quantum\App\Stages\Web; -use Quantum\App\Contracts\RequestStageInterface; use Quantum\Config\Exceptions\ConfigException; +use Quantum\App\Contracts\WebStageInterface; use Quantum\Lang\Exceptions\LangException; +use Quantum\App\Adapters\Web\WebContext; use Quantum\Lang\Factories\LangFactory; use Quantum\Di\Exceptions\DiException; use Quantum\ResourceCache\ViewCache; -use Quantum\App\Adapters\Web\RequestContext; use Quantum\Debugger\Debugger; use ReflectionException; -use Quantum\Di\Di; /** * Class PrepareRequestStage @@ -29,12 +28,12 @@ * * @package Quantum\App */ -class PrepareRequestStage implements RequestStageInterface +class PrepareRequestStage implements WebStageInterface { /** * @throws LangException|ConfigException|DiException|ReflectionException */ - public function process(RequestContext $context): void + public function process(WebContext $context): void { if ($context->isStopped()) { return; @@ -44,7 +43,7 @@ public function process(RequestContext $context): void $this->logDebugInfo(); - $context->setViewCache($this->setupViewCache()); + $this->setupViewCache($context); } /** @@ -62,18 +61,18 @@ private function logDebugInfo(): void /** * @throws ConfigException|DiException|ReflectionException */ - private function setupViewCache(): ViewCache + private function setupViewCache(WebContext $context): void { - if (!Di::isRegistered(ViewCache::class)) { - Di::register(ViewCache::class); + $container = $context->getContainer(); + + if (!$container->isRegistered(ViewCache::class)) { + $container->register(ViewCache::class); } - $viewCache = Di::get(ViewCache::class); + $viewCache = $context->getViewCache(); if ($viewCache->isEnabled()) { $viewCache->setup(); } - - return $viewCache; } } diff --git a/src/App/Stages/Web/ResolveRouteStage.php b/src/App/Stages/Web/ResolveRouteStage.php new file mode 100644 index 00000000..6aee907b --- /dev/null +++ b/src/App/Stages/Web/ResolveRouteStage.php @@ -0,0 +1,40 @@ +isStopped()) { + return; + } + + $request = $context->getRequest(); + + $request->setMatchedRoute((new RouteFinder($context->getRoutes()))->find($request)); + } +} diff --git a/src/App/Stages/Request/SendResponseStage.php b/src/App/Stages/Web/SendResponseStage.php similarity index 57% rename from src/App/Stages/Request/SendResponseStage.php rename to src/App/Stages/Web/SendResponseStage.php index d1d61519..bcfac5d6 100644 --- a/src/App/Stages/Request/SendResponseStage.php +++ b/src/App/Stages/Web/SendResponseStage.php @@ -8,13 +8,15 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages\Request; +namespace Quantum\App\Stages\Web; -use Quantum\App\Contracts\RequestStageInterface; use Quantum\Config\Exceptions\ConfigException; +use Quantum\App\Contracts\WebStageInterface; +use Quantum\App\Adapters\Web\WebContext; use Quantum\Di\Exceptions\DiException; -use Quantum\App\Adapters\Web\RequestContext; +use Quantum\Config\Config; use Quantum\Http\Response; +use Quantum\Http\Request; use Quantum\Config\Setup; use ReflectionException; use RuntimeException; @@ -24,16 +26,16 @@ * Class SendResponseStage * * Sends the response of the request and always clears the request state afterwards, - * even when sending fails. This stage never checks RequestContext::isStopped(). + * even when sending fails. This stage never checks WebContext::isStopped(). * * @package Quantum\App */ -class SendResponseStage implements RequestStageInterface +class SendResponseStage implements WebStageInterface { /** * @throws ConfigException|DiException|ReflectionException|Exception */ - public function process(RequestContext $context): void + public function process(WebContext $context): void { try { $response = $context->getResponse(); @@ -42,23 +44,23 @@ public function process(RequestContext $context): void throw new RuntimeException('The request has no response to send.'); } - $this->handleCors($response); + $this->handleCors($response, $context->getConfig()); $response->send(); } finally { - $this->cleanupRequestContext(); + $this->cleanupRequestState($context->getRequest()); } } /** * @throws ConfigException|DiException|ReflectionException */ - private function handleCors(Response $response): void + private function handleCors(Response $response, Config $config): void { - if (!config()->has('cors')) { - config()->import(new Setup('config', 'cors')); + if (!$config->has('cors')) { + $config->import(new Setup('config', 'cors')); } - foreach (config()->get('cors') as $key => $value) { + foreach ($config->get('cors') as $key => $value) { $response->setHeader($key, (string) $value); } } @@ -66,9 +68,9 @@ private function handleCors(Response $response): void /** * Clears request-scoped state after the response has been sent. */ - private function cleanupRequestContext(): void + private function cleanupRequestState(Request $request): void { - request()->setMatchedRoute(null); - request()->flush(); + $request->setMatchedRoute(null); + $request->flush(); } } diff --git a/tests/Unit/App/Adapters/Console/ConsoleContextTest.php b/tests/Unit/App/Adapters/Console/ConsoleContextTest.php index c549c156..80027d3c 100644 --- a/tests/Unit/App/Adapters/Console/ConsoleContextTest.php +++ b/tests/Unit/App/Adapters/Console/ConsoleContextTest.php @@ -11,6 +11,7 @@ use Quantum\App\Adapters\Console\ConsoleContext; use Quantum\App\Enums\ExitCode; use Quantum\App\AppContext; +use Quantum\Di\DiContainer; use RuntimeException; use Quantum\App\App; use Quantum\Di\Di; @@ -43,6 +44,17 @@ public function setUp(): void $this->consoleContext = new ConsoleContext($this->appContext); } + public function testConsoleContextIsAnAppContextBuiltFromTheOneItIsGiven(): void + { + $container = new DiContainer(); + + $consoleContext = new ConsoleContext(new AppContext('/base/dir', $container)); + + $this->assertInstanceOf(AppContext::class, $consoleContext); + $this->assertSame('/base/dir', $consoleContext->getBaseDir()); + $this->assertSame($container, $consoleContext->getContainer()); + } + public function testConsoleContextExposesApplicationInputAndOutputFromTheContainer(): void { $this->assertSame($this->application, $this->consoleContext->getApplication()); diff --git a/tests/Unit/App/Adapters/Console/ConsolePipelineTest.php b/tests/Unit/App/Adapters/Console/ConsolePipelineTest.php index caee0e55..746f0e56 100644 --- a/tests/Unit/App/Adapters/Console/ConsolePipelineTest.php +++ b/tests/Unit/App/Adapters/Console/ConsolePipelineTest.php @@ -3,12 +3,12 @@ namespace Quantum\Tests\Unit\App\Adapters\Console; use Quantum\App\Contracts\ConsoleStageInterface; -use Quantum\App\Contracts\RequestStageInterface; +use Quantum\App\Contracts\WebStageInterface; use Quantum\App\Contracts\BootStageInterface; use Quantum\Tests\Unit\AppTestCase; use Quantum\App\Adapters\Console\ConsoleContext; use Quantum\App\Adapters\Console\ConsolePipeline; -use Quantum\App\Adapters\Web\RequestContext; +use Quantum\App\Adapters\Web\WebContext; use InvalidArgumentException; use Quantum\App\Enums\ExitCode; use Quantum\App\AppContext; @@ -130,8 +130,8 @@ public function process(AppContext $context): void public function testPipelineRejectsRequestStage(): void { - $requestStage = new class () implements RequestStageInterface { - public function process(RequestContext $context): void + $requestStage = new class () implements WebStageInterface { + public function process(WebContext $context): void { } }; diff --git a/tests/Unit/App/Adapters/Web/RequestContextTest.php b/tests/Unit/App/Adapters/Web/RequestContextTest.php deleted file mode 100644 index 3dafda20..00000000 --- a/tests/Unit/App/Adapters/Web/RequestContextTest.php +++ /dev/null @@ -1,79 +0,0 @@ -appContext = App::getContext(); - $this->requestContext = new RequestContext($this->appContext); - } - - public function testRequestContextExposesAppContext(): void - { - $this->assertSame($this->appContext, $this->requestContext->getAppContext()); - } - - public function testRequestContextStoresMatchedRoute(): void - { - $this->assertNull($this->requestContext->getMatchedRoute()); - - $matchedRoute = new MatchedRoute(new Route(['GET'], '/test/am/tests', 'TestController', 'tests'), []); - - $this->requestContext->setMatchedRoute($matchedRoute); - - $this->assertSame($matchedRoute, $this->requestContext->getMatchedRoute()); - } - - public function testRequestContextStoresResponse(): void - { - $this->assertNull($this->requestContext->getResponse()); - - $this->requestContext->setResponse(response()); - - $this->assertSame(response(), $this->requestContext->getResponse()); - } - - public function testRequestContextStoresViewCache(): void - { - $this->assertNull($this->requestContext->getViewCache()); - - if (!Di::isRegistered(ViewCache::class)) { - Di::register(ViewCache::class); - } - - $viewCache = Di::get(ViewCache::class); - - $this->requestContext->setViewCache($viewCache); - - $this->assertSame($viewCache, $this->requestContext->getViewCache()); - } - - public function testRequestContextIsNotStoppedByDefault(): void - { - $this->assertFalse($this->requestContext->isStopped()); - } - - public function testRequestContextStopMarksContextStopped(): void - { - $this->requestContext->stop(); - - $this->assertTrue($this->requestContext->isStopped()); - } -} diff --git a/tests/Unit/App/Adapters/Web/WebContextTest.php b/tests/Unit/App/Adapters/Web/WebContextTest.php new file mode 100644 index 00000000..012fdab2 --- /dev/null +++ b/tests/Unit/App/Adapters/Web/WebContextTest.php @@ -0,0 +1,99 @@ +appContext = App::getContext(); + $this->webContext = new WebContext($this->appContext); + } + + public function testWebContextIsAnAppContextBuiltFromTheOneItIsGiven(): void + { + $container = new DiContainer(); + + $webContext = new WebContext(new AppContext('/base/dir', $container)); + + $this->assertInstanceOf(AppContext::class, $webContext); + $this->assertSame('/base/dir', $webContext->getBaseDir()); + $this->assertSame($container, $webContext->getContainer()); + } + + public function testWebContextExposesRequestAndRoutesFromTheContainer(): void + { + $container = new DiContainer(); + $request = Mockery::mock(Request::class); + $routes = new RouteCollection(); + + $container->set(Request::class, $request); + $container->set(RouteCollection::class, $routes); + + $webContext = new WebContext(new AppContext('/base/dir', $container)); + + $this->assertSame($request, $webContext->getRequest()); + $this->assertSame($routes, $webContext->getRoutes()); + } + + public function testWebContextReadsMatchedRouteFromTheRequest(): void + { + $this->assertNull($this->webContext->getMatchedRoute()); + + $matchedRoute = new MatchedRoute(new Route(['GET'], '/test/am/tests', 'TestController', 'tests'), []); + + request()->setMatchedRoute($matchedRoute); + + $this->assertSame($matchedRoute, $this->webContext->getMatchedRoute()); + } + + public function testWebContextStoresResponse(): void + { + $this->assertNull($this->webContext->getResponse()); + + $this->webContext->setResponse(response()); + + $this->assertSame(response(), $this->webContext->getResponse()); + } + + public function testWebContextReadsViewCacheFromTheContainer(): void + { + $container = new DiContainer(); + $viewCache = Mockery::mock(ViewCache::class); + $container->set(ViewCache::class, $viewCache); + + $webContext = new WebContext(new AppContext('/base/dir', $container)); + + $this->assertSame($viewCache, $webContext->getViewCache()); + } + + public function testWebContextIsNotStoppedByDefault(): void + { + $this->assertFalse($this->webContext->isStopped()); + } + + public function testWebContextStopMarksContextStopped(): void + { + $this->webContext->stop(); + + $this->assertTrue($this->webContext->isStopped()); + } +} diff --git a/tests/Unit/App/Adapters/Web/RequestPipelineTest.php b/tests/Unit/App/Adapters/Web/WebPipelineTest.php similarity index 65% rename from tests/Unit/App/Adapters/Web/RequestPipelineTest.php rename to tests/Unit/App/Adapters/Web/WebPipelineTest.php index dd0e8b92..f93eaa4e 100644 --- a/tests/Unit/App/Adapters/Web/RequestPipelineTest.php +++ b/tests/Unit/App/Adapters/Web/WebPipelineTest.php @@ -2,22 +2,22 @@ namespace Quantum\Tests\Unit\App\Adapters\Web; -use Quantum\App\Contracts\RequestStageInterface; +use Quantum\App\Contracts\WebStageInterface; use Quantum\App\Contracts\BootStageInterface; -use Quantum\App\Adapters\Web\RequestPipeline; +use Quantum\App\Adapters\Web\WebPipeline; use Quantum\Tests\Unit\AppTestCase; -use Quantum\App\Adapters\Web\RequestContext; +use Quantum\App\Adapters\Web\WebContext; use InvalidArgumentException; use Quantum\App\AppContext; use RuntimeException; -class RequestPipelineTest extends AppTestCase +class WebPipelineTest extends AppTestCase { - private RequestContext $requestContext; + private WebContext $webContext; public function setUp(): void { - $this->requestContext = new RequestContext($this->createContext()); + $this->webContext = new WebContext($this->createContext()); } public function tearDown(): void @@ -29,7 +29,7 @@ public function testPipelineRunsStagesInOrder(): void { $log = []; - $pipeline = new RequestPipeline([ + $pipeline = new WebPipeline([ $this->createStage(function () use (&$log): void { $log[] = 'first'; }), @@ -41,38 +41,38 @@ public function testPipelineRunsStagesInOrder(): void }), ]); - $pipeline->run($this->requestContext); + $pipeline->run($this->webContext); $this->assertSame(['first', 'second', 'third'], $log); } public function testEmptyPipelineRunsWithoutError(): void { - $pipeline = new RequestPipeline([]); + $pipeline = new WebPipeline([]); - $pipeline->run($this->requestContext); + $pipeline->run($this->webContext); - $this->assertFalse($this->requestContext->isStopped()); + $this->assertFalse($this->webContext->isStopped()); } public function testPipelinePassesContextToStages(): void { $received = null; - $pipeline = new RequestPipeline([ - $this->createStage(function (RequestContext $context) use (&$received): void { + $pipeline = new WebPipeline([ + $this->createStage(function (WebContext $context) use (&$received): void { $received = $context; }), ]); - $pipeline->run($this->requestContext); + $pipeline->run($this->webContext); - $this->assertSame($this->requestContext, $received); + $this->assertSame($this->webContext, $received); } public function testPipelinePropagatesException(): void { - $pipeline = new RequestPipeline([ + $pipeline = new WebPipeline([ $this->createStage(function (): void { throw new RuntimeException('Stage failed'); }), @@ -81,15 +81,15 @@ public function testPipelinePropagatesException(): void $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Stage failed'); - $pipeline->run($this->requestContext); + $pipeline->run($this->webContext); } public function testPipelineKeepsRunningStagesAfterContextIsStopped(): void { $log = []; - $pipeline = new RequestPipeline([ - $this->createStage(function (RequestContext $context) use (&$log): void { + $pipeline = new WebPipeline([ + $this->createStage(function (WebContext $context) use (&$log): void { $log[] = 'first'; $context->stop(); }), @@ -98,10 +98,10 @@ public function testPipelineKeepsRunningStagesAfterContextIsStopped(): void }), ]); - $pipeline->run($this->requestContext); + $pipeline->run($this->webContext); $this->assertSame(['first', 'second'], $log); - $this->assertTrue($this->requestContext->isStopped()); + $this->assertTrue($this->webContext->isStopped()); } public function testPipelineRejectsInvalidStage(): void @@ -109,7 +109,7 @@ public function testPipelineRejectsInvalidStage(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('All stages must implement'); - new RequestPipeline([new \stdClass()]); + new WebPipeline([new \stdClass()]); } public function testPipelineRejectsBootStage(): void @@ -123,12 +123,12 @@ public function process(AppContext $context): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('All stages must implement'); - new RequestPipeline([$bootStage]); + new WebPipeline([$bootStage]); } - private function createStage(callable $callback): RequestStageInterface + private function createStage(callable $callback): WebStageInterface { - return new class ($callback) implements RequestStageInterface { + return new class ($callback) implements WebStageInterface { private $callback; public function __construct(callable $callback) @@ -136,7 +136,7 @@ public function __construct(callable $callback) $this->callback = $callback; } - public function process(RequestContext $context): void + public function process(WebContext $context): void { ($this->callback)($context); } diff --git a/tests/Unit/App/AppContextTest.php b/tests/Unit/App/AppContextTest.php index bdbb16ab..a075bb4c 100644 --- a/tests/Unit/App/AppContextTest.php +++ b/tests/Unit/App/AppContextTest.php @@ -2,14 +2,11 @@ namespace Quantum\Tests\Unit\App; -use Quantum\Router\RouteCollection; use Quantum\Environment\Environment; use PHPUnit\Framework\TestCase; use Quantum\Di\DiContainer; use Quantum\App\AppContext; use Quantum\Config\Config; -use Quantum\Http\Response; -use Quantum\Http\Request; use Mockery; class AppContextTest extends TestCase @@ -50,37 +47,4 @@ public function testAppContextGetConfig(): void $this->assertSame($config, $context->getConfig()); } - - public function testAppContextGetRequest(): void - { - $container = new DiContainer(); - $request = Mockery::mock(Request::class); - $container->set(Request::class, $request); - - $context = new AppContext('/tmp', $container); - - $this->assertSame($request, $context->getRequest()); - } - - public function testAppContextGetResponse(): void - { - $container = new DiContainer(); - $response = Mockery::mock(Response::class); - $container->set(Response::class, $response); - - $context = new AppContext('/tmp', $container); - - $this->assertSame($response, $context->getResponse()); - } - - public function testAppContextGetRoutes(): void - { - $container = new DiContainer(); - $routes = new RouteCollection(); - $container->set(RouteCollection::class, $routes); - - $context = new AppContext('/tmp', $container); - - $this->assertSame($routes, $context->getRoutes()); - } } diff --git a/tests/Unit/App/Stages/Request/HandlePreflightStageTest.php b/tests/Unit/App/Stages/Request/HandlePreflightStageTest.php deleted file mode 100644 index 98e3854e..00000000 --- a/tests/Unit/App/Stages/Request/HandlePreflightStageTest.php +++ /dev/null @@ -1,42 +0,0 @@ -requestContext = new RequestContext(App::getContext()); - } - - public function testHandlePreflightStageRespondsNoContentToOptionsRequest(): void - { - request()->create('OPTIONS', '/test/am/tests'); - - (new HandlePreflightStage())->process($this->requestContext); - - $this->assertSame(response(), $this->requestContext->getResponse()); - $this->assertSame(StatusCode::NO_CONTENT, $this->requestContext->getResponse()->getStatusCode()); - $this->assertTrue($this->requestContext->isStopped()); - } - - public function testHandlePreflightStageIgnoresOtherMethods(): void - { - request()->create('GET', '/test/am/tests'); - - (new HandlePreflightStage())->process($this->requestContext); - - $this->assertNull($this->requestContext->getResponse()); - $this->assertFalse($this->requestContext->isStopped()); - } -} diff --git a/tests/Unit/App/Stages/Request/HandleRouteNotFoundStageTest.php b/tests/Unit/App/Stages/Request/HandleRouteNotFoundStageTest.php deleted file mode 100644 index 7d2a3b72..00000000 --- a/tests/Unit/App/Stages/Request/HandleRouteNotFoundStageTest.php +++ /dev/null @@ -1,71 +0,0 @@ -requestContext = new RequestContext(App::getContext()); - } - - public function testHandleRouteNotFoundStageRespondsNotFoundWhenNoRouteMatched(): void - { - request()->create('GET', '/non-existing-uri'); - - (new HandleRouteNotFoundStage())->process($this->requestContext); - - $this->assertSame(response(), $this->requestContext->getResponse()); - $this->assertSame(StatusCode::NOT_FOUND, $this->requestContext->getResponse()->getStatusCode()); - $this->assertSame(ContentType::HTML, $this->requestContext->getResponse()->getContentType()); - $this->assertTrue($this->requestContext->isStopped()); - } - - public function testHandleRouteNotFoundStageRespondsWithJsonForJsonRequests(): void - { - request()->create('GET', '/non-existing-uri', [], ['Accept' => 'application/json']); - - (new HandleRouteNotFoundStage())->process($this->requestContext); - - $this->assertSame(StatusCode::NOT_FOUND, $this->requestContext->getResponse()->getStatusCode()); - $this->assertSame(ContentType::JSON, $this->requestContext->getResponse()->getContentType()); - $this->assertStringContainsString('Page not found', $this->requestContext->getResponse()->getContent()); - $this->assertTrue($this->requestContext->isStopped()); - } - - public function testHandleRouteNotFoundStageIgnoresMatchedRoute(): void - { - $this->requestContext->setMatchedRoute( - new MatchedRoute(new Route(['GET'], '/test/am/tests', 'TestController', 'tests'), []) - ); - - (new HandleRouteNotFoundStage())->process($this->requestContext); - - $this->assertNull($this->requestContext->getResponse()); - $this->assertFalse($this->requestContext->isStopped()); - } - - public function testHandleRouteNotFoundStageSkipsWhenContextIsStopped(): void - { - request()->create('GET', '/non-existing-uri'); - - $this->requestContext->stop(); - - (new HandleRouteNotFoundStage())->process($this->requestContext); - - $this->assertNull($this->requestContext->getResponse()); - } -} diff --git a/tests/Unit/App/Stages/Request/ResolveRouteStageTest.php b/tests/Unit/App/Stages/Request/ResolveRouteStageTest.php deleted file mode 100644 index 085e852d..00000000 --- a/tests/Unit/App/Stages/Request/ResolveRouteStageTest.php +++ /dev/null @@ -1,55 +0,0 @@ -requestContext = new RequestContext(App::getContext()); - } - - public function testResolveRouteStageStoresMatchedRoute(): void - { - request()->create('GET', '/test/am/tests'); - - (new ResolveRouteStage())->process($this->requestContext); - - $this->assertNotNull($this->requestContext->getMatchedRoute()); - $this->assertSame('tests', $this->requestContext->getMatchedRoute()->getRoute()->getAction()); - $this->assertSame($this->requestContext->getMatchedRoute(), request()->getMatchedRoute()); - $this->assertFalse($this->requestContext->isStopped()); - } - - public function testResolveRouteStageLeavesContextEmptyWhenNoRouteMatches(): void - { - request()->create('GET', '/non-existing-uri'); - - (new ResolveRouteStage())->process($this->requestContext); - - $this->assertNull($this->requestContext->getMatchedRoute()); - $this->assertNull(request()->getMatchedRoute()); - $this->assertFalse($this->requestContext->isStopped()); - } - - public function testResolveRouteStageSkipsWhenContextIsStopped(): void - { - request()->create('GET', '/test/am/tests'); - - $this->requestContext->stop(); - - (new ResolveRouteStage())->process($this->requestContext); - - $this->assertNull($this->requestContext->getMatchedRoute()); - $this->assertNull(request()->getMatchedRoute()); - } -} diff --git a/tests/Unit/App/Stages/Request/DispatchRequestStageTest.php b/tests/Unit/App/Stages/Web/DispatchRequestStageTest.php similarity index 58% rename from tests/Unit/App/Stages/Request/DispatchRequestStageTest.php rename to tests/Unit/App/Stages/Web/DispatchRequestStageTest.php index 0462b3e5..0ba2d180 100644 --- a/tests/Unit/App/Stages/Request/DispatchRequestStageTest.php +++ b/tests/Unit/App/Stages/Web/DispatchRequestStageTest.php @@ -18,6 +18,18 @@ public function apply(Request $request, Closure $next): Response } } + class DispatchStageMwRecorder extends Middleware + { + public static ?Request $request = null; + + public function apply(Request $request, Closure $next): Response + { + self::$request = $request; + + return response()->json(['status' => 'recorded']); + } + } + class DispatchStageMwBlocker extends Middleware { public function apply(Request $request, Closure $next): Response @@ -27,15 +39,20 @@ public function apply(Request $request, Closure $next): Response } } -namespace Quantum\Tests\Unit\App\Stages\Request { +namespace Quantum\Tests\Unit\App\Stages\Web { use Quantum\Tests\_root\modules\Test\Middlewares\DispatchStageMwPassThrough; - use Quantum\App\Stages\Request\DispatchRequestStage; - use Quantum\App\Stages\Request\ResolveRouteStage; + use Quantum\Tests\_root\modules\Test\Middlewares\DispatchStageMwRecorder; + use Quantum\App\Stages\Web\DispatchRequestStage; + use Quantum\App\Stages\Web\ResolveRouteStage; use Quantum\Router\Exceptions\RouteException; + use Quantum\Di\Exceptions\DiException; use Quantum\Tests\Unit\AppTestCase; use Quantum\ResourceCache\ViewCache; - use Quantum\App\Adapters\Web\RequestContext; + use Quantum\App\Adapters\Web\WebContext; use Quantum\Router\MatchedRoute; + use Quantum\Di\DiContainer; + use Quantum\App\AppContext; + use Quantum\Http\Request; use Quantum\Http\Enums\StatusCode; use Quantum\Router\Route; use Quantum\App\App; @@ -44,13 +61,13 @@ public function apply(Request $request, Closure $next): Response class DispatchRequestStageTest extends AppTestCase { - private RequestContext $requestContext; + private WebContext $webContext; public function setUp(): void { parent::setUp(); - $this->requestContext = new RequestContext(App::getContext()); + $this->webContext = new WebContext(App::getContext()); DispatchStageMwPassThrough::$calls = []; } @@ -59,11 +76,11 @@ public function testDispatchRequestStageDispatchesMatchedRoute(): void { $this->prepareRequest('/test/am/tests'); - (new DispatchRequestStage())->process($this->requestContext); + (new DispatchRequestStage())->process($this->webContext); - $this->assertSame(response(), $this->requestContext->getResponse()); - $this->assertSame(StatusCode::OK, $this->requestContext->getResponse()->getStatusCode()); - $this->assertSame('', $this->requestContext->getResponse()->getContent()); + $this->assertSame(response(), $this->webContext->getResponse()); + $this->assertSame(StatusCode::OK, $this->webContext->getResponse()->getStatusCode()); + $this->assertSame('', $this->webContext->getResponse()->getContent()); } public function testDispatchRequestStageServesViewCacheHit(): void @@ -76,15 +93,15 @@ public function testDispatchRequestStageServesViewCacheHit(): void $viewsDirExisted = is_dir($viewsDir); $moduleDirExisted = is_dir($moduleDir); - $viewCache = $this->requestContext->getViewCache(); + $viewCache = $this->webContext->getViewCache(); $viewCache->setup(); $viewCache->enableCaching(true); $viewCache->set($uri, '

cached page

'); try { - (new DispatchRequestStage())->process($this->requestContext); + (new DispatchRequestStage())->process($this->webContext); - $this->assertSame('

cached page

', $this->requestContext->getResponse()->getContent()); + $this->assertSame('

cached page

', $this->webContext->getResponse()->getContent()); } finally { $viewCache->delete($uri); $viewCache->enableCaching(false); @@ -103,12 +120,12 @@ public function testDispatchRequestStageRunsModuleMiddlewaresBeforeController(): { $this->prepareRequest('/test/am/tests'); - $this->requestContext->getMatchedRoute()->getRoute()->addMiddlewares(['DispatchStageMwPassThrough']); + $this->webContext->getMatchedRoute()->getRoute()->addMiddlewares(['DispatchStageMwPassThrough']); - (new DispatchRequestStage())->process($this->requestContext); + (new DispatchRequestStage())->process($this->webContext); $this->assertSame(['DispatchStageMwPassThrough'], DispatchStageMwPassThrough::$calls); - $this->assertSame(StatusCode::OK, $this->requestContext->getResponse()->getStatusCode()); + $this->assertSame(StatusCode::OK, $this->webContext->getResponse()->getStatusCode()); } public function testDispatchRequestStageLetsMiddlewareShortCircuit(): void @@ -118,12 +135,11 @@ public function testDispatchRequestStageLetsMiddlewareShortCircuit(): void // The controller does not exist, so reaching the dispatcher would throw. $matchedRoute = new MatchedRoute($this->createRoute('tests', ['DispatchStageMwBlocker'], 'MissingController'), []); - $this->requestContext->setMatchedRoute($matchedRoute); request()->setMatchedRoute($matchedRoute); - (new DispatchRequestStage())->process($this->requestContext); + (new DispatchRequestStage())->process($this->webContext); - $this->assertStringContainsString('blocked', $this->requestContext->getResponse()->getContent()); + $this->assertStringContainsString('blocked', $this->webContext->getResponse()->getContent()); } public function testDispatchRequestStagePropagatesDispatchException(): void @@ -131,62 +147,79 @@ public function testDispatchRequestStagePropagatesDispatchException(): void $this->prepareRequest('/test/Test/5'); try { - (new DispatchRequestStage())->process($this->requestContext); + (new DispatchRequestStage())->process($this->webContext); $this->fail('Expected the dispatcher to fail for a missing controller action.'); } catch (RouteException $exception) { $this->assertInstanceOf(RouteException::class, $exception); } - $this->assertNull($this->requestContext->getResponse()); + $this->assertNull($this->webContext->getResponse()); } public function testDispatchRequestStageFailsWithoutMatchedRoute(): void { - $this->requestContext->setViewCache($this->viewCache()); + $this->registerViewCache(); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Matched route is not set.'); - (new DispatchRequestStage())->process($this->requestContext); + (new DispatchRequestStage())->process($this->webContext); } public function testDispatchRequestStageFailsWithoutViewCache(): void { - $this->requestContext->setMatchedRoute(new MatchedRoute($this->createRoute('tests'), [])); + request()->setMatchedRoute(new MatchedRoute($this->createRoute('tests'), [])); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('View cache is not set.'); + $this->expectException(DiException::class); + $this->expectExceptionMessage('The dependency `' . ViewCache::class . '` is not registered.'); - (new DispatchRequestStage())->process($this->requestContext); + (new DispatchRequestStage())->process($this->webContext); } public function testDispatchRequestStageSkipsWhenContextIsStopped(): void { $this->prepareRequest('/test/am/tests'); - $this->requestContext->stop(); + $this->webContext->stop(); - (new DispatchRequestStage())->process($this->requestContext); + (new DispatchRequestStage())->process($this->webContext); - $this->assertNull($this->requestContext->getResponse()); + $this->assertNull($this->webContext->getResponse()); + } + + public function testDispatchRequestStagePassesTheContextRequestToTheMiddlewares(): void + { + $this->prepareRequest('/test/am/tests'); + + $request = new Request(); + $request->create('GET', '/test/am/tests'); + $request->setMatchedRoute(new MatchedRoute($this->createRoute('tests', ['DispatchStageMwRecorder']), [])); + + $container = new DiContainer(); + $container->set(Request::class, $request); + $container->register(ViewCache::class); + + DispatchStageMwRecorder::$request = null; + + (new DispatchRequestStage())->process(new WebContext(new AppContext(PROJECT_ROOT, $container))); + + $this->assertSame($request, DispatchStageMwRecorder::$request); } private function prepareRequest(string $uri): void { request()->create('GET', $uri); - (new ResolveRouteStage())->process($this->requestContext); + (new ResolveRouteStage())->process($this->webContext); - $this->requestContext->setViewCache($this->viewCache()); + $this->registerViewCache(); } - private function viewCache(): ViewCache + private function registerViewCache(): void { if (!Di::isRegistered(ViewCache::class)) { Di::register(ViewCache::class); } - - return Di::get(ViewCache::class); } /** diff --git a/tests/Unit/App/Stages/Web/HandlePreflightStageTest.php b/tests/Unit/App/Stages/Web/HandlePreflightStageTest.php new file mode 100644 index 00000000..8c20e443 --- /dev/null +++ b/tests/Unit/App/Stages/Web/HandlePreflightStageTest.php @@ -0,0 +1,62 @@ +webContext = new WebContext(App::getContext()); + } + + public function testHandlePreflightStageRespondsNoContentToOptionsRequest(): void + { + request()->create('OPTIONS', '/test/am/tests'); + + (new HandlePreflightStage())->process($this->webContext); + + $this->assertSame(response(), $this->webContext->getResponse()); + $this->assertSame(StatusCode::NO_CONTENT, $this->webContext->getResponse()->getStatusCode()); + $this->assertTrue($this->webContext->isStopped()); + } + + public function testHandlePreflightStageIgnoresOtherMethods(): void + { + request()->create('GET', '/test/am/tests'); + + (new HandlePreflightStage())->process($this->webContext); + + $this->assertNull($this->webContext->getResponse()); + $this->assertFalse($this->webContext->isStopped()); + } + + public function testHandlePreflightStageReadsTheRequestFromTheContext(): void + { + request()->create('GET', '/test/am/tests'); + + $request = new Request(); + $request->create('OPTIONS', '/test/am/tests'); + + $container = new DiContainer(); + $container->set(Request::class, $request); + + $webContext = new WebContext(new AppContext(PROJECT_ROOT, $container)); + + (new HandlePreflightStage())->process($webContext); + + $this->assertTrue($webContext->isStopped()); + } +} diff --git a/tests/Unit/App/Stages/Web/HandleRouteNotFoundStageTest.php b/tests/Unit/App/Stages/Web/HandleRouteNotFoundStageTest.php new file mode 100644 index 00000000..a3b322ef --- /dev/null +++ b/tests/Unit/App/Stages/Web/HandleRouteNotFoundStageTest.php @@ -0,0 +1,71 @@ +webContext = new WebContext(App::getContext()); + } + + public function testHandleRouteNotFoundStageRespondsNotFoundWhenNoRouteMatched(): void + { + request()->create('GET', '/non-existing-uri'); + + (new HandleRouteNotFoundStage())->process($this->webContext); + + $this->assertSame(response(), $this->webContext->getResponse()); + $this->assertSame(StatusCode::NOT_FOUND, $this->webContext->getResponse()->getStatusCode()); + $this->assertSame(ContentType::HTML, $this->webContext->getResponse()->getContentType()); + $this->assertTrue($this->webContext->isStopped()); + } + + public function testHandleRouteNotFoundStageRespondsWithJsonForJsonRequests(): void + { + request()->create('GET', '/non-existing-uri', [], ['Accept' => 'application/json']); + + (new HandleRouteNotFoundStage())->process($this->webContext); + + $this->assertSame(StatusCode::NOT_FOUND, $this->webContext->getResponse()->getStatusCode()); + $this->assertSame(ContentType::JSON, $this->webContext->getResponse()->getContentType()); + $this->assertStringContainsString('Page not found', $this->webContext->getResponse()->getContent()); + $this->assertTrue($this->webContext->isStopped()); + } + + public function testHandleRouteNotFoundStageIgnoresMatchedRoute(): void + { + request()->setMatchedRoute( + new MatchedRoute(new Route(['GET'], '/test/am/tests', 'TestController', 'tests'), []) + ); + + (new HandleRouteNotFoundStage())->process($this->webContext); + + $this->assertNull($this->webContext->getResponse()); + $this->assertFalse($this->webContext->isStopped()); + } + + public function testHandleRouteNotFoundStageSkipsWhenContextIsStopped(): void + { + request()->create('GET', '/non-existing-uri'); + + $this->webContext->stop(); + + (new HandleRouteNotFoundStage())->process($this->webContext); + + $this->assertNull($this->webContext->getResponse()); + } +} diff --git a/tests/Unit/App/Stages/Request/PrepareRequestStageTest.php b/tests/Unit/App/Stages/Web/PrepareRequestStageTest.php similarity index 53% rename from tests/Unit/App/Stages/Request/PrepareRequestStageTest.php rename to tests/Unit/App/Stages/Web/PrepareRequestStageTest.php index 1331d6f3..2fd13f97 100644 --- a/tests/Unit/App/Stages/Request/PrepareRequestStageTest.php +++ b/tests/Unit/App/Stages/Web/PrepareRequestStageTest.php @@ -1,26 +1,28 @@ requestContext = new RequestContext(App::getContext()); + $this->webContext = new WebContext(App::getContext()); request()->create('GET', '/test/am/tests'); @@ -34,7 +36,7 @@ public function testPrepareRequestStageLoadsLanguage(): void { $this->assertFalse(config()->has('lang')); - (new PrepareRequestStage())->process($this->requestContext); + (new PrepareRequestStage())->process($this->webContext); $this->assertTrue(config()->has('lang')); } @@ -48,7 +50,7 @@ public function testPrepareRequestStageLogsRegisteredEventsToDebugger(): void event()->listen('test.event', $listener); - (new PrepareRequestStage())->process($this->requestContext); + (new PrepareRequestStage())->process($this->webContext); $cell = debugbar()->getStoreCell(Debugger::EVENTS); @@ -62,7 +64,7 @@ public function testPrepareRequestStageLogsNothingToDebuggerWithoutEventListener $this->assertSame([], event()->getRegistered()); - (new PrepareRequestStage())->process($this->requestContext); + (new PrepareRequestStage())->process($this->webContext); $this->assertSame([], debugbar()->getStoreCell(Debugger::EVENTS)); } @@ -74,19 +76,19 @@ public function testPrepareRequestStageSkipsDebuggerLoggingWhenDebugDisabled(): event()->listen('test.event', function (): void { }); - (new PrepareRequestStage())->process($this->requestContext); + (new PrepareRequestStage())->process($this->webContext); $this->assertSame([], debugbar()->getStoreCell(Debugger::EVENTS)); } - public function testPrepareRequestStageStoresViewCacheOnContext(): void + public function testPrepareRequestStageRegistersViewCache(): void { - $this->assertNull($this->requestContext->getViewCache()); + $this->assertFalse(Di::isRegistered(ViewCache::class)); - (new PrepareRequestStage())->process($this->requestContext); + (new PrepareRequestStage())->process($this->webContext); - $this->assertInstanceOf(ViewCache::class, $this->requestContext->getViewCache()); - $this->assertSame(Di::get(ViewCache::class), $this->requestContext->getViewCache()); + $this->assertInstanceOf(ViewCache::class, $this->webContext->getViewCache()); + $this->assertSame(Di::get(ViewCache::class), $this->webContext->getViewCache()); } public function testPrepareRequestStageSetsUpViewCacheWhenEnabled(): void @@ -101,7 +103,7 @@ public function testPrepareRequestStageSetsUpViewCacheWhenEnabled(): void Di::get(ViewCache::class)->enableCaching(true); try { - (new PrepareRequestStage())->process($this->requestContext); + (new PrepareRequestStage())->process($this->webContext); $this->assertTrue(config()->has('view_cache')); $this->assertDirectoryExists($moduleDir); @@ -118,18 +120,53 @@ public function testPrepareRequestStageSetsUpViewCacheWhenEnabled(): void public function testPrepareRequestStageDoesNotSetUpViewCacheWhenDisabled(): void { - (new PrepareRequestStage())->process($this->requestContext); + (new PrepareRequestStage())->process($this->webContext); $this->assertFalse(config()->has('view_cache')); } + public function testPrepareRequestStageRegistersViewCacheInTheContextContainer(): void + { + $container = new DiContainer(); + + (new PrepareRequestStage())->process(new WebContext(new AppContext(PROJECT_ROOT, $container))); + + $this->assertTrue($container->isRegistered(ViewCache::class)); + $this->assertFalse(Di::isRegistered(ViewCache::class)); + } + + public function testPrepareRequestStageSetsUpTheViewCacheOfTheContextContainer(): void + { + $viewsDir = base_dir() . DS . 'cache' . DS . 'views'; + $moduleDir = $viewsDir . DS . 'test'; + $viewsDirExisted = is_dir($viewsDir); + + $container = new DiContainer(); + $container->register(ViewCache::class); + $container->get(ViewCache::class)->enableCaching(true); + + try { + (new PrepareRequestStage())->process(new WebContext(new AppContext(PROJECT_ROOT, $container))); + + $this->assertDirectoryExists($moduleDir); + } finally { + if (is_dir($moduleDir)) { + rmdir($moduleDir); + } + + if (!$viewsDirExisted && is_dir($viewsDir)) { + rmdir($viewsDir); + } + } + } + public function testPrepareRequestStageSkipsWhenContextIsStopped(): void { - $this->requestContext->stop(); + $this->webContext->stop(); - (new PrepareRequestStage())->process($this->requestContext); + (new PrepareRequestStage())->process($this->webContext); $this->assertFalse(config()->has('lang')); - $this->assertNull($this->requestContext->getViewCache()); + $this->assertFalse(Di::isRegistered(ViewCache::class)); } } diff --git a/tests/Unit/App/Stages/Web/ResolveRouteStageTest.php b/tests/Unit/App/Stages/Web/ResolveRouteStageTest.php new file mode 100644 index 00000000..306b48a4 --- /dev/null +++ b/tests/Unit/App/Stages/Web/ResolveRouteStageTest.php @@ -0,0 +1,92 @@ +webContext = new WebContext(App::getContext()); + } + + public function testResolveRouteStageStoresMatchedRoute(): void + { + request()->create('GET', '/test/am/tests'); + + (new ResolveRouteStage())->process($this->webContext); + + $this->assertNotNull($this->webContext->getMatchedRoute()); + $this->assertSame('tests', $this->webContext->getMatchedRoute()->getRoute()->getAction()); + $this->assertSame($this->webContext->getMatchedRoute(), request()->getMatchedRoute()); + $this->assertFalse($this->webContext->isStopped()); + } + + public function testResolveRouteStageLeavesContextEmptyWhenNoRouteMatches(): void + { + request()->create('GET', '/non-existing-uri'); + + (new ResolveRouteStage())->process($this->webContext); + + $this->assertNull($this->webContext->getMatchedRoute()); + $this->assertNull(request()->getMatchedRoute()); + $this->assertFalse($this->webContext->isStopped()); + } + + public function testResolveRouteStageClearsARouteSetBeforeItWhenNoRouteMatches(): void + { + request()->create('GET', '/non-existing-uri'); + request()->setMatchedRoute(new MatchedRoute(new Route(['GET'], '/test/am/tests', 'TestController', 'tests'), [])); + + (new ResolveRouteStage())->process($this->webContext); + + $this->assertNull($this->webContext->getMatchedRoute()); + $this->assertNull(request()->getMatchedRoute()); + } + + public function testResolveRouteStageSkipsWhenContextIsStopped(): void + { + request()->create('GET', '/test/am/tests'); + + $this->webContext->stop(); + + (new ResolveRouteStage())->process($this->webContext); + + $this->assertNull($this->webContext->getMatchedRoute()); + $this->assertNull(request()->getMatchedRoute()); + } + + public function testResolveRouteStageReadsTheRequestAndRoutesFromTheContext(): void + { + request()->create('GET', '/test/am/tests'); + + $request = new Request(); + $request->create('GET', '/context/route'); + + $routes = new RouteCollection(); + $routes->add(new Route(['GET'], '/context/route', 'TestController', 'contextAction')); + + $container = new DiContainer(); + $container->set(Request::class, $request); + $container->set(RouteCollection::class, $routes); + + (new ResolveRouteStage())->process(new WebContext(new AppContext(PROJECT_ROOT, $container))); + + $this->assertSame('contextAction', $request->getMatchedRoute()->getRoute()->getAction()); + $this->assertNull(request()->getMatchedRoute()); + } +} diff --git a/tests/Unit/App/Stages/Request/SendResponseStageTest.php b/tests/Unit/App/Stages/Web/SendResponseStageTest.php similarity index 57% rename from tests/Unit/App/Stages/Request/SendResponseStageTest.php rename to tests/Unit/App/Stages/Web/SendResponseStageTest.php index 9e607d07..95481986 100644 --- a/tests/Unit/App/Stages/Request/SendResponseStageTest.php +++ b/tests/Unit/App/Stages/Web/SendResponseStageTest.php @@ -1,34 +1,38 @@ requestContext = new RequestContext(App::getContext()); + $this->webContext = new WebContext(App::getContext()); } public function testSendResponseStageSendsTheResponse(): void { - $this->requestContext->setResponse(response()->html('

hello

')); + $this->webContext->setResponse(response()->html('

hello

')); ob_start(); - (new SendResponseStage())->process($this->requestContext); + (new SendResponseStage())->process($this->webContext); $output = ob_get_clean(); $this->assertSame('

hello

', $output); @@ -38,10 +42,10 @@ public function testSendResponseStageAppliesCorsHeaders(): void { $this->assertFalse(config()->has('cors')); - $this->requestContext->setResponse(response()->html('')); + $this->webContext->setResponse(response()->html('')); ob_start(); - (new SendResponseStage())->process($this->requestContext); + (new SendResponseStage())->process($this->webContext); ob_end_clean(); $this->assertTrue(config()->has('cors')); @@ -53,10 +57,10 @@ public function testSendResponseStageCleansUpRequestAfterSending(): void { $this->prepareMatchedRequest(); - $this->requestContext->setResponse(response()->html('')); + $this->webContext->setResponse(response()->html('')); ob_start(); - (new SendResponseStage())->process($this->requestContext); + (new SendResponseStage())->process($this->webContext); ob_end_clean(); $this->assertNull(request()->getMatchedRoute()); @@ -70,7 +74,7 @@ public function testSendResponseStageCleansUpRequestWhenSendFails(): void response()->setHeader('X-Test', '1'); response()->json(['foo' => 'bar']); - $this->requestContext->setResponse(new class () extends Response { + $this->webContext->setResponse(new class () extends Response { public function send(): void { throw new HttpException('boom'); @@ -78,7 +82,7 @@ public function send(): void }); try { - (new SendResponseStage())->process($this->requestContext); + (new SendResponseStage())->process($this->webContext); $this->fail('Expected response sending to fail.'); } catch (HttpException $exception) { $this->assertSame('boom', $exception->getMessage()); @@ -96,7 +100,7 @@ public function testSendResponseStageFailsAndCleansUpWithoutResponse(): void $this->prepareMatchedRequest(); try { - (new SendResponseStage())->process($this->requestContext); + (new SendResponseStage())->process($this->webContext); $this->fail('Expected the stage to fail without a response.'); } catch (RuntimeException $exception) { $this->assertSame('The request has no response to send.', $exception->getMessage()); @@ -106,6 +110,36 @@ public function testSendResponseStageFailsAndCleansUpWithoutResponse(): void $this->assertNull(request()->getUri()); } + public function testSendResponseStageReadsTheConfigAndRequestFromTheContext(): void + { + $this->prepareMatchedRequest(); + + $request = new Request(); + $request->create('GET', '/context/route'); + $request->setMatchedRoute(new MatchedRoute(new Route(['GET'], '/context/route', 'TestController', 'tests'), [])); + + $config = new Config(); + $config->set('cors', ['X-Context-Cors' => 'yes']); + + $container = new DiContainer(); + $container->set(Request::class, $request); + $container->set(Config::class, $config); + + $webContext = new WebContext(new AppContext(PROJECT_ROOT, $container)); + $webContext->setResponse(response()->html('')); + + ob_start(); + (new SendResponseStage())->process($webContext); + ob_end_clean(); + + $this->assertSame('yes', response()->getHeader('X-Context-Cors')); + $this->assertFalse(config()->has('cors')); + $this->assertNull($request->getMatchedRoute()); + $this->assertNull($request->getUri()); + $this->assertNotNull(request()->getMatchedRoute()); + $this->assertNotNull(request()->getUri()); + } + private function prepareMatchedRequest(): void { request()->create('GET', '/test/am/tests');