Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
124 / 124
100.00% covered (success)
100.00%
25 / 25
CRAP
100.00% covered (success)
100.00%
1 / 1
Cookie
100.00% covered (success)
100.00%
124 / 124
100.00% covered (success)
100.00%
25 / 25
57
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%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAsString
n/a
0 / 0
n/a
0 / 0
1
 toString
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
7
 getDomain
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setDomain
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getExpires
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setExpires
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 isExpired
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setName
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setPath
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getSameSite
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setSameSite
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setValue
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setHttpOnly
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isHttpOnly
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setSecure
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isSecure
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 send
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
 parse
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
11
 create
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 makeArgumentValue
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 trimmedOrNull
100.00% covered (success)
100.00%
4 / 4
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 DateTime;
13use DateTimeInterface;
14use DateTimeZone;
15use Exception;
16use InvalidArgumentException;
17use JetBrains\PhpStorm\Deprecated;
18use JetBrains\PhpStorm\Pure;
19
20/**
21 * Class Cookie.
22 *
23 * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
24 * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies
25 * @see https://datatracker.ietf.org/doc/html/rfc6265
26 * @see https://www.php.net/manual/en/function.setcookie.php
27 *
28 * @package http
29 */
30class Cookie implements \Stringable
31{
32    protected ?string $domain = null;
33    protected ?DateTime $expires = null;
34    protected bool $httpOnly = false;
35    protected string $name;
36    protected ?string $path = null;
37    protected ?string $sameSite = null;
38    protected bool $secure = false;
39    protected string $value;
40
41    /**
42     * Cookie constructor.
43     *
44     * @param string $name
45     * @param string $value
46     */
47    public function __construct(string $name, string $value)
48    {
49        $this->setName($name);
50        $this->setValue($value);
51    }
52
53    public function __toString() : string
54    {
55        return $this->toString();
56    }
57
58    /**
59     * @return string
60     *
61     * @deprecated Use {@see Cookie::toString()}
62     *
63     * @codeCoverageIgnore
64     */
65    #[Deprecated(
66        reason: 'since HTTP Library version 5.3, use toString() instead',
67        replacement: '%class%->toString()'
68    )]
69    public function getAsString() : string
70    {
71        \trigger_error(
72            'Method ' . __METHOD__ . ' is deprecated',
73            \E_USER_DEPRECATED
74        );
75        return $this->toString();
76    }
77
78    /**
79     * @since 5.3
80     *
81     * @return string
82     */
83    public function toString() : string
84    {
85        $string = $this->getName() . '=' . $this->getValue();
86        $part = $this->getExpires();
87        if ($part !== null) {
88            $string .= '; expires=' . $this->expires->format(DateTimeInterface::RFC7231);
89            $string .= '; Max-Age=' . $this->expires->diff(new DateTime('-1 second'))->s;
90        }
91        $part = $this->getPath();
92        if ($part !== null) {
93            $string .= '; path=' . $part;
94        }
95        $part = $this->getDomain();
96        if ($part !== null) {
97            $string .= '; domain=' . $part;
98        }
99        $part = $this->isSecure();
100        if ($part) {
101            $string .= '; secure';
102        }
103        $part = $this->isHttpOnly();
104        if ($part) {
105            $string .= '; HttpOnly';
106        }
107        $part = $this->getSameSite();
108        if ($part !== null) {
109            $string .= '; SameSite=' . $part;
110        }
111        return $string;
112    }
113
114    /**
115     * @return string|null
116     */
117    #[Pure]
118    public function getDomain() : ?string
119    {
120        return $this->domain;
121    }
122
123    /**
124     * @param string|null $domain
125     *
126     * @return static
127     */
128    public function setDomain(?string $domain) : static
129    {
130        $this->domain = $domain;
131        return $this;
132    }
133
134    /**
135     * @return DateTime|null
136     */
137    #[Pure]
138    public function getExpires() : ?DateTime
139    {
140        return $this->expires;
141    }
142
143    /**
144     * @param DateTime|int|string|null $expires
145     *
146     * @throws Exception if can not create from format
147     *
148     * @return static
149     */
150    public function setExpires(DateTime | int | string | null $expires) : static
151    {
152        if ($expires instanceof DateTime) {
153            $expires = clone $expires;
154            $expires->setTimezone(new DateTimeZone('UTC'));
155        } elseif (\is_numeric($expires)) {
156            $expires = DateTime::createFromFormat('U', (string) $expires, new DateTimeZone('UTC'));
157        } elseif ($expires !== null) {
158            $expires = new DateTime($expires, new DateTimeZone('UTC'));
159        }
160        $this->expires = $expires; // @phpstan-ignore-line
161        return $this;
162    }
163
164    /**
165     * @return bool
166     */
167    public function isExpired() : bool
168    {
169        return $this->getExpires() && \time() > $this->getExpires()->getTimestamp();
170    }
171
172    /**
173     * @return string
174     */
175    #[Pure]
176    public function getName() : string
177    {
178        return $this->name;
179    }
180
181    /**
182     * @param string $name
183     *
184     * @return static
185     */
186    protected function setName(string $name) : static
187    {
188        $this->name = $name;
189        return $this;
190    }
191
192    /**
193     * @return string|null
194     */
195    #[Pure]
196    public function getPath() : ?string
197    {
198        return $this->path;
199    }
200
201    /**
202     * @param string|null $path
203     *
204     * @return static
205     */
206    public function setPath(?string $path) : static
207    {
208        $this->path = $path;
209        return $this;
210    }
211
212    /**
213     * @return string|null
214     */
215    #[Pure]
216    public function getSameSite() : ?string
217    {
218        return $this->sameSite;
219    }
220
221    /**
222     * @param string|null $sameSite Strict, Lax, Unset or None
223     *
224     * @throws InvalidArgumentException for invalid $sameSite value
225     *
226     * @return static
227     */
228    public function setSameSite(?string $sameSite) : static
229    {
230        if ($sameSite !== null) {
231            $sameSite = \ucfirst(\strtolower($sameSite));
232            if ( ! \in_array($sameSite, ['Strict', 'Lax', 'Unset', 'None'])) {
233                throw new InvalidArgumentException('SameSite must be Strict, Lax, Unset or None');
234            }
235        }
236        $this->sameSite = $sameSite;
237        return $this;
238    }
239
240    /**
241     * @return string
242     */
243    #[Pure]
244    public function getValue() : string
245    {
246        return $this->value;
247    }
248
249    /**
250     * @param string $value
251     *
252     * @return static
253     */
254    public function setValue(string $value) : static
255    {
256        $this->value = $value;
257        return $this;
258    }
259
260    /**
261     * @param bool $httpOnly
262     *
263     * @return static
264     */
265    public function setHttpOnly(bool $httpOnly = true) : static
266    {
267        $this->httpOnly = $httpOnly;
268        return $this;
269    }
270
271    /**
272     * @return bool
273     */
274    #[Pure]
275    public function isHttpOnly() : bool
276    {
277        return $this->httpOnly;
278    }
279
280    /**
281     * @param bool $secure
282     *
283     * @return static
284     */
285    public function setSecure(bool $secure = true) : static
286    {
287        $this->secure = $secure;
288        return $this;
289    }
290
291    /**
292     * @return bool
293     */
294    #[Pure]
295    public function isSecure() : bool
296    {
297        return $this->secure;
298    }
299
300    /**
301     * @return bool
302     */
303    public function send() : bool
304    {
305        $options = [];
306        $value = $this->getExpires();
307        if ($value) {
308            $options['expires'] = $value->getTimestamp();
309        }
310        $value = $this->getPath();
311        if ($value !== null) {
312            $options['path'] = $value;
313        }
314        $value = $this->getDomain();
315        if ($value !== null) {
316            $options['domain'] = $value;
317        }
318        $options['secure'] = $this->isSecure();
319        $options['httponly'] = $this->isHttpOnly();
320        $value = $this->getSameSite();
321        if ($value !== null) {
322            $options['samesite'] = $value;
323        }
324        // @phpstan-ignore-next-line
325        return \setcookie($this->getName(), $this->getValue(), $options);
326    }
327
328    /**
329     * Parses a Set-Cookie Header line and creates a new Cookie object.
330     *
331     * @param string $line
332     *
333     * @throws Exception if setExpires fail
334     *
335     * @return Cookie|null
336     */
337    public static function parse(string $line) : ?Cookie
338    {
339        $parts = \array_map('\trim', \explode(';', $line, 20));
340        $cookie = null;
341        foreach ($parts as $key => $part) {
342            [$arg, $val] = static::makeArgumentValue($part);
343            if ($key === 0) {
344                if (isset($arg, $val)) {
345                    $cookie = new Cookie($arg, $val);
346                    continue;
347                }
348                break;
349            }
350            if ($arg === null) {
351                continue;
352            }
353            switch (\strtolower($arg)) {
354                case 'expires':
355                    $cookie->setExpires($val);
356                    break;
357                case 'domain':
358                    $cookie->setDomain($val);
359                    break;
360                case 'path':
361                    $cookie->setPath($val);
362                    break;
363                case 'httponly':
364                    $cookie->setHttpOnly();
365                    break;
366                case 'secure':
367                    $cookie->setSecure();
368                    break;
369                case 'samesite':
370                    $cookie->setSameSite($val);
371                    break;
372            }
373        }
374        return $cookie;
375    }
376
377    /**
378     * Create Cookie objects from a Cookie Header line.
379     *
380     * @param string $line
381     *
382     * @return array<string,Cookie>
383     */
384    public static function create(string $line) : array
385    {
386        $items = \array_map('\trim', \explode(';', $line, 3000));
387        $cookies = [];
388        foreach ($items as $item) {
389            [$name, $value] = static::makeArgumentValue($item);
390            if (isset($name, $value)) {
391                $cookies[$name] = new Cookie($name, $value);
392            }
393        }
394        return $cookies;
395    }
396
397    /**
398     * @param string $part
399     *
400     * @return array<int,string|null>
401     */
402    protected static function makeArgumentValue(string $part) : array
403    {
404        $part = \array_pad(\explode('=', $part, 2), 2, null);
405        if ($part[0] !== null) {
406            $part[0] = static::trimmedOrNull($part[0]);
407        }
408        if ($part[1] !== null) {
409            $part[1] = static::trimmedOrNull($part[1]);
410        }
411        return $part;
412    }
413
414    protected static function trimmedOrNull(string $value) : ?string
415    {
416        $value = \trim($value);
417        if ($value === '') {
418            $value = null;
419        }
420        return $value;
421    }
422}