Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
107 / 107
100.00% covered (success)
100.00%
34 / 34
CRAP
100.00% covered (success)
100.00%
1 / 1
Message
100.00% covered (success)
100.00%
107 / 107
100.00% covered (success)
100.00%
34 / 34
52
100.00% covered (success)
100.00%
1 / 1
 __toString
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getStartLine
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 hasHeader
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getHeader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getHeaders
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getHeaderLine
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getHeaderLines
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 setHeader
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setHeaders
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 appendHeader
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getHeaderValueSeparator
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 removeHeader
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 removeHeaders
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 hasCookie
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCookie
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCookies
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCookie
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setCookies
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 removeCookie
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 removeCookies
100.00% covered (success)
100.00%
3 / 3
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
1
 setBody
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getProtocol
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setProtocol
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getMethod
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isMethod
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setMethod
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setStatusCode
100.00% covered (success)
100.00%
2 / 2
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
 getUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setUrl
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 parseContentType
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 parseQualityValues
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
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 BadMethodCallException;
13use InvalidArgumentException;
14use JetBrains\PhpStorm\Pure;
15
16/**
17 * Class Message.
18 *
19 * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages
20 * @see https://datatracker.ietf.org/doc/html/rfc7231
21 *
22 * @package http
23 */
24abstract class Message implements MessageInterface
25{
26    /**
27     * HTTP Message Protocol.
28     */
29    protected string $protocol = Protocol::HTTP_1_1;
30    /**
31     * HTTP Request URL.
32     */
33    protected URL $url;
34    /**
35     * HTTP Request Method.
36     */
37    protected string $method;
38    /**
39     * HTTP Response Status Code.
40     */
41    protected int $statusCode;
42    /**
43     * HTTP Message Body.
44     */
45    protected string $body;
46    /**
47     * HTTP Message Cookies.
48     *
49     * @var array<string,Cookie>
50     */
51    protected array $cookies = [];
52    /**
53     * HTTP Message Headers.
54     *
55     * @var array<string,string>
56     */
57    protected array $headers = [];
58
59    public function __toString() : string
60    {
61        $eol = "\r\n";
62        $message = $this->getStartLine() . $eol;
63        foreach ($this->getHeaderLines() as $headerLine) {
64            $message .= $headerLine . $eol;
65        }
66        $message .= $eol;
67        $message .= $this->getBody();
68        return $message;
69    }
70
71    /**
72     * Get the Message Start-Line.
73     *
74     * @throws BadMethodCallException if $this is not an instance of
75     * RequestInterface or ResponseInterface
76     *
77     * @return string
78     */
79    public function getStartLine() : string
80    {
81        if ($this instanceof RequestInterface) {
82            $query = $this->getUrl()->getQuery();
83            $query = ($query !== null && $query !== '') ? '?' . $query : '';
84            return $this->getMethod()
85                . ' ' . $this->getUrl()->getPath() . $query
86                . ' ' . $this->getProtocol();
87        }
88        if ($this instanceof ResponseInterface) {
89            return $this->getProtocol()
90                . ' ' . $this->getStatus();
91        }
92        throw new BadMethodCallException(
93            static::class . ' is not an instance of ' . RequestInterface::class
94            . ' or ' . ResponseInterface::class
95        );
96    }
97
98    #[Pure]
99    public function hasHeader(string $name, string $value = null) : bool
100    {
101        return $value === null
102            ? $this->getHeader($name) !== null
103            : $this->getHeader($name) === $value;
104    }
105
106    #[Pure]
107    public function getHeader(string $name) : ?string
108    {
109        return $this->headers[\strtolower($name)] ?? null;
110    }
111
112    /**
113     * @return array<string,string>
114     */
115    #[Pure]
116    public function getHeaders() : array
117    {
118        return $this->headers;
119    }
120
121    #[Pure]
122    public function getHeaderLine(string $name) : ?string
123    {
124        $value = $this->getHeader($name);
125        if ($value === null) {
126            return null;
127        }
128        $name = Header::getName($name);
129        return $name . ': ' . $value;
130    }
131
132    /**
133     * @return array<int,string>
134     */
135    #[Pure]
136    public function getHeaderLines() : array
137    {
138        $lines = [];
139        foreach ($this->getHeaders() as $name => $value) {
140            $name = Header::getName($name);
141            if (\str_contains($value, "\n")) {
142                foreach (\explode("\n", $value) as $val) {
143                    $lines[] = $name . ': ' . $val;
144                }
145                continue;
146            }
147            $lines[] = $name . ': ' . $value;
148        }
149        return $lines;
150    }
151
152    /**
153     * Set a Message header.
154     *
155     * @param string $name
156     * @param string $value
157     *
158     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers
159     *
160     * @return static
161     */
162    protected function setHeader(string $name, string $value) : static
163    {
164        $this->headers[\strtolower($name)] = $value;
165        return $this;
166    }
167
168    /**
169     * Set a list of headers.
170     *
171     * @param array<string,string> $headers
172     *
173     * @return static
174     */
175    protected function setHeaders(array $headers) : static
176    {
177        foreach ($headers as $name => $value) {
178            $this->setHeader((string) $name, (string) $value);
179        }
180        return $this;
181    }
182
183    /**
184     * Append a Message header.
185     *
186     * Used to set repeated header field names.
187     *
188     * @param string $name
189     * @param string $value
190     *
191     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers
192     *
193     * @return static
194     */
195    protected function appendHeader(string $name, string $value) : static
196    {
197        $current = $this->getHeader($name);
198        if ($current !== null) {
199            $separator = $this->getHeaderValueSeparator($name);
200            $value = $current . $separator . $value;
201        }
202        $this->setHeader($name, $value);
203        return $this;
204    }
205
206    /**
207     * @param string $headerName
208     *
209     * @see https://stackoverflow.com/a/38406581/6027968
210     *
211     * @return string
212     */
213    private function getHeaderValueSeparator(string $headerName) : string
214    {
215        if (Header::isMultiline($headerName)) {
216            return "\n";
217        }
218        return ', ';
219    }
220
221    /**
222     * Remove a header by name.
223     *
224     * @param string $name
225     *
226     * @return static
227     */
228    protected function removeHeader(string $name) : static
229    {
230        unset($this->headers[\strtolower($name)]);
231        return $this;
232    }
233
234    /**
235     * Remove many headers by a list of headers.
236     *
237     * @return static
238     */
239    protected function removeHeaders() : static
240    {
241        $this->headers = [];
242        return $this;
243    }
244
245    /**
246     * Say if the Message has a Cookie.
247     *
248     * @param string $name Cookie name
249     *
250     * @return bool
251     */
252    #[Pure]
253    public function hasCookie(string $name) : bool
254    {
255        return (bool) $this->getCookie($name);
256    }
257
258    /**
259     * Get a Cookie by name.
260     *
261     * @param string $name
262     *
263     * @return Cookie|null
264     */
265    #[Pure]
266    public function getCookie(string $name) : ?Cookie
267    {
268        return $this->cookies[$name] ?? null;
269    }
270
271    /**
272     * Get all Cookies.
273     *
274     * @return array<string,Cookie>
275     */
276    #[Pure]
277    public function getCookies() : array
278    {
279        return $this->cookies;
280    }
281
282    /**
283     * Set a new Cookie.
284     *
285     * @param Cookie $cookie
286     *
287     * @return static
288     */
289    protected function setCookie(Cookie $cookie) : static
290    {
291        $this->cookies[$cookie->getName()] = $cookie;
292        return $this;
293    }
294
295    /**
296     * Set a list of Cookies.
297     *
298     * @param array<int,Cookie> $cookies
299     *
300     * @return static
301     */
302    protected function setCookies(array $cookies) : static
303    {
304        foreach ($cookies as $cookie) {
305            $this->setCookie($cookie);
306        }
307        return $this;
308    }
309
310    /**
311     * Remove a Cookie by name.
312     *
313     * @param string $name
314     *
315     * @return static
316     */
317    protected function removeCookie(string $name) : static
318    {
319        unset($this->cookies[$name]);
320        return $this;
321    }
322
323    /**
324     * Remove many Cookies by names.
325     *
326     * @param array<int,string> $names
327     *
328     * @return static
329     */
330    protected function removeCookies(array $names) : static
331    {
332        foreach ($names as $name) {
333            $this->removeCookie($name);
334        }
335        return $this;
336    }
337
338    /**
339     * Get the Message body.
340     *
341     * @return string
342     */
343    #[Pure]
344    public function getBody() : string
345    {
346        return $this->body ?? '';
347    }
348
349    /**
350     * Set the Message body.
351     *
352     * @param string $body
353     *
354     * @return static
355     */
356    protected function setBody(string $body) : static
357    {
358        $this->body = $body;
359        return $this;
360    }
361
362    /**
363     * Get the HTTP protocol.
364     *
365     * @return string
366     */
367    #[Pure]
368    public function getProtocol() : string
369    {
370        return $this->protocol;
371    }
372
373    /**
374     * Set the HTTP protocol.
375     *
376     * @param string $protocol HTTP/1.1, HTTP/2, etc
377     *
378     * @return static
379     */
380    protected function setProtocol(string $protocol) : static
381    {
382        $this->protocol = Protocol::validate($protocol);
383        return $this;
384    }
385
386    /**
387     * Gets the HTTP Request Method.
388     *
389     * @return string $method One of: CONNECT, DELETE, GET, HEAD, OPTIONS,
390     * PATCH, POST, PUT, or TRACE
391     */
392    #[Pure]
393    protected function getMethod() : string
394    {
395        return $this->method;
396    }
397
398    /**
399     * @param string $method
400     *
401     * @throws InvalidArgumentException for invalid method
402     *
403     * @return bool
404     */
405    protected function isMethod(string $method) : bool
406    {
407        return $this->getMethod() === Method::validate($method);
408    }
409
410    /**
411     * Set the request method.
412     *
413     * @param string $method One of: CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH,
414     * POST, PUT, or TRACE
415     *
416     * @throws InvalidArgumentException for invalid method
417     *
418     * @return static
419     */
420    protected function setMethod(string $method) : static
421    {
422        $this->method = Method::validate($method);
423        return $this;
424    }
425
426    protected function setStatusCode(int $code) : static
427    {
428        $this->statusCode = Status::validate($code);
429        return $this;
430    }
431
432    /**
433     * Get the status code.
434     *
435     * @return int
436     */
437    #[Pure]
438    protected function getStatusCode() : int
439    {
440        return $this->statusCode;
441    }
442
443    protected function isStatusCode(int $code) : bool
444    {
445        return $this->getStatusCode() === Status::validate($code);
446    }
447
448    /**
449     * Gets the requested URL.
450     *
451     * @return URL
452     */
453    #[Pure]
454    protected function getUrl() : URL
455    {
456        return $this->url;
457    }
458
459    /**
460     * Set the Message URL.
461     *
462     * @param string|URL $url
463     *
464     * @return static
465     */
466    protected function setUrl(string | URL $url) : static
467    {
468        if ( ! $url instanceof URL) {
469            $url = new URL($url);
470        }
471        $this->url = $url;
472        return $this;
473    }
474
475    #[Pure]
476    protected function parseContentType() : ?string
477    {
478        $contentType = $this->getHeader('Content-Type');
479        if ($contentType === null) {
480            return null;
481        }
482        $contentType = \explode(';', $contentType, 2)[0];
483        return \trim($contentType);
484    }
485
486    /**
487     * @see https://developer.mozilla.org/en-US/docs/Glossary/Quality_values
488     * @see https://stackoverflow.com/a/33748742/6027968
489     *
490     * @param string|null $string
491     *
492     * @return array<string,float>
493     */
494    public static function parseQualityValues(?string $string) : array
495    {
496        if (empty($string)) {
497            return [];
498        }
499        $quality = \array_reduce(
500            \explode(',', $string, 20),
501            static function ($qualifier, $part) {
502                [$value, $priority] = \array_merge(\explode(';q=', $part), [1]);
503                $qualifier[\trim((string) $value)] = (float) $priority;
504                return $qualifier;
505            },
506            []
507        );
508        \arsort($quality);
509        return $quality;
510    }
511}