Lines 90.47% 19 / 21
Methods 80.00% 4 / 5
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 report 100.00% 2 / 2 100.00% 1 / 1 1
 render 100.00% 2 / 2 100.00% 1 / 1 1
 reportException 100.00% 3 / 3 100.00% 1 / 1 3
 renderException 100.00% 6 / 6 100.00% 1 / 1 4
 matches 75.00% 6 / 8 0.00% 0 / 1 4.25
23final class Exceptions
24{
25    /** @var array<callable(Throwable $e, ServerRequestInterface $request): void> */
26    private array $reportCallbacks = [];
27
28    /** @var array<callable(Throwable $e, ServerRequestInterface $request): ?ResponseInterface> */
29    private array $renderCallbacks = [];
30
31    /**
32     * Register a report callback.
33     *
34     * The callback is invoked for every exception whose type matches the
35     * type-hint of its first parameter. All matching report callbacks run.
36     *
37     * @param callable(Throwable $e, ServerRequestInterface $request): void $callback
38     * @return $this
39     */
40    public function report(callable $callback): self
41    {
42        $this->reportCallbacks[] = $callback;
43        return $this;
44    }
45
46    /**
47     * Register a render callback.
48     *
49     * The callback is invoked for every exception whose type matches the
50     * type-hint of its first parameter. The first callback to return a
51     * Response wins; a callback that returns null (or nothing) falls
52     * through to the next callback, then to the framework default.
53     *
54     * @param callable(Throwable $e, ServerRequestInterface $request): ?ResponseInterface $callback
55     * @return $this
56     */
57    public function render(callable $callback): self
58    {
59        $this->renderCallbacks[] = $callback;
60        return $this;
61    }
62
63    /**
64     * Run every report callback whose first-param type matches $e.
65     *
66     * $request is passed by value. Callbacks that need to stash state on
67     * the request should use the shared mutable RequestContext bag, e.g.
68     * RequestContext::fromRequest($request)?->set('key', $value), rather
69     * than reassigning the request.
70     */
71    public function reportException(Throwable $e, ServerRequestInterface $request): void
72    {
73        foreach ($this->reportCallbacks as $callback) {
74            if ($this->matches($callback, $e)) {
75                $callback($e, $request);
76            }
77        }
78    }
79
80    /**
81     * Return the first render callback's Response that matches $e,
82     * or null to fall through to the framework default.
83     */
84    public function renderException(Throwable $e, ServerRequestInterface $request): ?ResponseInterface
85    {
86        foreach ($this->renderCallbacks as $callback) {
87            if ($this->matches($callback, $e)) {
88                $response = $callback($e, $request);
89                if ($response instanceof ResponseInterface) {
90                    return $response;
91                }
92            }
93        }
94        return null;
95    }
96
97    /**
98     * Match a callback to an exception by the type-hint of its first
99     * parameter. A callback typed to Throwable matches everything; one typed
100     * to a specific class matches only that class (and subclasses).
101     */
102    private function matches(callable $callback, Throwable $e): bool
103    {
104        $ref = new ReflectionFunction(\Closure::fromCallable($callback));
105        $params = $ref->getParameters();
106        if ($params === []) {
107            return false;
108        }
109        $type = $params[0]->getType();
110        if ($type === null || !($type instanceof ReflectionNamedType)) {
111            return false;
112        }
113        return is_a($e, $type->getName());
114    }
115}