Skip to content

Commit 914ed58

Browse files
authored
feat(core): support exception reporting (#1264)
1 parent b110123 commit 914ed58

18 files changed

+373
-82
lines changed

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
},
4242
"require-dev": {
4343
"aidan-casey/mock-client": "dev-master",
44-
"carthage-software/mago": "0.22.2",
44+
"carthage-software/mago": "0.24.1",
4545
"guzzlehttp/psr7": "^2.6.1",
4646
"illuminate/view": "~11.7.0",
4747
"league/flysystem-aws-s3-v3": "^3.0",

mago.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ name = "strictness/require-return-type"
6464
ignore_arrow_function = true
6565
ignore_closure = true
6666

67+
# https://github.com/carthage-software/mago/issues/206
68+
[[linter.rules]]
69+
name = "best-practices/literal-named-argument"
70+
level = "off"
71+
6772
# https://github.com/carthage-software/mago/issues/146
6873
[[linter.rules]]
6974
name = "strictness/require-strict-types"

packages/console/src/Exceptions/ConsoleExceptionHandler.php

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use Tempest\Container\Tag;
1313
use Tempest\Core\AppConfig;
1414
use Tempest\Core\ExceptionHandler;
15+
use Tempest\Core\ExceptionReporter;
1516
use Tempest\Core\Kernel;
1617
use Tempest\Highlight\Escape;
1718
use Tempest\Highlight\Highlighter;
@@ -29,15 +30,13 @@ public function __construct(
2930
private Highlighter $highlighter,
3031
private Console $console,
3132
private ConsoleArgumentBag $argumentBag,
33+
private ExceptionReporter $exceptionReporter,
3234
) {}
3335

3436
public function handle(Throwable $throwable): void
3537
{
3638
try {
37-
foreach ($this->appConfig->exceptionProcessors as $processor) {
38-
$handler = $this->container->get($processor);
39-
$throwable = $handler->process($throwable);
40-
}
39+
$this->exceptionReporter->report($throwable);
4140

4241
$this->console
4342
->writeln()
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
namespace Tempest\Core;
4+
5+
use Tempest\Container\Container;
6+
use Tempest\Http\Request;
7+
use Tempest\Router\MatchedRoute;
8+
use Throwable;
9+
use Whoops\Handler\HandlerInterface;
10+
use Whoops\Handler\PrettyPageHandler;
11+
use Whoops\Run;
12+
13+
final readonly class DevelopmentExceptionHandler implements ExceptionHandler
14+
{
15+
private Run $whoops;
16+
17+
public function __construct(
18+
private Container $container,
19+
private ExceptionReporter $exceptionReporter,
20+
) {
21+
$this->whoops = new Run();
22+
$this->whoops->pushHandler($this->createHandler());
23+
}
24+
25+
public function handle(Throwable $throwable): void
26+
{
27+
$this->exceptionReporter->report($throwable);
28+
$this->whoops->handleException($throwable);
29+
}
30+
31+
private function createHandler(): HandlerInterface
32+
{
33+
$handler = new PrettyPageHandler();
34+
35+
$handler->addDataTableCallback('Route', function () {
36+
$route = $this->container->get(MatchedRoute::class);
37+
38+
if (! $route) {
39+
return [];
40+
}
41+
42+
return [
43+
'Handler' => $route->route->handler->getDeclaringClass()->getFileName() . ':' . $route->route->handler->getName(),
44+
'URI' => $route->route->uri,
45+
'Allowed parameters' => $route->route->parameters,
46+
'Received parameters' => $route->params,
47+
];
48+
});
49+
50+
$handler->addDataTableCallback('Request', function () {
51+
$request = $this->container->get(Request::class);
52+
53+
return [
54+
'URI' => $request->uri,
55+
'Method' => $request->method->value,
56+
'Headers' => $request->headers->toArray(),
57+
'Parsed body' => array_filter(array_values($request->body)) ? $request->body : [],
58+
'Raw body' => $request->raw,
59+
];
60+
});
61+
62+
return $handler;
63+
}
64+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
namespace Tempest\Core;
4+
5+
use Tempest\Console\Exceptions\ConsoleExceptionHandler;
6+
use Tempest\Container\Container;
7+
use Tempest\Container\Initializer;
8+
use Tempest\Container\Singleton;
9+
use Tempest\Router\Exceptions\HttpExceptionHandler;
10+
11+
final class ExceptionHandlerInitializer implements Initializer
12+
{
13+
#[Singleton]
14+
public function initialize(Container $container): ExceptionHandler
15+
{
16+
$config = $container->get(AppConfig::class);
17+
18+
return match (true) {
19+
PHP_SAPI === 'cli' => $container->get(ConsoleExceptionHandler::class),
20+
$config->environment->isLocal() => $container->get(DevelopmentExceptionHandler::class),
21+
default => $container->get(HttpExceptionHandler::class),
22+
};
23+
}
24+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
namespace Tempest\Core;
4+
5+
use Tempest\Container\Container;
6+
use Tempest\Container\Singleton;
7+
use Throwable;
8+
9+
#[Singleton]
10+
final class ExceptionReporter
11+
{
12+
private(set) array $reported = [];
13+
14+
public bool $enabled = true;
15+
16+
public function __construct(
17+
private readonly AppConfig $appConfig,
18+
private readonly Container $container,
19+
) {}
20+
21+
/**
22+
* Reports the given exception to the registered exceptionm processors.
23+
*/
24+
public function report(Throwable $throwable): void
25+
{
26+
$this->reported[] = $throwable;
27+
28+
if (! $this->enabled) {
29+
return;
30+
}
31+
32+
foreach ($this->appConfig->exceptionProcessors as $processor) {
33+
$handler = $this->container->get($processor);
34+
$throwable = $handler->process($throwable);
35+
}
36+
}
37+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
namespace Tempest\Core;
4+
5+
use Closure;
6+
use PHPUnit\Framework\Assert;
7+
8+
final readonly class ExceptionTester
9+
{
10+
public function __construct(
11+
private ExceptionReporter $reporter,
12+
) {}
13+
14+
/**
15+
* Prevents the reporter from reporting exceptions.
16+
*/
17+
public function preventReporting(bool $prevent = true): self
18+
{
19+
$this->reporter->enabled = ! $prevent;
20+
21+
return $this;
22+
}
23+
24+
/**
25+
* Asserts that the given `$exception` has been reported.
26+
*
27+
* @param null|Closure $callback A callback accepting the exception instance. The assertion fails if the callback returns `false`.
28+
* @param null|int $count If specified, the assertion fails if the exception has been reported a different amount of times.
29+
*/
30+
public function assertReported(string|object $exception, ?Closure $callback = null, ?int $count = null): self
31+
{
32+
Assert::assertNotNull(
33+
actual: $reports = $this->findReports($exception),
34+
message: 'The exception was not reported.',
35+
);
36+
37+
if ($count !== null) {
38+
Assert::assertCount($count, $reports, sprintf('Expected %s report(s), got %s.', $count, count($reports)));
39+
}
40+
41+
if ($callback !== null) {
42+
foreach ($reports as $dispatch) {
43+
Assert::assertNotFalse($callback($dispatch), 'The callback failed.');
44+
}
45+
}
46+
47+
return $this;
48+
}
49+
50+
/**
51+
* Asserts that the given `$exception` was not reported.
52+
*/
53+
public function assertNotReported(string|object $exception): self
54+
{
55+
Assert::assertEmpty(
56+
actual: $this->findReports($exception),
57+
message: 'The exception was reported.',
58+
);
59+
60+
return $this;
61+
}
62+
63+
/**
64+
* Asserts that no exceptions were reported.
65+
*/
66+
public function assertNothingReported(): self
67+
{
68+
Assert::assertEmpty(
69+
actual: $this->reporter->reported,
70+
message: sprintf('There are unexpected reported exceptions: [%s]', implode(', ', $this->reporter->reported)),
71+
);
72+
73+
return $this;
74+
}
75+
76+
private function findReports(string|object $exception): array
77+
{
78+
return array_filter($this->reporter->reported, function (string|object $reported) use ($exception) {
79+
if ($reported === $exception) {
80+
return true;
81+
}
82+
83+
if (class_exists($exception) && is_a($reported, $exception, allow_string: true)) {
84+
return true;
85+
}
86+
87+
return false;
88+
});
89+
}
90+
}

packages/core/src/FrameworkKernel.php

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ public function registerEmergencyExceptionHandler(): self
205205
// In development, we want to register a developer-friendly error
206206
// handler as soon as possible to catch any kind of exception.
207207
if (PHP_SAPI !== 'cli' && ! $environment->isProduction()) {
208-
new RegisterEmergencyExceptionHandler($this->container)->register();
208+
new RegisterEmergencyExceptionHandler()->register();
209209
}
210210

211211
return $this;
@@ -220,25 +220,17 @@ public function registerExceptionHandler(): self
220220
return $this;
221221
}
222222

223-
// We need an exception handler for the CLI in every
224-
// environment, and one for HTTP only in production.
225-
$handler = match (true) {
226-
PHP_SAPI === 'cli' => $this->container->get(ConsoleExceptionHandler::class),
227-
$appConfig->environment->isProduction() => $this->container->get(HttpExceptionHandler::class),
228-
default => null,
229-
};
230-
231-
if ($handler) {
232-
set_exception_handler($handler->handle(...));
233-
set_error_handler(fn (int $code, string $message, string $filename, int $line) => $handler->handle(
234-
new \ErrorException(
235-
message: $message,
236-
code: $code,
237-
filename: $filename,
238-
line: $line,
239-
),
240-
));
241-
}
223+
$handler = $this->container->get(ExceptionHandler::class);
224+
225+
set_exception_handler($handler->handle(...));
226+
set_error_handler(fn (int $code, string $message, string $filename, int $line) => $handler->handle(
227+
new \ErrorException(
228+
message: $message,
229+
code: $code,
230+
filename: $filename,
231+
line: $line,
232+
),
233+
));
242234

243235
return $this;
244236
}

packages/core/src/Kernel/RegisterEmergencyExceptionHandler.php

Lines changed: 1 addition & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2,57 +2,15 @@
22

33
namespace Tempest\Core\Kernel;
44

5-
use Tempest\Container\Container;
6-
use Tempest\Http\Request;
7-
use Tempest\Router\MatchedRoute;
8-
use Whoops\Handler\HandlerInterface;
95
use Whoops\Handler\PrettyPageHandler;
106
use Whoops\Run;
117

128
final readonly class RegisterEmergencyExceptionHandler
139
{
14-
public function __construct(
15-
private Container $container,
16-
) {}
17-
1810
public function register(): void
1911
{
2012
$whoops = new Run();
21-
$whoops->pushHandler($this->createHandler());
13+
$whoops->pushHandler(new PrettyPageHandler());
2214
$whoops->register();
2315
}
24-
25-
private function createHandler(): HandlerInterface
26-
{
27-
$handler = new PrettyPageHandler();
28-
29-
$handler->addDataTableCallback('Route', function () {
30-
$route = $this->container->get(MatchedRoute::class);
31-
32-
if (! $route) {
33-
return [];
34-
}
35-
36-
return [
37-
'Handler' => $route->route->handler->getDeclaringClass()->getFileName() . ':' . $route->route->handler->getName(),
38-
'URI' => $route->route->uri,
39-
'Allowed parameters' => $route->route->parameters,
40-
'Received parameters' => $route->params,
41-
];
42-
});
43-
44-
$handler->addDataTableCallback('Request', function () {
45-
$request = $this->container->get(Request::class);
46-
47-
return [
48-
'URI' => $request->uri,
49-
'Method' => $request->method->value,
50-
'Headers' => $request->headers->toArray(),
51-
'Parsed body' => array_filter(array_values($request->body)) ? $request->body : [],
52-
'Raw body' => $request->raw,
53-
];
54-
});
55-
56-
return $handler;
57-
}
5816
}

packages/core/src/functions.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
use Stringable;
88
use Tempest\Core\Composer;
99
use Tempest\Core\DeferredTasks;
10+
use Tempest\Core\ExceptionReporter;
1011
use Tempest\Core\Kernel;
1112
use Tempest\Support\Namespace\PathCouldNotBeMappedToNamespaceException;
13+
use Throwable;
1214

1315
use function Tempest\Support\Namespace\to_psr4_namespace;
1416
use function Tempest\Support\Path\to_absolute_path;
@@ -83,4 +85,12 @@ function defer(Closure $closure): void
8385
{
8486
get(DeferredTasks::class)->add($closure);
8587
}
88+
89+
/**
90+
* Passes the given exception through registered exception processors.
91+
*/
92+
function report(Throwable $throwable): void
93+
{
94+
get(ExceptionReporter::class)->report($throwable);
95+
}
8696
}

0 commit comments

Comments
 (0)