Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
Renderer
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
4 / 4
14
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 render
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
8
 createEngine
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 isJsonOnlyRequest
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\View\Boiler\Error;
6
7use Celema\Boiler\Engine;
8use Celema\Core\Error\Renderer as RendererInterface;
9use Celema\Core\Exception\HttpError;
10use Cosray\View\Boiler\Renderer as ViewRenderer;
11use Override;
12use Psr\Http\Message\ResponseFactoryInterface as ResponseFactory;
13use Psr\Http\Message\ResponseInterface as Response;
14use Psr\Http\Message\ServerRequestInterface as Request;
15use Throwable;
16
17/**
18 * @phpstan-import-type DirsInput from \Celema\Boiler\Engine
19 */
20final class Renderer implements RendererInterface
21{
22    /**
23     * @param non-empty-string $template
24     * @param DirsInput $dirs
25     * @param array<string, mixed> $context
26     * @param list<class-string> $trusted
27     */
28    public function __construct(
29        private string $template,
30        private string|array $dirs,
31        private array $context = [],
32        private array $trusted = [],
33        private bool $autoescape = true,
34    ) {}
35
36    #[Override]
37    public function render(
38        Throwable $exception,
39        ResponseFactory $factory,
40        Request $request,
41        bool $debug,
42    ): Response {
43        $payload = null;
44
45        if ($exception instanceof HttpError) {
46            $payload = $exception->payload();
47            $request = $exception->request() ?: $request;
48        }
49        $dirs = $this->dirs;
50        $engine = $this->createEngine($dirs);
51
52        $code = (int) $exception->getCode();
53        $status = $code < 100 || $code > 599 ? 500 : $code;
54        $response = $factory->createResponse($status);
55
56        if ($this->isJsonOnlyRequest($request)) {
57            $response = $response->withHeader('Content-Type', 'application/json');
58            $json = json_encode(
59                $payload ?? [
60                    'status' => $status,
61                    // Only HTTP errors carry a message meant for clients.
62                    'message' => $exception instanceof HttpError
63                        ? $exception->getMessage()
64                        : $response->getReasonPhrase(),
65                ],
66            );
67            $response->getBody()->write($json === false ? '{}' : $json);
68        } else {
69            $response->getBody()->write($engine->render($this->template, array_merge($this->context, [
70                'request' => $request,
71                'exception' => $exception,
72                'payload' => $payload,
73            ])));
74        }
75
76        return $response;
77    }
78
79    /** @param DirsInput $dirs */
80    private function createEngine(string|array $dirs): Engine
81    {
82        $trusted = [...ViewRenderer::TRUSTED, ...$this->trusted];
83
84        return $this->autoescape
85            ? Engine::create($dirs, trusted: $trusted)
86            : Engine::unescaped($dirs, trusted: $trusted);
87    }
88
89    private function isJsonOnlyRequest(Request $request): bool
90    {
91        // Split the Accept header into individual media types
92        $acceptedTypes = array_map('trim', explode(',', $request->getHeaderLine('Accept')));
93
94        foreach ($acceptedTypes as $type) {
95            // Remove quality values if present
96            $mediaType = trim(explode(';', $type)[0]);
97
98            if ($mediaType !== 'application/json') {
99                return false;
100            }
101        }
102
103        return true;
104    }
105}