Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
108 / 108
97.50% covered (success)
97.50%
78 / 80
39.68% covered (danger)
39.68%
50 / 126
93.10% covered (success)
93.10%
27 / 29
CRAP
0.00% covered (danger)
0.00%
0 / 1
Response
100.00% covered (success)
100.00%
108 / 108
97.50% covered (success)
97.50%
78 / 80
39.68% covered (danger)
39.68%
50 / 126
100.00% covered (success)
100.00%
29 / 29
744.18
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%
7 / 7
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 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%
28 / 28
95.00% covered (success)
95.00%
19 / 20
12.35% covered (danger)
12.35%
10 / 81
100.00% covered (success)
100.00%
1 / 1
146.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 Celema\Core;
6
7use Celema\Core\Exception\FileNotFoundException;
8use Celema\Core\Exception\RuntimeException;
9use Celema\Core\Factory\Factory;
10use Celema\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    /** @return array<array-key, string[]> */
93    public function headers(): array
94    {
95        return $this->psrResponse->getHeaders();
96    }
97
98    /** @return string[] */
99    public function getHeader(string $name): array
100    {
101        return $this->psrResponse->getHeader($name);
102    }
103
104    public function hasHeader(string $name): bool
105    {
106        return $this->psrResponse->hasHeader($name);
107    }
108
109    public function body(mixed $body): static
110    {
111        if ($body instanceof PsrStream) {
112            return $this->setStreamBody($body);
113        }
114
115        if (is_string($body) || $body instanceof Stringable) {
116            return $this->setStringBody((string) $body);
117        }
118
119        if (is_resource($body)) {
120            return $this->setResourceBody($body);
121        }
122
123        throw new RuntimeException(
124            'Only strings, Stringable or resources are allowed to create streams!',
125        );
126    }
127
128    protected function setStringBody(string $body): static
129    {
130        if ($this->streamFactory) {
131            return $this->setStreamBody($this->streamFactory->createStream($body));
132        }
133
134        $stream = $this->psrResponse->getBody();
135
136        if (!$stream->isWritable()) {
137            throw new RuntimeException('The response body is not writable!');
138        }
139
140        // PSR-7 streams cannot be truncated, so writing over existing content
141        // would leave its tail behind.
142        if ($stream->getSize() !== 0) {
143            throw new RuntimeException('Replacing a non-empty response body requires a stream factory');
144        }
145
146        $stream->write($body);
147
148        return $this;
149    }
150
151    protected function setStreamBody(PsrStream $body): static
152    {
153        $this->psrResponse = $this->psrResponse->withBody($body);
154
155        return $this;
156    }
157
158    /**
159     * @param resource $body
160     */
161    protected function setResourceBody(mixed $body): static
162    {
163        if ($this->streamFactory) {
164            $this->psrResponse = $this->psrResponse->withBody($this->streamFactory->createStreamFromResource(
165                $body,
166            ));
167
168            return $this;
169        }
170
171        throw new RuntimeException('No factory available to create stream from resource!');
172    }
173
174    public function getBody(): PsrStream
175    {
176        return $this->psrResponse->getBody();
177    }
178
179    public function write(string $content): static
180    {
181        $this->psrResponse->getBody()->write($content);
182
183        return $this;
184    }
185
186    public function redirect(string $url, int $code = 302): static
187    {
188        $this->header('Location', $url);
189        $this->status($code);
190
191        return $this;
192    }
193
194    public function withContentType(
195        string $contentType,
196        mixed $body = null,
197        int $code = 200,
198        string $reasonPhrase = '',
199    ): static {
200        $this->psrResponse = $this->psrResponse
201            ->withStatus($code, $reasonPhrase)
202            ->withAddedHeader('Content-Type', $contentType);
203
204        if ($body !== null) {
205            $this->body($body);
206        }
207
208        return $this;
209    }
210
211    /**
212     * @param null|PsrStream|resource|string $body
213     */
214    public function html(mixed $body = null, int $code = 200, string $reasonPhrase = ''): static
215    {
216        return $this->withContentType('text/html', $body, $code, $reasonPhrase);
217    }
218
219    /**
220     * @param null|PsrStream|resource|string $body
221     */
222    public function text(mixed $body = null, int $code = 200, string $reasonPhrase = ''): static
223    {
224        return $this->withContentType('text/plain', $body, $code, $reasonPhrase);
225    }
226
227    public function json(
228        mixed $data,
229        int $code = 200,
230        string $reasonPhrase = '',
231        int $flags = JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR,
232    ): static {
233        if ($data instanceof Traversable) {
234            $body = json_encode(iterator_to_array($data), $flags);
235        } else {
236            $body = json_encode($data, $flags);
237        }
238
239        return $this->withContentType('application/json', $body, $code, $reasonPhrase);
240    }
241
242    public function file(
243        string $file,
244        int $code = 200,
245        string $reasonPhrase = '',
246    ): static {
247        $this->validateFile($file);
248
249        if ($this->streamFactory === null) {
250            throw new RuntimeException('No factory available to create stream from file!');
251        }
252
253        // Opened first, so an unreadable file fails here instead of in finfo.
254        $stream = $this->streamFactory->createStreamFromFile($file, 'rb');
255        $finfo = new finfo(FILEINFO_MIME_TYPE);
256        $contentType = match (strtolower(pathinfo($file, PATHINFO_EXTENSION))) {
257            'js' => 'text/javascript',
258            'css' => 'text/css',
259            'json' => 'application/json',
260            'html' => 'text/html',
261            'md', 'markdown' => 'text/markdown',
262            'csv' => 'text/csv',
263            'xml' => 'application/xml',
264            'xhtml' => 'application/xhtml+xml',
265            default => $finfo->file($file),
266        };
267
268        $finfo = new finfo(FILEINFO_MIME_ENCODING);
269        $encoding = $finfo->file($file);
270
271        $this->psrResponse = $this->psrResponse
272            ->withStatus($code, $reasonPhrase)
273            ->withAddedHeader('Content-Type', $contentType === false ? 'application/octet-stream' : $contentType)
274            ->withBody($stream);
275
276        if ($encoding !== false) {
277            $this->psrResponse = $this->psrResponse->withAddedHeader('Content-Transfer-Encoding', $encoding);
278        }
279
280        $size = $stream->getSize();
281
282        if (!($size === null)) {
283            $this->psrResponse = $this->psrResponse->withAddedHeader('Content-Length', (string) $size);
284        }
285
286        return $this;
287    }
288
289    public function download(
290        string $file,
291        string $newName = '',
292        int $code = 200,
293        string $reasonPhrase = '',
294    ): static {
295        $response = $this->file($file, $code, $reasonPhrase);
296        $response->header(
297            'Content-Disposition',
298            'attachment; filename="' . ($newName ?: basename($file)) . '"',
299        );
300
301        return $response;
302    }
303
304    public function sendfile(
305        string $file,
306        int $code = 200,
307        string $reasonPhrase = '',
308    ): static {
309        $this->validateFile($file);
310        $server = strtolower($_SERVER['SERVER_SOFTWARE'] ?? '');
311        $this->psrResponse = $this->psrResponse->withStatus($code, $reasonPhrase);
312
313        if (str_contains($server, 'nginx')) {
314            $this->psrResponse = $this->psrResponse->withAddedHeader('X-Accel-Redirect', $file);
315        } else {
316            $this->psrResponse = $this->psrResponse->withAddedHeader('X-Sendfile', $file);
317        }
318
319        return $this;
320    }
321
322    protected function validateFile(string $file): void
323    {
324        if (!is_file($file)) {
325            throw new FileNotFoundException('File not found: ' . $file);
326        }
327    }
328}