diff --git a/src/Exception/AbstractInvalidMiddlewareException.php b/src/Exception/AbstractInvalidMiddlewareException.php new file mode 100644 index 0000000..f1b1e3b --- /dev/null +++ b/src/Exception/AbstractInvalidMiddlewareException.php @@ -0,0 +1,153 @@ +definitionString = DefinitionHelper::convertDefinitionToString($definition); + + parent::__construct($message, 0, $previous); + } + + public function getSolution(): ?string + { + $solution = [ + <<definitionString}` + SOLUTION + ]; + + $suggestion = $this->generateSuggestion(); + if ($suggestion !== null) { + $solution[] = '## Suggestion'; + $solution[] = $suggestion; + } + + $solution[] = << MyMiddleware::class, + '__construct()' => [ + 'someVar' => 42, + ], + ] + ``` + + Closure that returns `ResponseInterface`: + + ```php + static function (): ResponseInterface { + return new Response(418); + }, + ``` + + Closure that returns `MiddlewareInterface`: + + ```php + static function (): MiddlewareInterface { + return new TestMiddleware(); + } + ``` + + Action in controller: + + ```php + [App\Backend\UserController::class, 'index'] + ``` + + ## Related links + + - [Array definition syntax](https://github.com/yiisoft/definitions#arraydefinition) + - [Callable PHP documentation](https://www.php.net/manual/language.types.callable.php) + SOLUTION; + + return implode("\n\n", $solution); + } + + private function generateSuggestion(): ?string + { + if (DefinitionHelper::isControllerWithNonExistAction($this->definition)) { + return <<definition[0]}` exists, but does not contain method `{$this->definition[1]}()`. + + Try adding `{$this->definition[1]}()` action to `{$this->definition[0]}` controller: + + ```php + public function {$this->definition[1]}(): ResponseInterface + { + // TODO: Implement your action + } + ``` + SOLUTION; + } + + if (DefinitionHelper::isNotMiddlewareClassName($this->definition)) { + return sprintf( + 'Class `%s` exists, but does not implement `%s`.', + $this->definition, + MiddlewareInterface::class + ); + } + + if (DefinitionHelper::isStringNotClassName($this->definition)) { + return sprintf( + 'Class `%s` not found. It may be needed to install a package with this middleware.', + $this->definition + ); + } + + if (is_array($this->definition)) { + try { + DefinitionValidator::validateArrayDefinition($this->definition); + } catch (InvalidConfigException $e) { + return <<getMessage()} + ``` + SOLUTION; + } + + /** @psalm-suppress MixedArgument In valid array definition element "class" always is string */ + return sprintf( + 'Array definition is valid, class `%s` exists, but does not implement `%s`.', + $this->definition['class'], + MiddlewareInterface::class + ); + } + + return null; + } +} diff --git a/src/Exception/InvalidMiddlewareReturnTypeException.php b/src/Exception/InvalidMiddlewareReturnTypeException.php new file mode 100644 index 0000000..c4a463b --- /dev/null +++ b/src/Exception/InvalidMiddlewareReturnTypeException.php @@ -0,0 +1,39 @@ +definitionString = DefinitionHelper::convertDefinitionToString($definition); + + parent::__construct( + $definition, + sprintf( + 'Middleware %s must return an instance of `%s` or `%s`, %s returned.', + $this->definitionString, + MiddlewareInterface::class, + ResponseInterface::class, + ResponseHelper::convertToString($this->result), + ), + $previous, + ); + } + + public function getName(): string + { + return sprintf('Invalid middleware result type %s', get_debug_type($this->result)); + } +} diff --git a/src/Helper/DefinitionHelper.php b/src/Helper/DefinitionHelper.php new file mode 100644 index 0000000..ed82ebb --- /dev/null +++ b/src/Helper/DefinitionHelper.php @@ -0,0 +1,94 @@ + $value) { + $items[] = (is_string($key) ? '"' . $key . '" => ' : '') . self::convertToString($value); + } + return '[' . implode(', ', $items) . (count($middlewareDefinition) > 2 ? ', ...' : '') . ']'; + } + + return self::convertToString($middlewareDefinition); + } + + private static function convertToString(mixed $value): string + { + if (is_string($value)) { + return '"' . $value . '"'; + } + + if (is_int($value) || is_float($value)) { + return (string) $value; + } + + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if ($value === null) { + return '"null"'; + } + + if (is_object($value)) { + return sprintf('"%s"', $value::class); + } + + return sprintf('"%s"', gettype($value)); + } +} diff --git a/src/Helper/ResponseHelper.php b/src/Helper/ResponseHelper.php new file mode 100644 index 0000000..9f72bee --- /dev/null +++ b/src/Helper/ResponseHelper.php @@ -0,0 +1,57 @@ + ...', $key); + } + } else { + $items[] = '...'; + } + return sprintf( + '"[%s]" (array of %d%s)', + implode(', ', $items), + $count = count($response), + $count === 1 ? ' element' : ' elements' + ); + } + + if (is_bool($response)) { + return sprintf('"%s" (bool)', $response ? 'true' : 'false'); + } + + if (is_scalar($response)) { + return sprintf( + '"%s" (%s)', + (string)$response, + match (true) { + is_int($response) => 'int', + is_float($response) => 'float', + default => gettype($response) + } + ); + } + + return sprintf('"%s" (%s)', gettype($response), get_debug_type($response)); + } +} diff --git a/src/InvalidMiddlewareDefinitionException.php b/src/InvalidMiddlewareDefinitionException.php index 48280cf..5cca7fd 100644 --- a/src/InvalidMiddlewareDefinitionException.php +++ b/src/InvalidMiddlewareDefinitionException.php @@ -4,33 +4,25 @@ namespace Yiisoft\Middleware\Dispatcher; -use InvalidArgumentException; -use Psr\Http\Server\MiddlewareInterface; -use Yiisoft\Definitions\Exception\InvalidConfigException; -use Yiisoft\Definitions\Helpers\DefinitionValidator; -use Yiisoft\FriendlyException\FriendlyExceptionInterface; +use Throwable; +use Yiisoft\Middleware\Dispatcher\Exception\AbstractInvalidMiddlewareException; +use Yiisoft\Middleware\Dispatcher\Helper\DefinitionHelper; -use function array_slice; -use function count; -use function gettype; -use function is_array; -use function is_bool; -use function is_float; -use function is_int; -use function is_object; -use function is_string; - -final class InvalidMiddlewareDefinitionException extends InvalidArgumentException implements FriendlyExceptionInterface +final class InvalidMiddlewareDefinitionException extends AbstractInvalidMiddlewareException { - private readonly string $definitionString; - public function __construct( - private mixed $definition + mixed $definition, + ?Throwable $previous = null, ) { - $this->definitionString = $this->convertDefinitionToString($definition); + $this->definitionString = DefinitionHelper::convertDefinitionToString($definition); parent::__construct( - 'Parameter should be either PSR middleware class name or a callable. Got ' . $this->definitionString . '.' + $definition, + sprintf( + 'Parameter should be either PSR middleware class name or a callable. Got %s.', + $this->definitionString, + ), + $previous, ); } @@ -38,203 +30,4 @@ public function getName(): string { return 'Invalid middleware definition'; } - - public function getSolution(): ?string - { - $solution = [ - <<definitionString}` - SOLUTION - ]; - - $suggestion = $this->generateSuggestion(); - if ($suggestion !== null) { - $solution[] = '## Suggestion'; - $solution[] = $suggestion; - } - - $solution[] = << MyMiddleware::class, - '__construct()' => [ - 'someVar' => 42, - ], - ] - ``` - - Closure that returns `ResponseInterface`: - - ```php - static function (): ResponseInterface { - return new Response(418); - }, - ``` - - Closure that returns `MiddlewareInterface`: - - ```php - static function (): MiddlewareInterface { - return new TestMiddleware(); - } - ``` - - Action in controller: - - ```php - [App\Backend\UserController::class, 'index'] - ``` - - ## Related links - - - [Array definition syntax](https://github.com/yiisoft/definitions#arraydefinition) - - [Callable PHP documentation](https://www.php.net/manual/language.types.callable.php) - SOLUTION; - - return implode("\n\n", $solution); - } - - private function generateSuggestion(): ?string - { - if ($this->isControllerWithNonExistAction()) { - return <<definition[0]}` exists, but does not contain method `{$this->definition[1]}()`. - - Try adding `{$this->definition[1]}()` action to `{$this->definition[0]}` controller: - - ```php - public function {$this->definition[1]}(): ResponseInterface - { - // TODO: Implement your action - } - ``` - SOLUTION; - } - - if ($this->isNotMiddlewareClassName()) { - return sprintf( - 'Class `%s` exists, but does not implement `%s`.', - $this->definition, - MiddlewareInterface::class - ); - } - - if ($this->isStringNotClassName()) { - return sprintf( - 'Class `%s` not found. It may be needed to install a package with this middleware.', - $this->definition - ); - } - - if (is_array($this->definition)) { - try { - DefinitionValidator::validateArrayDefinition($this->definition); - } catch (InvalidConfigException $e) { - return <<getMessage()} - ``` - SOLUTION; - } - - /** @psalm-suppress MixedArgument In valid array definition element "class" always is string */ - return sprintf( - 'Array definition is valid, class `%s` exists, but does not implement `%s`.', - $this->definition['class'], - MiddlewareInterface::class - ); - } - - return null; - } - - /** - * @psalm-assert-if-true string $this->definition - */ - private function isStringNotClassName(): bool - { - return is_string($this->definition) - && !class_exists($this->definition); - } - - /** - * @psalm-assert-if-true class-string $this->definition - */ - private function isNotMiddlewareClassName(): bool - { - return is_string($this->definition) - && class_exists($this->definition); - } - - /** - * @psalm-assert-if-true array{0:class-string,1:string} $this->definition - */ - private function isControllerWithNonExistAction(): bool - { - return is_array($this->definition) - && array_keys($this->definition) === [0, 1] - && is_string($this->definition[0]) - && class_exists($this->definition[0]); - } - - private function convertDefinitionToString(mixed $middlewareDefinition): string - { - if (is_object($middlewareDefinition)) { - return 'an instance of "' . $middlewareDefinition::class . '"'; - } - - if (is_string($middlewareDefinition)) { - return '"' . $middlewareDefinition . '"'; - } - - if (is_array($middlewareDefinition)) { - $items = []; - /** @var mixed $value */ - foreach (array_slice($middlewareDefinition, 0, 2) as $key => $value) { - $items[] = (is_string($key) ? '"' . $key . '" => ' : '') . $this->convertToString($value); - } - return '[' . implode(', ', $items) . (count($middlewareDefinition) > 2 ? ', ...' : '') . ']'; - } - - return $this->convertToString($middlewareDefinition); - } - - private function convertToString(mixed $value): string - { - if (is_string($value)) { - return '"' . $value . '"'; - } - - if (is_int($value) || is_float($value)) { - return (string) $value; - } - - if (is_bool($value)) { - return $value ? 'true' : 'false'; - } - - if ($value === null) { - return 'null'; - } - - if (is_object($value)) { - return $value::class; - } - - return gettype($value); - } } diff --git a/src/MiddlewareFactory.php b/src/MiddlewareFactory.php index e9df36a..258e7a4 100644 --- a/src/MiddlewareFactory.php +++ b/src/MiddlewareFactory.php @@ -17,6 +17,7 @@ use Yiisoft\Definitions\Exception\InvalidConfigException; use Yiisoft\Definitions\Helpers\DefinitionValidator; use Yiisoft\Injector\Injector; +use Yiisoft\Middleware\Dispatcher\Exception\InvalidMiddlewareReturnTypeException; use function in_array; use function is_array; @@ -216,7 +217,7 @@ public function process( return $response->process($request, $handler); } - throw new InvalidMiddlewareDefinitionException($this->callback); + throw new InvalidMiddlewareReturnTypeException($this->callback, $response); } public function __debugInfo(): array @@ -273,7 +274,7 @@ public function process( return $response; } - throw new InvalidMiddlewareDefinitionException([$this->class, $this->method]); + throw new InvalidMiddlewareReturnTypeException([$this->class, $this->method], $response); } public function __debugInfo() diff --git a/tests/Exception/AbstractInvalidMiddlewareExceptionTest.php b/tests/Exception/AbstractInvalidMiddlewareExceptionTest.php new file mode 100644 index 0000000..490dda2 --- /dev/null +++ b/tests/Exception/AbstractInvalidMiddlewareExceptionTest.php @@ -0,0 +1,56 @@ + TestController::class, 'index'], + 'You may have an error in array definition. Array definition validation result', + ], + [ + ['class' => TestController::class], + 'Array definition is valid, ' . + 'class `Yiisoft\Middleware\Dispatcher\Tests\Support\TestController` exists, ' . + 'but does not implement `Psr\Http\Server\MiddlewareInterface`.', + ], + ]; + } + + /** + * @dataProvider dataSolution + */ + public function testSolution(mixed $definition, string $expectedSolution): void + { + $exception = static::createException($definition); + self::assertStringContainsString($expectedSolution, $exception->getSolution()); + } + + abstract protected function createException(mixed $definition): Throwable; +} diff --git a/tests/Exception/InvalidMiddlewareDefinitionExceptionTest.php b/tests/Exception/InvalidMiddlewareDefinitionExceptionTest.php new file mode 100644 index 0000000..d4c642a --- /dev/null +++ b/tests/Exception/InvalidMiddlewareDefinitionExceptionTest.php @@ -0,0 +1,70 @@ + TestController::class, 'index'], + '["class" => "Yiisoft\Middleware\Dispatcher\Tests\Support\TestController", "index"]', + ], + [ + ['object' => TestController::class, 'index'], + '["object" => "Yiisoft\Middleware\Dispatcher\Tests\Support\TestController", "index"]', + ], + [ + ['class' => TestController::class], + '["class" => "Yiisoft\Middleware\Dispatcher\Tests\Support\TestController"]', + ], + ]; + } + + /** + * @dataProvider dataExceptionMessage + */ + public function testExceptionMessage(mixed $definition, string $expectedMessage): void + { + $exception = self::createException($definition); + self::assertStringEndsWith('. Got ' . $expectedMessage . '.', $exception->getMessage()); + } + + public function testName(): void + { + $exception = new InvalidMiddlewareDefinitionException('test'); + + self::assertSame( + 'Invalid middleware definition', + $exception->getName() + ); + } + + protected function createException(mixed $definition): Throwable + { + return new InvalidMiddlewareDefinitionException($definition); + } +} diff --git a/tests/Exception/InvalidMiddlewareReturnTypeExceptionTest.php b/tests/Exception/InvalidMiddlewareReturnTypeExceptionTest.php new file mode 100644 index 0000000..2bfce43 --- /dev/null +++ b/tests/Exception/InvalidMiddlewareReturnTypeExceptionTest.php @@ -0,0 +1,124 @@ + "null"]', + ['key1' => null], + 'null', + ], + [ + 'Middleware ["key1" => "stdClass"]', + ['key1' => new stdClass()], + 'null', + ], + [ + 'Middleware ["key1" => true, "key2" => false]', + ['key1' => true, 'key2' => false], + 'null', + ], + [ + 'Middleware ["key1" => "null", "key2" => "null", ...]', + ['key1' => null, 'key2' => null, 'key3' => null], + 'null', + ], + [ + 'Middleware ["key" => "null"]', + ['key' => null], + 'null', + ], + [ + 'Middleware "resource"', + fopen('php://memory', 'r'), + 'null', + ], + [ + 'Middleware an instance of `Closure`', + fn() => 42, + 42, + ], + [ + 'Middleware an instance of `Closure`', + fn() => [new stdClass()], + new stdClass(), + ], + [ + 'Middleware an instance of `Closure`', + fn() => true, + true, + ], + [ + 'Middleware an instance of `Closure`', + fn() => false, + false, + ], + [ + 'Middleware an instance of `Closure`', + fn() => ['class' => null, 'setValue()' => [42], 'prepare()' => []], + ['class' => null, 'setValue()' => [42], 'prepare()' => []], + ], + ]; + } + + /** + * @dataProvider dataInvalidReturnType + */ + public function testUnknownDefinition(string $startMessage, mixed $definition, mixed $result): void + { + $exception = new InvalidMiddlewareReturnTypeException($definition, null); + self::assertStringStartsWith( + sprintf( + '%s must return an instance of `%s` or `%s`', + $startMessage, + MiddlewareInterface::class, + ResponseInterface::class, + ), + $exception->getMessage() + ); + } + + public static function dataProviderName(): iterable + { + yield 'null' => [null]; + yield 'string' => ['test']; + yield 'array' => [[]]; + yield 'object' => [new stdClass()]; + yield 'int' => [42]; + } + + /** + * @dataProvider dataProviderName + */ + public function testName(mixed $result): void + { + $exception = new InvalidMiddlewareReturnTypeException('test', $result); + + self::assertSame( + sprintf('Invalid middleware result type %s', get_debug_type($result)), + $exception->getName() + ); + } + + protected function createException(mixed $definition): Throwable + { + return new InvalidMiddlewareReturnTypeException($definition, new stdClass()); + } +} diff --git a/tests/Helper/ResponseHelperTest.php b/tests/Helper/ResponseHelperTest.php new file mode 100644 index 0000000..daa3776 --- /dev/null +++ b/tests/Helper/ResponseHelperTest.php @@ -0,0 +1,60 @@ + [ + ['error' => true, 'message' => 'Error'], + '"["error" => ..., "message" => ...]" (array of 2 elements)', + ]; + yield 'array 1' => [ + [true], + '"[...]" (array of 1 element)', + ]; + yield 'string' => [ + 'ok', + '"ok" (string)', + ]; + yield 'int' => [ + 555, + '"555" (int)', + ]; + yield 'float' => [ + 555.44, + '"555.44" (float)', + ]; + yield 'double' => [ + 555.444444343434343434343434343434343, + '"555.44444434343" (float)', + ]; + yield 'bool true' => [ + true, + '"true" (bool)', + ]; + yield 'bool false' => [ + false, + '"false" (bool)', + ]; + yield 'stdClass' => [ + new stdClass(), + '"stdClass" (object)', + ]; + } + + /** + * @dataProvider dataResult + */ + public function testResult(mixed $result, string $expected): void + { + $this->assertSame($expected, ResponseHelper::convertToString($result)); + } +} diff --git a/tests/InvalidMiddlewareDefinitionExceptionTest.php b/tests/InvalidMiddlewareDefinitionExceptionTest.php deleted file mode 100644 index e6cfa6d..0000000 --- a/tests/InvalidMiddlewareDefinitionExceptionTest.php +++ /dev/null @@ -1,105 +0,0 @@ - TestController::class, 'index'], - '["class" => "Yiisoft\Middleware\Dispatcher\Tests\Support\TestController", "index"]', - null, - ], - [ - ['object' => TestController::class, 'index'], - '["object" => "Yiisoft\Middleware\Dispatcher\Tests\Support\TestController", "index"]', - 'You may have an error in array definition. Array definition validation result', - ], - [ - ['class' => TestController::class], - '["class" => "Yiisoft\Middleware\Dispatcher\Tests\Support\TestController"]', - 'Array definition is valid, ' . - 'class `Yiisoft\Middleware\Dispatcher\Tests\Support\TestController` exists, ' . - 'but does not implement `Psr\Http\Server\MiddlewareInterface`.', - ], - ]; - } - - /** - * @dataProvider dataBase - */ - public function testBase(mixed $definition, string $expectedMessage, ?string $expectedSolution): void - { - $exception = new InvalidMiddlewareDefinitionException($definition); - self::assertStringEndsWith('. Got ' . $expectedMessage . '.', $exception->getMessage()); - if ($expectedSolution !== null) { - self::assertStringContainsString($expectedSolution, $exception->getSolution()); - } - } - - public function dataUnknownDefinition(): array - { - return [ - [42, '42'], - [[new stdClass()], '[stdClass]'], - [true, 'true'], - [false, 'false'], - [ - ['class' => null, 'setValue()' => [42], 'prepare()' => []], - '["class" => null, "setValue()" => array, ...]', - ], - ]; - } - - /** - * @dataProvider dataUnknownDefinition - */ - public function testUnknownDefinition(mixed $definition, string $value): void - { - $exception = new InvalidMiddlewareDefinitionException($definition); - self::assertSame( - 'Parameter should be either PSR middleware class name or a callable. Got ' . $value . '.', - $exception->getMessage() - ); - } - - public function testName(): void - { - $exception = new InvalidMiddlewareDefinitionException('test'); - - self::assertSame( - 'Invalid middleware definition', - $exception->getName() - ); - } -} diff --git a/tests/MiddlewareFactoryTest.php b/tests/MiddlewareFactoryTest.php index dd8515a..7949232 100644 --- a/tests/MiddlewareFactoryTest.php +++ b/tests/MiddlewareFactoryTest.php @@ -12,6 +12,7 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; +use Yiisoft\Middleware\Dispatcher\Exception\InvalidMiddlewareReturnTypeException; use Yiisoft\Middleware\Dispatcher\InvalidMiddlewareDefinitionException; use Yiisoft\Middleware\Dispatcher\MiddlewareFactory; use Yiisoft\Middleware\Dispatcher\ParametersResolverInterface; @@ -253,7 +254,12 @@ public function testInvalidMiddlewareWithWrongCallable(): void static fn() => 42 ); - $this->expectException(InvalidMiddlewareDefinitionException::class); + $this->expectException(InvalidMiddlewareReturnTypeException::class); + $this->expectExceptionMessage(sprintf( + 'Middleware an instance of `Closure` must return an instance of `%s` or `%s`, "42" (int) returned.', + MiddlewareInterface::class, + ResponseInterface::class, + )); $middleware->process( $this->createMock(ServerRequestInterface::class), $this->createMock(RequestHandlerInterface::class) @@ -284,7 +290,12 @@ public function testInvalidMiddlewareWithWrongController(): void ->getMiddlewareFactory($container) ->create([InvalidController::class, 'index']); - $this->expectException(InvalidMiddlewareDefinitionException::class); + $this->expectException(InvalidMiddlewareReturnTypeException::class); + $this->expectExceptionMessage(sprintf( + 'Middleware ["Yiisoft\Middleware\Dispatcher\Tests\Support\InvalidController", "index"] must return an instance of `%s` or `%s`, "200" (int) returned.', + MiddlewareInterface::class, + ResponseInterface::class, + )); $middleware->process( $this->createMock(ServerRequestInterface::class), $this->createMock(RequestHandlerInterface::class)