diff --git a/CHANGELOG.md b/CHANGELOG.md index ce738ec..abefd0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Enh #150: Cleanup templates, remove legacy code (@vjik) - New #151: Add `$traceLink` parameter to `HtmlRenderer` to allow linking to trace files (@vjik) +- New #153: Introduce `UserException` attribute to mark user exceptions (@vjik) ## 4.1.0 April 18, 2025 diff --git a/src/Exception/UserException.php b/src/Exception/UserException.php index b6ded57..8c78b1e 100644 --- a/src/Exception/UserException.php +++ b/src/Exception/UserException.php @@ -4,14 +4,34 @@ namespace Yiisoft\ErrorHandler\Exception; +use Attribute; use Exception; +use ReflectionClass; +use Throwable; + +use function count; /** - * UserException is the base class for exceptions that are meant to be shown to end users. - * Such exceptions are often caused by mistakes of end users. + * `UserException` is an exception and a class attribute that indicates + * the exception message is safe to display to end users. + * + * Usage: + * - throw directly (`throw new UserException(...)`) for explicit user-facing errors; + * - annotate any exception class with the `#[UserException]` attribute + * to mark its messages as user-facing without extending this class. * * @final */ +#[Attribute(Attribute::TARGET_CLASS)] class UserException extends Exception { + public static function isUserException(Throwable $throwable): bool + { + if ($throwable instanceof self) { + return true; + } + + $attributes = (new ReflectionClass($throwable))->getAttributes(self::class); + return count($attributes) > 0; + } } diff --git a/templates/production.php b/templates/production.php index d1a70a8..6551f7b 100644 --- a/templates/production.php +++ b/templates/production.php @@ -9,7 +9,7 @@ * @var HtmlRenderer $this */ -if ($throwable instanceof UserException) { +if (UserException::isUserException($throwable)) { $name = $this->getThrowableName($throwable); $message = $throwable->getMessage(); } else { diff --git a/tests/Exception/UserException/NotFoundException.php b/tests/Exception/UserException/NotFoundException.php new file mode 100644 index 0000000..15731b7 --- /dev/null +++ b/tests/Exception/UserException/NotFoundException.php @@ -0,0 +1,13 @@ +getMessage()); + assertInstanceOf(Exception::class, $exception); + } + + public static function dataIsUserException(): iterable + { + yield [true, new UserException()]; + yield [false, new Exception()]; + yield [true, new NotFoundException()]; + } + + #[DataProvider('dataIsUserException')] + public function testIsUserException(bool $expected, Throwable $exception): void + { + assertSame($expected, UserException::isUserException($exception)); + } +} diff --git a/tests/Factory/ThrowableResponseFactoryTest.php b/tests/Factory/ThrowableResponseFactoryTest.php index 6f6a239..0c9ae08 100644 --- a/tests/Factory/ThrowableResponseFactoryTest.php +++ b/tests/Factory/ThrowableResponseFactoryTest.php @@ -32,12 +32,8 @@ public function testHandleWithHeadRequestMethod(): void $this->createThrowable(), $this->createServerRequest('HEAD', ['Accept' => ['test/html']]) ); - $response - ->getBody() - ->rewind(); - $content = $response - ->getBody() - ->getContents(); + $response->getBody()->rewind(); + $content = $response->getBody()->getContents(); $this->assertEmpty($content); $this->assertSame([HeaderRenderer::DEFAULT_ERROR_MESSAGE], $response->getHeader('X-Error-Message'));