diff --git a/src/adapter/ServerRequestAdapter.php b/src/adapter/ServerRequestAdapter.php index 91520756..90bd2752 100644 --- a/src/adapter/ServerRequestAdapter.php +++ b/src/adapter/ServerRequestAdapter.php @@ -9,7 +9,9 @@ use yii\base\InvalidConfigException; use yii\helpers\Json; use yii\web\{Cookie, HeaderCollection}; +use yii\web\NotFoundHttpException; use yii2\extensions\psrbridge\exception\Message; +use yii2\extensions\psrbridge\http\Request; use function implode; use function in_array; @@ -364,6 +366,43 @@ public function getUrl(): string return $url; } + /** + * Resolves the route and parameters from the given {@see Request} instance using Yii2 UrlManager. + * + * Parses the request using Yii2 UrlManager and returns the resolved route and parameters. + * + * If the route is found, combine the parsed parameters with the query parameters from the PSR-7 + * ServerRequestAdapter. + * + * @param Request $request Request instance to resolve. + * + * @throws NotFoundHttpException if the route cannot be resolved by UrlManager. + * + * @return array Array containing the resolved route and combined parameters. + * + * @phpstan-return array + * + * Usage example: + * ```php + * [$route, $params] = $adapter->resolve($request); + * ``` + */ + public function resolve(Request $request): array + { + /** @phpstan-var array{0: string, 1: array}|false $result*/ + $result = Yii::$app->getUrlManager()->parseRequest($request); + + if ($result !== false) { + [$route, $params] = $result; + + $combinedParams = $params + $this->psrRequest->getQueryParams(); + + return [$route, $combinedParams]; + } + + throw new NotFoundHttpException(Yii::t('yii', Message::PAGE_NOT_FOUND->getMessage())); + } + /** * Extracts cookies from the PSR-7 ServerRequestInterface without validation. * diff --git a/src/exception/Message.php b/src/exception/Message.php index 261f6318..8d17f620 100644 --- a/src/exception/Message.php +++ b/src/exception/Message.php @@ -124,6 +124,13 @@ enum Message: string */ case NAME_MUST_BE_STRING_OR_NULL = "'name' must be a 'string' or 'null' in file specification."; + /** + * Error when the page is not found. + * + * Format: "Page not found." + */ + case PAGE_NOT_FOUND = 'Page not found.'; + /** * Error when the PSR-7 request adapter is not set. * diff --git a/src/http/Request.php b/src/http/Request.php index 492b11e6..6f1c143e 100644 --- a/src/http/Request.php +++ b/src/http/Request.php @@ -6,7 +6,7 @@ use Psr\Http\Message\{ServerRequestInterface, UploadedFileInterface}; use yii\base\InvalidConfigException; -use yii\web\{CookieCollection, HeaderCollection, UploadedFile}; +use yii\web\{CookieCollection, HeaderCollection, NotFoundHttpException, UploadedFile}; use yii2\extensions\psrbridge\adapter\ServerRequestAdapter; use yii2\extensions\psrbridge\exception\Message; @@ -105,7 +105,7 @@ public function getAuthCredentials(): array /** * Apache with php-cgi does not pass HTTP Basic authentication to PHP by default. - * To make it work, add one of the following lines to to your .htaccess file: + * To make it work, add one of the following lines to your .htaccess file: * * SetEnvIf Authorization .+ HTTP_AUTHORIZATION=$0 * --OR-- @@ -313,7 +313,7 @@ public function getParsedBody(): array|object|null /** * Retrieves the underlying PSR-7 ServerRequestInterface instance from the adapter. * - * Returns the PSR-7 {@see ServerRequestInterface} associated with this request via the internal adapter. + * Returns the PSR-7 ServerRequestInterface associated with this request via the internal adapter. * * If the adapter is not set, an {@see InvalidConfigException} is thrown to indicate misconfiguration or missing * bridge setup. @@ -519,11 +519,40 @@ public function reset(): void $this->adapter = null; } + /** + * Resolves the request parameters using PSR-7 adapter or Yii2 fallback. + * + * Returns an array of resolved request parameters by delegating to the PSR-7 ServerRequestAdapter if present, or + * falling back to the parent Yii2 implementation when no adapter is set. + * + * This method enables seamless interoperability between PSR-7 compatible HTTP stacks and legacy Yii2 workflows, + * ensuring consistent parameter resolution in both environments. + * + * @throws NotFoundHttpException if the route cannot be resolved by UrlManager. + * + * @return array Array of resolved request parameters for the current request. + * + * @phpstan-return array + * + * Usage example: + * ```php + * $params = $request->resolve(); + * ``` + */ + public function resolve(): array + { + if ($this->adapter !== null) { + return $this->adapter->resolve($this); + } + + return parent::resolve(); + } + /** * Sets the PSR-7 ServerRequestInterface instance for the current request. * - * Assigns a new {@see ServerRequestAdapter} wrapping the provided PSR-7 {@see ServerRequestInterface} to enable - * PSR-7 interoperability for the Yii2 Request component. + * Assigns a new {@see ServerRequestAdapter} wrapping the provided PSR-7 ServerRequestInterface to enable PSR-7 + * interoperability for the Yii2 Request component. * * This method is used to bridge PSR-7 compatible HTTP stacks with Yii2, allowing request data to be accessed via * the adapter. diff --git a/tests/TestCase.php b/tests/TestCase.php index 6b8cc2c8..0bf0284d 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -149,10 +149,7 @@ protected function statelessApplication(array $config = []): StatelessApplicatio 'enableStrictParsing' => false, 'enablePrettyUrl' => true, 'rules' => [ - [ - 'pattern' => '///', - 'route' => '/', - ], + 'site/update/' => 'site/update', ], ], ], diff --git a/tests/http/StatelessApplicationTest.php b/tests/http/StatelessApplicationTest.php index f881c5cd..f7808a51 100644 --- a/tests/http/StatelessApplicationTest.php +++ b/tests/http/StatelessApplicationTest.php @@ -10,12 +10,13 @@ use Psr\Http\Message\{ServerRequestFactoryInterface, StreamFactoryInterface, UploadedFileFactoryInterface}; use stdClass; use Yii; -use yii\base\{InvalidConfigException, Security}; +use yii\base\{Exception, InvalidConfigException, Security}; use yii\di\NotInstantiableException; use yii\helpers\Json; use yii\i18n\{Formatter, I18N}; use yii\log\Dispatcher; -use yii\web\{AssetManager, Session, UrlManager, User, View}; +use yii\web\{AssetManager, NotFoundHttpException, Session, UrlManager, User, View}; +use yii2\extensions\psrbridge\exception\Message; use yii2\extensions\psrbridge\http\{ErrorHandler, Request, Response}; use yii2\extensions\psrbridge\tests\provider\StatelessApplicationProvider; use yii2\extensions\psrbridge\tests\support\FactoryHelper; @@ -694,6 +695,9 @@ public function testMultipleRequestsWithDifferentSessionsInWorkerMode(): void } } + /** + * @throws InvalidConfigException if the configuration is invalid or incomplete. + */ public function testRecalculateMemoryLimitAfterResetAndIniChange(): void { $originalLimit = ini_get('memory_limit'); @@ -727,6 +731,9 @@ public function testRecalculateMemoryLimitAfterResetAndIniChange(): void ini_set('memory_limit', $originalLimit); } + /** + * @throws InvalidConfigException if the configuration is invalid or incomplete. + */ #[RequiresPhpExtension('runkit7')] public function testRenderExceptionSetsDisplayErrorsInDebugMode(): void { @@ -780,6 +787,9 @@ public function testRenderExceptionSetsDisplayErrorsInDebugMode(): void @runkit_constant_redefine('YII_ENV_TEST', true); } + /** + * @throws InvalidConfigException if the configuration is invalid or incomplete. + */ #[RequiresPhpExtension('runkit7')] public function testRenderExceptionWithErrorActionReturningResponseObject(): void { @@ -829,6 +839,9 @@ public function testRenderExceptionWithErrorActionReturningResponseObject(): voi @runkit_constant_redefine('YII_DEBUG', true); } + /** + * @throws InvalidConfigException if the configuration is invalid or incomplete. + */ public function testRenderExceptionWithRawFormat(): void { HTTPFunctions::set_sapi('apache2handler'); @@ -864,7 +877,7 @@ public function testRenderExceptionWithRawFormat(): void $body = $response->getBody()->getContents(); self::assertStringContainsString( - 'yii\base\Exception', + Exception::class, $body, 'RAW format response should contain exception class name.', ); @@ -1225,6 +1238,47 @@ public function testReturnJsonResponseWithQueryParametersForSiteGetRoute(): void ); } + /** + * @throws InvalidConfigException if the configuration is invalid or incomplete. + */ + public function testReturnJsonResponseWithRouteParameterForSiteUpdateRoute(): void + { + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/update/123', + ]; + + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $app = $this->statelessApplication(); + + $response = $app->handle($request); + + self::assertSame( + 200, + $response->getStatusCode(), + "Response 'status code' should be '200' for 'site/update/123' route in 'StatelessApplication', " . + 'indicating a successful update.', + ); + self::assertSame( + 'application/json; charset=UTF-8', + $response->getHeaderLine('Content-Type'), + "Response 'content-type' should be 'application/json; charset=UTF-8' for 'site/update/123' route in " . + "'StatelessApplication'.", + ); + self::assertSame( + '{"site/update":"123"}', + $response->getBody()->getContents(), + "Response 'body' should contain valid JSON with the route parameter for 'site/update/123' in " . + "'StatelessApplication'.", + ); + self::assertSame( + 'site/update/123', + $request->getUri()->getPath(), + "Request 'path' should be 'site/update/123' for 'site/update/123' route in 'StatelessApplication'.", + ); + } + /** * @throws InvalidConfigException if the configuration is invalid or incomplete. */ @@ -1846,6 +1900,72 @@ public function testThrowableOccursDuringRequestHandling(): void ); } + /** + * @throws InvalidConfigException if the configuration is invalid or incomplete. + */ + public function testThrowNotFoundHttpExceptionWhenStrictParsingDisabledAndRouteIsMissing(): void + { + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/profile/123', + ]; + + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $app = $this->statelessApplication(); + + $response = $app->handle($request); + + self::assertSame( + 404, + $response->getStatusCode(), + "Response 'status code' should be '404' when accessing a non-existent route in 'StatelessApplication', " . + "indicating a 'Not Found' error.", + ); + self::assertSame( + 'text/html; charset=UTF-8', + $response->getHeaders()['content-type'][0] ?? '', + "Response 'content-type' should be 'text/html; charset=UTF-8' for 'NotFoundHttpException' in " . + "'StatelessApplication'.", + ); + self::assertStringContainsString( + '
Not Found: Page not found.
', + $response->getBody()->getContents(), + "Response 'body' should contain the default not found message '
Not Found: Page not found.
' " . + "when a 'NotFoundHttpException' is thrown in 'StatelessApplication'.", + ); + } + + /** + * @throws InvalidConfigException if the configuration is invalid or incomplete. + */ + public function testThrowNotFoundHttpExceptionWhenStrictParsingEnabledAndRouteIsMissing(): void + { + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/profile/123', + ]; + + $request = FactoryHelper::createServerRequestCreator()->createFromGlobals(); + + $app = $this->statelessApplication( + [ + 'components' => [ + 'urlManager' => [ + 'enableStrictParsing' => true, + ], + ], + ], + ); + + $app->handle($request); + + $this->expectException(NotFoundHttpException::class); + $this->expectExceptionMessage(Message::PAGE_NOT_FOUND->getMessage()); + + $app->request->resolve(); + } + /** * @throws InvalidConfigException if the configuration is invalid or incomplete. */ @@ -1870,6 +1990,9 @@ static function () use (&$eventTriggered): void { self::assertTrue($eventTriggered, "Should trigger '{$eventName}' event during handle()"); } + /** + * @throws InvalidConfigException if the configuration is invalid or incomplete. + */ #[RequiresPhpExtension('runkit7')] public function testUseErrorViewLogicWithDebugFalseAndException(): void { @@ -1926,6 +2049,9 @@ public function testUseErrorViewLogicWithDebugFalseAndException(): void @runkit_constant_redefine('YII_DEBUG', true); } + /** + * @throws InvalidConfigException if the configuration is invalid or incomplete. + */ #[RequiresPhpExtension('runkit7')] public function testUseErrorViewLogicWithDebugFalseAndUserException(): void { @@ -1982,6 +2108,9 @@ public function testUseErrorViewLogicWithDebugFalseAndUserException(): void @runkit_constant_redefine('YII_DEBUG', true); } + /** + * @throws InvalidConfigException if the configuration is invalid or incomplete. + */ public function testUseErrorViewLogicWithDebugTrueAndUserException(): void { $_SERVER = [ @@ -2033,6 +2162,9 @@ public function testUseErrorViewLogicWithDebugTrueAndUserException(): void ); } + /** + * @throws InvalidConfigException if the configuration is invalid or incomplete. + */ public function testUseErrorViewLogicWithNonHtmlFormat(): void { $_SERVER = [ diff --git a/tests/support/stub/SiteController.php b/tests/support/stub/SiteController.php index b7e12fef..1a3ddb2b 100644 --- a/tests/support/stub/SiteController.php +++ b/tests/support/stub/SiteController.php @@ -122,7 +122,7 @@ public function actionErrorWithResponse(): Response } /** - * @throws Exception + * @throws Exception if an unexpected error occurs during execution. */ public function actionFile(): Response { @@ -290,8 +290,8 @@ public function actionStatuscode(): void } /** - * @throws Exception - * @throws RangeNotSatisfiableHttpException + * @throws Exception if an unexpected error occurs during execution. + * @throws RangeNotSatisfiableHttpException if the requested range is not satisfiable. */ public function actionStream(): Response { @@ -309,13 +309,29 @@ public function actionStream(): Response return $this->response->sendStreamAsFile($tmpFile, 'stream.txt', ['mimeType' => 'text/plain']); } + /** + * @throws Exception if an unexpected error occurs during execution. + */ public function actionTriggerException(): never { throw new Exception('Exception error message.'); } + /** + * @throws UserException if user-friendly error is triggered. + */ public function actionTriggerUserException(): never { throw new UserException('User-friendly error message.'); } + + /** + * @phpstan-return array + */ + public function actionUpdate(string|null $id = null): array + { + $this->response->format = Response::FORMAT_JSON; + + return ['site/update' => $id]; + } }