diff --git a/CHANGELOG.md b/CHANGELOG.md index bbb03ae..b219de9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ### Added - Autoregistration of stream filters using Composer autoload +- Cookie ## 0.1.2 - 2015-12-26 diff --git a/README.md b/README.md index d9d857d..07118fd 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ This package contains various PSR-7 tools which might be useful in an HTTP workf - Various Stream encoding tools - Message decorators - Message factory implementations for Guzzle PSR-7 and Diactoros +- Cookie implementation ## Documentation diff --git a/composer.json b/composer.json index 5044d35..0851035 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,8 @@ "guzzlehttp/psr7": "^1.0", "ext-zlib": "*", "phpspec/phpspec": "^2.4", - "henrikbjorn/phpspec-code-coverage" : "^1.0" + "henrikbjorn/phpspec-code-coverage" : "^1.0", + "coduo/phpspec-data-provider-extension": "^1.0" }, "suggest": { "zendframework/zend-diactoros": "Used with Diactoros Factories", diff --git a/phpspec.yml.ci b/phpspec.yml.ci index 5ea893a..f08e148 100644 --- a/phpspec.yml.ci +++ b/phpspec.yml.ci @@ -5,6 +5,7 @@ suites: formatter.name: pretty extensions: - PhpSpec\Extension\CodeCoverageExtension + - Coduo\PhpSpec\DataProvider\DataProviderExtension code_coverage: format: clover output: build/coverage.xml diff --git a/phpspec.yml.dist b/phpspec.yml.dist index 790b2d7..8d38a4e 100644 --- a/phpspec.yml.dist +++ b/phpspec.yml.dist @@ -3,3 +3,5 @@ suites: namespace: Http\Message psr4_prefix: Http\Message formatter.name: pretty +extensions: + - Coduo\PhpSpec\DataProvider\DataProviderExtension diff --git a/spec/CookieJarSpec.php b/spec/CookieJarSpec.php new file mode 100644 index 0000000..29afee4 --- /dev/null +++ b/spec/CookieJarSpec.php @@ -0,0 +1,178 @@ +shouldHaveType('Http\Message\CookieJar'); + } + + function it_is_an_iterator_aggregate() + { + $this->getIterator()->shouldHaveType('Iterator'); + } + + function it_has_a_cookie() + { + $cookie = new Cookie('name', 'value'); + + $this->addCookie($cookie); + + $this->hasCookie($cookie)->shouldReturn(true); + $this->hasCookies()->shouldReturn(true); + } + + function it_accepts_a_cookie() + { + $cookie = new Cookie('name', 'value'); + $cookie2 = new Cookie('name', 'value2'); + + $this->addCookie($cookie); + $this->addCookie($cookie2); + + $this->hasCookie($cookie)->shouldReturn(false); + $this->hasCookie($cookie2)->shouldReturn(true); + } + + function it_removes_a_cookie_with_an_empty_value() + { + $cookie = new Cookie('name', 'value'); + $cookie2 = new Cookie('name'); + + $this->addCookie($cookie); + $this->addCookie($cookie2); + + $this->hasCookie($cookie)->shouldReturn(false); + $this->hasCookie($cookie2)->shouldReturn(false); + } + + function it_removes_a_cookie_with_a_lower_expiration_time() + { + $cookie = new Cookie('name', 'value', 100); + $cookie2 = new Cookie('name', 'value', 1000); + + $this->addCookie($cookie); + $this->addCookie($cookie2); + + $this->hasCookie($cookie)->shouldReturn(false); + $this->hasCookie($cookie2)->shouldReturn(true); + } + + function it_removes_a_cookie() + { + $cookie = new Cookie('name', 'value', 100); + + $this->addCookie($cookie); + $this->removeCookie($cookie); + + $this->hasCookie($cookie)->shouldReturn(false); + } + + function it_returns_all_cookies() + { + $cookie = new Cookie('name', 'value'); + $cookie2 = new Cookie('name2', 'value'); + + $this->addCookie($cookie); + $this->addCookie($cookie2); + + $this->getCookies()->shouldBeAnArrayOfInstance('Http\Message\Cookie'); + } + + function it_returns_the_matching_cookies() + { + $cookie = new Cookie('name', 'value'); + $cookie2 = new Cookie('name', 'value2'); + + $this->addCookie($cookie); + + $this->getMatchingCookies($cookie2)->shouldBeAnArrayOfInstance('Http\Message\Cookie'); + } + + function it_sets_cookies() + { + $cookie = new Cookie('name', 'value'); + + $this->setCookies([$cookie]); + + $this->hasCookie($cookie)->shouldReturn(true); + $this->hasCookies()->shouldReturn(true); + $this->count()->shouldReturn(1); + } + + function it_accepts_cookies() + { + $cookie = new Cookie('name', 'value'); + $cookie2 = new Cookie('name2', 'value'); + + $this->addCookie($cookie); + $this->addCookies([$cookie2]); + + $this->hasCookie($cookie)->shouldReturn(true); + $this->hasCookie($cookie2)->shouldReturn(true); + $this->hasCookies()->shouldReturn(true); + $this->count()->shouldReturn(2); + } + + function it_removes_cookies() + { + $cookie = new Cookie('name', 'value'); + $cookie2 = new Cookie('name2', 'value'); + + $this->addCookies([$cookie, $cookie2]); + $this->removeCookies([$cookie2]); + + $this->hasCookie($cookie)->shouldReturn(true); + $this->hasCookie($cookie2)->shouldReturn(false); + $this->hasCookies()->shouldReturn(true); + $this->count()->shouldReturn(1); + } + + function it_removes_matching_cookies() + { + $cookie = new Cookie('name', 'value'); + $cookie2 = new Cookie('name2', 'value', 0, 'php-http.org'); + + $this->addCookies([$cookie, $cookie2]); + + $this->removeMatchingCookies('name2', 'php-http.org', '/'); + + $this->hasCookie($cookie)->shouldReturn(true); + $this->hasCookie($cookie2)->shouldReturn(false); + $this->hasCookies()->shouldReturn(true); + $this->count()->shouldReturn(1); + } + + function it_clears_cookies() + { + $cookie = new Cookie('name', 'value', 0, 'php-http.org'); + $cookie2 = new Cookie('name2', 'value'); + + $this->addCookies([$cookie, $cookie2]); + + $this->clear(); + + $this->hasCookies()->shouldReturn(false); + $this->count()->shouldReturn(0); + } + + public function getMatchers() + { + return [ + 'beAnArrayOfInstance' => function ($subject, $instance) { + foreach ($subject as $element) { + if (!$element instanceof $instance) { + return false; + } + } + + return true; + }, + ]; + } +} diff --git a/spec/CookieSpec.php b/spec/CookieSpec.php new file mode 100644 index 0000000..248968f --- /dev/null +++ b/spec/CookieSpec.php @@ -0,0 +1,255 @@ +beConstructedWith('name', 'value'); + } + + function it_is_initializable() + { + $this->shouldHaveType('Http\Message\Cookie'); + } + + function it_has_a_name() + { + $this->getName()->shouldReturn('name'); + } + + /** + * @dataProvider invalidCharacterExamples + */ + function it_throws_an_exception_when_the_name_contains_invalid_character($name, $shouldThrow) + { + $this->beConstructedWith($name); + + if ($shouldThrow) { + $expectation = $this->shouldThrow('InvalidArgumentException'); + } else { + $expectation = $this->shouldNotThrow('InvalidArgumentException'); + } + + $expectation->duringInstantiation(); + } + + function it_throws_an_expection_when_name_is_empty() + { + $this->beConstructedWith(''); + + $this->shouldThrow('InvalidArgumentException')->duringInstantiation(); + } + + function it_has_a_value() + { + $this->getValue()->shouldReturn('value'); + $this->hasValue()->shouldReturn(true); + } + + /** + * @dataProvider invalidCharacterExamples + */ + function it_throws_an_exception_when_the_value_contains_invalid_character($value, $shouldThrow) + { + $this->beConstructedWith('name', $value); + + if ($shouldThrow) { + $expectation = $this->shouldThrow('InvalidArgumentException'); + } else { + $expectation = $this->shouldNotThrow('InvalidArgumentException'); + } + + $expectation->duringInstantiation(); + } + + function it_accepts_a_value() + { + $cookie = $this->withValue('value2'); + + $cookie->shouldHaveType('Http\Message\Cookie'); + $cookie->getValue()->shouldReturn('value2'); + } + + /** + * @dataProvider invalidCharacterExamples + */ + function it_throws_an_exception_when_the_new_value_contains_invalid_character($value, $shouldThrow) + { + if ($shouldThrow) { + $expectation = $this->shouldThrow('InvalidArgumentException'); + } else { + $expectation = $this->shouldNotThrow('InvalidArgumentException'); + } + + $expectation->duringWithValue($value); + } + + function it_has_a_max_age_time() + { + $this->beConstructedWith('name', 'value', 10); + + $this->getMaxAge()->shouldReturn(10); + $this->hasMaxAge()->shouldReturn(true); + } + + function it_accepts_a_max_age() + { + $cookie = $this->withMaxAge(1); + + $cookie->shouldHaveType('Http\Message\Cookie'); + $cookie->getMaxAge()->shouldReturn(1); + } + + function it_throws_an_exception_when_max_age_is_invalid() + { + $this->shouldThrow('InvalidArgumentException')->duringWithMaxAge('-1'); + } + + function it_has_an_expires_attribute() + { + $expires = new \DateTime('+10 seconds'); + + $this->beConstructedWith('name', 'value', null, null, null, false, false, $expires); + + $this->getExpires()->shouldReturn($expires); + $this->hasExpires()->shouldReturn(true); + $this->isExpired()->shouldReturn(false); + } + + function it_accepts_an_expires_attribute() + { + $expires = new \DateTime('+10 seconds'); + + $cookie = $this->withExpires($expires); + + $cookie->shouldHaveType('Http\Message\Cookie'); + $cookie->getExpires()->shouldReturn($expires); + } + + function it_is_expired() + { + $this->beConstructedWith('name', 'value', null, null, null, false, false, new \DateTime('-2 minutes')); + + $this->getExpires()->shouldHaveType('DateTime'); + $this->hasExpires()->shouldReturn(true); + $this->isExpired()->shouldReturn(true); + } + + function it_has_a_domain() + { + $this->getDomain()->shouldReturn(null); + $this->hasDomain()->shouldReturn(false); + } + + function it_has_a_valid_domain() + { + $this->beConstructedWith('name', 'value', null, '.PhP-hTtP.oRg'); + + $this->getDomain()->shouldReturn('php-http.org'); + $this->hasDomain()->shouldReturn(true); + } + + function it_accepts_a_domain() + { + $cookie = $this->withDomain('.PhP-hTtP.oRg'); + + $cookie->shouldHaveType('Http\Message\Cookie'); + $cookie->getDomain()->shouldReturn('php-http.org'); + } + + function it_matches_a_domain() + { + $this->beConstructedWith('name', 'value', null, 'php-http.org'); + + $this->matchDomain('PhP-hTtP.oRg')->shouldReturn(true); + $this->matchDomain('127.0.0.1')->shouldReturn(false); + $this->matchDomain('wWw.PhP-hTtP.oRg')->shouldReturn(true); + } + + function it_has_a_path() + { + $this->getPath()->shouldReturn('/'); + } + + function it_accepts_a_path() + { + $cookie = $this->withPath('/path'); + + $cookie->shouldHaveType('Http\Message\Cookie'); + $cookie->getPath()->shouldReturn('/path'); + } + + function it_matches_a_path() + { + $this->beConstructedWith('name', 'value', null, null, '/path/to/somewhere'); + + $this->matchPath('/path/to/somewhere')->shouldReturn(true); + $this->matchPath('/path/to/somewhereelse')->shouldReturn(false); + } + + function it_matches_the_root_path() + { + $this->beConstructedWith('name', 'value', null, null, '/'); + + $this->matchPath('/')->shouldReturn(true); + } + + function it_is_secure() + { + $this->isSecure()->shouldReturn(false); + } + + function it_accepts_security() + { + $cookie = $this->withSecure(true); + + $cookie->shouldHaveType('Http\Message\Cookie'); + $cookie->isSecure()->shouldReturn(true); + } + + function it_can_be_http_only() + { + $this->isHttpOnly()->shouldReturn(false); + } + + function it_accepts_http_only() + { + $cookie = $this->withHttpOnly(true); + + $cookie->shouldHaveType('Http\Message\Cookie'); + $cookie->isHttpOnly()->shouldReturn(true); + } + + function it_matches_other_cookies() + { + $this->beConstructedWith('name', 'value', null, 'php-http.org'); + + $matches = new Cookie('name', 'value2', null, 'php-http.org'); + $notMatches = new Cookie('anotherName', 'value2', null, 'php-http.org'); + + $this->match($matches)->shouldReturn(true); + $this->match($notMatches)->shouldReturn(false); + } + + /** + * Provides examples for invalid characers in names and values. + * + * @return array + */ + public function invalidCharacterExamples() + { + return [ + ['a', false], + ["\x00", true], + ['z', false], + ["\x20", true], + ['0', false], + ["\x7F", true] + ]; + } +} diff --git a/src/Cookie.php b/src/Cookie.php new file mode 100644 index 0000000..379089a --- /dev/null +++ b/src/Cookie.php @@ -0,0 +1,478 @@ + + * + * @see http://tools.ietf.org/search/rfc6265 + */ +final class Cookie +{ + /** + * @var string + */ + private $name; + + /** + * @var string|null + */ + private $value; + + /** + * @var int|null + */ + private $maxAge; + + /** + * @var string|null + */ + private $domain; + + /** + * @var string + */ + private $path; + + /** + * @var bool + */ + private $secure; + + /** + * @var bool + */ + private $httpOnly; + + /** + * Expires attribute is HTTP 1.0 only and should be avoided. + * + * @var \DateTime|null + */ + private $expires; + + /** + * @param string $name + * @param string|null $value + * @param int $maxAge + * @param string|null $domain + * @param string|null $path + * @param bool $secure + * @param bool $httpOnly + * @param \DateTime|null $expires Expires attribute is HTTP 1.0 only and should be avoided. + * + * @throws \InvalidArgumentException If name, value or max age is not valid. + */ + public function __construct( + $name, + $value = null, + $maxAge = null, + $domain = null, + $path = null, + $secure = false, + $httpOnly = false, + \DateTime $expires = null + ) { + $this->validateName($name); + $this->validateValue($value); + $this->validateMaxAge($maxAge); + + $this->name = $name; + $this->value = $value; + $this->maxAge = $maxAge; + $this->expires = $expires; + $this->domain = $this->normalizeDomain($domain); + $this->path = $this->normalizePath($path); + $this->secure = (bool) $secure; + $this->httpOnly = (bool) $httpOnly; + } + + /** + * Returns the name. + * + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Returns the value. + * + * @return string|null + */ + public function getValue() + { + return $this->value; + } + + /** + * Checks if there is a value. + * + * @return bool + */ + public function hasValue() + { + return isset($this->value); + } + + /** + * Sets the value. + * + * @param string|null $value + * + * @return Cookie + */ + public function withValue($value) + { + $this->validateValue($value); + + $new = clone $this; + $new->value = $value; + + return $new; + } + + /** + * Returns the max age. + * + * @return int|null + */ + public function getMaxAge() + { + return $this->maxAge; + } + + /** + * Checks if there is a max age. + * + * @return bool + */ + public function hasMaxAge() + { + return isset($this->maxAge); + } + + /** + * Sets the max age. + * + * @param int|null $maxAge + * + * @return Cookie + */ + public function withMaxAge($maxAge) + { + $this->validateMaxAge($maxAge); + + $new = clone $this; + $new->maxAge = $maxAge; + + return $new; + } + + /** + * Returns the expiration time. + * + * @return \DateTime|null + */ + public function getExpires() + { + return $this->expires; + } + + /** + * Checks if there is an expiration time. + * + * @return bool + */ + public function hasExpires() + { + return isset($this->expires); + } + + /** + * Sets the expires. + * + * @param \DateTime|null $expires + * + * @return Cookie + */ + public function withExpires(\DateTime $expires = null) + { + $new = clone $this; + $new->expires = $expires; + + return $new; + } + + /** + * Checks if the cookie is expired. + * + * @return bool + */ + public function isExpired() + { + return isset($this->expires) and $this->expires < new \DateTime(); + } + + /** + * Returns the domain. + * + * @return string|null + */ + public function getDomain() + { + return $this->domain; + } + + /** + * Checks if there is a domain. + * + * @return bool + */ + public function hasDomain() + { + return isset($this->domain); + } + + /** + * Sets the domain. + * + * @param string|null $domain + * + * @return Cookie + */ + public function withDomain($domain) + { + $new = clone $this; + $new->domain = $this->normalizeDomain($domain); + + return $new; + } + + /** + * Checks whether this cookie is meant for this domain. + * + * @see http://tools.ietf.org/html/rfc6265#section-5.1.3 + * + * @param string $domain + * + * @return bool + */ + public function matchDomain($domain) + { + // Domain is not set or exact match + if (!$this->hasDomain() || strcasecmp($domain, $this->domain) === 0) { + return true; + } + + // Domain is not an IP address + if (filter_var($domain, FILTER_VALIDATE_IP)) { + return false; + } + + return (bool) preg_match(sprintf('/\b%s$/i', preg_quote($this->domain)), $domain); + } + + /** + * Returns the path. + * + * @return string + */ + public function getPath() + { + return $this->path; + } + + /** + * Sets the path. + * + * @param string|null $path + * + * @return Cookie + */ + public function withPath($path) + { + $new = clone $this; + $new->path = $this->normalizePath($path); + + return $new; + } + + /** + * Checks whether this cookie is meant for this path. + * + * @see http://tools.ietf.org/html/rfc6265#section-5.1.4 + * + * @param string $path + * + * @return bool + */ + public function matchPath($path) + { + return $this->path === $path || (strpos($path, $this->path.'/') === 0); + } + + /** + * Checks whether this cookie may only be sent over HTTPS. + * + * @return bool + */ + public function isSecure() + { + return $this->secure; + } + + /** + * Sets whether this cookie should only be sent over HTTPS. + * + * @param bool $secure + * + * @return Cookie + */ + public function withSecure($secure) + { + $new = clone $this; + $new->secure = (bool) $secure; + + return $new; + } + + /** + * Check whether this cookie may not be accessed through Javascript. + * + * @return bool + */ + public function isHttpOnly() + { + return $this->httpOnly; + } + + /** + * Sets whether this cookie may not be accessed through Javascript. + * + * @param bool $httpOnly + * + * @return Cookie + */ + public function withHttpOnly($httpOnly) + { + $new = clone $this; + $new->httpOnly = (bool) $httpOnly; + + return $new; + } + + /** + * Checks if this cookie represents the same cookie as $cookie. + * + * This does not compare the values, only name, domain and path. + * + * @param Cookie $cookie + * + * @return bool + */ + public function match(Cookie $cookie) + { + return $this->name === $cookie->name && $this->domain === $cookie->domain and $this->path === $cookie->path; + } + + /** + * Validates the name attribute. + * + * @see http://tools.ietf.org/search/rfc2616#section-2.2 + * + * @param string $name + * + * @throws \InvalidArgumentException If the name is empty or contains invalid characters. + */ + private function validateName($name) + { + if (strlen($name) < 1) { + throw new \InvalidArgumentException('The name cannot be empty'); + } + + // Name attribute is a token as per spec in RFC 2616 + if (preg_match('/[\x00-\x20\x22\x28-\x29\x2C\x2F\x3A-\x40\x5B-\x5D\x7B\x7D\x7F]/', $name)) { + throw new \InvalidArgumentException(sprintf('The cookie name "%s" contains invalid characters.', $name)); + } + } + + /** + * Validates a value. + * + * @see http://tools.ietf.org/html/rfc6265#section-4.1.1 + * + * @param string|null $value + * + * @throws \InvalidArgumentException If the value contains invalid characters. + */ + private function validateValue($value) + { + if (isset($value)) { + if (preg_match('/[^\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]/', $value)) { + throw new \InvalidArgumentException(sprintf('The cookie value "%s" contains invalid characters.', $value)); + } + } + } + + /** + * Validates a Max-Age attribute. + * + * @param int|null $maxAge + * + * @throws \InvalidArgumentException If the Max-Age is not an empty or integer value. + */ + private function validateMaxAge($maxAge) + { + if (isset($maxAge)) { + if (!is_int($maxAge)) { + throw new \InvalidArgumentException('Max-Age must be integer'); + } + } + } + + /** + * Remove the leading '.' and lowercase the domain as per spec in RFC 6265. + * + * @see http://tools.ietf.org/html/rfc6265#section-4.1.2.3 + * @see http://tools.ietf.org/html/rfc6265#section-5.1.3 + * @see http://tools.ietf.org/html/rfc6265#section-5.2.3 + * + * @param string|null $domain + * + * @return string + */ + private function normalizeDomain($domain) + { + if (isset($domain)) { + $domain = ltrim(strtolower($domain), '.'); + } + + return $domain; + } + + /** + * Processes path as per spec in RFC 6265. + * + * @see http://tools.ietf.org/html/rfc6265#section-5.1.4 + * @see http://tools.ietf.org/html/rfc6265#section-5.2.4 + * + * @param string|null $path + * + * @return string + */ + private function normalizePath($path) + { + $path = rtrim($path, '/'); + + if (empty($path) or substr($path, 0, 1) !== '/') { + $path = '/'; + } + + return $path; + } +} diff --git a/src/CookieJar.php b/src/CookieJar.php new file mode 100644 index 0000000..ab267d3 --- /dev/null +++ b/src/CookieJar.php @@ -0,0 +1,220 @@ + + */ +final class CookieJar implements \Countable, \IteratorAggregate +{ + /** + * @var \SplObjectStorage + */ + protected $cookies; + + public function __construct() + { + $this->cookies = new \SplObjectStorage(); + } + + /** + * Checks if there is a cookie. + * + * @param Cookie $cookie + * + * @return bool + */ + public function hasCookie(Cookie $cookie) + { + return $this->cookies->contains($cookie); + } + + /** + * Adds a cookie. + * + * @param Cookie $cookie + */ + public function addCookie(Cookie $cookie) + { + if (!$this->hasCookie($cookie)) { + $cookies = $this->getMatchingCookies($cookie); + + foreach ($cookies as $matchingCookie) { + if ($cookie->getValue() !== $matchingCookie->getValue() || $cookie->getMaxAge() > $matchingCookie->getMaxAge()) { + $this->removeCookie($matchingCookie); + + continue; + } + } + + if ($cookie->hasValue()) { + $this->cookies->attach($cookie); + } + } + } + + /** + * Removes a cookie. + * + * @param Cookie $cookie + */ + public function removeCookie(Cookie $cookie) + { + $this->cookies->detach($cookie); + } + + /** + * Returns the cookies. + * + * @return Cookie[] + */ + public function getCookies() + { + $match = function ($matchCookie) { + return true; + }; + + return $this->findMatchingCookies($match); + } + + /** + * Returns all matching cookies. + * + * @param Cookie $cookie + * + * @return Cookie[] + */ + public function getMatchingCookies(Cookie $cookie) + { + $match = function ($matchCookie) use ($cookie) { + return $matchCookie->match($cookie); + }; + + return $this->findMatchingCookies($match); + } + + /** + * Finds matching cookies based on a callable. + * + * @param callable $match + * + * @return Cookie[] + */ + protected function findMatchingCookies(callable $match) + { + $cookies = []; + + foreach ($this->cookies as $cookie) { + if ($match($cookie)) { + $cookies[] = $cookie; + } + } + + return $cookies; + } + + /** + * Checks if there are cookies. + * + * @return bool + */ + public function hasCookies() + { + return $this->cookies->count() > 0; + } + + /** + * Sets the cookies and removes any previous one. + * + * @param Cookie[] $cookies + */ + public function setCookies(array $cookies) + { + $this->clear(); + $this->addCookies($cookies); + } + + /** + * Adds some cookies. + * + * @param Cookie[] $cookies + */ + public function addCookies(array $cookies) + { + foreach ($cookies as $cookie) { + $this->addCookie($cookie); + } + } + + /** + * Removes some cookies. + * + * @param Cookie[] $cookies + */ + public function removeCookies(array $cookies) + { + foreach ($cookies as $cookie) { + $this->removeCookie($cookie); + } + } + + /** + * Removes cookies which match the given parameters. + * + * Null means that parameter should not be matched + * + * @param string|null $name + * @param string|null $domain + * @param string|null $path + */ + public function removeMatchingCookies($name = null, $domain = null, $path = null) + { + $match = function ($cookie) use ($name, $domain, $path) { + $match = true; + + if (isset($name)) { + $match = $match && ($cookie->getName() === $name); + } + + if (isset($domain)) { + $match = $match && $cookie->matchDomain($domain); + } + + if (isset($path)) { + $match = $match && $cookie->matchPath($path); + } + + return $match; + }; + + $cookies = $this->findMatchingCookies($match); + + $this->removeCookies($cookies); + } + + /** + * Removes all cookies. + */ + public function clear() + { + $this->cookies = new \SplObjectStorage(); + } + + /** + * {@inheritdoc} + */ + public function count() + { + return $this->cookies->count(); + } + + /** + * {@inheritdoc} + */ + public function getIterator() + { + return clone $this->cookies; + } +}