• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

IlyasDeckers / ody-core / 13532154862

25 Feb 2025 10:24PM UTC coverage: 30.374% (+1.7%) from 28.706%
13532154862

push

github

web-flow
Update php.yml

544 of 1791 relevant lines covered (30.37%)

9.13 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/src/Handlers/ErrorHandler.php
1
<?php
2
declare(strict_types=1);
3

4
namespace Ody\Core\Handlers;
5

6
use Psr\Http\Message\ResponseFactoryInterface;
7
use Psr\Http\Message\ResponseInterface;
8
use Psr\Http\Message\ServerRequestInterface;
9
use Psr\Log\LoggerInterface;
10
use RuntimeException;
11
use Ody\Core\Error\Renderers\HtmlErrorRenderer;
12
use Ody\Core\Error\Renderers\JsonErrorRenderer;
13
use Ody\Core\Error\Renderers\PlainTextErrorRenderer;
14
use Ody\Core\Error\Renderers\XmlErrorRenderer;
15
use Ody\Core\Exception\HttpException;
16
use Ody\Core\Exception\HttpMethodNotAllowedException;
17
use Ody\Core\Interfaces\CallableResolverInterface;
18
use Ody\Core\Interfaces\ErrorHandlerInterface;
19
use Ody\Core\Interfaces\ErrorRendererInterface;
20
use Ody\Core\Logger;
21
use Throwable;
22

23
use function array_intersect;
24
use function array_key_exists;
25
use function array_keys;
26
use function call_user_func;
27
use function count;
28
use function current;
29
use function explode;
30
use function implode;
31
use function next;
32
use function preg_match;
33

34
/**
35
 * Default application error handler
36
 *
37
 * It outputs the error message and diagnostic information in one of the following formats:
38
 * JSON, XML, Plain Text or HTML based on the Accept header.
39
 * @api
40
 */
41
class ErrorHandler implements ErrorHandlerInterface
42
{
43
    protected string $defaultErrorRendererContentType = 'text/html';
44

45
    /**
46
     * @var ErrorRendererInterface|string|callable
47
     */
48
    protected $defaultErrorRenderer = HtmlErrorRenderer::class;
49

50
    /**
51
     * @var ErrorRendererInterface|string|callable
52
     */
53
    protected $logErrorRenderer = PlainTextErrorRenderer::class;
54

55
    /**
56
     * @var array<string|callable>
57
     */
58
    protected array $errorRenderers = [
59
        'application/json' => JsonErrorRenderer::class,
60
        'application/xml' => XmlErrorRenderer::class,
61
        'text/xml' => XmlErrorRenderer::class,
62
        'text/html' => HtmlErrorRenderer::class,
63
        'text/plain' => PlainTextErrorRenderer::class,
64
    ];
65

66
    protected bool $displayErrorDetails = false;
67

68
    protected bool $logErrors;
69

70
    protected bool $logErrorDetails = false;
71

72
    protected ?string $contentType = null;
73

74
    protected ?string $method = null;
75

76
    protected ServerRequestInterface $request;
77

78
    protected Throwable $exception;
79

80
    protected int $statusCode;
81

82
    protected CallableResolverInterface $callableResolver;
83

84
    protected ResponseFactoryInterface $responseFactory;
85

86
    protected LoggerInterface $logger;
87

88
    public function __construct(
×
89
        CallableResolverInterface $callableResolver,
90
        ResponseFactoryInterface $responseFactory,
91
        ?LoggerInterface $logger = null
92
    ) {
93
        $this->callableResolver = $callableResolver;
×
94
        $this->responseFactory = $responseFactory;
×
95
        $this->logger = $logger ?: $this->getDefaultLogger();
×
96
    }
97

98
    /**
99
     * Invoke error handler
100
     *
101
     * @param ServerRequestInterface $request             The most recent Request object
102
     * @param Throwable              $exception           The caught Exception object
103
     * @param bool                   $displayErrorDetails Whether or not to display the error details
104
     * @param bool                   $logErrors           Whether or not to log errors
105
     * @param bool                   $logErrorDetails     Whether or not to log error details
106
     */
107
    public function __invoke(
×
108
        ServerRequestInterface $request,
109
        Throwable $exception,
110
        bool $displayErrorDetails,
111
        bool $logErrors,
112
        bool $logErrorDetails
113
    ): ResponseInterface {
114
        $this->displayErrorDetails = $displayErrorDetails;
×
115
        $this->logErrors = $logErrors;
×
116
        $this->logErrorDetails = $logErrorDetails;
×
117
        $this->request = $request;
×
118
        $this->exception = $exception;
×
119
        $this->method = $request->getMethod();
×
120
        $this->statusCode = $this->determineStatusCode();
×
121
        if ($this->contentType === null) {
×
122
            $this->contentType = $this->determineContentType($request);
×
123
        }
124

125
        if ($logErrors) {
×
126
            $this->writeToErrorLog();
×
127
        }
128

129
        return $this->respond();
×
130
    }
131

132
    /**
133
     * Force the content type for all error handler responses.
134
     *
135
     * @param string|null $contentType The content type
136
     */
137
    public function forceContentType(?string $contentType): void
×
138
    {
139
        $this->contentType = $contentType;
×
140
    }
141

142
    protected function determineStatusCode(): int
×
143
    {
144
        if ($this->method === 'OPTIONS') {
×
145
            return 200;
×
146
        }
147

148
        if ($this->exception instanceof HttpException) {
×
149
            return $this->exception->getCode();
×
150
        }
151

152
        return 500;
×
153
    }
154

155
    /**
156
     * Determine which content type we know about is wanted to use Accept header
157
     *
158
     * Note: This method is a bare-bones implementation designed specifically for
159
     * Slim's error handling requirements. Consider a fully-feature solution such
160
     * as willdurand/negotiation for any other situation.
161
     */
162
    protected function determineContentType(ServerRequestInterface $request): ?string
×
163
    {
164
        $acceptHeader = $request->getHeaderLine('Accept');
×
165
        $selectedContentTypes = array_intersect(
×
166
            explode(',', $acceptHeader),
×
167
            array_keys($this->errorRenderers)
×
168
        );
×
169
        $count = count($selectedContentTypes);
×
170

171
        if ($count) {
×
172
            $current = current($selectedContentTypes);
×
173

174
            /**
175
             * Ensure other supported content types take precedence over text/plain
176
             * when multiple content types are provided via Accept header.
177
             */
178
            if ($current === 'text/plain' && $count > 1) {
×
179
                $next = next($selectedContentTypes);
×
180
                if (is_string($next)) {
×
181
                    return $next;
×
182
                }
183
            }
184

185
            if (is_string($current)) {
×
186
                return $current;
×
187
            }
188
        }
189

190
        if (preg_match('/\+(json|xml)/', $acceptHeader, $matches)) {
×
191
            $mediaType = 'application/' . $matches[1];
×
192
            if (array_key_exists($mediaType, $this->errorRenderers)) {
×
193
                return $mediaType;
×
194
            }
195
        }
196

197
        return null;
×
198
    }
199

200
    /**
201
     * Determine which renderer to use based on content type
202
     *
203
     * @throws RuntimeException
204
     */
205
    protected function determineRenderer(): callable
×
206
    {
207
        if ($this->contentType !== null && array_key_exists($this->contentType, $this->errorRenderers)) {
×
208
            $renderer = $this->errorRenderers[$this->contentType];
×
209
        } else {
210
            $renderer = $this->defaultErrorRenderer;
×
211
        }
212

213
        return $this->callableResolver->resolve($renderer);
×
214
    }
215

216
    /**
217
     * Register an error renderer for a specific content-type
218
     *
219
     * @param string  $contentType  The content-type this renderer should be registered to
220
     * @param ErrorRendererInterface|string|callable $errorRenderer The error renderer
221
     */
222
    public function registerErrorRenderer(string $contentType, $errorRenderer): void
×
223
    {
224
        $this->errorRenderers[$contentType] = $errorRenderer;
×
225
    }
226

227
    /**
228
     * Set the default error renderer
229
     *
230
     * @param string                                 $contentType   The content type of the default error renderer
231
     * @param ErrorRendererInterface|string|callable $errorRenderer The default error renderer
232
     */
233
    public function setDefaultErrorRenderer(string $contentType, $errorRenderer): void
×
234
    {
235
        $this->defaultErrorRendererContentType = $contentType;
×
236
        $this->defaultErrorRenderer = $errorRenderer;
×
237
    }
238

239
    /**
240
     * Set the renderer for the error logger
241
     *
242
     * @param ErrorRendererInterface|string|callable $logErrorRenderer
243
     */
244
    public function setLogErrorRenderer($logErrorRenderer): void
×
245
    {
246
        $this->logErrorRenderer = $logErrorRenderer;
×
247
    }
248

249
    /**
250
     * Write to the error log if $logErrors has been set to true
251
     */
252
    protected function writeToErrorLog(): void
×
253
    {
254
        $renderer = $this->callableResolver->resolve($this->logErrorRenderer);
×
255
        $error = $renderer($this->exception, $this->logErrorDetails);
×
256
        if ($this->logErrorRenderer === PlainTextErrorRenderer::class && !$this->displayErrorDetails) {
×
257
            $error .= "\nTips: To display error details in HTTP response ";
×
258
            $error .= 'set "displayErrorDetails" to true in the ErrorHandler constructor.';
×
259
        }
260
        $this->logError($error);
×
261
    }
262

263
    /**
264
     * Wraps the error_log function so that this can be easily tested
265
     */
266
    protected function logError(string $error): void
×
267
    {
268
        $this->logger->error($error);
×
269
    }
270

271
    /**
272
     * Returns a default logger implementation.
273
     */
274
    protected function getDefaultLogger(): LoggerInterface
×
275
    {
276
        return new Logger();
×
277
    }
278

279
    protected function respond(): ResponseInterface
×
280
    {
281
        $response = $this->responseFactory->createResponse($this->statusCode);
×
282
        if ($this->contentType !== null && array_key_exists($this->contentType, $this->errorRenderers)) {
×
283
            $response = $response->withHeader('Content-type', $this->contentType);
×
284
        } else {
285
            $response = $response->withHeader('Content-type', $this->defaultErrorRendererContentType);
×
286
        }
287

288
        if ($this->exception instanceof HttpMethodNotAllowedException) {
×
289
            $allowedMethods = implode(', ', $this->exception->getAllowedMethods());
×
290
            $response = $response->withHeader('Allow', $allowedMethods);
×
291
        }
292

293
        $renderer = $this->determineRenderer();
×
294
        $body = call_user_func($renderer, $this->exception, $this->displayErrorDetails);
×
295
        if ($body !== false) {
×
296
            /** @var string $body */
297
            $response->getBody()->write($body);
×
298
        }
299

300
        return $response;
×
301
    }
302
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc