Skip to content

Commit 4b3912d

Browse files
Improve SmartExceptionHandler fallbacks and templates; update tests/docs, PHPUnit config, and gitignore
1 parent 6704800 commit 4b3912d

6 files changed

Lines changed: 141 additions & 71 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
.idea
22
.vscode
33
.phpunit.result.cache
4+
.phpunit.cache
45
build
56
vendor
67
phpunix.xml
78
composer.lock
89
composer.local.json
9-
composer.local.lock
10+
composer.local.lock

CHANGELOG.md

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,20 @@
22

33
All notable changes to this package are documented in this file.
44

5-
## [1.1.0] — 2025-11-16
5+
## [1.2.0] - 2026-01-02
6+
7+
### Changed
8+
9+
- Debug mode now rethrows view-renderer errors instead of swallowing them.
10+
- Plain-text fallback no longer exposes exception details.
11+
- HTTP status codes are normalized to the 100-599 range.
12+
- View renderer callables are normalized to closures.
13+
- Template base path is configurable via constructor.
14+
- Status template file tests now use a temp directory.
15+
- README updated with behavior notes and template path example.
16+
- PHPUnit configuration migrated; .phpunit.cache ignored.
17+
18+
## [1.1.0] - 2025-11-16
619

720
### Changed
821

@@ -11,17 +24,17 @@ All notable changes to this package are documented in this file.
1124
- Consistent message generation style: strict matching of the HTTP code with predefined text is used.
1225
- Updated the footer in the template (Codemonster Errors).
1326

14-
## [1.0.0] 2025-11-10
27+
## [1.0.0] - 2025-11-10
1528

1629
### Added
1730

18-
- Base interface `ExceptionHandlerInterface`
31+
- Base interface `ExceptionHandlerInterface`.
1932
- `SmartExceptionHandler` class with support for:
20-
- Automatic HTTP status detection (404, 500, 401, etc.)
21-
- Debug mode (`debug.php`) and production mode (`generic.php`)
22-
- Fallback logic for template errors
23-
- Works without dependence on `View` (via custom `callable $viewRenderer`)
33+
- Automatic HTTP status detection (404, 500, 401, etc.).
34+
- Debug mode (`debug.php`) and production mode (`generic.php`).
35+
- Fallback logic for template errors.
36+
- Works without dependence on `View` (via custom `callable $viewRenderer`).
2437
- Base HTML templates:
25-
- `resources/views/errors/generic.php`
26-
- `resources/views/errors/debug.php`
27-
- Full unit test coverage (`PHPUnit 9.612.0`)
38+
- `resources/views/errors/generic.php`.
39+
- `resources/views/errors/debug.php`.
40+
- Full unit test coverage (PHPUnit 9.6-12.0).

README.md

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,20 @@
77

88
Universal package for handling exceptions and HTTP errors.
99

10-
## 📦 Installation
10+
## Installation
1111

1212
```bash
1313
composer require codemonster-ru/errors
1414
```
1515

16-
## 🚀 Quick Start
16+
## Quick Start
1717

1818
It can be used as part of a framework or on its own in any PHP project.
1919

2020
### Example 1. Minimal use
2121

2222
```php
23-
use Codemonster\Errors\Contracts\ExceptionHandlerInterface;
2423
use Codemonster\Errors\Handlers\SmartExceptionHandler;
25-
use Codemonster\Http\Response;
2624

2725
require __DIR__ . '/vendor/autoload.php';
2826

@@ -65,26 +63,49 @@ try {
6563
}
6664
```
6765

68-
## 🧱 Template structure
66+
## Template structure
6967

7068
```
7169
resources/views/errors/
72-
├── generic.php # error page for production
73-
└── debug.php # debug page for developers
70+
- generic.php # error page for production
71+
- debug.php # debug page for developers
72+
- 404.php # optional, per-status page
73+
- 500.php # optional, per-status page
7474
```
7575

76-
## 🧪 Testing
76+
Any 3-digit HTTP status file will be used when present.
77+
You can override the template base path with the third constructor argument.
78+
Constructor: `new SmartExceptionHandler(?callable $viewRenderer = null, bool $debug = false, ?string $templatePath = null)`
79+
80+
Example:
81+
82+
```php
83+
$handler = new SmartExceptionHandler(
84+
viewRenderer: null,
85+
debug: false,
86+
templatePath: __DIR__ . '/resources/views/errors'
87+
);
88+
```
89+
90+
## Behavior
91+
92+
- Uses `errors.debug` when `debug: true`.
93+
- Uses `errors.<status>` when a status-specific template exists.
94+
- Falls back to `errors.generic`, then to a plain-text response.
95+
- In debug mode, renderer exceptions are rethrown.
96+
97+
## Testing
7798

7899
You can run tests with the command:
79100

80101
```bash
81102
composer test
82103
```
83104

84-
## 👨‍💻 Author
105+
## Author
85106

86107
[**Kirill Kolesnikov**](https://github.com/KolesnikovKirill)
87108

88-
## 📜 License
109+
## License
89110

90111
[MIT](https://github.com/codemonster-ru/errors/blob/main/LICENSE)

phpunit.xml.dist

Lines changed: 15 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,16 @@
11
<?xml version="1.0" encoding="UTF-8"?>
2-
<phpunit
3-
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4-
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
5-
bootstrap="vendor/autoload.php"
6-
colors="true"
7-
stopOnFailure="false"
8-
cacheResult="true"
9-
executionOrder="depends,defects"
10-
beStrictAboutTestsThatDoNotTestAnything="true"
11-
>
12-
13-
<testsuites>
14-
<testsuite name="Codemonster Errors Test Suite">
15-
<directory>tests</directory>
16-
</testsuite>
17-
</testsuites>
18-
19-
<php>
20-
<env name="APP_ENV" value="testing" />
21-
<env name="APP_DEBUG" value="true" />
22-
<env name="APP_NAME" value="Codemonster Errors" />
23-
</php>
24-
25-
<logging>
26-
<junit outputFile="build/test-results/junit.xml" />
27-
<text outputFile="build/test-results/test.log" />
28-
</logging>
29-
30-
</phpunit>
2+
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/12.4/phpunit.xsd" bootstrap="vendor/autoload.php" colors="true" stopOnFailure="false" cacheResult="true" executionOrder="depends,defects" beStrictAboutTestsThatDoNotTestAnything="true" cacheDirectory=".phpunit.cache">
3+
<testsuites>
4+
<testsuite name="Codemonster Errors Test Suite">
5+
<directory>tests</directory>
6+
</testsuite>
7+
</testsuites>
8+
<php>
9+
<env name="APP_ENV" value="testing"/>
10+
<env name="APP_DEBUG" value="true"/>
11+
<env name="APP_NAME" value="Codemonster Errors"/>
12+
</php>
13+
<logging>
14+
<junit outputFile="build/test-results/junit.xml"/>
15+
</logging>
16+
</phpunit>

src/Handlers/SmartExceptionHandler.php

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,23 @@
44

55
use Codemonster\Errors\Contracts\ExceptionHandlerInterface;
66
use Codemonster\Http\Response;
7+
use Closure;
78
use Throwable;
89

910
class SmartExceptionHandler implements ExceptionHandlerInterface
1011
{
11-
protected $viewRenderer;
12+
protected ?Closure $viewRenderer;
1213
protected bool $debug;
14+
protected string $templatePath;
1315

14-
public function __construct(?callable $viewRenderer = null, bool $debug = false)
16+
/**
17+
* Order: errors.debug (debug=true), errors.{status}, errors.generic, fallback plain-text.
18+
*/
19+
public function __construct(?callable $viewRenderer = null, bool $debug = false, ?string $templatePath = null)
1520
{
16-
$this->viewRenderer = $viewRenderer;
21+
$this->viewRenderer = $viewRenderer ? Closure::fromCallable($viewRenderer) : null;
1722
$this->debug = $debug;
23+
$this->templatePath = $templatePath ?? (dirname(__DIR__, 2) . '/resources/views/errors');
1824
}
1925

2026
public function handle(Throwable $e): Response
@@ -26,8 +32,12 @@ public function handle(Throwable $e): Response
2632
$status = $e->getStatusCode();
2733
}
2834

35+
if (!is_int($status) || $status < 100 || $status > 599) {
36+
$status = 500;
37+
}
38+
2939
if ($this->debug) {
30-
return $this->renderTemplate('errors.debug', ['exception' => $e], $status) ?? $this->fallbackDebug($e);
40+
return $this->renderTemplate('errors.debug', ['exception' => $e], $status) ?? $this->fallbackDebug($e, $status);
3141
}
3242

3343
return $this->renderTemplate(
@@ -55,21 +65,28 @@ protected function renderTemplate(string $template, array $data, int $status): ?
5565
{
5666
if ($this->viewRenderer) {
5767
try {
58-
$html = call_user_func($this->viewRenderer, $template, $data);
68+
$html = ($this->viewRenderer)($template, $data);
5969

6070
if ($html) {
6171
return new Response($html, $status, ['Content-Type' => 'text/html']);
6272
}
63-
} catch (Throwable) {
73+
} catch (Throwable $renderError) {
74+
if ($this->debug) {
75+
throw $renderError;
76+
}
6477
}
6578
}
6679

67-
$basePath = dirname(__DIR__, 2) . '/resources/views/errors';
80+
$basePath = $this->templatePath;
6881
$fileMap = [
6982
'errors.generic' => "$basePath/generic.php",
7083
'errors.debug' => "$basePath/debug.php",
7184
];
7285

86+
if (!isset($fileMap[$template]) && preg_match('/^errors\.(\d{3})$/', $template, $matches)) {
87+
$fileMap[$template] = sprintf('%s/%s.php', $basePath, $matches[1]);
88+
}
89+
7390
if (isset($fileMap[$template]) && is_file($fileMap[$template])) {
7491
ob_start();
7592
extract($data, EXTR_SKIP);
@@ -87,17 +104,14 @@ protected function renderTemplate(string $template, array $data, int $status): ?
87104
protected function fallbackPlain(Throwable $e, int $status): Response
88105
{
89106
$content = sprintf(
90-
"HTTP %d %s\nin %s:%d",
91-
$status,
92-
$e->getMessage(),
93-
$e->getFile(),
94-
$e->getLine()
107+
"HTTP %d\nAn unexpected error occurred.",
108+
$status
95109
);
96110

97111
return new Response($content, $status, ['Content-Type' => 'text/plain']);
98112
}
99113

100-
protected function fallbackDebug(Throwable $e): Response
114+
protected function fallbackDebug(Throwable $e, int $status): Response
101115
{
102116
$content = sprintf(
103117
"[%s] %s\nin %s:%d\n\n%s",
@@ -108,6 +122,6 @@ protected function fallbackDebug(Throwable $e): Response
108122
$e->getTraceAsString()
109123
);
110124

111-
return new Response($content, 500, ['Content-Type' => 'text/plain']);
125+
return new Response($content, $status, ['Content-Type' => 'text/plain']);
112126
}
113127
}

tests/SmartExceptionHandlerTest.php

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,18 +51,53 @@ public function getStatusCode(): int
5151
self::assertSame(404, $response->getStatusCode());
5252
}
5353

54-
public function testFallbackDebugOutputWhenRendererFails(): void
54+
public function testRendersStatusTemplateFromFileWhenPresent(): void
55+
{
56+
$basePath = sys_get_temp_dir() . '/codemonster-errors-' . uniqid('', true);
57+
$templatePath = $basePath . '/404.php';
58+
59+
if (!is_dir($basePath)) {
60+
mkdir($basePath, 0777, true);
61+
}
62+
63+
try {
64+
file_put_contents(
65+
$templatePath,
66+
"<?php echo 'status-template-' . (int) (\$status ?? 0);"
67+
);
68+
69+
$exception = new class('Page missing', 404) extends RuntimeException {
70+
public function getStatusCode(): int
71+
{
72+
return 404;
73+
}
74+
};
75+
76+
$handler = new SmartExceptionHandler(null, false, $basePath);
77+
$response = $handler->handle($exception);
78+
79+
self::assertSame(404, $response->getStatusCode());
80+
self::assertStringContainsString('status-template-404', (string)$response);
81+
} finally {
82+
if (is_file($templatePath)) {
83+
unlink($templatePath);
84+
}
85+
86+
if (is_dir($basePath)) {
87+
rmdir($basePath);
88+
}
89+
}
90+
}
91+
92+
public function testThrowsWhenRendererFailsInDebugMode(): void
5593
{
5694
$renderer = static function (): string {
5795
throw new RuntimeException('View failed');
5896
};
5997

6098
$handler = new SmartExceptionHandler($renderer, true);
61-
$response = $handler->handle(new RuntimeException('Crash'));
62-
63-
self::assertInstanceOf(Response::class, $response);
64-
self::assertSame(500, $response->getStatusCode());
65-
self::assertStringContainsString('<html', (string)$response);
66-
self::assertStringContainsString('Crash', (string)$response);
99+
$this->expectException(RuntimeException::class);
100+
$this->expectExceptionMessage('View failed');
101+
$handler->handle(new RuntimeException('Crash'));
67102
}
68103
}

0 commit comments

Comments
 (0)