Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
220 / 220
100.00% covered (success)
100.00%
57 / 57
CRAP
100.00% covered (success)
100.00%
1 / 1
Response
100.00% covered (success)
100.00%
220 / 220
100.00% covered (success)
100.00%
57 / 57
99
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 __toString
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 getRequest
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getBody
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 setBody
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 prependBody
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 appendBody
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCookie
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCookies
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeCookie
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeCookies
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setHeader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setHeaders
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 appendHeader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeHeader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeHeaders
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCsp
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setCspReportOnly
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getCsp
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCspReportOnly
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasCsp
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasCspReportOnly
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeCsp
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 removeCspReportOnly
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getStatus
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setStatus
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setStatusCode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getStatusCode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isStatusCode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setStatusReason
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getStatusReason
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isSent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 redirect
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
 send
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 sendAll
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 sendBody
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 sendCookies
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 sendHeaders
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 negotiateCsp
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 negotiateContentType
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
 negotiateEtag
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
7
 setJson
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 setCache
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 setNoCache
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getCacheSeconds
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setAutoEtag
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 isAutoEtag
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setContentType
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 setContentLanguage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setContentEncoding
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setContentLength
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setDate
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 setEtag
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 setExpires
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 setLastModified
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 setNotModified
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setDebugCollector
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
1<?php declare(strict_types=1);
2/*
3 * This file is part of Aplus Framework HTTP Library.
4 *
5 * (c) Natan Felles <natanfelles@gmail.com>
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 */
10namespace Framework\HTTP;
11
12use DateTime;
13use DateTimeInterface;
14use DateTimeZone;
15use Framework\HTTP\Debug\HTTPCollector;
16use InvalidArgumentException;
17use JetBrains\PhpStorm\Pure;
18use JsonException;
19use LogicException;
20
21/**
22 * Class Response.
23 *
24 * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages#HTTP_Responses
25 *
26 * @package http
27 */
28class Response extends Message implements ResponseInterface
29{
30    use ResponseDownload;
31
32    protected int $cacheSeconds = 0;
33    protected bool $isSent = false;
34    protected Request $request;
35    /**
36     * HTTP Response Status Code.
37     */
38    protected int $statusCode = Status::OK;
39    /**
40     * HTTP Response Status Reason.
41     */
42    protected string $statusReason = 'OK';
43    protected ?string $sendedBody = null;
44    protected bool $inToString = false;
45    protected bool $autoEtag = false;
46    protected string $autoEtagHashAlgo = 'xxh3';
47    protected HTTPCollector $debugCollector;
48    protected CSP $csp;
49    protected CSP $cspReportOnly;
50
51    /**
52     * Response constructor.
53     *
54     * @param Request $request
55     */
56    public function __construct(Request $request)
57    {
58        $this->request = $request;
59        $this->setProtocol($this->request->getProtocol());
60    }
61
62    public function __toString() : string
63    {
64        if ($this->getHeader(Header::DATE) === null) {
65            $this->setDate(new DateTime());
66        }
67        if ($this->getHeader(Header::CONTENT_TYPE) === null) {
68            $this->setContentType('text/html');
69        }
70        if ($this->hasDownload()) {
71            $this->inToString = true;
72            $this->sendDownload();
73            $this->inToString = false;
74        }
75        return parent::__toString();
76    }
77
78    /**
79     * @return Request
80     */
81    #[Pure]
82    public function getRequest() : Request
83    {
84        return $this->request;
85    }
86
87    /**
88     * Get the Response body.
89     *
90     * @return string
91     */
92    public function getBody() : string
93    {
94        if ($this->sendedBody !== null) {
95            return $this->sendedBody;
96        }
97        $buffer = '';
98        if (\ob_get_length()) {
99            $buffer = \ob_get_contents();
100            \ob_clean();
101        }
102        return $this->body = parent::getBody() . $buffer;
103    }
104
105    /**
106     * Set the Response body.
107     *
108     * @param string $body
109     *
110     * @return static
111     */
112    public function setBody(string $body) : static
113    {
114        if (\ob_get_length()) {
115            \ob_clean();
116        }
117        return parent::setBody($body);
118    }
119
120    /**
121     * Prepend a string to the body.
122     *
123     * @param string $content
124     *
125     * @return static
126     */
127    public function prependBody(string $content) : static
128    {
129        return parent::setBody($content . $this->getBody());
130    }
131
132    /**
133     * Append a string to the body.
134     *
135     * @param string $content
136     *
137     * @return static
138     */
139    public function appendBody(string $content) : static
140    {
141        return parent::setBody($this->getBody() . $content);
142    }
143
144    public function setCookie(Cookie $cookie) : static
145    {
146        return parent::setCookie($cookie);
147    }
148
149    public function setCookies(array $cookies) : static
150    {
151        return parent::setCookies($cookies);
152    }
153
154    public function removeCookie(string $name) : static
155    {
156        return parent::removeCookie($name);
157    }
158
159    public function removeCookies(array $names) : static
160    {
161        return parent::removeCookies($names);
162    }
163
164    public function setHeader(string $name, string $value) : static
165    {
166        return parent::setHeader($name, $value);
167    }
168
169    public function setHeaders(array $headers) : static
170    {
171        return parent::setHeaders($headers);
172    }
173
174    public function appendHeader(string $name, string $value) : static
175    {
176        return parent::appendHeader($name, $value);
177    }
178
179    public function removeHeader(string $name) : static
180    {
181        return parent::removeHeader($name);
182    }
183
184    public function removeHeaders() : static
185    {
186        return parent::removeHeaders();
187    }
188
189    /**
190     * Set the Content-Security-Policy instance.
191     *
192     * @param CSP $csp
193     *
194     * @return static
195     */
196    public function setCsp(CSP $csp) : static
197    {
198        $this->csp = $csp;
199        return $this;
200    }
201
202    /**
203     * Set the Content-Security-Policy-Report-Only instance.
204     *
205     * @param CSP $csp
206     *
207     * @return static
208     */
209    public function setCspReportOnly(CSP $csp) : static
210    {
211        $this->cspReportOnly = $csp;
212        return $this;
213    }
214
215    /**
216     * Get the Content-Security-Policy instance or null.
217     *
218     * @return CSP|null
219     */
220    public function getCsp() : CSP | null
221    {
222        return $this->csp ?? null;
223    }
224
225    /**
226     * Get the Content-Security-Policy-Report-Only instance or null.
227     *
228     * @return CSP|null
229     */
230    public function getCspReportOnly() : CSP | null
231    {
232        return $this->cspReportOnly ?? null;
233    }
234
235    /**
236     * Tells if the Content-Security-Policy instance has been set.
237     *
238     * @return bool
239     */
240    public function hasCsp() : bool
241    {
242        return isset($this->csp);
243    }
244
245    /**
246     * Tells if the Content-Security-Policy-Report-Only instance has been set.
247     *
248     * @return bool
249     */
250    public function hasCspReportOnly() : bool
251    {
252        return isset($this->cspReportOnly);
253    }
254
255    /**
256     * Remove the Content-Security-Policy instance.
257     *
258     * @return static
259     */
260    public function removeCsp() : static
261    {
262        unset($this->csp);
263        return $this;
264    }
265
266    /**
267     * Remove the Content-Security-Policy-Report-Only instance.
268     *
269     * @return static
270     */
271    public function removeCspReportOnly() : static
272    {
273        unset($this->cspReportOnly);
274        return $this;
275    }
276
277    /**
278     * Get the status line without the protocol part.
279     *
280     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages#status_line
281     *
282     * @return string
283     */
284    #[Pure]
285    public function getStatus() : string
286    {
287        return "{$this->statusCode} {$this->statusReason}";
288    }
289
290    /**
291     * Set the status part in the start-line.
292     *
293     * @param int $code
294     * @param string|null $reason
295     *
296     * @throws InvalidArgumentException if status code is invalid
297     * @throws LogicException is status code is unknown and a reason is not set
298     *
299     * @return static
300     */
301    public function setStatus(int $code, string $reason = null) : static
302    {
303        $this->setStatusCode($code);
304        $reason ?: $reason = Status::getReason($code);
305        $this->setStatusReason($reason);
306        return $this;
307    }
308
309    /**
310     * Set the status code.
311     *
312     * @param int $code
313     *
314     * @throws InvalidArgumentException if status code is invalid
315     *
316     * @return static
317     */
318    public function setStatusCode(int $code) : static
319    {
320        return parent::setStatusCode($code);
321    }
322
323    /**
324     * Get the status code.
325     *
326     * @return int
327     */
328    #[Pure]
329    public function getStatusCode() : int
330    {
331        return parent::getStatusCode();
332    }
333
334    /**
335     * @param int $code
336     *
337     * @throws InvalidArgumentException if status code is invalid
338     *
339     * @return bool
340     */
341    public function isStatusCode(int $code) : bool
342    {
343        return parent::isStatusCode($code);
344    }
345
346    /**
347     * Set a custom status reason.
348     *
349     * @param string $reason
350     *
351     * @return static
352     */
353    public function setStatusReason(string $reason) : static
354    {
355        $this->statusReason = $reason;
356        return $this;
357    }
358
359    /**
360     * Get the status reason.
361     *
362     * @return string
363     */
364    #[Pure]
365    public function getStatusReason() : string
366    {
367        return $this->statusReason;
368    }
369
370    /**
371     * Say if the response was sent.
372     *
373     * @return bool
374     */
375    #[Pure]
376    public function isSent() : bool
377    {
378        return $this->isSent;
379    }
380
381    /**
382     * Sets the HTTP Redirect Response with data accessible in the next HTTP Request.
383     *
384     * @param string $location Location Header value
385     * @param array|mixed[] $data Session data available on next Request
386     * @param int|null $code HTTP Redirect status code. Leave null to determine
387     * based on the current HTTP method.
388     *
389     * @see http://en.wikipedia.org/wiki/Post/Redirect/Get
390     * @see Request::getRedirectData()
391     *
392     * @throws InvalidArgumentException for invalid Redirection code
393     * @throws LogicException if PHP Session is not active to set redirect data
394     *
395     * @return static
396     */
397    public function redirect(string $location, array $data = [], int $code = null) : static
398    {
399        if ($code === null) {
400            $code = $this->request->getMethod() === Method::GET
401                ? Status::TEMPORARY_REDIRECT
402                : Status::SEE_OTHER;
403        } elseif ($code < 300 || $code > 399) {
404            throw new InvalidArgumentException("Invalid Redirection code: {$code}");
405        }
406        $this->setStatus($code);
407        $this->setHeader(ResponseHeader::LOCATION, $location);
408        if ($data) {
409            if (\session_status() !== \PHP_SESSION_ACTIVE) {
410                throw new LogicException('Session must be active to set redirect data');
411            }
412            $_SESSION['$']['redirect_data'] = $data;
413        }
414        return $this;
415    }
416
417    /**
418     * Send the Response headers, cookies and body to the output.
419     *
420     * @throws LogicException if Response is already sent
421     */
422    public function send() : void
423    {
424        if (isset($this->debugCollector)) {
425            $start = \microtime(true);
426            $this->sendAll();
427            $end = \microtime(true);
428            $this->debugCollector->addData([
429                'start' => $start,
430                'end' => $end,
431                'message' => 'response',
432                'type' => 'send',
433            ]);
434            return;
435        }
436        $this->sendAll();
437    }
438
439    protected function sendAll() : void
440    {
441        if ($this->isSent) {
442            throw new LogicException('Response is already sent');
443        }
444        $this->sendHeaders();
445        $this->sendCookies();
446        $this->hasDownload() ? $this->sendDownload() : $this->sendBody();
447        $this->isSent = true;
448    }
449
450    protected function sendBody() : void
451    {
452        echo $this->sendedBody = $this->getBody();
453        $this->body = '';
454    }
455
456    protected function sendCookies() : void
457    {
458        foreach ($this->cookies as $cookie) {
459            $cookie->send();
460        }
461    }
462
463    /**
464     * Send the HTTP headers to the output.
465     *
466     * @throws LogicException if headers are already sent
467     */
468    protected function sendHeaders() : void
469    {
470        if (\headers_sent()) {
471            throw new LogicException('Headers are already sent');
472        }
473        if ($this->getHeader(Header::DATE) === null) {
474            $this->setDate(new DateTime());
475        }
476        if ($this->getHeader(Header::CONTENT_TYPE) === null) {
477            $this->negotiateContentType();
478        }
479        if ($this->isAutoEtag() && ! $this->hasDownload()) {
480            $this->negotiateEtag();
481        }
482        $this->negotiateCsp();
483        \header($this->getStartLine());
484        foreach ($this->getHeaderLines() as $line) {
485            \header($line);
486        }
487    }
488
489    /**
490     * Set the Content-Security-Policy and Content-Security-Policy-Report-Only
491     * headers if the CSP classes are set and the response has not downloads.
492     *
493     * @return void
494     */
495    protected function negotiateCsp() : void
496    {
497        $csp = $this->getCsp();
498        if ($csp && ! $this->hasDownload()) {
499            $this->setHeader(
500                ResponseHeader::CONTENT_SECURITY_POLICY,
501                $csp->render()
502            );
503        }
504        $csp = $this->getCspReportOnly();
505        if ($csp && ! $this->hasDownload()) {
506            $this->setHeader(
507                ResponseHeader::CONTENT_SECURITY_POLICY_REPORT_ONLY,
508                $csp->render()
509            );
510        }
511    }
512
513    /**
514     * Negotiates the Content-Type header, setting the MIME type "text/html" if
515     * the response body is not empty.
516     *
517     * If the response body is empty, it checks servers to set the header to an
518     * empty value, which causes the server to remove the Content-Type header,
519     * and it will not appear to the client from the request.
520     *
521     * The header will also not be set on the PHP Development Server when the
522     * body is empty.
523     *
524     * This prevents the Content-Type from appearing without it being needed in,
525     * for example, REST API responses.
526     *
527     * @see https://stackoverflow.com/a/21029402/6027968
528     */
529    protected function negotiateContentType() : void
530    {
531        if ($this->getBody() !== '') {
532            $this->setContentType('text/html');
533            return;
534        }
535        $software = (string) $this->getRequest()->getServer('SERVER_SOFTWARE');
536        $software = \strtolower($software);
537        // These servers remove headers if they are set to an empty value:
538        $servers = [
539            'apache',
540            'lighttpd',
541            'nginx',
542        ];
543        foreach ($servers as $server) {
544            if (\str_contains($software, $server)) {
545                $this->setHeader(Header::CONTENT_TYPE, '');
546                return;
547            }
548        }
549        // Prevent PHP Development Server from setting the default Content-Type:
550        if (\str_contains($software, 'php')) {
551            \ini_set('default_mimetype', '');
552        }
553    }
554
555    /**
556     * Set the ETag header, based on the Response body, and start the
557     * negotiation.
558     *
559     * - Empty the body and set a status 304 (Not Modified) if the Request
560     * If-None-Match header has the same value of the generated ETag, on GET and
561     * HEAD requests.
562     *
563     * - Empty the body and set a status 412 (Precondition Failed) if the Request
564     * If-Match header is set and has not the same value of the generated ETag,
565     * on non-GET or non-HEAD requests.
566     *
567     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag
568     */
569    protected function negotiateEtag() : void
570    {
571        // Content-Length is required by Firefox,
572        // otherwise it does not send the If-None-Match header
573        $this->setContentLength(\strlen($this->getBody()));
574        $etag = \hash($this->autoEtagHashAlgo, $this->getBody());
575        $this->setEtag($etag);
576        $etag = '"' . $etag . '"';
577        // Cache of unchanged resources:
578        $ifNoneMatch = $this->getRequest()->getHeader(RequestHeader::IF_NONE_MATCH);
579        if ($ifNoneMatch !== null
580            && ($ifNoneMatch === $etag || $ifNoneMatch === 'W/' . $etag)
581            && \in_array(
582                $this->getRequest()->getMethod(),
583                [Method::GET, Method::HEAD],
584                true
585            )
586        ) {
587            $this->setNotModified();
588            $this->setBody('');
589            return;
590        }
591        // Avoid mid-air collisions:
592        $ifMatch = $this->getRequest()->getHeader(RequestHeader::IF_MATCH);
593        if ($ifMatch !== null && $ifMatch !== $etag) {
594            $this->setBody('');
595            $this->setStatus(Status::PRECONDITION_FAILED);
596        }
597    }
598
599    /**
600     * Set Response body and Content-Type as JSON.
601     *
602     * @param mixed $data The data being encoded. Can be any type except
603     * a resource.
604     * @param int|null $flags <p>
605     * Bitmask consisting of
606     * {@see \JSON_HEX_QUOT}<br/>
607     * {@see \JSON_HEX_TAG}<br/>
608     * {@see \JSON_HEX_AMP}<br/>
609     * {@see \JSON_HEX_APOS}<br/>
610     * {@see \JSON_NUMERIC_CHECK}<br/>
611     * {@see \JSON_PRETTY_PRINT}<br/>
612     * {@see \JSON_UNESCAPED_SLASHES}<br/>
613     * {@see \JSON_FORCE_OBJECT}<br/>
614     * {@see \JSON_UNESCAPED_UNICODE}<br/>
615     * {@see \JSON_THROW_ON_ERROR}<br/>
616     * The behaviour of these constants is described on the JSON constants page.
617     * </p>
618     * <p>Default is <b>JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE</b>
619     * when null. Set 0 to do not use none.</p>
620     * @param int<1,max> $depth Set the maximum depth. Must be greater than zero.
621     *
622     * @see https://www.php.net/manual/en/function.json-encode.php
623     * @see https://www.php.net/manual/en/json.constants.php
624     *
625     * @throws JsonException if json_encode() fails
626     *
627     * @return static
628     */
629    public function setJson(mixed $data, int $flags = null, int $depth = 512) : static
630    {
631        if ($flags === null) {
632            $flags = \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE;
633        }
634        $data = \json_encode($data, $flags | \JSON_THROW_ON_ERROR, $depth);
635        $this->setContentType('application/json');
636        $this->setBody($data);
637        return $this;
638    }
639
640    /**
641     * Set the Cache-Control header.
642     *
643     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
644     * @see https://stackoverflow.com/a/3492459/6027968
645     *
646     * @param int $seconds
647     * @param bool $public
648     *
649     * @return static
650     */
651    public function setCache(int $seconds, bool $public = false) : static
652    {
653        $date = new DateTime();
654        $date->modify('+' . $seconds . ' seconds');
655        $this->setExpires($date);
656        $this->setHeader(
657            Header::CACHE_CONTROL,
658            ($public ? 'public' : 'private') . ', max-age=' . $seconds
659        );
660        $this->cacheSeconds = $seconds;
661        return $this;
662    }
663
664    /**
665     * Clear the browser cache.
666     *
667     * @return static
668     */
669    public function setNoCache() : static
670    {
671        $this->setHeader(
672            Header::CACHE_CONTROL,
673            'no-cache, no-store, max-age=0'
674        );
675        $this->cacheSeconds = 0;
676        return $this;
677    }
678
679    /**
680     * Get the number of seconds the cache is active.
681     *
682     * @return int
683     */
684    #[Pure]
685    public function getCacheSeconds() : int
686    {
687        return $this->cacheSeconds;
688    }
689
690    /**
691     * Enable or disable the capability of auto-add the ETag header and
692     * negotiate the response with it.
693     *
694     * @param bool $active
695     * @param string|null $hashAlgo
696     *
697     * @see Response::negotiateEtag()
698     *
699     * @return static
700     */
701    public function setAutoEtag(bool $active = true, string $hashAlgo = null) : static
702    {
703        $this->autoEtag = $active;
704        if ($hashAlgo !== null) {
705            $this->autoEtagHashAlgo = $hashAlgo;
706        }
707        return $this;
708    }
709
710    /**
711     * @return bool
712     */
713    public function isAutoEtag() : bool
714    {
715        return $this->autoEtag;
716    }
717
718    /**
719     * Set the Content-Type header.
720     *
721     * @param string $mime
722     * @param string $charset
723     *
724     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type
725     *
726     * @return static
727     */
728    public function setContentType(string $mime, string $charset = 'UTF-8') : static
729    {
730        $this->setHeader(
731            Header::CONTENT_TYPE,
732            $mime . ($charset ? '; charset=' . $charset : '')
733        );
734        return $this;
735    }
736
737    /**
738     * Set the Content-Language header.
739     *
740     * @param string $language
741     *
742     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Language
743     *
744     * @return static
745     */
746    public function setContentLanguage(string $language) : static
747    {
748        $this->setHeader(Header::CONTENT_LANGUAGE, $language);
749        return $this;
750    }
751
752    /**
753     * Set the Content-Encoding header.
754     *
755     * @param string $encoding
756     *
757     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding
758     *
759     * @return static
760     */
761    public function setContentEncoding(string $encoding) : static
762    {
763        $this->setHeader(Header::CONTENT_ENCODING, $encoding);
764        return $this;
765    }
766
767    /**
768     * Set the Content-Length header.
769     *
770     * @param int $length
771     *
772     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Length
773     *
774     * @return static
775     */
776    public function setContentLength(int $length) : static
777    {
778        $this->setHeader(Header::CONTENT_LENGTH, (string) $length);
779        return $this;
780    }
781
782    /**
783     * Set the Date header.
784     *
785     * @param DateTime $datetime
786     *
787     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Date
788     *
789     * @return static
790     */
791    public function setDate(DateTime $datetime) : static
792    {
793        $date = clone $datetime;
794        $date->setTimezone(new DateTimeZone('UTC'));
795        $this->setHeader(
796            Header::DATE,
797            $date->format(DateTimeInterface::RFC7231)
798        );
799        return $this;
800    }
801
802    /**
803     * Set the ETag header.
804     *
805     * @param string $etag
806     * @param bool $strong
807     *
808     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag
809     *
810     * @return static
811     */
812    public function setEtag(string $etag, bool $strong = true) : static
813    {
814        $etag = '"' . $etag . '"';
815        if ($strong === false) {
816            $etag = 'W/' . $etag;
817        }
818        $this->setHeader(ResponseHeader::ETAG, $etag);
819        return $this;
820    }
821
822    /**
823     * Set the Expires header.
824     *
825     * @param DateTime $datetime
826     *
827     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Expires
828     *
829     * @return static
830     */
831    public function setExpires(DateTime $datetime) : static
832    {
833        $date = clone $datetime;
834        $date->setTimezone(new DateTimeZone('UTC'));
835        $this->setHeader(
836            ResponseHeader::EXPIRES,
837            $date->format(DateTimeInterface::RFC7231)
838        );
839        return $this;
840    }
841
842    /**
843     * Set the Last-Modified header.
844     *
845     * @param DateTime $datetime
846     *
847     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified
848     *
849     * @return static
850     */
851    public function setLastModified(DateTime $datetime) : static
852    {
853        $date = clone $datetime;
854        $date->setTimezone(new DateTimeZone('UTC'));
855        $this->setHeader(
856            ResponseHeader::LAST_MODIFIED,
857            $date->format(DateTimeInterface::RFC7231)
858        );
859        return $this;
860    }
861
862    /**
863     * Set the status line as "Not Modified".
864     *
865     * @return static
866     */
867    public function setNotModified() : static
868    {
869        $this->setStatus(Status::NOT_MODIFIED);
870        return $this;
871    }
872
873    public function setDebugCollector(HTTPCollector $debugCollector) : static
874    {
875        $this->debugCollector = $debugCollector;
876        $this->debugCollector->setResponse($this);
877        return $this;
878    }
879}