Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
106 / 106
98.61% covered (success)
98.61%
71 / 72
76.56% covered (warning)
76.56%
49 / 64
96.55% covered (success)
96.55%
28 / 29
CRAP
0.00% covered (danger)
0.00%
0 / 1
Response
100.00% covered (success)
100.00%
106 / 106
98.61% covered (success)
98.61%
71 / 72
76.56% covered (warning)
76.56%
49 / 64
100.00% covered (success)
100.00%
29 / 29
89.16
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
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 create
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 unwrap
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 wrap
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 status
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getStatusCode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getReasonPhrase
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getProtocolVersion
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 protocolVersion
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 header
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeHeader
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 headers
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getHeader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasHeader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 body
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
9 / 9
71.43% covered (warning)
71.43%
5 / 7
100.00% covered (success)
100.00%
1 / 1
5.58
 setStringBody
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 setStreamBody
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setResourceBody
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getBody
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 write
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 redirect
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 withContentType
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
3 / 3
50.00% covered (danger)
50.00%
1 / 2
100.00% covered (success)
100.00%
1 / 1
2.50
 html
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 text
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 json
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 file
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
14 / 14
50.00% covered (danger)
50.00%
10 / 20
100.00% covered (success)
100.00%
1 / 1
30.00
 download
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 sendfile
100.00% covered (success)
100.00%
7 / 7
85.71% covered (warning)
85.71%
6 / 7
50.00% covered (danger)
50.00%
2 / 4
100.00% covered (success)
100.00%
1 / 1
2.50
 validateFile
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Celemas\Core;
6
7use Celemas\Core\Exception\FileNotFoundException;
8use Celemas\Core\Exception\RuntimeException;
9use Celemas\Core\Factory\Factory;
10use Celemas\Router\ResponseWrapper;
11use finfo;
12use Override;
13use Psr\Http\Message\ResponseInterface as PsrResponse;
14use Psr\Http\Message\StreamFactoryInterface as PsrStreamFactory;
15use Psr\Http\Message\StreamInterface as PsrStream;
16use Stringable;
17use Traversable;
18
19/** @api */
20class Response implements ResponseWrapper
21{
22    public function __construct(
23        protected PsrResponse $psrResponse,
24        protected readonly ?PsrStreamFactory $streamFactory = null,
25    ) {}
26
27    public static function create(Factory $factory): self
28    {
29        return new self($factory->response(), $factory->streamFactory());
30    }
31
32    #[Override]
33    public function unwrap(): PsrResponse
34    {
35        return $this->psrResponse;
36    }
37
38    public function wrap(PsrResponse $response): static
39    {
40        $this->psrResponse = $response;
41
42        return $this;
43    }
44
45    public function status(int $statusCode, string $reasonPhrase = ''): static
46    {
47        if ($reasonPhrase === '') {
48            $this->psrResponse = $this->psrResponse->withStatus($statusCode);
49        } else {
50            $this->psrResponse = $this->psrResponse->withStatus($statusCode, $reasonPhrase);
51        }
52
53        return $this;
54    }
55
56    public function getStatusCode(): int
57    {
58        return $this->psrResponse->getStatusCode();
59    }
60
61    public function getReasonPhrase(): string
62    {
63        return $this->psrResponse->getReasonPhrase();
64    }
65
66    public function getProtocolVersion(): string
67    {
68        return $this->psrResponse->getProtocolVersion();
69    }
70
71    public function protocolVersion(string $protocol): static
72    {
73        $this->psrResponse = $this->psrResponse->withProtocolVersion($protocol);
74
75        return $this;
76    }
77
78    public function header(string $name, string $value): static
79    {
80        $this->psrResponse = $this->psrResponse->withAddedHeader($name, $value);
81
82        return $this;
83    }
84
85    public function removeHeader(string $name): static
86    {
87        $this->psrResponse = $this->psrResponse->withoutHeader($name);
88
89        return $this;
90    }
91
92    public function headers(): array
93    {
94        return $this->psrResponse->getHeaders();
95    }
96
97    public function getHeader(string $name): array
98    {
99        return $this->psrResponse->getHeader($name);
100    }
101
102    public function hasHeader(string $name): bool
103    {
104        return $this->psrResponse->hasHeader($name);
105    }
106
107    public function body(mixed $body): static
108    {
109        if ($body instanceof PsrStream) {
110            return $this->setStreamBody($body);
111        }
112
113        if (is_string($body) || $body instanceof Stringable) {
114            return $this->setStringBody((string) $body);
115        }
116
117        if (is_resource($body)) {
118            return $this->setResourceBody($body);
119        }
120
121        throw new RuntimeException(
122            'Only strings, Stringable or resources are allowed to create streams!',
123        );
124    }
125
126    protected function setStringBody(string $body): static
127    {
128        if ($this->streamFactory) {
129            $this->psrResponse = $this->psrResponse->withBody($this->streamFactory->createStream($body));
130
131            return $this;
132        }
133
134        $stream = $this->psrResponse->getBody();
135
136        if ($stream->isWritable()) {
137            $stream->rewind();
138            $stream->write($body);
139
140            return $this;
141        }
142
143        throw new RuntimeException('The response body is not writable!');
144    }
145
146    protected function setStreamBody(PsrStream $body): static
147    {
148        $this->psrResponse = $this->psrResponse->withBody($body);
149
150        return $this;
151    }
152
153    /**
154     * @param resource $body
155     */
156    protected function setResourceBody(mixed $body): static
157    {
158        if ($this->streamFactory) {
159            $this->psrResponse = $this->psrResponse->withBody($this->streamFactory->createStreamFromResource(
160                $body,
161            ));
162
163            return $this;
164        }
165
166        throw new RuntimeException('No factory available to create stream from resource!');
167    }
168
169    public function getBody(): PsrStream
170    {
171        return $this->psrResponse->getBody();
172    }
173
174    public function write(string $content): static
175    {
176        $this->psrResponse->getBody()->write($content);
177
178        return $this;
179    }
180
181    public function redirect(string $url, int $code = 302): static
182    {
183        $this->header('Location', $url);
184        $this->status($code);
185
186        return $this;
187    }
188
189    public function withContentType(
190        string $contentType,
191        mixed $body = null,
192        int $code = 200,
193        string $reasonPhrase = '',
194    ): static {
195        $this->psrResponse = $this->psrResponse
196            ->withStatus($code, $reasonPhrase)
197            ->withAddedHeader('Content-Type', $contentType);
198
199        if ($body !== null) {
200            $this->body($body);
201        }
202
203        return $this;
204    }
205
206    /**
207     * @param null|PsrStream|resource|string $body
208     */
209    public function html(mixed $body = null, int $code = 200, string $reasonPhrase = ''): static
210    {
211        return $this->withContentType('text/html', $body, $code, $reasonPhrase);
212    }
213
214    /**
215     * @param null|PsrStream|resource|string $body
216     */
217    public function text(mixed $body = null, int $code = 200, string $reasonPhrase = ''): static
218    {
219        return $this->withContentType('text/plain', $body, $code, $reasonPhrase);
220    }
221
222    public function json(
223        mixed $data,
224        int $code = 200,
225        string $reasonPhrase = '',
226        int $flags = JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR,
227    ): static {
228        if ($data instanceof Traversable) {
229            $body = json_encode(iterator_to_array($data), $flags);
230        } else {
231            $body = json_encode($data, $flags);
232        }
233
234        return $this->withContentType('application/json', $body, $code, $reasonPhrase);
235    }
236
237    public function file(
238        string $file,
239        int $code = 200,
240        string $reasonPhrase = '',
241    ): static {
242        $this->validateFile($file);
243
244        $finfo = new finfo(FILEINFO_MIME_TYPE);
245        $contentType = match (strtolower(pathinfo($file, PATHINFO_EXTENSION))) {
246            'js' => 'text/javascript',
247            'css' => 'text/css',
248            'json' => 'application/json',
249            'html' => 'text/html',
250            'md' => 'text/markdown',
251            'markdown' => 'text/markdown',
252            'csv' => 'text/csv',
253            'xml' => 'application/xml',
254            'xhtml' => 'application/xhtml+xml',
255            default => $finfo->file($file),
256        };
257
258        $finfo = new finfo(FILEINFO_MIME_ENCODING);
259        $encoding = $finfo->file($file);
260        assert(isset($this->streamFactory), 'Stream factory must be set before creating a file stream');
261        $stream = $this->streamFactory->createStreamFromFile($file, 'rb');
262
263        $this->psrResponse = $this->psrResponse
264            ->withStatus($code, $reasonPhrase)
265            ->withAddedHeader('Content-Type', $contentType)
266            ->withAddedHeader('Content-Transfer-Encoding', $encoding)
267            ->withBody($stream);
268
269        $size = $stream->getSize();
270
271        if (!($size === null)) {
272            $this->psrResponse = $this->psrResponse->withAddedHeader('Content-Length', (string) $size);
273        }
274
275        return $this;
276    }
277
278    public function download(
279        string $file,
280        string $newName = '',
281        int $code = 200,
282        string $reasonPhrase = '',
283    ): static {
284        $response = $this->file($file, $code, $reasonPhrase);
285        $response->header(
286            'Content-Disposition',
287            'attachment; filename="' . ($newName ?: basename($file)) . '"',
288        );
289
290        return $response;
291    }
292
293    public function sendfile(
294        string $file,
295        int $code = 200,
296        string $reasonPhrase = '',
297    ): static {
298        $this->validateFile($file);
299        $server = strtolower($_SERVER['SERVER_SOFTWARE'] ?? '');
300        $this->psrResponse = $this->psrResponse->withStatus($code, $reasonPhrase);
301
302        if (str_contains($server, 'nginx')) {
303            $this->psrResponse = $this->psrResponse->withAddedHeader('X-Accel-Redirect', $file);
304        } else {
305            $this->psrResponse = $this->psrResponse->withAddedHeader('X-Sendfile', $file);
306        }
307
308        return $this;
309    }
310
311    protected function validateFile(string $file): void
312    {
313        if (!is_file($file)) {
314            throw new FileNotFoundException('File not found: ' . $file);
315        }
316    }
317}