From fe0f61a825cf4f526ab25207b7c5590f45d86ad8 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sun, 23 Feb 2020 21:33:12 +0300 Subject: [PATCH 01/26] draft :: first iteration --- src/Header/Accept.php | 23 +++++++++ src/Header/Date.php | 15 ++++++ src/Header/DefaultHeader.php | 7 +++ src/Header/Header.php | 52 +++++++++++++++++++ src/Header/ListedValues.php | 7 +++ src/Header/WithParams.php | 13 +++++ src/Header/WithQualityParam.php | 8 +++ src/HeaderValues.php | 90 +++++++++++++++++++++++++++++++++ tests/Header/HeaderTest.php | 13 +++++ tests/HeaderValuesTest.php | 89 ++++++++++++++++++++++++++++++++ 10 files changed, 317 insertions(+) create mode 100644 src/Header/Accept.php create mode 100644 src/Header/Date.php create mode 100644 src/Header/DefaultHeader.php create mode 100644 src/Header/Header.php create mode 100644 src/Header/ListedValues.php create mode 100644 src/Header/WithParams.php create mode 100644 src/Header/WithQualityParam.php create mode 100644 src/HeaderValues.php create mode 100644 tests/Header/HeaderTest.php create mode 100644 tests/HeaderValuesTest.php diff --git a/src/Header/Accept.php b/src/Header/Accept.php new file mode 100644 index 0000000..4e402ae --- /dev/null +++ b/src/Header/Accept.php @@ -0,0 +1,23 @@ +value); + } +} diff --git a/src/Header/DefaultHeader.php b/src/Header/DefaultHeader.php new file mode 100644 index 0000000..0ffbb20 --- /dev/null +++ b/src/Header/DefaultHeader.php @@ -0,0 +1,7 @@ + */ + private array $params = []; + private string $quality = '1'; + + public function __construct(string $value) + { + $this->value = $value; + } + + public function __toString(): string + { + return $this->value; + } + + public function getValue(): string + { + return $this->value; + } + /** + * @return mixed + */ + public function getParsedValue() + { + return $this->getValue(); + } + + // with params + protected function withParams(array $params) + { + $clone = clone $this; + $clone->params = $params; + return $clone; + } + protected function getParams(): iterable + { + return $this->params; + } + // with quality + protected function getQuality(): string + { + return $this->quality; + } +} diff --git a/src/Header/ListedValues.php b/src/Header/ListedValues.php new file mode 100644 index 0000000..92730f8 --- /dev/null +++ b/src/Header/ListedValues.php @@ -0,0 +1,7 @@ + $params + * @return $this + */ + public function withParams(array $params); +} diff --git a/src/Header/WithQualityParam.php b/src/Header/WithQualityParam.php new file mode 100644 index 0000000..23efbee --- /dev/null +++ b/src/Header/WithQualityParam.php @@ -0,0 +1,8 @@ +headerClass = $headerClass; + if (class_exists($headerClass)) { + if (!is_subclass_of($headerClass, Header::class, true)) { + throw new InvalidArgumentException("{$headerClass} is not a header."); + } + if (is_a($headerClass, DefaultHeader::class, true)) { + throw new InvalidArgumentException("{$headerClass} has no header name."); + } + $this->headerName = $headerClass::NAME; + $this->listedValues = is_subclass_of($headerClass, ListedValues::class, true); + $this->withParams = is_subclass_of($headerClass, WithParams::class, true); + } else { + $this->headerName = $headerClass; + $this->headerClass = DefaultHeader::class; + } + } + + /** + * @param Header[]|string[] $headers + * @param string $headerClass + * @return static + * @throws InvalidArgumentException + */ + public static function createFromArray(array $headers, string $headerClass): self + { + $collection = new static($headerClass); + foreach ($headers as $header) { + $collection->add($header); + } + return $collection; + } + + public function getName(): string + { + return $this->headerName; + } + + /** + * @param string|Header $value + * @return $this + */ + public function add($value): self + { + if ($value instanceof Header) { + if (get_class($value) !== $this->headerClass) { + throw new InvalidArgumentException( + sprintf('The value must be an instance of %s, %s given', $this->headerClass, get_class($value)) + ); + } + $this->collection[] = $value; + return $this; + } + if (is_string($value)) { + $this->collection[] = new $this->headerClass($value); + return $this; + } + throw new InvalidArgumentException(sprintf('The value must be an instance of %s or string', $this->headerClass)); + } + + public function getValues(): array + { + $result = []; + foreach ($this->collection as $header) { + $result[] = $header->getValue(); + } + return $result; + } +} diff --git a/tests/Header/HeaderTest.php b/tests/Header/HeaderTest.php new file mode 100644 index 0000000..601eeb9 --- /dev/null +++ b/tests/Header/HeaderTest.php @@ -0,0 +1,13 @@ +assertSame(true, true); + } +} diff --git a/tests/HeaderValuesTest.php b/tests/HeaderValuesTest.php new file mode 100644 index 0000000..3f27252 --- /dev/null +++ b/tests/HeaderValuesTest.php @@ -0,0 +1,89 @@ +assertSame('Date', $values->getName()); + } + public function testErrorWithDefaultHeaderClass() + { + $this->expectException(InvalidArgumentException::class); + new HeaderValues(DefaultHeader::class); + } + public function testErrorWithHeaderClass() + { + $this->expectException(InvalidArgumentException::class); + new HeaderValues(Header::class); + } + public function testErrorIfNotHeaderClass() + { + $this->expectException(InvalidArgumentException::class); + new HeaderValues(\DateTimeImmutable::class); + } + public function testCreateFromOneStringValue() + { + $headers = ['Newauth realm="apps", type=1, title="Login to \\"apps\\"", Basic realm="simple"']; + + $values = HeaderValues::createFromArray($headers, 'WWW-Authenticate'); + + $this->assertSame('WWW-Authenticate', $values->getName()); + $this->assertSame($headers, $values->getValues()); + } + public function testCreateFromFewStringValues() + { + $headers = [ + 'text/*;q=0.3', + 'text/html;q=0.7', + 'text/html;level=1', + 'text/html;level=2;q=0.4', + '*/*;q=0.5', + 'text/*', + 'text/plain', + 'text/plain;format=flowed', + '*/*', + ]; + + $values = HeaderValues::createFromArray($headers, 'accept'); + + $this->assertSame('accept', $values->getName()); + $this->assertSame($headers, $values->getValues()); + } + public function testAddObject() + { + $headers = [ + 'text/*;q=0.3', + 'text/html;q=0.7', + ]; + $values = HeaderValues::createFromArray($headers, Accept::class); + + $values->add(new Accept('*/*')); + + $this->assertSame(Accept::NAME, $values->getName()); + $this->assertSame(['text/*;q=0.3', 'text/html;q=0.7', '*/*'], $values->getValues()); + } + public function testExceptionWhenAddOtherObject() + { + $headers = [ + 'text/*;q=0.3', + 'text/html;q=0.7', + ]; + $values = HeaderValues::createFromArray($headers, Accept::class); + + $this->expectException(InvalidArgumentException::class); + + $values->add(new Date('*/*')); + } + +} From 1a954d92b810b5b23d1b0a486703236b265312b2 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 26 Feb 2020 20:58:14 +0300 Subject: [PATCH 02/26] draft :: second iteration --- src/Header.php | 241 ++++++++++++++++++ src/Header/Accept.php | 2 +- src/Header/Date.php | 2 +- src/Header/DefaultHeader.php | 7 - src/Header/DefaultHeaderValue.php | 7 + src/Header/Header.php | 52 ---- src/Header/HeaderValue.php | 71 ++++++ src/HeaderValues.php | 90 ------- .../{HeaderTest.php => HeaderValueTest.php} | 2 +- tests/Header/Stub/QualityHeaderValue.php | 10 + tests/HeaderTest.php | 171 +++++++++++++ tests/HeaderValuesTest.php | 89 ------- 12 files changed, 503 insertions(+), 241 deletions(-) create mode 100644 src/Header.php delete mode 100644 src/Header/DefaultHeader.php create mode 100644 src/Header/DefaultHeaderValue.php delete mode 100644 src/Header/Header.php create mode 100644 src/Header/HeaderValue.php delete mode 100644 src/HeaderValues.php rename tests/Header/{HeaderTest.php => HeaderValueTest.php} (80%) create mode 100644 tests/Header/Stub/QualityHeaderValue.php create mode 100644 tests/HeaderTest.php delete mode 100644 tests/HeaderValuesTest.php diff --git a/src/Header.php b/src/Header.php new file mode 100644 index 0000000..b78714b --- /dev/null +++ b/src/Header.php @@ -0,0 +1,241 @@ +?@[\\]{}'; + private const PART_NONE = 0; + private const PART_VALUE = 1; + private const PART_PARAM_NAME = 2; + private const PART_PARAM_QUOTED_VALUE = 3; + private const PART_PARAM_VALUE = 4; + + + public function __construct(string $nameOrClass) + { + $this->headerClass = $nameOrClass; + if (class_exists($nameOrClass)) { + if (!is_subclass_of($nameOrClass, HeaderValue::class, true)) { + throw new InvalidArgumentException("{$nameOrClass} is not a header."); + } + if (is_a($nameOrClass, DefaultHeaderValue::class, true)) { + throw new InvalidArgumentException("{$nameOrClass} has no header name."); + } + $this->headerName = $nameOrClass::NAME; + $this->listedValues = is_subclass_of($nameOrClass, ListedValues::class, true); + $this->withParams = is_subclass_of($nameOrClass, WithParams::class, true); + } else { + $this->headerName = $nameOrClass; + $this->headerClass = DefaultHeaderValue::class; + } + } + + /** + * @param HeaderValue[]|string[] $headers + * @param string $headerClass + * @return static + * @throws InvalidArgumentException + */ + public static function createFromArray(array $headers, string $headerClass): self + { + $new = new static($headerClass); + foreach ($headers as $header) { + $new->add($header); + } + return $new; + } + + public function getName(): string + { + return $this->headerName; + } + + /** + * @param string|HeaderValue $value + * @return $this + */ + public function add($value): self + { + if ($value instanceof HeaderValue) { + if (get_class($value) !== $this->headerClass) { + throw new InvalidArgumentException( + sprintf('The value must be an instance of %s, %s given', $this->headerClass, get_class($value)) + ); + } + $this->collection[] = $value; + return $this; + } + if (is_string($value)) { + $this->parseAndCollect($value); + return $this; + } + throw new InvalidArgumentException(sprintf('The value must be an instance of %s or string', $this->headerClass)); + } + + public function getValues(): array + { + return $this->collection; + } + public function getStrings(): array + { + $result = []; + foreach ($this->collection as $header) { + $result[] = $header->__toString(); + } + return $result; + } + + private function parseAndCollect(string $body): void + { + if (!$this->listedValues && !$this->withParams) { + $this->collection[] = new $this->headerClass(trim($body)); + return; + } + $part = self::PART_VALUE; + $buffer = ''; + $key = ''; + $value = ''; + $error = ''; + $params = []; + $resetState = static function () use (&$key, &$value, &$buffer, &$params) { + $key = $buffer = $value = ''; + $params = []; + }; + $added = 0; + try { + /** @see https://tools.ietf.org/html/rfc7230#section-3.2.6 */ + for ($i = 0, $length = strlen($body); $i < $length; ++$i) { + $s = $body[$i]; + if ($part === self::PART_VALUE) { + if ($s === '=' && $this->withParams) { + $part = self::PART_PARAM_VALUE; + $key = trim($buffer); + $buffer = ''; + if (strpos($key, ' ') === false) { + continue; + } + $key = preg_replace('/\s+/', ' ', $key); + $chunks = explode(' ', $key); + if (count($chunks) > 2) { + throw new \Exception('Syntax error'); + } + [$value, $key] = $chunks; + } elseif ($s === ';' && $this->withParams) { + $part = self::PART_PARAM_NAME; + $value = trim($buffer); + $buffer = ''; + } elseif ($s === ',' && $this->listedValues) { + $this->collection[] = new $this->headerClass(trim($buffer)); + ++$added; + $resetState(); + } else { + $buffer .= $s; + } + continue; + } + if ($part === self::PART_PARAM_NAME) { + if ($s === '=') { + $key = trim($buffer); + $buffer = ''; + $part = self::PART_PARAM_VALUE; + } elseif (strpos(self::DELIMITERS, $s) === false) { + $buffer .= $s; + } else { + throw new \Exception("Delimiter char \"{$s}\" in a param name"); + } + continue; + } + if ($part === self::PART_NONE) { + if (ord($s) <= 32) { + continue; + } elseif ($s === ';' && $this->withParams) { + $part = self::PART_PARAM_NAME; + continue; + } elseif ($s === ',' && $this->listedValues) { + $this->collection[] = (new $this->headerClass(trim($buffer)))->withParams($params); + ++$added; + $resetState(); + } else { + throw new \Exception('Expected Separator'); + } + } + if ($part === self::PART_PARAM_VALUE) { + if ($buffer === '') { + if ($s === '"') { + $part = self::PART_PARAM_QUOTED_VALUE; + } elseif (ord($s) <= 32) { + continue; + } else { + $buffer .= $s; + } + } elseif (ord($s) <= 32) { + $part = self::PART_NONE; + $params[$key] = rtrim($buffer); + $key = $buffer = ''; + } elseif (strpos(self::DELIMITERS, $s) === false) { + $buffer .= $s; + } elseif ($s === ';') { + $part = self::PART_PARAM_NAME; + $params[$key] = rtrim($buffer); + $key = $buffer = ''; + } elseif ($s === ',' && $this->listedValues) { + $params[$key] = $buffer; + $this->collection[] = (new $this->headerClass(trim($buffer)))->withParams($params); + ++$added; + $resetState(); + } else { + throw new \Exception("Delimiter char \"{$s}\" in a unquoted param value"); + } + continue; + } + if ($part === self::PART_PARAM_QUOTED_VALUE) { + if ($s === '\\') { // quoted pair + if (++$i >= $length) { + throw new \Exception('Incorrect quoted pair'); + } else { + $buffer .= $body[$i]; + } + } elseif ($s === '"') { // end + $part = self::PART_NONE; + $params[$key] = $buffer; + $key = $buffer = ''; + } else { + $buffer .= $s; + } + } + } + } catch (\Exception $e) { + $error = $e->getMessage(); + } + if ($added > 0 && $value === '' && $buffer === '') { + return; + } + /** @var HeaderValue $item */ + $item = new $this->headerClass($part === self::PART_VALUE ? trim($buffer) : $value); + if (in_array($part, [self::PART_PARAM_VALUE, self::PART_PARAM_QUOTED_VALUE], true)) { + $params[$key] = $buffer; + } + if (count($params) > 0) { + $item = $item->withParams($params); + } + if ($error !== '') { + $item = $item->withError($error); + } + $this->collection[] = $item; + } +} diff --git a/src/Header/Accept.php b/src/Header/Accept.php index 4e402ae..51e04ea 100644 --- a/src/Header/Accept.php +++ b/src/Header/Accept.php @@ -2,7 +2,7 @@ namespace Yiisoft\Http\Header; -class Accept extends Header implements WithQualityParam +class Accept extends HeaderValue implements WithQualityParam { public const NAME = 'Accept'; diff --git a/src/Header/Date.php b/src/Header/Date.php index f17a1b8..9d566a8 100644 --- a/src/Header/Date.php +++ b/src/Header/Date.php @@ -4,7 +4,7 @@ use DateTimeImmutable; -class Date extends Header +class Date extends HeaderValue { public const NAME = 'Date'; diff --git a/src/Header/DefaultHeader.php b/src/Header/DefaultHeader.php deleted file mode 100644 index 0ffbb20..0000000 --- a/src/Header/DefaultHeader.php +++ /dev/null @@ -1,7 +0,0 @@ - */ - private array $params = []; - private string $quality = '1'; - - public function __construct(string $value) - { - $this->value = $value; - } - - public function __toString(): string - { - return $this->value; - } - - public function getValue(): string - { - return $this->value; - } - /** - * @return mixed - */ - public function getParsedValue() - { - return $this->getValue(); - } - - // with params - protected function withParams(array $params) - { - $clone = clone $this; - $clone->params = $params; - return $clone; - } - protected function getParams(): iterable - { - return $this->params; - } - // with quality - protected function getQuality(): string - { - return $this->quality; - } -} diff --git a/src/Header/HeaderValue.php b/src/Header/HeaderValue.php new file mode 100644 index 0000000..6eb451d --- /dev/null +++ b/src/Header/HeaderValue.php @@ -0,0 +1,71 @@ + */ + private array $params = []; + private string $quality = '1'; + private ?string $error = null; + + public function __construct(string $value) + { + $this->value = $value; + } + + public function __toString(): string + { + $params = []; + foreach ($this->params as $key => $value) { + $escaped = preg_replace('/(\\\\.)/', '\\$1', $value); + $params[] = $key . '=' . (strlen($escaped) > strlen($value) ? "\"{$escaped}\"" : $value); + } + return $this->value === '' ? implode(';', $params) : implode(';', [$this->value, ...$params]); + } + + public function getValue(): string + { + return $this->value; + } + /** + * @return mixed + */ + public function getParsedValue() + { + return $this->getValue(); + } + + // with params + public function withParams(array $params) + { + $clone = clone $this; + $clone->params = $params; + if ($clone instanceof WithQualityParam) { + $clone->quality = $params['q'] ?? '1'; + } + return $clone; + } + public function getParams(): iterable + { + return $this->params; + } + // with quality + public function getQuality(): string + { + return $this->quality; + } + public function withError(string $error) + { + $clone = clone $this; + $clone->error = $error; + return $clone; + } + public function getError(): ?string + { + return $this->error; + } +} diff --git a/src/HeaderValues.php b/src/HeaderValues.php deleted file mode 100644 index f57f203..0000000 --- a/src/HeaderValues.php +++ /dev/null @@ -1,90 +0,0 @@ -headerClass = $headerClass; - if (class_exists($headerClass)) { - if (!is_subclass_of($headerClass, Header::class, true)) { - throw new InvalidArgumentException("{$headerClass} is not a header."); - } - if (is_a($headerClass, DefaultHeader::class, true)) { - throw new InvalidArgumentException("{$headerClass} has no header name."); - } - $this->headerName = $headerClass::NAME; - $this->listedValues = is_subclass_of($headerClass, ListedValues::class, true); - $this->withParams = is_subclass_of($headerClass, WithParams::class, true); - } else { - $this->headerName = $headerClass; - $this->headerClass = DefaultHeader::class; - } - } - - /** - * @param Header[]|string[] $headers - * @param string $headerClass - * @return static - * @throws InvalidArgumentException - */ - public static function createFromArray(array $headers, string $headerClass): self - { - $collection = new static($headerClass); - foreach ($headers as $header) { - $collection->add($header); - } - return $collection; - } - - public function getName(): string - { - return $this->headerName; - } - - /** - * @param string|Header $value - * @return $this - */ - public function add($value): self - { - if ($value instanceof Header) { - if (get_class($value) !== $this->headerClass) { - throw new InvalidArgumentException( - sprintf('The value must be an instance of %s, %s given', $this->headerClass, get_class($value)) - ); - } - $this->collection[] = $value; - return $this; - } - if (is_string($value)) { - $this->collection[] = new $this->headerClass($value); - return $this; - } - throw new InvalidArgumentException(sprintf('The value must be an instance of %s or string', $this->headerClass)); - } - - public function getValues(): array - { - $result = []; - foreach ($this->collection as $header) { - $result[] = $header->getValue(); - } - return $result; - } -} diff --git a/tests/Header/HeaderTest.php b/tests/Header/HeaderValueTest.php similarity index 80% rename from tests/Header/HeaderTest.php rename to tests/Header/HeaderValueTest.php index 601eeb9..e317927 100644 --- a/tests/Header/HeaderTest.php +++ b/tests/Header/HeaderValueTest.php @@ -4,7 +4,7 @@ use PHPUnit\Framework\TestCase; -class HeaderTest extends TestCase +class HeaderValueTest extends TestCase { public function testInit() { diff --git a/tests/Header/Stub/QualityHeaderValue.php b/tests/Header/Stub/QualityHeaderValue.php new file mode 100644 index 0000000..8041b16 --- /dev/null +++ b/tests/Header/Stub/QualityHeaderValue.php @@ -0,0 +1,10 @@ +assertSame('Date', $values->getName()); + } + public function testErrorWithDefaultHeaderClass() + { + $this->expectException(InvalidArgumentException::class); + new Header(HeaderValue::class); + } + public function testErrorWithHeaderClass() + { + $this->expectException(InvalidArgumentException::class); + new Header(HeaderValue::class); + } + public function testErrorIfNotHeaderClass() + { + $this->expectException(InvalidArgumentException::class); + new Header(\DateTimeImmutable::class); + } + public function testCreateFromOneStringValue() + { + $headers = ['Newauth realm="apps", type=1, title="Login to \\"apps\\"", Basic realm="simple"']; + + $values = Header::createFromArray($headers, 'WWW-Authenticate'); + + $this->assertSame('WWW-Authenticate', $values->getName()); + $this->assertSame($headers, $values->getStrings()); + } + public function testCreateFromFewStringValues() + { + $headers = [ + 'text/*;q=0.3', + 'text/html;q=0.7', + 'text/html;level=1', + 'text/html;level=2;q=0.4', + '*/*;q=0.5', + 'text/*', + 'text/plain', + 'text/plain;format=flowed', + '*/*', + ]; + + $values = Header::createFromArray($headers, 'accept'); + + $this->assertSame('accept', $values->getName()); + $this->assertSame($headers, $values->getStrings()); + } + public function testAddObject() + { + $headers = [ + 'text/*;q=0.3', + 'text/html;q=0.7', + ]; + $values = Header::createFromArray($headers, Accept::class); + + $values->add(new Accept('*/*')); + + $this->assertSame(Accept::NAME, $values->getName()); + $this->assertSame(['text/*;q=0.3', 'text/html;q=0.7', '*/*'], $values->getStrings()); + } + public function testExceptionWhenAddOtherObject() + { + $headers = [ + 'text/*;q=0.3', + 'text/html;q=0.7', + ]; + $values = Header::createFromArray($headers, Accept::class); + + $this->expectException(InvalidArgumentException::class); + + $values->add(new Date('*/*')); + } + + public function valueAndParametersDataProvider(): array + { + return [ + 'empty' => ['', '', []], + 'noParams' => ['test', 'test', []], + 'withParams' => ['test;q=1.0;version=2', 'test', ['q' => '1.0', 'version' => '2']], + 'simple1' => ['audio/*;q=0.2', 'audio/*', ['q' => '0.2']], + 'simple2' => ['gzip;q=1.0', 'gzip', ['q' => '1.0']], + 'simple3' => ['identity;q=0.5', 'identity', ['q' => '0.5']], + 'simple4' => ['*;q=0', '*', ['q' => '0']], + 'witQuotedParameter' => [ + 'test;noqoute=test;qoute="test2"', + 'test', + ['noqoute' => 'test', 'qoute' => 'test2'], + 'test;noqoute=test;qoute=test2', + ], + 'withSpaces' => [ + 'test; q=1.0; version=2', + 'test', + ['q' => '1.0', 'version' => '2'], + 'test;q=1.0;version=2', + ], + 'moreSpaces' => [ + 'test ; q = 1.0 ; version = 2', + 'test', + ['q' => '1.0', 'version' => '2'], + 'test;q=1.0;version=2', + ], + 'emptyValueWithParam' => ['param=value', '', ['param' => 'value']], + 'emptyValueWithParam2' => ['param=value;a=b', '', ['param' => 'value', 'a' => 'b']], + 'missingDelimiterBtwValueAndParam' => ['value a=a1', 'value', ['a' => 'a1'], 'value;a=a1'], + ]; + } + + /** + * @dataProvider valueAndParametersDataProvider + */ + public function testParsingAndRepackOfValueAndParams( + string $input, + string $value, + array $params, + string $output = null + ): void { + $header = new Header(QualityHeaderValue::class); + $header->add($input); + /** @var QualityHeaderValue $headerValue */ + $headerValue = $header->getValues()[0]; + + $this->assertSame($value, $headerValue->getValue()); + $this->assertSame($params, $headerValue->getParams()); + $this->assertSame($output ?? $input, $headerValue->__toString()); + } + + + public function incorrectValueAndParametersDataProvider(): array + { + return [ + 'quotedValue' => ['"value"', false, '"value"', []], + 'doubleColon' => [': value;a=b', false, ': value', ['a' => 'b']], + 'missingDelimiterBtwParams' => ['value; a=a1 b=b1', true, 'value', ['a' => 'a1'], 'value;a=a1'], + ]; + } + /** + * @dataProvider incorrectValueAndParametersDataProvider + */ + public function testParsingAndRepackOfIncorrectValueAndParams( + string $input, + bool $withError, + string $value, + array $params, + string $output = null + ): void { + $header = new Header(QualityHeaderValue::class); + $header->add($input); + /** @var QualityHeaderValue $headerValue */ + $headerValue = $header->getValues()[0]; + + $this->assertSame($withError, $headerValue->getError() !== null); + $this->assertSame($value, $headerValue->getValue()); + $this->assertSame($params, $headerValue->getParams()); + $this->assertSame($output ?? $input, $headerValue->__toString()); + } +} diff --git a/tests/HeaderValuesTest.php b/tests/HeaderValuesTest.php deleted file mode 100644 index 3f27252..0000000 --- a/tests/HeaderValuesTest.php +++ /dev/null @@ -1,89 +0,0 @@ -assertSame('Date', $values->getName()); - } - public function testErrorWithDefaultHeaderClass() - { - $this->expectException(InvalidArgumentException::class); - new HeaderValues(DefaultHeader::class); - } - public function testErrorWithHeaderClass() - { - $this->expectException(InvalidArgumentException::class); - new HeaderValues(Header::class); - } - public function testErrorIfNotHeaderClass() - { - $this->expectException(InvalidArgumentException::class); - new HeaderValues(\DateTimeImmutable::class); - } - public function testCreateFromOneStringValue() - { - $headers = ['Newauth realm="apps", type=1, title="Login to \\"apps\\"", Basic realm="simple"']; - - $values = HeaderValues::createFromArray($headers, 'WWW-Authenticate'); - - $this->assertSame('WWW-Authenticate', $values->getName()); - $this->assertSame($headers, $values->getValues()); - } - public function testCreateFromFewStringValues() - { - $headers = [ - 'text/*;q=0.3', - 'text/html;q=0.7', - 'text/html;level=1', - 'text/html;level=2;q=0.4', - '*/*;q=0.5', - 'text/*', - 'text/plain', - 'text/plain;format=flowed', - '*/*', - ]; - - $values = HeaderValues::createFromArray($headers, 'accept'); - - $this->assertSame('accept', $values->getName()); - $this->assertSame($headers, $values->getValues()); - } - public function testAddObject() - { - $headers = [ - 'text/*;q=0.3', - 'text/html;q=0.7', - ]; - $values = HeaderValues::createFromArray($headers, Accept::class); - - $values->add(new Accept('*/*')); - - $this->assertSame(Accept::NAME, $values->getName()); - $this->assertSame(['text/*;q=0.3', 'text/html;q=0.7', '*/*'], $values->getValues()); - } - public function testExceptionWhenAddOtherObject() - { - $headers = [ - 'text/*;q=0.3', - 'text/html;q=0.7', - ]; - $values = HeaderValues::createFromArray($headers, Accept::class); - - $this->expectException(InvalidArgumentException::class); - - $values->add(new Date('*/*')); - } - -} From cac8cabf376b2238072f53163779e447a31e3a78 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 27 Feb 2020 00:21:41 +0300 Subject: [PATCH 03/26] draft :: next iteration --- src/Header.php | 39 ++++++++++++++++++++++---------------- src/Header/HeaderValue.php | 13 ++++++++----- tests/HeaderTest.php | 31 +++++++++++++++++++++++------- 3 files changed, 55 insertions(+), 28 deletions(-) diff --git a/src/Header.php b/src/Header.php index b78714b..c6f130f 100644 --- a/src/Header.php +++ b/src/Header.php @@ -8,7 +8,7 @@ use Yiisoft\Http\Header\ListedValues; use Yiisoft\Http\Header\WithParams; -final class Header +final class Header implements \IteratorAggregate { private string $headerClass; private string $headerName; @@ -45,6 +45,11 @@ public function __construct(string $nameOrClass) } } + public function getIterator() + { + return $this->collection; + } + /** * @param HeaderValue[]|string[] $headers * @param string $headerClass @@ -110,12 +115,18 @@ private function parseAndCollect(string $body): void $buffer = ''; $key = ''; $value = ''; - $error = ''; $params = []; + $error = null; $resetState = static function () use (&$key, &$value, &$buffer, &$params) { $key = $buffer = $value = ''; $params = []; }; + $addParam = static function ($key, $value) use (&$params) { + $key = strtolower($key); + if (!key_exists($key, $params)) { + $params[$key] = $value; + } + }; $added = 0; try { /** @see https://tools.ietf.org/html/rfc7230#section-3.2.6 */ @@ -123,17 +134,19 @@ private function parseAndCollect(string $body): void $s = $body[$i]; if ($part === self::PART_VALUE) { if ($s === '=' && $this->withParams) { - $part = self::PART_PARAM_VALUE; $key = trim($buffer); $buffer = ''; if (strpos($key, ' ') === false) { + $part = self::PART_PARAM_VALUE; continue; } $key = preg_replace('/\s+/', ' ', $key); $chunks = explode(' ', $key); if (count($chunks) > 2) { + $buffer = $chunks[0]; throw new \Exception('Syntax error'); } + $part = self::PART_PARAM_VALUE; [$value, $key] = $chunks; } elseif ($s === ';' && $this->withParams) { $part = self::PART_PARAM_NAME; @@ -185,16 +198,16 @@ private function parseAndCollect(string $body): void } } elseif (ord($s) <= 32) { $part = self::PART_NONE; - $params[$key] = rtrim($buffer); + $addParam($key, $buffer); $key = $buffer = ''; } elseif (strpos(self::DELIMITERS, $s) === false) { $buffer .= $s; } elseif ($s === ';') { $part = self::PART_PARAM_NAME; - $params[$key] = rtrim($buffer); + $addParam($key, $buffer); $key = $buffer = ''; } elseif ($s === ',' && $this->listedValues) { - $params[$key] = $buffer; + $addParam($key, $buffer); $this->collection[] = (new $this->headerClass(trim($buffer)))->withParams($params); ++$added; $resetState(); @@ -212,7 +225,7 @@ private function parseAndCollect(string $body): void } } elseif ($s === '"') { // end $part = self::PART_NONE; - $params[$key] = $buffer; + $addParam($key, $buffer); $key = $buffer = ''; } else { $buffer .= $s; @@ -220,7 +233,7 @@ private function parseAndCollect(string $body): void } } } catch (\Exception $e) { - $error = $e->getMessage(); + $error = $e; } if ($added > 0 && $value === '' && $buffer === '') { return; @@ -228,14 +241,8 @@ private function parseAndCollect(string $body): void /** @var HeaderValue $item */ $item = new $this->headerClass($part === self::PART_VALUE ? trim($buffer) : $value); if (in_array($part, [self::PART_PARAM_VALUE, self::PART_PARAM_QUOTED_VALUE], true)) { - $params[$key] = $buffer; - } - if (count($params) > 0) { - $item = $item->withParams($params); - } - if ($error !== '') { - $item = $item->withError($error); + $addParam($key, $buffer); } - $this->collection[] = $item; + $this->collection[] = $item->withError($error)->withParams($params); } } diff --git a/src/Header/HeaderValue.php b/src/Header/HeaderValue.php index 6eb451d..1af2c6e 100644 --- a/src/Header/HeaderValue.php +++ b/src/Header/HeaderValue.php @@ -2,6 +2,8 @@ namespace Yiisoft\Http\Header; +use Exception; + abstract class HeaderValue { public const NAME = null; @@ -10,7 +12,7 @@ abstract class HeaderValue /** @var array */ private array $params = []; private string $quality = '1'; - private ?string $error = null; + private ?Exception $error = null; public function __construct(string $value) { @@ -21,8 +23,9 @@ public function __toString(): string { $params = []; foreach ($this->params as $key => $value) { - $escaped = preg_replace('/(\\\\.)/', '\\$1', $value); - $params[] = $key . '=' . (strlen($escaped) > strlen($value) ? "\"{$escaped}\"" : $value); + $escaped = preg_replace('/([\\\\"])/', '\\\\$1', $value); + $quoted = $value === '' || strpos(' ', $escaped) !== false || strlen($escaped) > strlen($value); + $params[] = $key . '=' . ($quoted ? "\"{$escaped}\"" : $value); } return $this->value === '' ? implode(';', $params) : implode(';', [$this->value, ...$params]); } @@ -58,13 +61,13 @@ public function getQuality(): string { return $this->quality; } - public function withError(string $error) + public function withError(?Exception $error) { $clone = clone $this; $clone->error = $error; return $clone; } - public function getError(): ?string + public function getError(): ?Exception { return $this->error; } diff --git a/tests/HeaderTest.php b/tests/HeaderTest.php index a941866..e2b1c5d 100644 --- a/tests/HeaderTest.php +++ b/tests/HeaderTest.php @@ -96,19 +96,33 @@ public function valueAndParametersDataProvider(): array 'simple2' => ['gzip;q=1.0', 'gzip', ['q' => '1.0']], 'simple3' => ['identity;q=0.5', 'identity', ['q' => '0.5']], 'simple4' => ['*;q=0', '*', ['q' => '0']], - 'witQuotedParameter' => [ - 'test;noqoute=test;qoute="test2"', + 'quotedParameter' => [ + 'test;noquote=test;quote="test2"', 'test', - ['noqoute' => 'test', 'qoute' => 'test2'], - 'test;noqoute=test;qoute=test2', + ['noquote' => 'test', 'quote' => 'test2'], + 'test;noquote=test;quote=test2', ], - 'withSpaces' => [ + 'quotedEmptyParameter' => ['quote=""', '', ['quote' => '']], + 'singleQuoted' => ["a='a'", '', ['a' => "'a'"]], + 'mixedQuotes' => [ + 'a="tes\'t";sp=" s p ";test="\'test\'";test2="\\"quoted\\" test"', + '', + ['a' => 'tes\'t', 'sp' => ' s p ', 'test' => '\'test\'', 'test2' => '"quoted" test'], + 'a=tes\'t;sp= s p ;test=\'test\';test2="\\"quoted\\" test"', + ], + 'slashes' => [ + 'a="\\t\\e\\s\\t";b="te\\\\st";c="\\"\\"', + '', + ['a' => 'test', 'b' => 'te\\st', 'c' => '""'], + 'a=test;b="te\\\\st";c="\\"\\""', + ], + 'withSpacesAfterDelimiters' => [ 'test; q=1.0; version=2', 'test', ['q' => '1.0', 'version' => '2'], 'test;q=1.0;version=2', ], - 'moreSpaces' => [ + 'spacesAroundDelimiters' => [ 'test ; q = 1.0 ; version = 2', 'test', ['q' => '1.0', 'version' => '2'], @@ -116,7 +130,7 @@ public function valueAndParametersDataProvider(): array ], 'emptyValueWithParam' => ['param=value', '', ['param' => 'value']], 'emptyValueWithParam2' => ['param=value;a=b', '', ['param' => 'value', 'a' => 'b']], - 'missingDelimiterBtwValueAndParam' => ['value a=a1', 'value', ['a' => 'a1'], 'value;a=a1'], + 'missingDelimiterBtwValueAndParam' => ['value a=a1', 'value', ['a' => 'a1'], 'value;a=a1'], ]; } @@ -146,6 +160,9 @@ public function incorrectValueAndParametersDataProvider(): array 'quotedValue' => ['"value"', false, '"value"', []], 'doubleColon' => [': value;a=b', false, ': value', ['a' => 'b']], 'missingDelimiterBtwParams' => ['value; a=a1 b=b1', true, 'value', ['a' => 'a1'], 'value;a=a1'], + 'doubleDelimiter1' => ['value; a=a1;;b=b1', true, 'value', ['a' => 'a1'], 'value;a=a1'], + 'doubleDelimiter2' => ['value;;a=a1;b=b1', true, 'value', [], 'value'], + 'tooMoreSpaces' => ['foo bar param=1', true, 'foo', [], 'foo'], ]; } /** From bdd0ca7d32242a2a329305b74457cfe8a4056e8a Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 27 Feb 2020 16:03:07 +0300 Subject: [PATCH 04/26] More tests --- src/Header.php | 44 +++++++++++----- src/Header/HeaderValue.php | 19 +++++-- tests/Header/Stub/WithParamsHeaderValue.php | 11 ++++ tests/HeaderTest.php | 57 +++++++++++++++++---- 4 files changed, 105 insertions(+), 26 deletions(-) create mode 100644 tests/Header/Stub/WithParamsHeaderValue.php diff --git a/src/Header.php b/src/Header.php index c6f130f..93488c1 100644 --- a/src/Header.php +++ b/src/Header.php @@ -92,14 +92,24 @@ public function add($value): self throw new InvalidArgumentException(sprintf('The value must be an instance of %s or string', $this->headerClass)); } - public function getValues(): array + public function getValues($ignoreIncorrect = true): array { - return $this->collection; + $result = []; + foreach ($this->collection as $header) { + if ($ignoreIncorrect && $header->getError() !== null) { + continue; + } + $result[] = $header; + } + return $result; } - public function getStrings(): array + public function getStrings($ignoreIncorrect = true): array { $result = []; foreach ($this->collection as $header) { + if ($ignoreIncorrect && $header->getError() !== null) { + continue; + } $result[] = $header->__toString(); } return $result; @@ -134,16 +144,17 @@ private function parseAndCollect(string $body): void $s = $body[$i]; if ($part === self::PART_VALUE) { if ($s === '=' && $this->withParams) { - $key = trim($buffer); + $key = ltrim($buffer); $buffer = ''; - if (strpos($key, ' ') === false) { + if (preg_match('/\s/', $key) === 0) { $part = self::PART_PARAM_VALUE; continue; } $key = preg_replace('/\s+/', ' ', $key); $chunks = explode(' ', $key); - if (count($chunks) > 2) { - $buffer = $chunks[0]; + if (count($chunks) > 2 || preg_match('/\s$/', $key) === 1) { + array_pop($chunks); + $buffer = implode(' ',$chunks); throw new \Exception('Syntax error'); } $part = self::PART_PARAM_VALUE; @@ -166,10 +177,12 @@ private function parseAndCollect(string $body): void $key = trim($buffer); $buffer = ''; $part = self::PART_PARAM_VALUE; - } elseif (strpos(self::DELIMITERS, $s) === false) { - $buffer .= $s; - } else { + } elseif (strpos(self::DELIMITERS, $s) !== false) { throw new \Exception("Delimiter char \"{$s}\" in a param name"); + } elseif (ord($s) <= 32 && $buffer !== '') { + throw new \Exception("Space in a param name"); + } else { + $buffer .= $s; } continue; } @@ -193,8 +206,10 @@ private function parseAndCollect(string $body): void $part = self::PART_PARAM_QUOTED_VALUE; } elseif (ord($s) <= 32) { continue; - } else { + } elseif (strpos(self::DELIMITERS, $s) === false) { $buffer .= $s; + } else { + throw new \Exception("Delimiter char \"{$s}\" in a unquoted param value"); } } elseif (ord($s) <= 32) { $part = self::PART_NONE; @@ -212,6 +227,7 @@ private function parseAndCollect(string $body): void ++$added; $resetState(); } else { + $buffer = ''; throw new \Exception("Delimiter char \"{$s}\" in a unquoted param value"); } continue; @@ -241,7 +257,11 @@ private function parseAndCollect(string $body): void /** @var HeaderValue $item */ $item = new $this->headerClass($part === self::PART_VALUE ? trim($buffer) : $value); if (in_array($part, [self::PART_PARAM_VALUE, self::PART_PARAM_QUOTED_VALUE], true)) { - $addParam($key, $buffer); + if ($buffer === '') { + $error = $error ?? new \Exception('Empty value should be quoted'); + } else { + $addParam($key, $buffer); + } } $this->collection[] = $item->withError($error)->withParams($params); } diff --git a/src/Header/HeaderValue.php b/src/Header/HeaderValue.php index 1af2c6e..bc3d2c9 100644 --- a/src/Header/HeaderValue.php +++ b/src/Header/HeaderValue.php @@ -24,7 +24,9 @@ public function __toString(): string $params = []; foreach ($this->params as $key => $value) { $escaped = preg_replace('/([\\\\"])/', '\\\\$1', $value); - $quoted = $value === '' || strpos(' ', $escaped) !== false || strlen($escaped) > strlen($value); + $quoted = $value === '' + || strlen($escaped) > strlen($value) + || preg_match('/[(),\\/:;<=>?@\\[\\\\\\]{} ]/', $value) === 1; $params[] = $key . '=' . ($quoted ? "\"{$escaped}\"" : $value); } return $this->value === '' ? implode(';', $params) : implode(';', [$this->value, ...$params]); @@ -42,11 +44,16 @@ public function getParsedValue() return $this->getValue(); } - // with params public function withParams(array $params) { $clone = clone $this; - $clone->params = $params; + $clone->params = []; + foreach ($params as $key => $value) { + $key = strtolower($key); + if (!key_exists($key, $clone->params)) { + $clone->params[$key] = $value; + } + } if ($clone instanceof WithQualityParam) { $clone->quality = $params['q'] ?? '1'; } @@ -54,7 +61,11 @@ public function withParams(array $params) } public function getParams(): iterable { - return $this->params; + $result = $this->params; + if ($this instanceof WithQualityParam) { + $result['q'] = $this->getQuality(); + } + return $result; } // with quality public function getQuality(): string diff --git a/tests/Header/Stub/WithParamsHeaderValue.php b/tests/Header/Stub/WithParamsHeaderValue.php new file mode 100644 index 0000000..ffd8053 --- /dev/null +++ b/tests/Header/Stub/WithParamsHeaderValue.php @@ -0,0 +1,11 @@ + 'tes\'t', 'sp' => ' s p ', 'test' => '\'test\'', 'test2' => '"quoted" test'], - 'a=tes\'t;sp= s p ;test=\'test\';test2="\\"quoted\\" test"', + 'a=tes\'t;sp=" s p ";test=\'test\';test2="\\"quoted\\" test"', ], 'slashes' => [ 'a="\\t\\e\\s\\t";b="te\\\\st";c="\\"\\"', @@ -116,6 +116,10 @@ public function valueAndParametersDataProvider(): array ['a' => 'test', 'b' => 'te\\st', 'c' => '""'], 'a=test;b="te\\\\st";c="\\"\\""', ], + 'specChars1' => ['*|*/*\\*;*=test;test=*', '*|*/*\\*', ['*' => 'test', 'test' => '*']], + 'numbers' => ['123.45;a=8888;b="999"', '123.45', ['a' => '8888', 'b' => '999'], '123.45;a=8888;b=999'], + 'specChars2' => ['param*1=a;param*2=b', '', ['param*1' => 'a', 'param*2' => 'b']], + 'withSpacesAfterDelimiters' => [ 'test; q=1.0; version=2', 'test', @@ -123,7 +127,7 @@ public function valueAndParametersDataProvider(): array 'test;q=1.0;version=2', ], 'spacesAroundDelimiters' => [ - 'test ; q = 1.0 ; version = 2', + 'test ; q=1.0 ; version=2', 'test', ['q' => '1.0', 'version' => '2'], 'test;q=1.0;version=2', @@ -131,6 +135,19 @@ public function valueAndParametersDataProvider(): array 'emptyValueWithParam' => ['param=value', '', ['param' => 'value']], 'emptyValueWithParam2' => ['param=value;a=b', '', ['param' => 'value', 'a' => 'b']], 'missingDelimiterBtwValueAndParam' => ['value a=a1', 'value', ['a' => 'a1'], 'value;a=a1'], + 'case' => ['VaLue;A=TEST;TEST=B', 'VaLue', ['a' => 'TEST', 'test' => 'B'], 'VaLue;a=TEST;test=B'], + 'percent' => [ + '%12%34%;a=%1;b="foo-%32-bar"', + '%12%34%', + ['a' => '%1', 'b' => 'foo-%32-bar'], + '%12%34%;a=%1;b=foo-%32-bar', + ], + 'RFC2231/5987-1' => [ + 'attachment;filename*=UTF-8\'\'foo-%c3%a4-%e2%82%ac.html', + 'attachment', + ['filename*' => 'UTF-8\'\'foo-%c3%a4-%e2%82%ac.html'], + 'attachment;filename*=UTF-8\'\'foo-%c3%a4-%e2%82%ac.html', + ], ]; } @@ -143,10 +160,10 @@ public function testParsingAndRepackOfValueAndParams( array $params, string $output = null ): void { - $header = new Header(QualityHeaderValue::class); + $header = new Header(WithParamsHeaderValue::class); $header->add($input); - /** @var QualityHeaderValue $headerValue */ - $headerValue = $header->getValues()[0]; + /** @var WithParamsHeaderValue $headerValue */ + $headerValue = $header->getValues(true)[0]; $this->assertSame($value, $headerValue->getValue()); $this->assertSame($params, $headerValue->getParams()); @@ -160,9 +177,29 @@ public function incorrectValueAndParametersDataProvider(): array 'quotedValue' => ['"value"', false, '"value"', []], 'doubleColon' => [': value;a=b', false, ': value', ['a' => 'b']], 'missingDelimiterBtwParams' => ['value; a=a1 b=b1', true, 'value', ['a' => 'a1'], 'value;a=a1'], + 'spacesInParamKey' => ['value;a a=b', true, 'value', [], 'value'], + 'spaces1' => ['test ; q = 1.0 ; version = 2', true, 'test', [], 'test'], + 'spaces2' => ['q = 1.0', true, 'q', [], 'q'], + 'spaces3' => ['a=b c', true, '', ['a' => 'b'], 'a=b'], 'doubleDelimiter1' => ['value; a=a1;;b=b1', true, 'value', ['a' => 'a1'], 'value;a=a1'], 'doubleDelimiter2' => ['value;;a=a1;b=b1', true, 'value', [], 'value'], - 'tooMoreSpaces' => ['foo bar param=1', true, 'foo', [], 'foo'], + 'tooMoreSpaces' => ['foo bar param=1', true, 'foo bar', [], 'foo bar'], + 'invalidQuotes1' => ['a="', true, '', [], ''], + 'invalidQuotes2' => ['a="test', false, '', ['a' => 'test'], 'a=test'], + 'invalidQuotes3' => ['a=test"', true, '', [], ''], + 'invalidQuotes4' => ['a=te"st', true, '', [], ''], + 'invalidEmptyValue' => ['a=b; c=', true, '', ['a' => 'b'], 'a=b'], + 'invalidEmptyParam' => ['a=b; ;c=d', true, '', ['a' => 'b'], 'a=b'], + 'semicolonAtEnd' => ['a=b;', false, '', ['a' => 'b'], 'a=b'], + 'comma' => ['a=test,test', true, '', [], ''], + 'sameParamName' => ['a=T1;a="T2"',false, '', ['a' => 'T1'], 'a=T1'], + 'sameParamNameCase' => ['aa=T1;Aa="T2"',false, '', ['aa' => 'T1'], 'aa=T1'], + 'brokenToken' => ['a=foo[1](2).html', true, '', [], ''], + 'brokenSyntax1' => ['a==b', true, '', [], ''], + 'brokenSyntax2' => ['value; a *=b', true, 'value', [], 'value'], + 'brokenSyntax3' => ['value;a *=b', true, 'value', [], 'value'], + # Invalid syntax but most browsers accept the umlaut with warn + 'brokenToken2' => ['a=foo-ä.html', false, '', ['a' => 'foo-ä.html']], ]; } /** @@ -175,10 +212,10 @@ public function testParsingAndRepackOfIncorrectValueAndParams( array $params, string $output = null ): void { - $header = new Header(QualityHeaderValue::class); + $header = new Header(WithParamsHeaderValue::class); $header->add($input); - /** @var QualityHeaderValue $headerValue */ - $headerValue = $header->getValues()[0]; + /** @var WithParamsHeaderValue $headerValue */ + $headerValue = $header->getValues(false)[0]; $this->assertSame($withError, $headerValue->getError() !== null); $this->assertSame($value, $headerValue->getValue()); From 63b61edf1ee9c4494b129e6d556c307d071e615c Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 27 Feb 2020 23:14:01 +0300 Subject: [PATCH 05/26] more tests and fixes --- src/Header.php | 133 ++++++++++-------- src/Header/Accept.php | 18 +-- src/Header/HeaderValue.php | 60 ++++++-- src/Header/WithQualityParam.php | 3 + tests/Header/Stub/ListedValuesHeaderValue.php | 12 ++ .../ListedValuesWithParamsHeaderValue.php | 12 ++ tests/Header/Stub/QualityHeaderValue.php | 6 +- tests/Header/Stub/WithParamsHeaderValue.php | 2 +- tests/HeaderTest.php | 124 +++++++++++++++- 9 files changed, 281 insertions(+), 89 deletions(-) create mode 100644 tests/Header/Stub/ListedValuesHeaderValue.php create mode 100644 tests/Header/Stub/ListedValuesWithParamsHeaderValue.php diff --git a/src/Header.php b/src/Header.php index 93488c1..8b2d246 100644 --- a/src/Header.php +++ b/src/Header.php @@ -8,7 +8,7 @@ use Yiisoft\Http\Header\ListedValues; use Yiisoft\Http\Header\WithParams; -final class Header implements \IteratorAggregate +final class Header implements \IteratorAggregate, \Countable { private string $headerClass; private string $headerName; @@ -45,10 +45,14 @@ public function __construct(string $nameOrClass) } } - public function getIterator() + public function getIterator(): iterable { return $this->collection; } + public function count(): int + { + return count($this->collection); + } /** * @param HeaderValue[]|string[] $headers @@ -69,29 +73,10 @@ public function getName(): string { return $this->headerName; } - /** - * @param string|HeaderValue $value - * @return $this + * @param bool $ignoreIncorrect + * @return HeaderValue[] */ - public function add($value): self - { - if ($value instanceof HeaderValue) { - if (get_class($value) !== $this->headerClass) { - throw new InvalidArgumentException( - sprintf('The value must be an instance of %s, %s given', $this->headerClass, get_class($value)) - ); - } - $this->collection[] = $value; - return $this; - } - if (is_string($value)) { - $this->parseAndCollect($value); - return $this; - } - throw new InvalidArgumentException(sprintf('The value must be an instance of %s or string', $this->headerClass)); - } - public function getValues($ignoreIncorrect = true): array { $result = []; @@ -103,6 +88,10 @@ public function getValues($ignoreIncorrect = true): array } return $result; } + /** + * @param bool $ignoreIncorrect + * @return string[] + */ public function getStrings($ignoreIncorrect = true): array { $result = []; @@ -115,6 +104,28 @@ public function getStrings($ignoreIncorrect = true): array return $result; } + /** + * @param string|HeaderValue $value + * @return $this + */ + public function add($value): self + { + if ($value instanceof HeaderValue) { + if (get_class($value) !== $this->headerClass) { + throw new InvalidArgumentException( + sprintf('The value must be an instance of %s, %s given', $this->headerClass, get_class($value)) + ); + } + $this->collection[] = $value; + return $this; + } + if (is_string($value)) { + $this->parseAndCollect($value); + return $this; + } + throw new InvalidArgumentException(sprintf('The value must be an instance of %s or string', $this->headerClass)); + } + private function parseAndCollect(string $body): void { if (!$this->listedValues && !$this->withParams) { @@ -127,16 +138,25 @@ private function parseAndCollect(string $body): void $value = ''; $params = []; $error = null; - $resetState = static function () use (&$key, &$value, &$buffer, &$params) { - $key = $buffer = $value = ''; - $params = []; - }; $addParam = static function ($key, $value) use (&$params) { - $key = strtolower($key); if (!key_exists($key, $params)) { $params[$key] = $value; } }; + $collectHeaderValue = function () use (&$key, &$value, &$buffer, &$params, &$added, &$error) { + /** @var HeaderValue $item */ + $item = new $this->headerClass($value); + if ($this->withParams) { + $item = $item->withParams($params); + } + if ($error !== null) { + $item = $item->withError($error); + } + $this->collection[] = $item; + $key = $buffer = $value = ''; + $params = []; + ++$added; + }; $added = 0; try { /** @see https://tools.ietf.org/html/rfc7230#section-3.2.6 */ @@ -154,7 +174,7 @@ private function parseAndCollect(string $body): void $chunks = explode(' ', $key); if (count($chunks) > 2 || preg_match('/\s$/', $key) === 1) { array_pop($chunks); - $buffer = implode(' ',$chunks); + $buffer = implode(' ', $chunks); throw new \Exception('Syntax error'); } $part = self::PART_PARAM_VALUE; @@ -164,9 +184,8 @@ private function parseAndCollect(string $body): void $value = trim($buffer); $buffer = ''; } elseif ($s === ',' && $this->listedValues) { - $this->collection[] = new $this->headerClass(trim($buffer)); - ++$added; - $resetState(); + $value = trim($buffer); + $collectHeaderValue(); } else { $buffer .= $s; } @@ -174,32 +193,20 @@ private function parseAndCollect(string $body): void } if ($part === self::PART_PARAM_NAME) { if ($s === '=') { - $key = trim($buffer); + $key = $buffer; $buffer = ''; $part = self::PART_PARAM_VALUE; } elseif (strpos(self::DELIMITERS, $s) !== false) { throw new \Exception("Delimiter char \"{$s}\" in a param name"); - } elseif (ord($s) <= 32 && $buffer !== '') { - throw new \Exception("Space in a param name"); + } elseif (ord($s) <= 32) { + if ($buffer !== '') { + throw new \Exception("Space in a param name"); + } } else { $buffer .= $s; } continue; } - if ($part === self::PART_NONE) { - if (ord($s) <= 32) { - continue; - } elseif ($s === ';' && $this->withParams) { - $part = self::PART_PARAM_NAME; - continue; - } elseif ($s === ',' && $this->listedValues) { - $this->collection[] = (new $this->headerClass(trim($buffer)))->withParams($params); - ++$added; - $resetState(); - } else { - throw new \Exception('Expected Separator'); - } - } if ($part === self::PART_PARAM_VALUE) { if ($buffer === '') { if ($s === '"') { @@ -222,10 +229,9 @@ private function parseAndCollect(string $body): void $addParam($key, $buffer); $key = $buffer = ''; } elseif ($s === ',' && $this->listedValues) { + $part = self::PART_VALUE; $addParam($key, $buffer); - $this->collection[] = (new $this->headerClass(trim($buffer)))->withParams($params); - ++$added; - $resetState(); + $collectHeaderValue(); } else { $buffer = ''; throw new \Exception("Delimiter char \"{$s}\" in a unquoted param value"); @@ -246,23 +252,34 @@ private function parseAndCollect(string $body): void } else { $buffer .= $s; } + continue; + } + if ($part === self::PART_NONE) { + if (ord($s) <= 32) { + continue; + } elseif ($s === ';' && $this->withParams) { + $part = self::PART_PARAM_NAME; + } elseif ($s === ',' && $this->listedValues) { + $part = self::PART_VALUE; + $collectHeaderValue(); + } else { + throw new \Exception('Expected Separator'); + } } } } catch (\Exception $e) { $error = $e; } - if ($added > 0 && $value === '' && $buffer === '') { - return; - } /** @var HeaderValue $item */ - $item = new $this->headerClass($part === self::PART_VALUE ? trim($buffer) : $value); - if (in_array($part, [self::PART_PARAM_VALUE, self::PART_PARAM_QUOTED_VALUE], true)) { + if ($part === self::PART_VALUE) { + $value = trim($buffer); + } elseif (in_array($part, [self::PART_PARAM_VALUE, self::PART_PARAM_QUOTED_VALUE], true)) { if ($buffer === '') { $error = $error ?? new \Exception('Empty value should be quoted'); } else { $addParam($key, $buffer); } } - $this->collection[] = $item->withError($error)->withParams($params); + $collectHeaderValue(); } } diff --git a/src/Header/Accept.php b/src/Header/Accept.php index 51e04ea..b8bc5f9 100644 --- a/src/Header/Accept.php +++ b/src/Header/Accept.php @@ -2,22 +2,10 @@ namespace Yiisoft\Http\Header; +/** + * @see https://tools.ietf.org/html/rfc7231#section-5.3.2 + */ class Accept extends HeaderValue implements WithQualityParam { public const NAME = 'Accept'; - - public function getParams(): iterable - { - return parent::getParams(); - } - - public function withParams(array $params) - { - return parent::withParams($params); - } - - public function getQuality(): string - { - return parent::getQuality(); - } } diff --git a/src/Header/HeaderValue.php b/src/Header/HeaderValue.php index bc3d2c9..92b0ad1 100644 --- a/src/Header/HeaderValue.php +++ b/src/Header/HeaderValue.php @@ -9,12 +9,19 @@ abstract class HeaderValue public const NAME = null; protected string $value; - /** @var array */ + /** + * @see WithParams + * @var array + */ private array $params = []; + /** + * @see WithQualityParam + * @var string + */ private string $quality = '1'; private ?Exception $error = null; - public function __construct(string $value) + public function __construct(string $value = '') { $this->value = $value; } @@ -22,11 +29,16 @@ public function __construct(string $value) public function __toString(): string { $params = []; - foreach ($this->params as $key => $value) { + foreach ($this->getParams() as $key => $value) { + if ($key === 'q' && $this instanceof WithQualityParam) { + if ($value === '1') { + continue; + } + } $escaped = preg_replace('/([\\\\"])/', '\\\\$1', $value); $quoted = $value === '' - || strlen($escaped) > strlen($value) - || preg_match('/[(),\\/:;<=>?@\\[\\\\\\]{} ]/', $value) === 1; + || strlen($escaped) !== strlen($value) + || preg_match('/[\\s,;()\\/:<=>?@\\[\\\\\\]{}]/', $value) === 1; $params[] = $key . '=' . ($quoted ? "\"{$escaped}\"" : $value); } return $this->value === '' ? implode(';', $params) : implode(';', [$this->value, ...$params]); @@ -44,8 +56,17 @@ public function getParsedValue() return $this->getValue(); } - public function withParams(array $params) + /** + * @param array $params + * @return $this + * @throws Exception + */ + public function withParams(array $params): self { + if (!$this instanceof WithParams) { + #todo: test it + throw new Exception(sprintf('Method withParams requires %s interface', WithParams::class)); + } $clone = clone $this; $clone->params = []; foreach ($params as $key => $value) { @@ -55,7 +76,12 @@ public function withParams(array $params) } } if ($clone instanceof WithQualityParam) { - $clone->quality = $params['q'] ?? '1'; + if (key_exists('q', $clone->params)) { + $clone->setQuality($clone->params['q']); + unset($clone->params['q']); + } else { + $clone->setQuality('1'); + } } return $clone; } @@ -63,16 +89,22 @@ public function getParams(): iterable { $result = $this->params; if ($this instanceof WithQualityParam) { - $result['q'] = $this->getQuality(); + $result['q'] = $this->quality; } return $result; } - // with quality + /** + * @return string value between 0.000 and 1.000 + */ public function getQuality(): string { return $this->quality; } - public function withError(?Exception $error) + /** + * @param Exception|null $error + * @return $this + */ + public function withError(?Exception $error): self { $clone = clone $this; $clone->error = $error; @@ -82,4 +114,12 @@ public function getError(): ?Exception { return $this->error; } + protected function setQuality(string $q): bool + { + if (preg_match('/^0(?:\\.\\d{1,3})?$|^1(?:\\.0{1,3})?$/', $q) !== 1) { + return false; + } + $this->quality = rtrim($q, '0.') ?: '0'; + return true; + } } diff --git a/src/Header/WithQualityParam.php b/src/Header/WithQualityParam.php index 23efbee..c3103b9 100644 --- a/src/Header/WithQualityParam.php +++ b/src/Header/WithQualityParam.php @@ -2,6 +2,9 @@ namespace Yiisoft\Http\Header; +/** + * @see https://tools.ietf.org/html/rfc7231#section-5.3.1 + */ interface WithQualityParam extends ListedValues, WithParams { public function getQuality(); diff --git a/tests/Header/Stub/ListedValuesHeaderValue.php b/tests/Header/Stub/ListedValuesHeaderValue.php new file mode 100644 index 0000000..0839932 --- /dev/null +++ b/tests/Header/Stub/ListedValuesHeaderValue.php @@ -0,0 +1,12 @@ + ['', '', []], 'noParams' => ['test', 'test', []], - 'withParams' => ['test;q=1.0;version=2', 'test', ['q' => '1.0', 'version' => '2']], + 'withParams' => ['test;q=1;version=2', 'test', ['q' => '1', 'version' => '2']], 'simple1' => ['audio/*;q=0.2', 'audio/*', ['q' => '0.2']], 'simple2' => ['gzip;q=1.0', 'gzip', ['q' => '1.0']], 'simple3' => ['identity;q=0.5', 'identity', ['q' => '0.5']], @@ -150,7 +153,6 @@ public function valueAndParametersDataProvider(): array ], ]; } - /** * @dataProvider valueAndParametersDataProvider */ @@ -170,7 +172,6 @@ public function testParsingAndRepackOfValueAndParams( $this->assertSame($output ?? $input, $headerValue->__toString()); } - public function incorrectValueAndParametersDataProvider(): array { return [ @@ -191,7 +192,8 @@ public function incorrectValueAndParametersDataProvider(): array 'invalidEmptyValue' => ['a=b; c=', true, '', ['a' => 'b'], 'a=b'], 'invalidEmptyParam' => ['a=b; ;c=d', true, '', ['a' => 'b'], 'a=b'], 'semicolonAtEnd' => ['a=b;', false, '', ['a' => 'b'], 'a=b'], - 'comma' => ['a=test,test', true, '', [], ''], + 'comma1' => ['foo, bar;a=test', false, 'foo, bar', ['a' => 'test'], 'foo, bar;a=test'], + 'comma2' => ['a=test,test', true, '', [], ''], 'sameParamName' => ['a=T1;a="T2"',false, '', ['a' => 'T1'], 'a=T1'], 'sameParamNameCase' => ['aa=T1;Aa="T2"',false, '', ['aa' => 'T1'], 'aa=T1'], 'brokenToken' => ['a=foo[1](2).html', true, '', [], ''], @@ -222,4 +224,118 @@ public function testParsingAndRepackOfIncorrectValueAndParams( $this->assertSame($params, $headerValue->getParams()); $this->assertSame($output ?? $input, $headerValue->__toString()); } + + public function QualityParametersDataProvider(): array + { + return [ + 'q1' => ['1', true, '1'], + 'q1.0' => ['1.0', true, '1'], + 'q1.000' => ['1.000', true, '1'], + 'q1.0000' => ['1.0000', false], + 'q1.' => ['1.', false], + 'q1.1' => ['1.1', false], + 'q2.0' => ['2.0', false], + 'q-0.001' => ['-0.001', false], + 'q-0' => ['-0', false], + 'q<0' => ['-1', false], + 'q0' => ['0', true, '0'], + 'q0.0' => ['0.0', true, '0'], + 'q0.000' => ['0.000', true, '0'], + 'q0.0000' => ['0.0000', false], + 'q0.0001' => ['0.0001', false], + 'q0.05' => ['0.05', true, '0.05'], + 'q0,05' => ['0,05', false], + ]; + } + /** + * @dataProvider QualityParametersDataProvider + */ + public function testParsingAndRepackOfQualityParams( + string $setQuality, + bool $result, + ?string $getQuality = null + ): void { + $defaultValue = '0.987'; + $headerValue = (new QualityHeaderValue())->withParams(['q' => $defaultValue]); + + $this->assertSame($result, $headerValue->setQuality($setQuality)); + $this->assertSame($getQuality ?? $defaultValue, $headerValue->getQuality()); + } + + public function ListedValuesDataProvider(): array + { + return [ + 'twoSimple' => ['value1,value2', ['value1', 'value2']], + 'moreSimple' => ['value1,value2,value1,value2', ['value1', 'value2', 'value1', 'value2']], + 'spaces' => [' value1 , value2 ', ['value1', 'value2']], + 'paramsImitation' => ['value1;q=1, value2 ; q=2', ['value1;q=1', 'value2 ; q=2']], + 'commas1' => ['value1,,value2', ['value1', '', 'value2']], + 'commas2' => [',, ,', ['', '', '', '']], + 'chars' => [',!@# $%^&*()!"№;%:=-?.,', ['', '!@# $%^&*()!"№;%:=-?.', '']], + ]; + } + /** + * @dataProvider ListedValuesDataProvider + */ + public function testParsingAndRepackListedValues(string $input, array $values): void + { + $header = new Header(ListedValuesHeaderValue::class); + $header->add($input); + $strings = $header->getStrings(true); + $this->assertSame($values, $strings); + } + + public function ListedValuesWithParamsDataProvider(): array + { + return [ + 'simpleQ' => ['value1;q=1,value2;q=2', [ + ['value1', ['q' => '1']], + ['value2', ['q' => '2']], + ]], + 'Accept' => ['text/*;q=0.3, text/html;q=0.7, text/html;level=1, text/html;level=2;q=0.4', [ + ['text/*', ['q' => '0.3']], + ['text/html', ['q' => '0.7']], + ['text/html', ['level' => '1']], + ['text/html', ['level' => '2','q' => '0.4']], + ]], + 'spacesAccept' => [' text/* ; q=0.3,text/html; q=0.7 , text/html ;level=1,text/html ; level=2;q=0.4', [ + ['text/*', ['q' => '0.3']], + ['text/html', ['q' => '0.7']], + ['text/html', ['level' => '1']], + ['text/html', ['level' => '2','q' => '0.4']], + ]], + 'Forwarded' => [ + 'for=192.0.2.43, for="[2001:db8:cafe::17]", for=unknown, for=192.0.2.60;proto=http;by=203.0.113.43', + [ + ['', ['for' => '192.0.2.43']], + ['', ['for' => '[2001:db8:cafe::17]']], + ['', ['for' => 'unknown']], + ['', ['for' => '192.0.2.60', 'proto' => 'http', 'by' => '203.0.113.43']], + ], + ], + 'WWW-Authenticate' => [ + 'Newauth realm="apps", type=1, title="Login to \\"apps\\"", Basic realm="simple"', + [ + ['Newauth', ['realm' => 'apps']], + ['', ['type' => '1']], + ['', ['title' => 'Login to "apps"']], + ['Basic', ['realm' => 'simple']], + ], + ], + 'badSyntax1' => [';', [['', []]]], // added empty value + 'badSyntax2' => [';,', []], // no values added + ]; + } + /** + * @dataProvider ListedValuesWithParamsDataProvider + */ + public function testParsingAndRepackListedValuesWithParams(string $input, array $valueParams): void { + $header = new Header(ListedValuesWithParamsHeaderValue::class); + $result = []; + $header->add($input); + foreach ($header->getValues(true) as $value) { + $result[] = [$value->getValue(), $value->getParams()]; + } + $this->assertSame($valueParams, $result); + } } From d6d9f088b7deadd58a9a3ce9933a96234ae9950c Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 28 Feb 2020 13:41:55 +0300 Subject: [PATCH 06/26] added tests; fixes --- composer.json | 3 +- src/Header.php | 81 ++++++++++++++++---------- src/Header/Accept.php | 2 +- src/Header/Date.php | 12 +++- src/Header/HeaderValue.php | 32 +++++----- src/Header/WithParams.php | 5 +- src/Header/WithQualityParam.php | 5 +- tests/Header/HeaderValueTest.php | 30 +++++++++- tests/Header/Stub/DummyHeaderValue.php | 11 ++++ tests/HeaderTest.php | 20 ++++--- 10 files changed, 138 insertions(+), 63 deletions(-) create mode 100644 tests/Header/Stub/DummyHeaderValue.php diff --git a/composer.json b/composer.json index b5f29ed..4731780 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,8 @@ "source": "https://github.com/yiisoft/http" }, "require": { - "php": "^7.4" + "php": "^7.4", + "psr/http-message": "^1.0" }, "require-dev": { "phpunit/phpunit": "^9.0", diff --git a/src/Header.php b/src/Header.php index 8b2d246..771547f 100644 --- a/src/Header.php +++ b/src/Header.php @@ -3,6 +3,7 @@ namespace Yiisoft\Http; use InvalidArgumentException; +use Psr\Http\Message\MessageInterface; use Yiisoft\Http\Header\DefaultHeaderValue; use Yiisoft\Http\Header\HeaderValue; use Yiisoft\Http\Header\ListedValues; @@ -18,13 +19,14 @@ final class Header implements \IteratorAggregate, \Countable private bool $listedValues = false; private bool $withParams = false; - private const DELIMITERS = '"(),/:;<=>?@[\\]{}'; - private const PART_NONE = 0; - private const PART_VALUE = 1; - private const PART_PARAM_NAME = 2; - private const PART_PARAM_QUOTED_VALUE = 3; - private const PART_PARAM_VALUE = 4; - + // Parsing's constants + private const + DELIMITERS = '"(),/:;<=>?@[\\]{}', + READ_NONE = 0, + READ_VALUE = 1, + READ_PARAM_NAME = 2, + READ_PARAM_QUOTED_VALUE = 3, + READ_PARAM_VALUE = 4; public function __construct(string $nameOrClass) { @@ -53,7 +55,6 @@ public function count(): int { return count($this->collection); } - /** * @param HeaderValue[]|string[] $headers * @param string $headerClass @@ -73,6 +74,10 @@ public function getName(): string { return $this->headerName; } + public function getValueClass(): string + { + return $this->headerClass; + } /** * @param bool $ignoreIncorrect * @return HeaderValue[] @@ -81,7 +86,7 @@ public function getValues($ignoreIncorrect = true): array { $result = []; foreach ($this->collection as $header) { - if ($ignoreIncorrect && $header->getError() !== null) { + if ($ignoreIncorrect && $header->hasError()) { continue; } $result[] = $header; @@ -96,7 +101,7 @@ public function getStrings($ignoreIncorrect = true): array { $result = []; foreach ($this->collection as $header) { - if ($ignoreIncorrect && $header->getError() !== null) { + if ($ignoreIncorrect && $header->hasError()) { continue; } $result[] = $header->__toString(); @@ -123,7 +128,23 @@ public function add($value): self $this->parseAndCollect($value); return $this; } - throw new InvalidArgumentException(sprintf('The value must be an instance of %s or string', $this->headerClass)); + throw new InvalidArgumentException( + sprintf('The value must be an instance of %s or string', $this->headerClass) + ); + } + + public function inject(MessageInterface $message, bool $replace = true, bool $ignoreIncorrect = true): MessageInterface + { + if ($replace) { + $message = $message->withoutHeader($this->headerName); + } + foreach ($this->collection as $value) { + if ($ignoreIncorrect && $value->hasError()) { + continue; + } + $message = $message->withAddedHeader($this->headerName, $value->__toString()); + } + return $message; } private function parseAndCollect(string $body): void @@ -132,7 +153,7 @@ private function parseAndCollect(string $body): void $this->collection[] = new $this->headerClass(trim($body)); return; } - $part = self::PART_VALUE; + $part = self::READ_VALUE; $buffer = ''; $key = ''; $value = ''; @@ -162,12 +183,12 @@ private function parseAndCollect(string $body): void /** @see https://tools.ietf.org/html/rfc7230#section-3.2.6 */ for ($i = 0, $length = strlen($body); $i < $length; ++$i) { $s = $body[$i]; - if ($part === self::PART_VALUE) { + if ($part === self::READ_VALUE) { if ($s === '=' && $this->withParams) { $key = ltrim($buffer); $buffer = ''; if (preg_match('/\s/', $key) === 0) { - $part = self::PART_PARAM_VALUE; + $part = self::READ_PARAM_VALUE; continue; } $key = preg_replace('/\s+/', ' ', $key); @@ -177,10 +198,10 @@ private function parseAndCollect(string $body): void $buffer = implode(' ', $chunks); throw new \Exception('Syntax error'); } - $part = self::PART_PARAM_VALUE; + $part = self::READ_PARAM_VALUE; [$value, $key] = $chunks; } elseif ($s === ';' && $this->withParams) { - $part = self::PART_PARAM_NAME; + $part = self::READ_PARAM_NAME; $value = trim($buffer); $buffer = ''; } elseif ($s === ',' && $this->listedValues) { @@ -191,11 +212,11 @@ private function parseAndCollect(string $body): void } continue; } - if ($part === self::PART_PARAM_NAME) { + if ($part === self::READ_PARAM_NAME) { if ($s === '=') { $key = $buffer; $buffer = ''; - $part = self::PART_PARAM_VALUE; + $part = self::READ_PARAM_VALUE; } elseif (strpos(self::DELIMITERS, $s) !== false) { throw new \Exception("Delimiter char \"{$s}\" in a param name"); } elseif (ord($s) <= 32) { @@ -207,10 +228,10 @@ private function parseAndCollect(string $body): void } continue; } - if ($part === self::PART_PARAM_VALUE) { + if ($part === self::READ_PARAM_VALUE) { if ($buffer === '') { if ($s === '"') { - $part = self::PART_PARAM_QUOTED_VALUE; + $part = self::READ_PARAM_QUOTED_VALUE; } elseif (ord($s) <= 32) { continue; } elseif (strpos(self::DELIMITERS, $s) === false) { @@ -219,17 +240,17 @@ private function parseAndCollect(string $body): void throw new \Exception("Delimiter char \"{$s}\" in a unquoted param value"); } } elseif (ord($s) <= 32) { - $part = self::PART_NONE; + $part = self::READ_NONE; $addParam($key, $buffer); $key = $buffer = ''; } elseif (strpos(self::DELIMITERS, $s) === false) { $buffer .= $s; } elseif ($s === ';') { - $part = self::PART_PARAM_NAME; + $part = self::READ_PARAM_NAME; $addParam($key, $buffer); $key = $buffer = ''; } elseif ($s === ',' && $this->listedValues) { - $part = self::PART_VALUE; + $part = self::READ_VALUE; $addParam($key, $buffer); $collectHeaderValue(); } else { @@ -238,7 +259,7 @@ private function parseAndCollect(string $body): void } continue; } - if ($part === self::PART_PARAM_QUOTED_VALUE) { + if ($part === self::READ_PARAM_QUOTED_VALUE) { if ($s === '\\') { // quoted pair if (++$i >= $length) { throw new \Exception('Incorrect quoted pair'); @@ -246,7 +267,7 @@ private function parseAndCollect(string $body): void $buffer .= $body[$i]; } } elseif ($s === '"') { // end - $part = self::PART_NONE; + $part = self::READ_NONE; $addParam($key, $buffer); $key = $buffer = ''; } else { @@ -254,13 +275,13 @@ private function parseAndCollect(string $body): void } continue; } - if ($part === self::PART_NONE) { + if ($part === self::READ_NONE) { if (ord($s) <= 32) { continue; } elseif ($s === ';' && $this->withParams) { - $part = self::PART_PARAM_NAME; + $part = self::READ_PARAM_NAME; } elseif ($s === ',' && $this->listedValues) { - $part = self::PART_VALUE; + $part = self::READ_VALUE; $collectHeaderValue(); } else { throw new \Exception('Expected Separator'); @@ -271,9 +292,9 @@ private function parseAndCollect(string $body): void $error = $e; } /** @var HeaderValue $item */ - if ($part === self::PART_VALUE) { + if ($part === self::READ_VALUE) { $value = trim($buffer); - } elseif (in_array($part, [self::PART_PARAM_VALUE, self::PART_PARAM_QUOTED_VALUE], true)) { + } elseif (in_array($part, [self::READ_PARAM_VALUE, self::READ_PARAM_QUOTED_VALUE], true)) { if ($buffer === '') { $error = $error ?? new \Exception('Empty value should be quoted'); } else { diff --git a/src/Header/Accept.php b/src/Header/Accept.php index b8bc5f9..b95e8a0 100644 --- a/src/Header/Accept.php +++ b/src/Header/Accept.php @@ -5,7 +5,7 @@ /** * @see https://tools.ietf.org/html/rfc7231#section-5.3.2 */ -class Accept extends HeaderValue implements WithQualityParam +final class Accept extends HeaderValue implements WithQualityParam { public const NAME = 'Accept'; } diff --git a/src/Header/Date.php b/src/Header/Date.php index 9d566a8..2d1190c 100644 --- a/src/Header/Date.php +++ b/src/Header/Date.php @@ -4,12 +4,20 @@ use DateTimeImmutable; -class Date extends HeaderValue +final class Date extends HeaderValue { public const NAME = 'Date'; - public function getParsedValue(): DateTimeImmutable + /** + * @return DateTimeImmutable + * @throws \Exception + */ + public function getDatetimeValue(): DateTimeImmutable { return new DateTimeImmutable($this->value); } + public function withValueFromDatetime(DateTimeImmutable $date): self + { + return $this->withValue($date->format(self::HTTP_DATETIME_FORMAT)); + } } diff --git a/src/Header/HeaderValue.php b/src/Header/HeaderValue.php index 92b0ad1..029fca1 100644 --- a/src/Header/HeaderValue.php +++ b/src/Header/HeaderValue.php @@ -20,12 +20,12 @@ abstract class HeaderValue */ private string $quality = '1'; private ?Exception $error = null; + protected const HTTP_DATETIME_FORMAT = 'D, d M Y H:i:s \\G\\M\\T'; public function __construct(string $value = '') { $this->value = $value; } - public function __toString(): string { $params = []; @@ -44,30 +44,28 @@ public function __toString(): string return $this->value === '' ? implode(';', $params) : implode(';', [$this->value, ...$params]); } - public function getValue(): string + public function withValue(string $value): self { - return $this->value; + $clone = clone $this; + $clone->value = $value; + return $clone; } - /** - * @return mixed - */ - public function getParsedValue() + public function getValue(): string { - return $this->getValue(); + return $this->value; } /** + * It makes sense to use only for HeaderValues that implement the WithParams interface * @param array $params * @return $this - * @throws Exception */ public function withParams(array $params): self { + $clone = clone $this; if (!$this instanceof WithParams) { - #todo: test it - throw new Exception(sprintf('Method withParams requires %s interface', WithParams::class)); + return $clone; } - $clone = clone $this; $clone->params = []; foreach ($params as $key => $value) { $key = strtolower($key); @@ -85,7 +83,7 @@ public function withParams(array $params): self } return $clone; } - public function getParams(): iterable + public function getParams(): array { $result = $this->params; if ($this instanceof WithQualityParam) { @@ -93,13 +91,11 @@ public function getParams(): iterable } return $result; } - /** - * @return string value between 0.000 and 1.000 - */ public function getQuality(): string { return $this->quality; } + /** * @param Exception|null $error * @return $this @@ -110,6 +106,10 @@ public function withError(?Exception $error): self $clone->error = $error; return $clone; } + public function hasError(): bool + { + return $this->error !== null; + } public function getError(): ?Exception { return $this->error; diff --git a/src/Header/WithParams.php b/src/Header/WithParams.php index 0bb2aca..891d3c8 100644 --- a/src/Header/WithParams.php +++ b/src/Header/WithParams.php @@ -4,7 +4,10 @@ interface WithParams { - public function getParams(): iterable; + /** + * @return array + */ + public function getParams(): array; /** * @param array $params * @return $this diff --git a/src/Header/WithQualityParam.php b/src/Header/WithQualityParam.php index c3103b9..963e2f9 100644 --- a/src/Header/WithQualityParam.php +++ b/src/Header/WithQualityParam.php @@ -7,5 +7,8 @@ */ interface WithQualityParam extends ListedValues, WithParams { - public function getQuality(); + /** + * @return string value between 0.000 and 1.000 + */ + public function getQuality(): string; } diff --git a/tests/Header/HeaderValueTest.php b/tests/Header/HeaderValueTest.php index e317927..bb8409c 100644 --- a/tests/Header/HeaderValueTest.php +++ b/tests/Header/HeaderValueTest.php @@ -2,12 +2,38 @@ namespace Yiisoft\Http\Tests\Header; +use Exception; use PHPUnit\Framework\TestCase; +use Yiisoft\Http\Tests\Header\Stub\DummyHeaderValue; +use Yiisoft\Http\Tests\Header\Stub\WithParamsHeaderValue; class HeaderValueTest extends TestCase { - public function testInit() + public function testWithValueReturnsImmutability() { - $this->assertSame(true, true); + $value = new DummyHeaderValue(); + + $clone = $value->withValue('test'); + + $this->assertSame(get_class($value), get_class($clone)); + $this->assertNotSame($value, $clone); + } + public function testWithParamsReturnsImmutability() + { + $value = new WithParamsHeaderValue(); + + $clone = $value->withParams([]); + + $this->assertSame(get_class($value), get_class($clone)); + $this->assertNotSame($value, $clone); + } + public function testWithErrorReturnsImmutability() + { + $value = new DummyHeaderValue(); + + $clone = $value->withError(null); + + $this->assertSame(get_class($value), get_class($clone)); + $this->assertNotSame($value, $clone); } } diff --git a/tests/Header/Stub/DummyHeaderValue.php b/tests/Header/Stub/DummyHeaderValue.php new file mode 100644 index 0000000..8c8d865 --- /dev/null +++ b/tests/Header/Stub/DummyHeaderValue.php @@ -0,0 +1,11 @@ +assertSame('Date', $values->getName()); + $this->assertSame(Date::class, $values->getValueClass()); } public function testErrorWithDefaultHeaderClass() { @@ -219,13 +220,13 @@ public function testParsingAndRepackOfIncorrectValueAndParams( /** @var WithParamsHeaderValue $headerValue */ $headerValue = $header->getValues(false)[0]; - $this->assertSame($withError, $headerValue->getError() !== null); + $this->assertSame($withError, $headerValue->hasError()); $this->assertSame($value, $headerValue->getValue()); $this->assertSame($params, $headerValue->getParams()); $this->assertSame($output ?? $input, $headerValue->__toString()); } - public function QualityParametersDataProvider(): array + public function qualityParametersDataProvider(): array { return [ 'q1' => ['1', true, '1'], @@ -248,7 +249,7 @@ public function QualityParametersDataProvider(): array ]; } /** - * @dataProvider QualityParametersDataProvider + * @dataProvider qualityParametersDataProvider */ public function testParsingAndRepackOfQualityParams( string $setQuality, @@ -262,7 +263,7 @@ public function testParsingAndRepackOfQualityParams( $this->assertSame($getQuality ?? $defaultValue, $headerValue->getQuality()); } - public function ListedValuesDataProvider(): array + public function listedValuesDataProvider(): array { return [ 'twoSimple' => ['value1,value2', ['value1', 'value2']], @@ -275,7 +276,7 @@ public function ListedValuesDataProvider(): array ]; } /** - * @dataProvider ListedValuesDataProvider + * @dataProvider listedValuesDataProvider */ public function testParsingAndRepackListedValues(string $input, array $values): void { @@ -285,7 +286,7 @@ public function testParsingAndRepackListedValues(string $input, array $values): $this->assertSame($values, $strings); } - public function ListedValuesWithParamsDataProvider(): array + public function listedValuesWithParamsDataProvider(): array { return [ 'simpleQ' => ['value1;q=1,value2;q=2', [ @@ -327,9 +328,10 @@ public function ListedValuesWithParamsDataProvider(): array ]; } /** - * @dataProvider ListedValuesWithParamsDataProvider + * @dataProvider listedValuesWithParamsDataProvider */ - public function testParsingAndRepackListedValuesWithParams(string $input, array $valueParams): void { + public function testParsingAndRepackListedValuesWithParams(string $input, array $valueParams): void + { $header = new Header(ListedValuesWithParamsHeaderValue::class); $result = []; $header->add($input); From a65abebb0997244d944017c8e7077fa7a82ee3cd Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 29 Feb 2020 14:58:21 +0300 Subject: [PATCH 07/26] Refactoring; Added ParsingException --- src/Header/Accept.php | 11 --- src/Header/DefaultHeaderValue.php | 7 -- src/{ => Header}/Header.php | 85 +++++++++++-------- src/Header/ParsingException.php | 19 +++++ src/Header/Value/Accept.php | 15 ++++ .../BaseHeaderValue.php} | 14 ++- src/Header/{ => Value}/Date.php | 6 +- src/Header/Value/DefaultValue.php | 15 ++++ src/Header/Value/Forwarded.php | 16 ++++ tests/{ => Header}/HeaderTest.php | 25 +++--- tests/Header/Stub/DummyHeaderValue.php | 11 --- tests/Header/Stub/ListedValuesHeaderValue.php | 12 --- .../ListedValuesWithParamsHeaderValue.php | 12 --- tests/Header/Stub/WithParamsHeaderValue.php | 11 --- .../BaseHeaderValueTest.php} | 23 +++-- tests/Header/Value/Stub/DummyHeaderValue.php | 11 +++ .../Value/Stub/ListedValuesHeaderValue.php | 12 +++ .../ListedValuesWithParamsHeaderValue.php | 12 +++ .../{ => Value}/Stub/QualityHeaderValue.php | 4 +- .../Value/Stub/WithParamsHeaderValue.php | 11 +++ 20 files changed, 207 insertions(+), 125 deletions(-) delete mode 100644 src/Header/Accept.php delete mode 100644 src/Header/DefaultHeaderValue.php rename src/{ => Header}/Header.php (79%) create mode 100644 src/Header/ParsingException.php create mode 100644 src/Header/Value/Accept.php rename src/Header/{HeaderValue.php => Value/BaseHeaderValue.php} (91%) rename src/Header/{ => Value}/Date.php (80%) create mode 100644 src/Header/Value/DefaultValue.php create mode 100644 src/Header/Value/Forwarded.php rename tests/{ => Header}/HeaderTest.php (94%) delete mode 100644 tests/Header/Stub/DummyHeaderValue.php delete mode 100644 tests/Header/Stub/ListedValuesHeaderValue.php delete mode 100644 tests/Header/Stub/ListedValuesWithParamsHeaderValue.php delete mode 100644 tests/Header/Stub/WithParamsHeaderValue.php rename tests/Header/{HeaderValueTest.php => Value/BaseHeaderValueTest.php} (52%) create mode 100644 tests/Header/Value/Stub/DummyHeaderValue.php create mode 100644 tests/Header/Value/Stub/ListedValuesHeaderValue.php create mode 100644 tests/Header/Value/Stub/ListedValuesWithParamsHeaderValue.php rename tests/Header/{ => Value}/Stub/QualityHeaderValue.php (55%) create mode 100644 tests/Header/Value/Stub/WithParamsHeaderValue.php diff --git a/src/Header/Accept.php b/src/Header/Accept.php deleted file mode 100644 index b95e8a0..0000000 --- a/src/Header/Accept.php +++ /dev/null @@ -1,11 +0,0 @@ -headerClass = $nameOrClass; if (class_exists($nameOrClass)) { - if (!is_subclass_of($nameOrClass, HeaderValue::class, true)) { + if (!is_subclass_of($nameOrClass, BaseHeaderValue::class, true)) { throw new InvalidArgumentException("{$nameOrClass} is not a header."); } - if (is_a($nameOrClass, DefaultHeaderValue::class, true)) { + if (empty($nameOrClass::NAME)) { throw new InvalidArgumentException("{$nameOrClass} has no header name."); } $this->headerName = $nameOrClass::NAME; $this->listedValues = is_subclass_of($nameOrClass, ListedValues::class, true); $this->withParams = is_subclass_of($nameOrClass, WithParams::class, true); } else { + if ($nameOrClass === '') { + throw new InvalidArgumentException("Empty header name."); + } $this->headerName = $nameOrClass; - $this->headerClass = DefaultHeaderValue::class; + $this->headerClass = DefaultValue::class; } } public function getIterator(): iterable { - return $this->collection; + return $this->getValues(true); } public function count(): int { return count($this->collection); } /** - * @param HeaderValue[]|string[] $headers - * @param string $headerClass + * @param BaseHeaderValue[]|string[] $values + * @param string $headerClass * @return static * @throws InvalidArgumentException */ - public static function createFromArray(array $headers, string $headerClass): self + public static function createFromArray(array $values, string $headerClass): self { - $new = new static($headerClass); - foreach ($headers as $header) { - $new->add($header); - } - return $new; + return (new static($headerClass))->addArray($values); } public function getName(): string @@ -80,7 +82,7 @@ public function getValueClass(): string } /** * @param bool $ignoreIncorrect - * @return HeaderValue[] + * @return BaseHeaderValue[] */ public function getValues($ignoreIncorrect = true): array { @@ -110,12 +112,23 @@ public function getStrings($ignoreIncorrect = true): array } /** - * @param string|HeaderValue $value + * @param string[]|BaseHeaderValue[] $values + * @return $this + */ + public function addArray(array $values): self + { + foreach ($values as $value) { + $this->add($value); + } + return $this; + } + /** + * @param string|BaseHeaderValue $value * @return $this */ public function add($value): self { - if ($value instanceof HeaderValue) { + if ($value instanceof BaseHeaderValue) { if (get_class($value) !== $this->headerClass) { throw new InvalidArgumentException( sprintf('The value must be an instance of %s, %s given', $this->headerClass, get_class($value)) @@ -165,7 +178,7 @@ private function parseAndCollect(string $body): void } }; $collectHeaderValue = function () use (&$key, &$value, &$buffer, &$params, &$added, &$error) { - /** @var HeaderValue $item */ + /** @var BaseHeaderValue $item */ $item = new $this->headerClass($value); if ($this->withParams) { $item = $item->withParams($params); @@ -181,8 +194,8 @@ private function parseAndCollect(string $body): void $added = 0; try { /** @see https://tools.ietf.org/html/rfc7230#section-3.2.6 */ - for ($i = 0, $length = strlen($body); $i < $length; ++$i) { - $s = $body[$i]; + for ($pos = 0, $length = strlen($body); $pos < $length; ++$pos) { + $s = $body[$pos]; if ($part === self::READ_VALUE) { if ($s === '=' && $this->withParams) { $key = ltrim($buffer); @@ -196,7 +209,7 @@ private function parseAndCollect(string $body): void if (count($chunks) > 2 || preg_match('/\s$/', $key) === 1) { array_pop($chunks); $buffer = implode(' ', $chunks); - throw new \Exception('Syntax error'); + throw new ParsingException($body, $pos, 'Syntax error'); } $part = self::READ_PARAM_VALUE; [$value, $key] = $chunks; @@ -218,10 +231,10 @@ private function parseAndCollect(string $body): void $buffer = ''; $part = self::READ_PARAM_VALUE; } elseif (strpos(self::DELIMITERS, $s) !== false) { - throw new \Exception("Delimiter char \"{$s}\" in a param name"); + throw new ParsingException($body, $pos, 'Delimiter char in a param name'); } elseif (ord($s) <= 32) { if ($buffer !== '') { - throw new \Exception("Space in a param name"); + throw new ParsingException($body, $pos, 'Space in a param name'); } } else { $buffer .= $s; @@ -237,7 +250,7 @@ private function parseAndCollect(string $body): void } elseif (strpos(self::DELIMITERS, $s) === false) { $buffer .= $s; } else { - throw new \Exception("Delimiter char \"{$s}\" in a unquoted param value"); + throw new ParsingException($body, $pos, 'Delimiter char in a unquoted param value'); } } elseif (ord($s) <= 32) { $part = self::READ_NONE; @@ -255,16 +268,16 @@ private function parseAndCollect(string $body): void $collectHeaderValue(); } else { $buffer = ''; - throw new \Exception("Delimiter char \"{$s}\" in a unquoted param value"); + throw new ParsingException($body, $pos, 'Delimiter char in a unquoted param value'); } continue; } if ($part === self::READ_PARAM_QUOTED_VALUE) { if ($s === '\\') { // quoted pair - if (++$i >= $length) { - throw new \Exception('Incorrect quoted pair'); + if (++$pos >= $length) { + throw new ParsingException($body, $pos, 'Incorrect quoted pair'); } else { - $buffer .= $body[$i]; + $buffer .= $body[$pos]; } } elseif ($s === '"') { // end $part = self::READ_NONE; @@ -284,19 +297,19 @@ private function parseAndCollect(string $body): void $part = self::READ_VALUE; $collectHeaderValue(); } else { - throw new \Exception('Expected Separator'); + throw new ParsingException($body, $pos, 'Expected Separator'); } } } - } catch (\Exception $e) { + } catch (ParsingException $e) { $error = $e; } - /** @var HeaderValue $item */ + /** @var BaseHeaderValue $item */ if ($part === self::READ_VALUE) { $value = trim($buffer); } elseif (in_array($part, [self::READ_PARAM_VALUE, self::READ_PARAM_QUOTED_VALUE], true)) { if ($buffer === '') { - $error = $error ?? new \Exception('Empty value should be quoted'); + $error = $error ?? new ParsingException($body, $pos, 'Empty value should be quoted'); } else { $addParam($key, $buffer); } diff --git a/src/Header/ParsingException.php b/src/Header/ParsingException.php new file mode 100644 index 0000000..3bd7087 --- /dev/null +++ b/src/Header/ParsingException.php @@ -0,0 +1,19 @@ +value = $value; + $this->position = $position; + parent::__construct($message, $code, $previous); + } +} diff --git a/src/Header/Value/Accept.php b/src/Header/Value/Accept.php new file mode 100644 index 0000000..e8ad492 --- /dev/null +++ b/src/Header/Value/Accept.php @@ -0,0 +1,15 @@ +value === '' ? implode(';', $params) : implode(';', [$this->value, ...$params]); } + public static function createHeader(): Header + { + return new Header(static::class); + } + public function withValue(string $value): self { $clone = clone $this; diff --git a/src/Header/Date.php b/src/Header/Value/Date.php similarity index 80% rename from src/Header/Date.php rename to src/Header/Value/Date.php index 2d1190c..845c24e 100644 --- a/src/Header/Date.php +++ b/src/Header/Value/Date.php @@ -1,10 +1,12 @@ assertSame('Date', $values->getName()); $this->assertSame(Date::class, $values->getValueClass()); } - public function testErrorWithDefaultHeaderClass() + public function testErrorWhenHeaderValueHasNoHeaderName() { $this->expectException(InvalidArgumentException::class); - new Header(HeaderValue::class); + $this->expectExceptionMessageMatches('/no header name/'); + new Header(DefaultValue::class); } public function testErrorWithHeaderClass() { $this->expectException(InvalidArgumentException::class); - new Header(HeaderValue::class); + $this->expectExceptionMessageMatches('/not a header/'); + new Header(BaseHeaderValue::class); } public function testErrorIfNotHeaderClass() { diff --git a/tests/Header/Stub/DummyHeaderValue.php b/tests/Header/Stub/DummyHeaderValue.php deleted file mode 100644 index 8c8d865..0000000 --- a/tests/Header/Stub/DummyHeaderValue.php +++ /dev/null @@ -1,11 +0,0 @@ -assertSame(get_class($value), get_class($clone)); $this->assertNotSame($value, $clone); } - public function testWithParamsReturnsImmutability() + public function testWithParamsImmutability() { $value = new WithParamsHeaderValue(); @@ -27,7 +27,7 @@ public function testWithParamsReturnsImmutability() $this->assertSame(get_class($value), get_class($clone)); $this->assertNotSame($value, $clone); } - public function testWithErrorReturnsImmutability() + public function testWithErrorImmutability() { $value = new DummyHeaderValue(); @@ -36,4 +36,11 @@ public function testWithErrorReturnsImmutability() $this->assertSame(get_class($value), get_class($clone)); $this->assertNotSame($value, $clone); } + public function testCreateHeader() + { + $header = DummyHeaderValue::createHeader(); + + $this->assertInstanceOf(Header::class, $header); + $this->assertSame(DummyHeaderValue::NAME, $header->getName()); + } } diff --git a/tests/Header/Value/Stub/DummyHeaderValue.php b/tests/Header/Value/Stub/DummyHeaderValue.php new file mode 100644 index 0000000..a7f517a --- /dev/null +++ b/tests/Header/Value/Stub/DummyHeaderValue.php @@ -0,0 +1,11 @@ + Date: Mon, 2 Mar 2020 18:35:18 +0300 Subject: [PATCH 08/26] added some header values --- src/Header/Header.php | 47 ++++++++++----------- src/Header/SortableHeader.php | 31 ++++++++++++++ src/Header/Value/Accept.php | 15 ------- src/Header/Value/Accept/Accept.php | 13 ++++++ src/Header/Value/Accept/AcceptCharset.php | 13 ++++++ src/Header/Value/Accept/AcceptEncoding.php | 13 ++++++ src/Header/Value/Accept/AcceptLanguage.php | 13 ++++++ src/Header/Value/Accept/Base.php | 17 ++++++++ src/Header/Value/Allow.php | 15 +++++++ src/Header/Value/BaseHeaderValue.php | 2 +- src/Header/Value/Date.php | 25 ----------- src/Header/Value/Date/Base.php | 29 +++++++++++++ src/Header/Value/Date/Date.php | 10 +++++ src/Header/Value/Date/Expires.php | 10 +++++ src/Header/Value/Date/IfModifiedSince.php | 13 ++++++ src/Header/Value/Date/IfUnmodifiedSince.php | 13 ++++++ src/Header/Value/Date/LastModified.php | 13 ++++++ src/Header/Value/SortableValue.php | 16 +++++++ tests/Header/HeaderTest.php | 28 ++++++------ 19 files changed, 256 insertions(+), 80 deletions(-) create mode 100644 src/Header/SortableHeader.php delete mode 100644 src/Header/Value/Accept.php create mode 100644 src/Header/Value/Accept/Accept.php create mode 100644 src/Header/Value/Accept/AcceptCharset.php create mode 100644 src/Header/Value/Accept/AcceptEncoding.php create mode 100644 src/Header/Value/Accept/AcceptLanguage.php create mode 100644 src/Header/Value/Accept/Base.php create mode 100644 src/Header/Value/Allow.php delete mode 100644 src/Header/Value/Date.php create mode 100644 src/Header/Value/Date/Base.php create mode 100644 src/Header/Value/Date/Date.php create mode 100644 src/Header/Value/Date/Expires.php create mode 100644 src/Header/Value/Date/IfModifiedSince.php create mode 100644 src/Header/Value/Date/IfUnmodifiedSince.php create mode 100644 src/Header/Value/Date/LastModified.php create mode 100644 src/Header/Value/SortableValue.php diff --git a/src/Header/Header.php b/src/Header/Header.php index 26fd9e4..8b05976 100644 --- a/src/Header/Header.php +++ b/src/Header/Header.php @@ -9,16 +9,16 @@ use Yiisoft\Http\Header\Value\DefaultValue; use Yiisoft\Http\Header\Value\BaseHeaderValue; -final class Header implements \IteratorAggregate, \Countable +class Header implements \IteratorAggregate, \Countable { - private string $headerClass; - private string $headerName; + protected bool $listedValues = false; + protected bool $withParams = false; + protected string $headerClass; + protected string $headerName; /** @var BaseHeaderValue[] */ - private array $collection = []; - - private bool $listedValues = false; - private bool $withParams = false; + protected array $collection = []; + protected const DEFAULT_VALUE_CLASS = DefaultValue::class; // Parsing's constants private const DELIMITERS = '"(),/:;<=>?@[\\]{}', @@ -49,7 +49,7 @@ public function __construct(string $nameOrClass) throw new InvalidArgumentException("Empty header name."); } $this->headerName = $nameOrClass; - $this->headerClass = DefaultValue::class; + $this->headerClass = self::DEFAULT_VALUE_CLASS; } } @@ -61,16 +61,6 @@ public function count(): int { return count($this->collection); } - /** - * @param BaseHeaderValue[]|string[] $values - * @param string $headerClass - * @return static - * @throws InvalidArgumentException - */ - public static function createFromArray(array $values, string $headerClass): self - { - return (new static($headerClass))->addArray($values); - } public function getName(): string { @@ -84,7 +74,7 @@ public function getValueClass(): string * @param bool $ignoreIncorrect * @return BaseHeaderValue[] */ - public function getValues($ignoreIncorrect = true): array + public function getValues(bool $ignoreIncorrect = true): array { $result = []; foreach ($this->collection as $header) { @@ -99,7 +89,7 @@ public function getValues($ignoreIncorrect = true): array * @param bool $ignoreIncorrect * @return string[] */ - public function getStrings($ignoreIncorrect = true): array + public function getStrings(bool $ignoreIncorrect = true): array { $result = []; foreach ($this->collection as $header) { @@ -134,7 +124,7 @@ public function add($value): self sprintf('The value must be an instance of %s, %s given', $this->headerClass, get_class($value)) ); } - $this->collection[] = $value; + $this->addValue($value); return $this; } if (is_string($value)) { @@ -146,8 +136,11 @@ public function add($value): self ); } - public function inject(MessageInterface $message, bool $replace = true, bool $ignoreIncorrect = true): MessageInterface - { + public function inject( + MessageInterface $message, + bool $replace = true, + bool $ignoreIncorrect = true + ): MessageInterface { if ($replace) { $message = $message->withoutHeader($this->headerName); } @@ -160,10 +153,14 @@ public function inject(MessageInterface $message, bool $replace = true, bool $ig return $message; } + protected function addValue(BaseHeaderValue $value): void + { + $this->collection[] = $value; + } private function parseAndCollect(string $body): void { if (!$this->listedValues && !$this->withParams) { - $this->collection[] = new $this->headerClass(trim($body)); + $this->addValue(new $this->headerClass(trim($body))); return; } $part = self::READ_VALUE; @@ -186,7 +183,7 @@ private function parseAndCollect(string $body): void if ($error !== null) { $item = $item->withError($error); } - $this->collection[] = $item; + $this->addValue($item); $key = $buffer = $value = ''; $params = []; ++$added; diff --git a/src/Header/SortableHeader.php b/src/Header/SortableHeader.php new file mode 100644 index 0000000..0e442b2 --- /dev/null +++ b/src/Header/SortableHeader.php @@ -0,0 +1,31 @@ +headerClass, WithQualityParam::class, true)) { + throw new InvalidArgumentException(sprintf("should implement %s", WithQualityParam::class)); + } + } + + /** + * Todo + * Add and sort value + */ + protected function addValue(BaseHeaderValue $value): void + { + parent::addValue($value); + } +} diff --git a/src/Header/Value/Accept.php b/src/Header/Value/Accept.php deleted file mode 100644 index e8ad492..0000000 --- a/src/Header/Value/Accept.php +++ /dev/null @@ -1,15 +0,0 @@ -value); - } - public function withValueFromDatetime(DateTimeImmutable $date): self - { - return $this->withValue($date->format(self::HTTP_DATETIME_FORMAT)); - } -} diff --git a/src/Header/Value/Date/Base.php b/src/Header/Value/Date/Base.php new file mode 100644 index 0000000..bf82489 --- /dev/null +++ b/src/Header/Value/Date/Base.php @@ -0,0 +1,29 @@ +value); + } catch (Exception $e) { + $this->error = $e; + return null; + } + } + + public function withValueFromDatetime(DateTimeImmutable $date): self + { + return $this->withValue($date->format(self::HTTP_DATETIME_FORMAT)); + } +} diff --git a/src/Header/Value/Date/Date.php b/src/Header/Value/Date/Date.php new file mode 100644 index 0000000..12f9580 --- /dev/null +++ b/src/Header/Value/Date/Date.php @@ -0,0 +1,10 @@ +addArray($headers); - $this->assertSame('WWW-Authenticate', $values->getName()); - $this->assertSame($headers, $values->getStrings()); + $this->assertSame('WWW-Authenticate', $header->getName()); + $this->assertSame($headers, $header->getStrings()); } public function testCreateFromFewStringValues() { @@ -62,10 +62,10 @@ public function testCreateFromFewStringValues() '*/*', ]; - $values = Header::createFromArray($headers, 'accept'); + $header = (new Header('accept'))->addArray($headers); - $this->assertSame('accept', $values->getName()); - $this->assertSame($headers, $values->getStrings()); + $this->assertSame('accept', $header->getName()); + $this->assertSame($headers, $header->getStrings()); } public function testAddObject() { @@ -73,12 +73,12 @@ public function testAddObject() 'text/*;q=0.3', 'text/html;q=0.7', ]; - $values = Header::createFromArray($headers, Accept::class); + $header = (new Header(Accept::class))->addArray($headers); - $values->add(new Accept('*/*')); + $header->add(new Accept('*/*')); - $this->assertSame(Accept::NAME, $values->getName()); - $this->assertSame(['text/*;q=0.3', 'text/html;q=0.7', '*/*'], $values->getStrings()); + $this->assertSame(Accept::NAME, $header->getName()); + $this->assertSame(['text/*;q=0.3', 'text/html;q=0.7', '*/*'], $header->getStrings()); } public function testExceptionWhenAddOtherObject() { @@ -86,11 +86,11 @@ public function testExceptionWhenAddOtherObject() 'text/*;q=0.3', 'text/html;q=0.7', ]; - $values = Header::createFromArray($headers, Accept::class); + $header = (new Header(Accept::class))->addArray($headers); $this->expectException(InvalidArgumentException::class); - $values->add(new Date('*/*')); + $header->add(new Date('*/*')); } public function valueAndParametersDataProvider(): array From fb602323d8f85523450820c8aa2fa81a14eb275a Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 3 Mar 2020 01:09:13 +0300 Subject: [PATCH 09/26] Added sorting method for values with quality param --- src/Header/Header.php | 6 +- src/Header/SortableHeader.php | 26 ++++++++- tests/Header/SortableHeaderTest.php | 91 +++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 tests/Header/SortableHeaderTest.php diff --git a/src/Header/Header.php b/src/Header/Header.php index 8b05976..e693710 100644 --- a/src/Header/Header.php +++ b/src/Header/Header.php @@ -42,15 +42,15 @@ public function __construct(string $nameOrClass) throw new InvalidArgumentException("{$nameOrClass} has no header name."); } $this->headerName = $nameOrClass::NAME; - $this->listedValues = is_subclass_of($nameOrClass, ListedValues::class, true); - $this->withParams = is_subclass_of($nameOrClass, WithParams::class, true); } else { if ($nameOrClass === '') { throw new InvalidArgumentException("Empty header name."); } $this->headerName = $nameOrClass; - $this->headerClass = self::DEFAULT_VALUE_CLASS; + $this->headerClass = static::DEFAULT_VALUE_CLASS; } + $this->listedValues = is_subclass_of($this->headerClass, ListedValues::class, true); + $this->withParams = is_subclass_of($this->headerClass, WithParams::class, true); } public function getIterator(): iterable diff --git a/src/Header/SortableHeader.php b/src/Header/SortableHeader.php index 0e442b2..c3c12d7 100644 --- a/src/Header/SortableHeader.php +++ b/src/Header/SortableHeader.php @@ -16,16 +16,36 @@ class SortableHeader extends Header public function __construct(string $nameOrClass) { parent::__construct($nameOrClass); if (!is_subclass_of($this->headerClass, WithQualityParam::class, true)) { - throw new InvalidArgumentException(sprintf("should implement %s", WithQualityParam::class)); + throw new InvalidArgumentException( + sprintf("%s class does not implement %s", $this->headerClass, WithQualityParam::class) + ); } } /** * Todo - * Add and sort value + * Add and and sort value */ protected function addValue(BaseHeaderValue $value): void { - parent::addValue($value); + if (count($this->collection) === 0) { + $this->collection[] = $value; + return; + } + for ($pos = array_key_last($this->collection); $pos >= 0; --$pos) { + $item = $this->collection[$pos]; + if ((float)$item->getQuality() >= (float)$value->getQuality()) { + break; + } + } + if ($pos < 0) { + array_unshift($this->collection, $value); + } elseif ($pos === array_key_last($this->collection)) { + $this->collection[] = $value; + } else { + $toPush = array_slice($this->collection, $pos + 1); + $this->collection = array_slice($this->collection, 0, $pos + 1); + array_push($this->collection, $value, ...$toPush); + } } } diff --git a/tests/Header/SortableHeaderTest.php b/tests/Header/SortableHeaderTest.php new file mode 100644 index 0000000..ae93390 --- /dev/null +++ b/tests/Header/SortableHeaderTest.php @@ -0,0 +1,91 @@ +assertSame('Test-Quality', $values->getName()); + $this->assertSame(QualityHeaderValue::class, $values->getValueClass()); + } + public function testErrorWithHeaderClass() + { + $this->expectException(InvalidArgumentException::class); + new SortableHeader(Date::class); + } + public function testCreateFromStringValues() + { + $header = (new SortableHeader('Accept'))->add('*/*'); + + $this->assertSame('Accept', $header->getName()); + $this->assertSame(['*/*'], $header->getStrings()); + } + public function testCreateFromFewStringValues() + { + $headers = [ + 'text/*;q=0.3', + 'text/html', + '*/*;q=0.001', + 'text/plain;q=0.5', + ]; + + $header = (new SortableHeader('Accept-Test'))->addArray($headers); + + $this->assertSame('Accept-Test', $header->getName()); + $this->assertSame( + [ + 'text/html', + 'text/plain;q=0.5', + 'text/*;q=0.3', + '*/*;q=0.001', + ], + $header->getStrings() + ); + } + public function testCreateFromManyStringValues() + { + $headers = [ + 'text/*;q=0.3', + 'text/html;q=0.7', + 'text/html;level=1', + 'text/html;level=2;q=0.4', + '*/*;q=0.5', + 'text/*', + 'text/plain', + 'text/plain;format=flowed', + '*/*', + ]; + + $header = (new SortableHeader('Accept'))->addArray($headers); + + $this->assertSame('Accept', $header->getName()); + $this->assertSame( + [ + 'text/html;level=1', + 'text/*', + 'text/plain', + 'text/plain;format=flowed', + '*/*', + 'text/html;q=0.7', + '*/*;q=0.5', + 'text/html;level=2;q=0.4', + 'text/*;q=0.3', + ], + $header->getStrings() + ); + } +} From 9f3cb05982839d7c954e25589baecc952509273a Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 3 Mar 2020 17:49:44 +0300 Subject: [PATCH 10/26] added AcceptHeader; refactoring --- src/Header/AcceptHeader.php | 67 ++++++++++++ src/Header/SortableHeader.php | 20 ++-- src/Header/Value/Accept/Base.php | 6 +- src/Header/Value/BaseHeaderValue.php | 4 +- tests/Header/AcceptHeaderTest.php | 148 +++++++++++++++++++++++++++ tests/Header/SortableHeaderTest.php | 6 -- 6 files changed, 228 insertions(+), 23 deletions(-) create mode 100644 src/Header/AcceptHeader.php create mode 100644 tests/Header/AcceptHeaderTest.php diff --git a/src/Header/AcceptHeader.php b/src/Header/AcceptHeader.php new file mode 100644 index 0000000..9f9fc6f --- /dev/null +++ b/src/Header/AcceptHeader.php @@ -0,0 +1,67 @@ +headerClass, Base::class, true)) { + throw new InvalidArgumentException( + sprintf("%s class is not an instance of %s", $this->headerClass, Base::class) + ); + } + } + + /** + * Add value in order + */ + protected function addValue(BaseHeaderValue $value): void + { + if (count($this->collection) === 0) { + $this->collection[] = $value; + return; + } + for ($pos = array_key_last($this->collection); $pos >= 0; --$pos) { + $item = $this->collection[$pos]; + $result = (float)$item->getQuality() <=> (float)$value->getQuality(); + if ($result > 0) { + break; + } elseif ($result === 0) { + $itemTypes = array_reverse(explode('/', $item->getValue())); + $valueTypes = array_reverse(explode('/', $value->getValue())); + var_dump($itemTypes, $valueTypes); + $result = count($itemTypes) <=> count($valueTypes); + if ($result > 0) { + break; + } elseif ($result === 0) { + foreach ($itemTypes as $part => $itemType) { + if ($itemType === '*' xor $valueTypes[$part] === '*') { + if ($itemType !== '*') { + break 2; + } else { + $this->collection[$pos + 1] = $item; + continue 2; + } + } + } + if (count($item->getParams()) >= count($value->getParams())) { + break; + } + } + } + $this->collection[$pos + 1] = $item; + } + $this->collection[$pos + 1] = $value; + } +} diff --git a/src/Header/SortableHeader.php b/src/Header/SortableHeader.php index c3c12d7..1b88fd6 100644 --- a/src/Header/SortableHeader.php +++ b/src/Header/SortableHeader.php @@ -23,8 +23,7 @@ public function __construct(string $nameOrClass) { } /** - * Todo - * Add and and sort value + * Add value in order */ protected function addValue(BaseHeaderValue $value): void { @@ -34,18 +33,13 @@ protected function addValue(BaseHeaderValue $value): void } for ($pos = array_key_last($this->collection); $pos >= 0; --$pos) { $item = $this->collection[$pos]; - if ((float)$item->getQuality() >= (float)$value->getQuality()) { - break; + $result = (float)$item->getQuality() <=> (float)$value->getQuality(); + if ($result >= 0) { + $this->collection[$pos + 1] = $value; + return; } + $this->collection[$pos + 1] = $item; } - if ($pos < 0) { - array_unshift($this->collection, $value); - } elseif ($pos === array_key_last($this->collection)) { - $this->collection[] = $value; - } else { - $toPush = array_slice($this->collection, $pos + 1); - $this->collection = array_slice($this->collection, 0, $pos + 1); - array_push($this->collection, $value, ...$toPush); - } + $this->collection[0] = $value; } } diff --git a/src/Header/Value/Accept/Base.php b/src/Header/Value/Accept/Base.php index f1b149c..c6a95dd 100644 --- a/src/Header/Value/Accept/Base.php +++ b/src/Header/Value/Accept/Base.php @@ -4,14 +4,14 @@ namespace Yiisoft\Http\Header\Value\Accept; -use Yiisoft\Http\Header\SortableHeader; +use Yiisoft\Http\Header\AcceptHeader; use Yiisoft\Http\Header\Value\BaseHeaderValue; use Yiisoft\Http\Header\WithQualityParam; abstract class Base extends BaseHeaderValue implements WithQualityParam { - public static function createHeader(): SortableHeader + public static function createHeader(): AcceptHeader { - return new SortableHeader(static::class); + return new AcceptHeader(static::class); } } diff --git a/src/Header/Value/BaseHeaderValue.php b/src/Header/Value/BaseHeaderValue.php index 7171a06..b1778c8 100644 --- a/src/Header/Value/BaseHeaderValue.php +++ b/src/Header/Value/BaseHeaderValue.php @@ -6,6 +6,7 @@ use Exception; use Yiisoft\Http\Header\Header; +use Yiisoft\Http\Header\SortableHeader; use Yiisoft\Http\Header\WithParams; use Yiisoft\Http\Header\WithQualityParam; @@ -51,7 +52,8 @@ public function __toString(): string public static function createHeader(): Header { - return new Header(static::class); + $class = is_subclass_of(static::class, WithQualityParam::class) ? SortableHeader::class : Header::class; + return new $class(static::class); } public function withValue(string $value): self diff --git a/tests/Header/AcceptHeaderTest.php b/tests/Header/AcceptHeaderTest.php new file mode 100644 index 0000000..59f69d1 --- /dev/null +++ b/tests/Header/AcceptHeaderTest.php @@ -0,0 +1,148 @@ +assertSame('Accept', $values->getName()); + $this->assertSame(Accept::class, $values->getValueClass()); + } + public function testErrorWithHeaderClass() + { + $this->expectException(InvalidArgumentException::class); + new AcceptHeader(QualityHeaderValue::class); + } + public function testCreateFromStringValues() + { + $header = (new AcceptHeader('Accept'))->add('*/*'); + + $this->assertSame('Accept', $header->getName()); + $this->assertSame(['*/*'], $header->getStrings()); + } + public function testCreateFromFewStringValues() + { + $headers = [ + 'text/*;q=0.3', + 'text/html', + '*/*;q=0.001', + 'text/plain;q=0.5', + ]; + + $header = (new AcceptHeader('Accept-Test'))->addArray($headers); + + $this->assertSame('Accept-Test', $header->getName()); + $this->assertSame( + [ + 'text/html', + 'text/plain;q=0.5', + 'text/*;q=0.3', + '*/*;q=0.001', + ], + $header->getStrings() + ); + } + public function testParamsPrioritySortingWithSameQuality() + { + $headers = [ + 'text/html', + 'text/plain;foo=1;bar=2', + 'text/plain;foo=3', + 'text/plain;foo=1', + 'text/plain', + 'text/plain;foo=2', + ]; + + $header = (new AcceptHeader('Accept'))->addArray($headers); + + $this->assertSame('Accept', $header->getName()); + $this->assertSame( + [ + 'text/plain;foo=1;bar=2', + 'text/plain;foo=3', + 'text/plain;foo=1', + 'text/plain;foo=2', + 'text/html', + 'text/plain', + ], + $header->getStrings() + ); + } + public function testPrioritySortingWithoutParams() + { + $headers = ' text/*, text/plain, text/plain;format=flowed, */*'; + + $header = (new AcceptHeader('Accept'))->add($headers); + + $this->assertSame('Accept', $header->getName()); + $this->assertSame( + [ + 'text/plain;format=flowed', + 'text/plain', + 'text/*', + '*/*', + ], + $header->getStrings() + ); + } + public function testPrioritySortingOfIncorrectValuesDataWithoutParams() + { + $headers = 'foo/bar/*, foo/bar/baz, */bar, */*/*, foo/*/*, foo/*/baz, foo/bar'; + + $header = (new AcceptHeader('Accept'))->add($headers); + + $this->assertSame('Accept', $header->getName()); + $this->assertSame( + [ + 'foo/bar/baz', + 'foo/*/baz', + 'foo/bar/*', + 'foo/*/*', + '*/*/*', + 'foo/bar', + '*/bar', + ], + $header->getStrings() + ); + } + public function testCreateFromManyMixedStringValues() + { + $headers = [ + 'text/*;q=0.3', + 'text/html;q=0.7', + 'text/html;level=1', + 'text/html;level=2;q=0.4', + '*/*;q=0.5', + 'text/*', + 'text/plain', + 'text/plain;format=flowed', + '*/*', + ]; + + $header = (new AcceptHeader('Accept'))->addArray($headers); + + $this->assertSame('Accept', $header->getName()); + $this->assertSame( + [ + 'text/html;level=1', + 'text/plain;format=flowed', + 'text/plain', + 'text/*', + '*/*', + 'text/html;q=0.7', + '*/*;q=0.5', + 'text/html;level=2;q=0.4', + 'text/*;q=0.3', + ], + $header->getStrings() + ); + } +} diff --git a/tests/Header/SortableHeaderTest.php b/tests/Header/SortableHeaderTest.php index ae93390..8f50ad2 100644 --- a/tests/Header/SortableHeaderTest.php +++ b/tests/Header/SortableHeaderTest.php @@ -5,14 +5,8 @@ use InvalidArgumentException; use PHPUnit\Framework\TestCase; use Yiisoft\Http\Header\SortableHeader; -use Yiisoft\Http\Header\Value\Accept\Accept; -use Yiisoft\Http\Header\Value\BaseHeaderValue; use Yiisoft\Http\Header\Value\Date\Date; -use Yiisoft\Http\Header\Value\DefaultValue; -use Yiisoft\Http\Tests\Header\Value\Stub\ListedValuesHeaderValue; -use Yiisoft\Http\Tests\Header\Value\Stub\ListedValuesWithParamsHeaderValue; use Yiisoft\Http\Tests\Header\Value\Stub\QualityHeaderValue; -use Yiisoft\Http\Tests\Header\Value\Stub\WithParamsHeaderValue; class SortableHeaderTest extends TestCase { From 1805477097fc077121c740f4d82001ae5fec2388 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 4 Mar 2020 00:05:23 +0300 Subject: [PATCH 11/26] added Russian description draft; some refactoring --- README.php | 63 +++++++++++++++++++ src/Header/AcceptHeader.php | 1 - src/Header/Header.php | 4 +- src/Header/SortableHeader.php | 4 +- src/Header/Value/Allow.php | 4 +- src/Header/Value/ListedValue.php | 16 +++++ .../{DefaultValue.php => SimpleValue.php} | 2 +- .../{SortableValue.php => SortedValue.php} | 2 +- tests/Header/HeaderTest.php | 4 +- 9 files changed, 89 insertions(+), 11 deletions(-) create mode 100644 README.php create mode 100644 src/Header/Value/ListedValue.php rename src/Header/Value/{DefaultValue.php => SimpleValue.php} (83%) rename src/Header/Value/{SortableValue.php => SortedValue.php} (79%) diff --git a/README.php b/README.php new file mode 100644 index 0000000..23833c0 --- /dev/null +++ b/README.php @@ -0,0 +1,63 @@ +## HTTP заголовки + +Из совокупности всех рассмотренных HTTP заголовков были выделены некоторые важные особенности: + +- Некоторые заголовки могут иметь не одно значение, а целый список. Значения таких заголовков могут быть перечислены + через запятую в одном заголовке или по отдельности в одноимённых заголовках. +- Некоторые заголовки могут иметь параметры. Параметры отделяются символом ";" от значения. +- Заголовки со множественными значениями могут иметь особый тип параметра "q" (quality), который может означет +приоритет, "качество" или "вес" значения. +- Существуют заголовки, не имеющие вышеперечисленные качества, лиибо имебщие особый формат. + +Указанные свойства прививаются заголовкам с помощью интерфейсов. Например: +```php +/** @see https://tools.ietf.org/html/rfc7231#section-7.4.1 */ +final class Allow extends BaseHeaderValue implements ListedValues {} +/** @see https://tools.ietf.org/html/rfc7239 */ +final class Forwarded extends BaseHeaderValue implements WithParams, ListedValues {} +``` + +Любой заголовок при вводе/выводе может быть перечислен несколько раз, поэтому работа с люибым заголовком подразумевается +как с коллекцией значений. Базовым классом для коллекции значений заголовков является `\Yiisoft\Http\Header\Header`. \ +Для заголовков, поддерживающих множетво значений, важно соблюдение последовательности этих зачений. Поэтому для +сортируемых списков значений выделенна отдельная коллекция `\Yiisoft\Http\Header\SortableHeader`, а текже специальная +коллекция `\Yiisoft\Http\Header\AcceptHeader` для семейства заголовков `Accept`. + +Рассмотрим пример с заголовком Forwarded [[RFC](https://tools.ietf.org/html/rfc7239)].\ +Заголовок представляет из себя список из пустых значений с параметрами: + +``` +Forwarded: for=192.0.2.43,for="[2001:db8:cafe::17]",for=unknown +Forwarded: for=192.0.2.60;proto=http;by=203.0.113.43 +``` + +Следующий код выводит все значения параметров `for`: + +```php +addArray($request->getHeader(Forwarded::NAME)); +foreach ($header->getValues() as $headerValue) { + $paramFor = $headerValue->getParams()['for'] ?? null; + if ($paramFor === null) { + continue; + } + echo $paramFor . "\r\n"; +} +?> +``` + +Если вы не хотите описывать заголвок в отдельном классе, то можете использовать заготовленные классы: +```php +addArray(['foo', 'bar', 'baz']); +// создать заголовок с перечисляемыми сортируемыми значениями +\Yiisoft\Http\Header\Value\SortedValue::createHeader('My-Sorted-List') + ->addArray(['foo', 'bar;q=0.5', 'baz;q=0']) + ->inject($response); +?> +``` diff --git a/src/Header/AcceptHeader.php b/src/Header/AcceptHeader.php index 9f9fc6f..14b407c 100644 --- a/src/Header/AcceptHeader.php +++ b/src/Header/AcceptHeader.php @@ -40,7 +40,6 @@ protected function addValue(BaseHeaderValue $value): void } elseif ($result === 0) { $itemTypes = array_reverse(explode('/', $item->getValue())); $valueTypes = array_reverse(explode('/', $value->getValue())); - var_dump($itemTypes, $valueTypes); $result = count($itemTypes) <=> count($valueTypes); if ($result > 0) { break; diff --git a/src/Header/Header.php b/src/Header/Header.php index e693710..9556a50 100644 --- a/src/Header/Header.php +++ b/src/Header/Header.php @@ -6,7 +6,7 @@ use InvalidArgumentException; use Psr\Http\Message\MessageInterface; -use Yiisoft\Http\Header\Value\DefaultValue; +use Yiisoft\Http\Header\Value\SimpleValue; use Yiisoft\Http\Header\Value\BaseHeaderValue; class Header implements \IteratorAggregate, \Countable @@ -18,7 +18,7 @@ class Header implements \IteratorAggregate, \Countable /** @var BaseHeaderValue[] */ protected array $collection = []; - protected const DEFAULT_VALUE_CLASS = DefaultValue::class; + protected const DEFAULT_VALUE_CLASS = SimpleValue::class; // Parsing's constants private const DELIMITERS = '"(),/:;<=>?@[\\]{}', diff --git a/src/Header/SortableHeader.php b/src/Header/SortableHeader.php index 1b88fd6..7af756d 100644 --- a/src/Header/SortableHeader.php +++ b/src/Header/SortableHeader.php @@ -6,12 +6,12 @@ use InvalidArgumentException; use Yiisoft\Http\Header\Value\BaseHeaderValue; -use Yiisoft\Http\Header\Value\SortableValue; +use Yiisoft\Http\Header\Value\SortedValue; class SortableHeader extends Header { // todo: sorting, comparing - protected const DEFAULT_VALUE_CLASS = SortableValue::class; + protected const DEFAULT_VALUE_CLASS = SortedValue::class; public function __construct(string $nameOrClass) { parent::__construct($nameOrClass); diff --git a/src/Header/Value/Allow.php b/src/Header/Value/Allow.php index 121098c..ef3ed4e 100644 --- a/src/Header/Value/Allow.php +++ b/src/Header/Value/Allow.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Yiisoft\Http\Header\Value\Date; +namespace Yiisoft\Http\Header\Value; use Yiisoft\Http\Header\ListedValues; /** * @see https://tools.ietf.org/html/rfc7231#section-7.4.1 */ -final class Allow extends Base implements ListedValues +final class Allow extends BaseHeaderValue implements ListedValues { public const NAME = 'Allow'; } diff --git a/src/Header/Value/ListedValue.php b/src/Header/Value/ListedValue.php new file mode 100644 index 0000000..1c6b277 --- /dev/null +++ b/src/Header/Value/ListedValue.php @@ -0,0 +1,16 @@ +expectException(InvalidArgumentException::class); $this->expectExceptionMessageMatches('/no header name/'); - new Header(DefaultValue::class); + new Header(SimpleValue::class); } public function testErrorWithHeaderClass() { From 1b0f63d9f01b404b9c347afbd400042e2fdb6c58 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 4 Mar 2020 21:47:25 +0300 Subject: [PATCH 12/26] Documentation is now separated; more tests; more refactoring --- README.md | 43 +----- README.php | 63 -------- docs/en/README.md | 46 ++++++ docs/ru/README.md | 126 +++++++++++++++ src/Header/AcceptHeader.php | 17 +- src/Header/Header.php | 5 + src/Header/SortableHeader.php | 1 - src/Header/Value/Accept/Accept.php | 12 +- src/Header/Value/Accept/AcceptCharset.php | 3 +- src/Header/Value/Accept/AcceptEncoding.php | 3 +- src/Header/Value/Accept/AcceptLanguage.php | 3 +- src/Header/Value/Accept/Base.php | 17 -- src/Header/Value/BaseHeaderValue.php | 23 ++- src/Header/Value/Date/Base.php | 29 ---- src/Header/Value/Date/Date.php | 23 ++- src/Header/Value/Date/Expires.php | 2 +- src/Header/Value/Date/IfModifiedSince.php | 2 +- src/Header/Value/Date/IfUnmodifiedSince.php | 2 +- src/Header/Value/Date/LastModified.php | 2 +- tests/Header/AcceptHeaderTest.php | 145 +++++++++++++++++- tests/Header/HeaderTest.php | 4 +- tests/Header/SortableHeaderTest.php | 6 +- tests/Header/Value/BaseHeaderValueTest.php | 34 ++++ ...yHeaderValue.php => SortedHeaderValue.php} | 2 +- 24 files changed, 425 insertions(+), 188 deletions(-) delete mode 100644 README.php create mode 100644 docs/en/README.md create mode 100644 docs/ru/README.md delete mode 100644 src/Header/Value/Accept/Base.php delete mode 100644 src/Header/Value/Date/Base.php rename tests/Header/Value/Stub/{QualityHeaderValue.php => SortedHeaderValue.php} (68%) diff --git a/README.md b/README.md index 6445237..e0cdfa0 100644 --- a/README.md +++ b/README.md @@ -14,44 +14,7 @@ The package provides handy HTTP utility such as method constants. [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/yiisoft/http/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/yiisoft/http/?branch=master) [![Code Coverage](https://scrutinizer-ci.com/g/yiisoft/http/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/yiisoft/http/?branch=master) -## Method constants +## Documentation -Individual HTTP methods could be referenced as - -```php -use Yiisoft\Http\Method; - -Method::GET; -Method::POST; -Method::PUT; -Method::DELETE; -Method::PATCH; -Method::HEAD; -Method::OPTIONS; -``` - -To have a list of these, use: - -```php -use Yiisoft\Http\Method; - -Method::ANY; -``` - -## HTTP status codes - -Status codes could be referenced by name as: - -```php -use Yiisoft\Http\Status; - -Status::NOT_FOUND; -``` - -Status text could be obtained as the following: - -```php -use Yiisoft\Http\Status; - -Status::TEXTS[Status::NOT_FOUND]; -``` +- [English](docs/en/README.md) +- [Russian](docs/ru/README.md) diff --git a/README.php b/README.php deleted file mode 100644 index 23833c0..0000000 --- a/README.php +++ /dev/null @@ -1,63 +0,0 @@ -## HTTP заголовки - -Из совокупности всех рассмотренных HTTP заголовков были выделены некоторые важные особенности: - -- Некоторые заголовки могут иметь не одно значение, а целый список. Значения таких заголовков могут быть перечислены - через запятую в одном заголовке или по отдельности в одноимённых заголовках. -- Некоторые заголовки могут иметь параметры. Параметры отделяются символом ";" от значения. -- Заголовки со множественными значениями могут иметь особый тип параметра "q" (quality), который может означет -приоритет, "качество" или "вес" значения. -- Существуют заголовки, не имеющие вышеперечисленные качества, лиибо имебщие особый формат. - -Указанные свойства прививаются заголовкам с помощью интерфейсов. Например: -```php -/** @see https://tools.ietf.org/html/rfc7231#section-7.4.1 */ -final class Allow extends BaseHeaderValue implements ListedValues {} -/** @see https://tools.ietf.org/html/rfc7239 */ -final class Forwarded extends BaseHeaderValue implements WithParams, ListedValues {} -``` - -Любой заголовок при вводе/выводе может быть перечислен несколько раз, поэтому работа с люибым заголовком подразумевается -как с коллекцией значений. Базовым классом для коллекции значений заголовков является `\Yiisoft\Http\Header\Header`. \ -Для заголовков, поддерживающих множетво значений, важно соблюдение последовательности этих зачений. Поэтому для -сортируемых списков значений выделенна отдельная коллекция `\Yiisoft\Http\Header\SortableHeader`, а текже специальная -коллекция `\Yiisoft\Http\Header\AcceptHeader` для семейства заголовков `Accept`. - -Рассмотрим пример с заголовком Forwarded [[RFC](https://tools.ietf.org/html/rfc7239)].\ -Заголовок представляет из себя список из пустых значений с параметрами: - -``` -Forwarded: for=192.0.2.43,for="[2001:db8:cafe::17]",for=unknown -Forwarded: for=192.0.2.60;proto=http;by=203.0.113.43 -``` - -Следующий код выводит все значения параметров `for`: - -```php -addArray($request->getHeader(Forwarded::NAME)); -foreach ($header->getValues() as $headerValue) { - $paramFor = $headerValue->getParams()['for'] ?? null; - if ($paramFor === null) { - continue; - } - echo $paramFor . "\r\n"; -} -?> -``` - -Если вы не хотите описывать заголвок в отдельном классе, то можете использовать заготовленные классы: -```php -addArray(['foo', 'bar', 'baz']); -// создать заголовок с перечисляемыми сортируемыми значениями -\Yiisoft\Http\Header\Value\SortedValue::createHeader('My-Sorted-List') - ->addArray(['foo', 'bar;q=0.5', 'baz;q=0']) - ->inject($response); -?> -``` diff --git a/docs/en/README.md b/docs/en/README.md new file mode 100644 index 0000000..b4a1648 --- /dev/null +++ b/docs/en/README.md @@ -0,0 +1,46 @@ +# HTTP Package + +## Method constants + +Individual HTTP methods could be referenced as + +```php +use Yiisoft\Http\Method; + +Method::GET; +Method::POST; +Method::PUT; +Method::DELETE; +Method::PATCH; +Method::HEAD; +Method::OPTIONS; +``` + +To have a list of these, use: + +```php +use Yiisoft\Http\Method; + +Method::ANY; +``` + +## HTTP status codes + +Status codes could be referenced by name as: + +```php +use Yiisoft\Http\Status; + +Status::NOT_FOUND; +``` + +Status text could be obtained as the following: + +```php +use Yiisoft\Http\Status; + +Status::TEXTS[Status::NOT_FOUND]; +``` + +## HTTP headers + diff --git a/docs/ru/README.md b/docs/ru/README.md new file mode 100644 index 0000000..a9d9736 --- /dev/null +++ b/docs/ru/README.md @@ -0,0 +1,126 @@ +# HTTP Package + +## Method constants + +Individual HTTP methods could be referenced as + +```php +use Yiisoft\Http\Method; + +Method::GET; +Method::POST; +Method::PUT; +Method::DELETE; +Method::PATCH; +Method::HEAD; +Method::OPTIONS; +``` + +To have a list of these, use: + +```php +use Yiisoft\Http\Method; + +Method::ANY; +``` + +## HTTP status codes + +Status codes could be referenced by name as: + +```php +use Yiisoft\Http\Status; + +Status::NOT_FOUND; +``` + +Status text could be obtained as the following: + +```php +use Yiisoft\Http\Status; + +Status::TEXTS[Status::NOT_FOUND]; +``` + +## HTTP заголовки + +Из совокупности всех рассмотренных HTTP заголовков были выделены некоторые важные особенности: + +- Некоторые заголовки могут иметь не одно значение, а целый список. Значения таких заголовков могут быть перечислены + через запятую в одном заголовке или нескольких одноимённых заголовках. +- Некоторые заголовки могут иметь параметры. Параметры отделяются символом ";" от значения. +- Заголовки со множественными значениями могут иметь особый тип параметра "q" (quality), который может означает +приоритет, "качество" или "вес" значения. +- Существуют заголовки, не имеющие вышеперечисленные качества, либо имеющие особый формат. + +Указанные свойства прививаются заголовкам с помощью интерфейсов. Например: + +```php +/** @see https://tools.ietf.org/html/rfc7231#section-7.4.1 */ +final class Allow extends BaseHeaderValue implements ListedValues {} +/** @see https://tools.ietf.org/html/rfc7239 */ +final class Forwarded extends BaseHeaderValue implements WithParams, ListedValues {} +``` + +Указание интерфейсов влияет только на процессы парсинга и генерирования строки поля значения заголовка. +Таким образом, при отсутствующем интерфейсе `WithParams` вам будут доступны методы `withParams()` и `getParams()`, но +при генерировании строки параметры будут игнорироваться. + +Рассморим поведение парсинга на примере: + +```php +$header = XHeader::createHeader()->add('foo;param1=test;q=0.5, bar'); +``` +- Если класс `XHeader` не будет иметь интерфейс `ListedValues`, то вся строка станет единственным значением заголовка. +- При наличии только интерфейса `ListedValues` у заголовка будет два значения: `foo;param1=test;q=0.5` и `bar`. +- `ListedValues` и `WithParams` вместе дадут ожидаемый разбор на значения и параметры, однако параметр `q` не будет + учитываться в качестве параметра сортировки, а метод `getQuality()` будет всегда возвращать "1". +- Введение интерфейса `WithQualityParam` добавит автоматическую сортировку значений, метод `getQuality()` будет зависеть + от параметра `q`. + +Любой заголовок при вводе/выводе может быть перечислен несколько раз, поэтому работа с любым заголовком подразумевается +как с коллекцией значений. Базовым классом для коллекции значений заголовков является `\Yiisoft\Http\Header\Header`. \ +Для заголовков, поддерживающих множество значений, важно соблюдение последовательности этих значений. Поэтому для +сортируемых списков значений выделена отдельная коллекция `\Yiisoft\Http\Header\SortableHeader`, а также специальная +коллекция `\Yiisoft\Http\Header\AcceptHeader` для семейства заголовков `Accept`. + +Рассмотрим пример с заголовком Forwarded [[RFC](https://tools.ietf.org/html/rfc7239)].\ +Заголовок представляет собой список из пустых значений с возможными параметрами `by`, `for`, `host` и `proto`: + +``` +Forwarded: for=192.0.2.43,for="[2001:db8:cafe::17]",for=unknown +Forwarded: for=192.0.2.60;proto=http;by=203.0.113.43 +``` + +Следующий код выводит все значения параметров `for`: + +```php +extract($request); +foreach ($header->getValues() as $headerValue) { + $paramFor = $headerValue->getParams()['for'] ?? null; + if ($paramFor === null) { + continue; + } + echo $paramFor . "\r\n"; +} +?> +``` + + + +Если вы не хотите описывать заголовок в отдельном классе, то можете использовать заготовленные классы: +```php +addArray(['foo', 'bar', 'baz']); +// создать заголовок с перечисляемыми сортируемыми значениями +\Yiisoft\Http\Header\Value\SortedValue::createHeader('My-Sorted-List') + ->addArray(['foo', 'bar;q=0.5', 'baz;q=0']) + ->inject($response); +?> +``` diff --git a/src/Header/AcceptHeader.php b/src/Header/AcceptHeader.php index 14b407c..2695b35 100644 --- a/src/Header/AcceptHeader.php +++ b/src/Header/AcceptHeader.php @@ -6,19 +6,18 @@ use InvalidArgumentException; use Yiisoft\Http\Header\Value\Accept\Accept; -use Yiisoft\Http\Header\Value\Accept\Base; use Yiisoft\Http\Header\Value\BaseHeaderValue; class AcceptHeader extends Header { - // todo: sorting, comparing + // todo: comparing protected const DEFAULT_VALUE_CLASS = Accept::class; public function __construct(string $nameOrClass) { parent::__construct($nameOrClass); - if (!is_subclass_of($this->headerClass, Base::class, true)) { + if (!is_a($this->headerClass, Accept::class, true)) { throw new InvalidArgumentException( - sprintf("%s class is not an instance of %s", $this->headerClass, Base::class) + sprintf("%s class is not an instance of %s", $this->headerClass, Accept::class) ); } } @@ -38,8 +37,14 @@ protected function addValue(BaseHeaderValue $value): void if ($result > 0) { break; } elseif ($result === 0) { - $itemTypes = array_reverse(explode('/', $item->getValue())); - $valueTypes = array_reverse(explode('/', $value->getValue())); + $separator = $this->headerClass::VALUE_SEPARATOR; + if ($separator !== '') { + $itemTypes = array_reverse(explode($separator, $item->getValue())); + $valueTypes = array_reverse(explode($separator, $value->getValue())); + } else { + $itemTypes = [$item->getValue()]; + $valueTypes = [$value->getValue()]; + } $result = count($itemTypes) <=> count($valueTypes); if ($result > 0) { break; diff --git a/src/Header/Header.php b/src/Header/Header.php index 9556a50..270b246 100644 --- a/src/Header/Header.php +++ b/src/Header/Header.php @@ -152,6 +152,11 @@ public function inject( } return $message; } + public function extract(MessageInterface $message): self + { + $this->addArray($message->getHeader($this->headerName)); + return $this; + } protected function addValue(BaseHeaderValue $value): void { diff --git a/src/Header/SortableHeader.php b/src/Header/SortableHeader.php index 7af756d..61ced64 100644 --- a/src/Header/SortableHeader.php +++ b/src/Header/SortableHeader.php @@ -10,7 +10,6 @@ class SortableHeader extends Header { - // todo: sorting, comparing protected const DEFAULT_VALUE_CLASS = SortedValue::class; public function __construct(string $nameOrClass) { diff --git a/src/Header/Value/Accept/Accept.php b/src/Header/Value/Accept/Accept.php index 5558e7f..ea72636 100644 --- a/src/Header/Value/Accept/Accept.php +++ b/src/Header/Value/Accept/Accept.php @@ -4,10 +4,20 @@ namespace Yiisoft\Http\Header\Value\Accept; +use Yiisoft\Http\Header\AcceptHeader; +use Yiisoft\Http\Header\Value\BaseHeaderValue; +use Yiisoft\Http\Header\WithQualityParam; + /** * @see https://tools.ietf.org/html/rfc7231#section-5.3.2 */ -final class Accept extends Base +class Accept extends BaseHeaderValue implements WithQualityParam { public const NAME = 'Accept'; + public const VALUE_SEPARATOR = '/'; + + final public static function createHeader(): AcceptHeader + { + return new AcceptHeader(static::class); + } } diff --git a/src/Header/Value/Accept/AcceptCharset.php b/src/Header/Value/Accept/AcceptCharset.php index c8d7b89..3f3bbbc 100644 --- a/src/Header/Value/Accept/AcceptCharset.php +++ b/src/Header/Value/Accept/AcceptCharset.php @@ -7,7 +7,8 @@ /** * @see https://tools.ietf.org/html/rfc7231#section-5.3.4 */ -final class AcceptCharset extends Base +final class AcceptCharset extends Accept { public const NAME = 'Accept-Charset'; + public const VALUE_SEPARATOR = ''; } diff --git a/src/Header/Value/Accept/AcceptEncoding.php b/src/Header/Value/Accept/AcceptEncoding.php index 73aef14..cacdc5c 100644 --- a/src/Header/Value/Accept/AcceptEncoding.php +++ b/src/Header/Value/Accept/AcceptEncoding.php @@ -7,7 +7,8 @@ /** * @see https://tools.ietf.org/html/rfc7231#section-5.3.4 */ -final class AcceptEncoding extends Base +final class AcceptEncoding extends Accept { public const NAME = 'Accept-Encoding'; + public const VALUE_SEPARATOR = ''; } diff --git a/src/Header/Value/Accept/AcceptLanguage.php b/src/Header/Value/Accept/AcceptLanguage.php index 64ceb11..dbbeff2 100644 --- a/src/Header/Value/Accept/AcceptLanguage.php +++ b/src/Header/Value/Accept/AcceptLanguage.php @@ -7,7 +7,8 @@ /** * @see https://tools.ietf.org/html/rfc7231#section-5.3.5 */ -final class AcceptLanguage extends Base +final class AcceptLanguage extends Accept { public const NAME = 'Accept-Language'; + public const VALUE_SEPARATOR = '-'; } diff --git a/src/Header/Value/Accept/Base.php b/src/Header/Value/Accept/Base.php deleted file mode 100644 index c6a95dd..0000000 --- a/src/Header/Value/Accept/Base.php +++ /dev/null @@ -1,17 +0,0 @@ -getParams() as $key => $value) { - if ($key === 'q' && $this instanceof WithQualityParam) { - if ($value === '1') { - continue; + if ($this instanceof WithParams) { + foreach ($this->getParams() as $key => $value) { + if ($key === 'q' && $this instanceof WithQualityParam) { + if ($value === '1') { + continue; + } } + $escaped = preg_replace('/([\\\\"])/', '\\\\$1', $value); + $quoted = $value === '' + || strlen($escaped) !== strlen($value) + || preg_match('/[\\s,;()\\/:<=>?@\\[\\\\\\]{}]/', $value) === 1; + $params[] = $key . '=' . ($quoted ? "\"{$escaped}\"" : $value); } - $escaped = preg_replace('/([\\\\"])/', '\\\\$1', $value); - $quoted = $value === '' - || strlen($escaped) !== strlen($value) - || preg_match('/[\\s,;()\\/:<=>?@\\[\\\\\\]{}]/', $value) === 1; - $params[] = $key . '=' . ($quoted ? "\"{$escaped}\"" : $value); } return $this->value === '' ? implode(';', $params) : implode(';', [$this->value, ...$params]); } @@ -75,9 +77,6 @@ public function getValue(): string public function withParams(array $params): self { $clone = clone $this; - if (!$this instanceof WithParams) { - return $clone; - } $clone->params = []; foreach ($params as $key => $value) { $key = strtolower($key); diff --git a/src/Header/Value/Date/Base.php b/src/Header/Value/Date/Base.php deleted file mode 100644 index bf82489..0000000 --- a/src/Header/Value/Date/Base.php +++ /dev/null @@ -1,29 +0,0 @@ -value); - } catch (Exception $e) { - $this->error = $e; - return null; - } - } - - public function withValueFromDatetime(DateTimeImmutable $date): self - { - return $this->withValue($date->format(self::HTTP_DATETIME_FORMAT)); - } -} diff --git a/src/Header/Value/Date/Date.php b/src/Header/Value/Date/Date.php index 12f9580..1322fd8 100644 --- a/src/Header/Value/Date/Date.php +++ b/src/Header/Value/Date/Date.php @@ -4,7 +4,28 @@ namespace Yiisoft\Http\Header\Value\Date; -final class Date extends Base +use DateTimeImmutable; +use Exception; +use Yiisoft\Http\Header\Value\BaseHeaderValue; + +class Date extends BaseHeaderValue { public const NAME = 'Date'; + + protected const HTTP_DATETIME_FORMAT = 'D, d M Y H:i:s \\G\\M\\T'; + + final public function getDatetimeValue(): ?DateTimeImmutable + { + try { + return new DateTimeImmutable($this->value); + } catch (Exception $e) { + $this->error = $e; + return null; + } + } + + final public function withValueFromDatetime(DateTimeImmutable $date): self + { + return $this->withValue($date->format(self::HTTP_DATETIME_FORMAT)); + } } diff --git a/src/Header/Value/Date/Expires.php b/src/Header/Value/Date/Expires.php index 835e844..7c05491 100644 --- a/src/Header/Value/Date/Expires.php +++ b/src/Header/Value/Date/Expires.php @@ -4,7 +4,7 @@ namespace Yiisoft\Http\Header\Value\Date; -final class Expires extends Base +final class Expires extends Date { public const NAME = 'Expires'; } diff --git a/src/Header/Value/Date/IfModifiedSince.php b/src/Header/Value/Date/IfModifiedSince.php index 60ff27b..257da8f 100644 --- a/src/Header/Value/Date/IfModifiedSince.php +++ b/src/Header/Value/Date/IfModifiedSince.php @@ -7,7 +7,7 @@ /** * @see https://tools.ietf.org/html/rfc7232#section-3.3 */ -final class IfModifiedSince extends Base +final class IfModifiedSince extends Date { public const NAME = 'If-Modified-Since'; } diff --git a/src/Header/Value/Date/IfUnmodifiedSince.php b/src/Header/Value/Date/IfUnmodifiedSince.php index 1d565e5..6d9d032 100644 --- a/src/Header/Value/Date/IfUnmodifiedSince.php +++ b/src/Header/Value/Date/IfUnmodifiedSince.php @@ -7,7 +7,7 @@ /** * @see https://tools.ietf.org/html/rfc7232#section-3.4 */ -final class IfUnmodifiedSince extends Base +final class IfUnmodifiedSince extends Date { public const NAME = 'If-Unmodified-Since'; } diff --git a/src/Header/Value/Date/LastModified.php b/src/Header/Value/Date/LastModified.php index ac05a0d..f5ed8d0 100644 --- a/src/Header/Value/Date/LastModified.php +++ b/src/Header/Value/Date/LastModified.php @@ -7,7 +7,7 @@ /** * @see https://tools.ietf.org/html/rfc7232#section-2.2 */ -final class LastModified extends Base +final class LastModified extends Date { public const NAME = 'Last-Modified'; } diff --git a/tests/Header/AcceptHeaderTest.php b/tests/Header/AcceptHeaderTest.php index 59f69d1..1b469ca 100644 --- a/tests/Header/AcceptHeaderTest.php +++ b/tests/Header/AcceptHeaderTest.php @@ -6,7 +6,10 @@ use PHPUnit\Framework\TestCase; use Yiisoft\Http\Header\AcceptHeader; use Yiisoft\Http\Header\Value\Accept\Accept; -use Yiisoft\Http\Tests\Header\Value\Stub\QualityHeaderValue; +use Yiisoft\Http\Header\Value\Accept\AcceptCharset; +use Yiisoft\Http\Header\Value\Accept\AcceptEncoding; +use Yiisoft\Http\Header\Value\Accept\AcceptLanguage; +use Yiisoft\Http\Tests\Header\Value\Stub\SortedHeaderValue; class AcceptHeaderTest extends TestCase { @@ -19,7 +22,7 @@ public function testHeaderValueClassPassed() public function testErrorWithHeaderClass() { $this->expectException(InvalidArgumentException::class); - new AcceptHeader(QualityHeaderValue::class); + new AcceptHeader(SortedHeaderValue::class); } public function testCreateFromStringValues() { @@ -76,7 +79,8 @@ public function testParamsPrioritySortingWithSameQuality() $header->getStrings() ); } - public function testPrioritySortingWithoutParams() + + public function testAcceptPrioritySortingWithoutParams() { $headers = ' text/*, text/plain, text/plain;format=flowed, */*'; @@ -93,7 +97,7 @@ public function testPrioritySortingWithoutParams() $header->getStrings() ); } - public function testPrioritySortingOfIncorrectValuesDataWithoutParams() + public function testAcceptPrioritySortingOfIncorrectValuesDataWithoutParams() { $headers = 'foo/bar/*, foo/bar/baz, */bar, */*/*, foo/*/*, foo/*/baz, foo/bar'; @@ -113,7 +117,7 @@ public function testPrioritySortingOfIncorrectValuesDataWithoutParams() $header->getStrings() ); } - public function testCreateFromManyMixedStringValues() + public function testAcceptCreateFromManyMixedStringValues() { $headers = [ 'text/*;q=0.3', @@ -145,4 +149,135 @@ public function testCreateFromManyMixedStringValues() $header->getStrings() ); } + + public function testAcceptCharsetPrioritySortingWithoutParams() + { + $headers = '*, utf-8, iso-8859-5'; + + $header = AcceptCharset::createHeader()->add($headers); + + $this->assertSame('Accept-Charset', $header->getName()); + $this->assertSame( + [ + 'utf-8', + 'iso-8859-5', + '*', + ], + $header->getStrings() + ); + } + public function testAcceptCharsetSortingManyValues() + { + $headers = ['iso-8859-5, unicode-1-1;q=0.8, utf-8, undef/ned, *;q=0']; + + $header = AcceptCharset::createHeader()->addArray($headers); + + $this->assertSame('Accept-Charset', $header->getName()); + $this->assertSame( + [ + 'iso-8859-5', + 'utf-8', + 'undef/ned', + 'unicode-1-1;q=0.8', + '*;q=0', + ], + $header->getStrings() + ); + } + + public function testAcceptEncodingPrioritySortingWithoutParams() + { + $headers = '*, compress, some/encoding, gzip'; + + $header = AcceptEncoding::createHeader()->add($headers); + + $this->assertSame('Accept-Encoding', $header->getName()); + $this->assertSame( + [ + 'compress', + 'some/encoding', + 'gzip', + '*', + ], + $header->getStrings() + ); + } + public function testAcceptEncodingSortingManyValues() + { + $headers = [ + 'compress, *', + '', + 'gzip;q=1.0, identity; q=0.5, deflate;q=0', + ]; + + $header = AcceptEncoding::createHeader()->addArray($headers); + + $this->assertSame('Accept-Encoding', $header->getName()); + $this->assertSame( + [ + 'compress', + '', + 'gzip', + '*', + 'identity;q=0.5', + 'deflate;q=0', + ], + $header->getStrings() + ); + } + + public function testAcceptLanguagePrioritySortingWithoutParams() + { + $headers = [ + 'zh-Hant-CN-x', + 'zh-Hant-CN-x-private1-private2', + 'zh-Hant-CN', + 'zh-Hant-CN-x-private1', + ]; + + $header = AcceptLanguage::createHeader()->addArray($headers); + + $this->assertSame('Accept-Language', $header->getName()); + $this->assertSame( + [ + 'zh-Hant-CN-x-private1-private2', + 'zh-Hant-CN-x-private1', + 'zh-Hant-CN-x', + 'zh-Hant-CN', + ], + $header->getStrings() + ); + } + public function testAcceptLanguageCreateFromManyMixedStringValues() + { + $headers = [ + 'da', + 'zh-Hant-CN-x-private1', + 'fr-FR', + 'fr', + '*;q=0.1', + 'en-gb;q=0.8', + 'en;q=0.7', + 'bg;q=0.1', + 'ru-RU;q=0.1', + ]; + + $header = AcceptLanguage::createHeader()->addArray($headers); + + $this->assertSame('Accept-Language', $header->getName()); + $this->assertSame( + [ + 'zh-Hant-CN-x-private1', + 'fr-FR', + 'da', + 'fr', + 'en-gb;q=0.8', + 'en;q=0.7', + 'ru-RU;q=0.1', + 'bg;q=0.1', + '*;q=0.1', + ], + $header->getStrings() + ); + } } diff --git a/tests/Header/HeaderTest.php b/tests/Header/HeaderTest.php index e39d2bc..100ebe6 100644 --- a/tests/Header/HeaderTest.php +++ b/tests/Header/HeaderTest.php @@ -11,7 +11,7 @@ use Yiisoft\Http\Header\Value\SimpleValue; use Yiisoft\Http\Tests\Header\Value\Stub\ListedValuesHeaderValue; use Yiisoft\Http\Tests\Header\Value\Stub\ListedValuesWithParamsHeaderValue; -use Yiisoft\Http\Tests\Header\Value\Stub\QualityHeaderValue; +use Yiisoft\Http\Tests\Header\Value\Stub\SortedHeaderValue; use Yiisoft\Http\Tests\Header\Value\Stub\WithParamsHeaderValue; class HeaderTest extends TestCase @@ -260,7 +260,7 @@ public function testParsingAndRepackOfQualityParams( ?string $getQuality = null ): void { $defaultValue = '0.987'; - $headerValue = (new QualityHeaderValue())->withParams(['q' => $defaultValue]); + $headerValue = (new SortedHeaderValue())->withParams(['q' => $defaultValue]); $this->assertSame($result, $headerValue->setQuality($setQuality)); $this->assertSame($getQuality ?? $defaultValue, $headerValue->getQuality()); diff --git a/tests/Header/SortableHeaderTest.php b/tests/Header/SortableHeaderTest.php index 8f50ad2..24f4a17 100644 --- a/tests/Header/SortableHeaderTest.php +++ b/tests/Header/SortableHeaderTest.php @@ -6,15 +6,15 @@ use PHPUnit\Framework\TestCase; use Yiisoft\Http\Header\SortableHeader; use Yiisoft\Http\Header\Value\Date\Date; -use Yiisoft\Http\Tests\Header\Value\Stub\QualityHeaderValue; +use Yiisoft\Http\Tests\Header\Value\Stub\SortedHeaderValue; class SortableHeaderTest extends TestCase { public function testHeaderValueClassPassed() { - $values = new SortableHeader(QualityHeaderValue::class); + $values = new SortableHeader(SortedHeaderValue::class); $this->assertSame('Test-Quality', $values->getName()); - $this->assertSame(QualityHeaderValue::class, $values->getValueClass()); + $this->assertSame(SortedHeaderValue::class, $values->getValueClass()); } public function testErrorWithHeaderClass() { diff --git a/tests/Header/Value/BaseHeaderValueTest.php b/tests/Header/Value/BaseHeaderValueTest.php index 3ce1eb2..55761c3 100644 --- a/tests/Header/Value/BaseHeaderValueTest.php +++ b/tests/Header/Value/BaseHeaderValueTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\TestCase; use Yiisoft\Http\Header\Header; use Yiisoft\Http\Tests\Header\Value\Stub\DummyHeaderValue; +use Yiisoft\Http\Tests\Header\Value\Stub\SortedHeaderValue; use Yiisoft\Http\Tests\Header\Value\Stub\WithParamsHeaderValue; class BaseHeaderValueTest extends TestCase @@ -43,4 +44,37 @@ public function testCreateHeader() $this->assertInstanceOf(Header::class, $header); $this->assertSame(DummyHeaderValue::NAME, $header->getName()); } + public function testBehaviorWithoutParams() + { + $params = ['q' => '0.4', 'param' => 'test', 'foo' => 'bar']; + $value = (new DummyHeaderValue('foo')) + ->withParams($params); + + $this->assertFalse($value->hasError()); + $this->assertSame('foo', (string)$value); + $this->assertSame('1', $value->getQuality()); + $this->assertEquals($params, $value->getParams()); + } + public function testBehaviorWithParams() + { + $params = ['q' => '0.4', 'param' => 'test', 'foo' => 'bar']; + $value = (new WithParamsHeaderValue('foo')) + ->withParams($params); + + $this->assertFalse($value->hasError()); + $this->assertSame('foo;q=0.4;param=test;foo=bar', (string)$value); + $this->assertSame('1', $value->getQuality()); + $this->assertEquals($params, $value->getParams()); + } + public function testBehaviorWithQualityParam() + { + $params = ['param' => 'test', 'foo' => 'bar', 'q' => '0.4']; + $value = (new SortedHeaderValue('foo')) + ->withParams($params); + + $this->assertFalse($value->hasError()); + $this->assertSame('foo;param=test;foo=bar;q=0.4', (string)$value); + $this->assertSame('0.4', $value->getQuality()); + $this->assertEquals(['q' => '0.4', 'param' => 'test', 'foo' => 'bar'], $value->getParams()); + } } diff --git a/tests/Header/Value/Stub/QualityHeaderValue.php b/tests/Header/Value/Stub/SortedHeaderValue.php similarity index 68% rename from tests/Header/Value/Stub/QualityHeaderValue.php rename to tests/Header/Value/Stub/SortedHeaderValue.php index 27fef45..cc77019 100644 --- a/tests/Header/Value/Stub/QualityHeaderValue.php +++ b/tests/Header/Value/Stub/SortedHeaderValue.php @@ -4,7 +4,7 @@ use Yiisoft\Http\Header\WithQualityParam; -final class QualityHeaderValue extends \Yiisoft\Http\Header\Value\BaseHeaderValue implements WithQualityParam +final class SortedHeaderValue extends \Yiisoft\Http\Header\Value\BaseHeaderValue implements WithQualityParam { public const NAME = 'Test-Quality'; public function setQuality(string $q): bool From 219623232907a9b549fe1ee3b938ed87af67ea2b Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 6 Mar 2020 00:21:11 +0300 Subject: [PATCH 13/26] Header class now immutable; more refactoring; added cache headers and others --- docs/ru/README.md | 15 +++- src/Header/AcceptHeader.php | 7 +- src/Header/DateHeader.php | 26 +++++++ src/Header/Header.php | 70 ++++++++++++------- src/Header/SortableHeader.php | 7 +- src/Header/Value/Cache/Age.php | 15 ++++ src/Header/Value/Cache/CacheControl.php | 15 ++++ src/Header/Value/{Date => Cache}/Expires.php | 4 +- .../Value/{Date => Cache}/LastModified.php | 4 +- src/Header/Value/Cache/Pragma.php | 15 ++++ src/Header/Value/Cache/Warning.php | 15 ++++ .../{Date => Condition}/IfModifiedSince.php | 4 +- .../{Date => Condition}/IfUnmodifiedSince.php | 4 +- src/Header/Value/Connection.php | 12 ++++ src/Header/Value/Date.php | 47 +++++++++++++ src/Header/Value/Date/Date.php | 31 -------- tests/Header/AcceptHeaderTest.php | 24 +++---- tests/Header/HeaderTest.php | 47 ++++++++----- tests/Header/SortableHeaderTest.php | 8 +-- 19 files changed, 271 insertions(+), 99 deletions(-) create mode 100644 src/Header/DateHeader.php create mode 100644 src/Header/Value/Cache/Age.php create mode 100644 src/Header/Value/Cache/CacheControl.php rename src/Header/Value/{Date => Cache}/Expires.php (56%) rename src/Header/Value/{Date => Cache}/LastModified.php (69%) create mode 100644 src/Header/Value/Cache/Pragma.php create mode 100644 src/Header/Value/Cache/Warning.php rename src/Header/Value/{Date => Condition}/IfModifiedSince.php (68%) rename src/Header/Value/{Date => Condition}/IfUnmodifiedSince.php (69%) create mode 100644 src/Header/Value/Connection.php create mode 100644 src/Header/Value/Date.php delete mode 100644 src/Header/Value/Date/Date.php diff --git a/docs/ru/README.md b/docs/ru/README.md index a9d9736..e38a214 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -100,6 +100,7 @@ use \Yiisoft\Http\Header\Value\Forwarded; /** @var \Psr\Http\Message\ServerRequestInterface $request */ $header = Forwarded::createHeader()->extract($request); + foreach ($header->getValues() as $headerValue) { $paramFor = $headerValue->getParams()['for'] ?? null; if ($paramFor === null) { @@ -112,15 +113,25 @@ foreach ($header->getValues() as $headerValue) { +```php +/** @var \Psr\Http\Message\ResponseInterface $response */ +$dateHeader = \Yiisoft\Http\Header\Value\Date::createHeader() + ->withValue(new DateTimeImmutable()) + ->inject($response); +$dateHeader = \Yiisoft\Http\Header\Value\Cache\Expires::createHeader() + ->withValue(new DateTimeImmutable('+1 day')) + ->inject($response); +``` + Если вы не хотите описывать заголовок в отдельном классе, то можете использовать заготовленные классы: ```php addArray(['foo', 'bar', 'baz']); + ->withValues(['foo', 'bar', 'baz']); // создать заголовок с перечисляемыми сортируемыми значениями \Yiisoft\Http\Header\Value\SortedValue::createHeader('My-Sorted-List') - ->addArray(['foo', 'bar;q=0.5', 'baz;q=0']) + ->withValues(['foo', 'bar;q=0.5', 'baz;q=0']) ->inject($response); ?> ``` diff --git a/src/Header/AcceptHeader.php b/src/Header/AcceptHeader.php index 2695b35..92f02ed 100644 --- a/src/Header/AcceptHeader.php +++ b/src/Header/AcceptHeader.php @@ -8,12 +8,13 @@ use Yiisoft\Http\Header\Value\Accept\Accept; use Yiisoft\Http\Header\Value\BaseHeaderValue; -class AcceptHeader extends Header +final class AcceptHeader extends Header { // todo: comparing protected const DEFAULT_VALUE_CLASS = Accept::class; - public function __construct(string $nameOrClass) { + public function __construct(string $nameOrClass) + { parent::__construct($nameOrClass); if (!is_a($this->headerClass, Accept::class, true)) { throw new InvalidArgumentException( @@ -25,7 +26,7 @@ public function __construct(string $nameOrClass) { /** * Add value in order */ - protected function addValue(BaseHeaderValue $value): void + protected function collect(BaseHeaderValue $value): void { if (count($this->collection) === 0) { $this->collection[] = $value; diff --git a/src/Header/DateHeader.php b/src/Header/DateHeader.php new file mode 100644 index 0000000..7397139 --- /dev/null +++ b/src/Header/DateHeader.php @@ -0,0 +1,26 @@ +format(DateTimeInterface::RFC7231); + } + parent::addValue($value); + } +} diff --git a/src/Header/Header.php b/src/Header/Header.php index 270b246..c3f3f1f 100644 --- a/src/Header/Header.php +++ b/src/Header/Header.php @@ -105,37 +105,32 @@ public function getStrings(bool $ignoreIncorrect = true): array * @param string[]|BaseHeaderValue[] $values * @return $this */ - public function addArray(array $values): self + public function withValues(array $values): self { + $clone = clone $this; foreach ($values as $value) { - $this->add($value); + $clone->addValue($value); } - return $this; + return $clone; } /** * @param string|BaseHeaderValue $value * @return $this */ - public function add($value): self + public function withValue($value): self { - if ($value instanceof BaseHeaderValue) { - if (get_class($value) !== $this->headerClass) { - throw new InvalidArgumentException( - sprintf('The value must be an instance of %s, %s given', $this->headerClass, get_class($value)) - ); - } - $this->addValue($value); - return $this; - } - if (is_string($value)) { - $this->parseAndCollect($value); - return $this; - } - throw new InvalidArgumentException( - sprintf('The value must be an instance of %s or string', $this->headerClass) - ); + $clone = clone $this; + $clone->addValue($value); + return $clone; } + /** + * Export header values into Request or Response + * @param MessageInterface $message Request or Response instance + * @param bool $replace Replace existing headers + * @param bool $ignoreIncorrect Don't export values that have error + * @return MessageInterface + */ public function inject( MessageInterface $message, bool $replace = true, @@ -152,20 +147,47 @@ public function inject( } return $message; } + /** + * Import header values from Request or Response + * @param MessageInterface $message Request or Response instance + * @return $this + */ public function extract(MessageInterface $message): self { - $this->addArray($message->getHeader($this->headerName)); + $this->withValues($message->getHeader($this->headerName)); return $this; } - protected function addValue(BaseHeaderValue $value): void + /** + * @param string|BaseHeaderValue $value + */ + protected function addValue($value): void + { + if ($value instanceof BaseHeaderValue) { + if (get_class($value) !== $this->headerClass) { + throw new InvalidArgumentException( + sprintf('The value must be an instance of %s, %s given', $this->headerClass, get_class($value)) + ); + } + $this->collect($value); + return; + } + if (is_string($value)) { + $this->parseAndCollect($value); + return; + } + throw new InvalidArgumentException( + sprintf('The value must be an instance of %s or string', $this->headerClass) + ); + } + protected function collect(BaseHeaderValue $value): void { $this->collection[] = $value; } private function parseAndCollect(string $body): void { if (!$this->listedValues && !$this->withParams) { - $this->addValue(new $this->headerClass(trim($body))); + $this->collect(new $this->headerClass(trim($body))); return; } $part = self::READ_VALUE; @@ -188,7 +210,7 @@ private function parseAndCollect(string $body): void if ($error !== null) { $item = $item->withError($error); } - $this->addValue($item); + $this->collect($item); $key = $buffer = $value = ''; $params = []; ++$added; diff --git a/src/Header/SortableHeader.php b/src/Header/SortableHeader.php index 61ced64..f3cc29e 100644 --- a/src/Header/SortableHeader.php +++ b/src/Header/SortableHeader.php @@ -8,11 +8,12 @@ use Yiisoft\Http\Header\Value\BaseHeaderValue; use Yiisoft\Http\Header\Value\SortedValue; -class SortableHeader extends Header +final class SortableHeader extends Header { protected const DEFAULT_VALUE_CLASS = SortedValue::class; - public function __construct(string $nameOrClass) { + public function __construct(string $nameOrClass) + { parent::__construct($nameOrClass); if (!is_subclass_of($this->headerClass, WithQualityParam::class, true)) { throw new InvalidArgumentException( @@ -24,7 +25,7 @@ public function __construct(string $nameOrClass) { /** * Add value in order */ - protected function addValue(BaseHeaderValue $value): void + protected function collect(BaseHeaderValue $value): void { if (count($this->collection) === 0) { $this->collection[] = $value; diff --git a/src/Header/Value/Cache/Age.php b/src/Header/Value/Cache/Age.php new file mode 100644 index 0000000..e559356 --- /dev/null +++ b/src/Header/Value/Cache/Age.php @@ -0,0 +1,15 @@ +format(DateTimeInterface::RFC7231); + } + parent::__construct($value); + } + + final public static function createHeader(): DateHeader + { + return new DateHeader(static::class); + } + + final public function getDatetimeValue(): ?DateTimeImmutable + { + try { + return new DateTimeImmutable($this->value); + } catch (Exception $e) { + $this->error = $e; + return null; + } + } + + final public function withValueFromDatetime(DateTimeInterface $date): self + { + return $this->withValue($date->format(DateTimeInterface::RFC7231)); + } +} diff --git a/src/Header/Value/Date/Date.php b/src/Header/Value/Date/Date.php deleted file mode 100644 index 1322fd8..0000000 --- a/src/Header/Value/Date/Date.php +++ /dev/null @@ -1,31 +0,0 @@ -value); - } catch (Exception $e) { - $this->error = $e; - return null; - } - } - - final public function withValueFromDatetime(DateTimeImmutable $date): self - { - return $this->withValue($date->format(self::HTTP_DATETIME_FORMAT)); - } -} diff --git a/tests/Header/AcceptHeaderTest.php b/tests/Header/AcceptHeaderTest.php index 1b469ca..29806a6 100644 --- a/tests/Header/AcceptHeaderTest.php +++ b/tests/Header/AcceptHeaderTest.php @@ -26,7 +26,7 @@ public function testErrorWithHeaderClass() } public function testCreateFromStringValues() { - $header = (new AcceptHeader('Accept'))->add('*/*'); + $header = (new AcceptHeader('Accept'))->withValue('*/*'); $this->assertSame('Accept', $header->getName()); $this->assertSame(['*/*'], $header->getStrings()); @@ -40,7 +40,7 @@ public function testCreateFromFewStringValues() 'text/plain;q=0.5', ]; - $header = (new AcceptHeader('Accept-Test'))->addArray($headers); + $header = (new AcceptHeader('Accept-Test'))->withValues($headers); $this->assertSame('Accept-Test', $header->getName()); $this->assertSame( @@ -64,7 +64,7 @@ public function testParamsPrioritySortingWithSameQuality() 'text/plain;foo=2', ]; - $header = (new AcceptHeader('Accept'))->addArray($headers); + $header = (new AcceptHeader('Accept'))->withValues($headers); $this->assertSame('Accept', $header->getName()); $this->assertSame( @@ -84,7 +84,7 @@ public function testAcceptPrioritySortingWithoutParams() { $headers = ' text/*, text/plain, text/plain;format=flowed, */*'; - $header = (new AcceptHeader('Accept'))->add($headers); + $header = (new AcceptHeader('Accept'))->withValue($headers); $this->assertSame('Accept', $header->getName()); $this->assertSame( @@ -101,7 +101,7 @@ public function testAcceptPrioritySortingOfIncorrectValuesDataWithoutParams() { $headers = 'foo/bar/*, foo/bar/baz, */bar, */*/*, foo/*/*, foo/*/baz, foo/bar'; - $header = (new AcceptHeader('Accept'))->add($headers); + $header = (new AcceptHeader('Accept'))->withValue($headers); $this->assertSame('Accept', $header->getName()); $this->assertSame( @@ -131,7 +131,7 @@ public function testAcceptCreateFromManyMixedStringValues() '*/*', ]; - $header = (new AcceptHeader('Accept'))->addArray($headers); + $header = (new AcceptHeader('Accept'))->withValues($headers); $this->assertSame('Accept', $header->getName()); $this->assertSame( @@ -154,7 +154,7 @@ public function testAcceptCharsetPrioritySortingWithoutParams() { $headers = '*, utf-8, iso-8859-5'; - $header = AcceptCharset::createHeader()->add($headers); + $header = AcceptCharset::createHeader()->withValue($headers); $this->assertSame('Accept-Charset', $header->getName()); $this->assertSame( @@ -170,7 +170,7 @@ public function testAcceptCharsetSortingManyValues() { $headers = ['iso-8859-5, unicode-1-1;q=0.8, utf-8, undef/ned, *;q=0']; - $header = AcceptCharset::createHeader()->addArray($headers); + $header = AcceptCharset::createHeader()->withValues($headers); $this->assertSame('Accept-Charset', $header->getName()); $this->assertSame( @@ -189,7 +189,7 @@ public function testAcceptEncodingPrioritySortingWithoutParams() { $headers = '*, compress, some/encoding, gzip'; - $header = AcceptEncoding::createHeader()->add($headers); + $header = AcceptEncoding::createHeader()->withValue($headers); $this->assertSame('Accept-Encoding', $header->getName()); $this->assertSame( @@ -210,7 +210,7 @@ public function testAcceptEncodingSortingManyValues() 'gzip;q=1.0, identity; q=0.5, deflate;q=0', ]; - $header = AcceptEncoding::createHeader()->addArray($headers); + $header = AcceptEncoding::createHeader()->withValues($headers); $this->assertSame('Accept-Encoding', $header->getName()); $this->assertSame( @@ -235,7 +235,7 @@ public function testAcceptLanguagePrioritySortingWithoutParams() 'zh-Hant-CN-x-private1', ]; - $header = AcceptLanguage::createHeader()->addArray($headers); + $header = AcceptLanguage::createHeader()->withValues($headers); $this->assertSame('Accept-Language', $header->getName()); $this->assertSame( @@ -262,7 +262,7 @@ public function testAcceptLanguageCreateFromManyMixedStringValues() 'ru-RU;q=0.1', ]; - $header = AcceptLanguage::createHeader()->addArray($headers); + $header = AcceptLanguage::createHeader()->withValues($headers); $this->assertSame('Accept-Language', $header->getName()); $this->assertSame( diff --git a/tests/Header/HeaderTest.php b/tests/Header/HeaderTest.php index 100ebe6..1e3c3ca 100644 --- a/tests/Header/HeaderTest.php +++ b/tests/Header/HeaderTest.php @@ -7,8 +7,9 @@ use Yiisoft\Http\Header\Header; use Yiisoft\Http\Header\Value\Accept\Accept; use Yiisoft\Http\Header\Value\BaseHeaderValue; -use Yiisoft\Http\Header\Value\Date\Date; +use Yiisoft\Http\Header\Value\Date; use Yiisoft\Http\Header\Value\SimpleValue; +use Yiisoft\Http\Tests\Header\Value\Stub\DummyHeaderValue; use Yiisoft\Http\Tests\Header\Value\Stub\ListedValuesHeaderValue; use Yiisoft\Http\Tests\Header\Value\Stub\ListedValuesWithParamsHeaderValue; use Yiisoft\Http\Tests\Header\Value\Stub\SortedHeaderValue; @@ -39,11 +40,29 @@ public function testErrorIfNotHeaderClass() $this->expectException(InvalidArgumentException::class); new Header(\DateTimeImmutable::class); } + public function testWithValueImmutability() + { + $header = new Header('WWW-Authenticate'); + + $clone = $header->withValue('test'); + + $this->assertSame(get_class($header), get_class($clone)); + $this->assertNotSame($header, $clone); + } + public function testWithValuesImmutability() + { + $header = new Header('WWW-Authenticate'); + + $clone = $header->withValues(['test']); + + $this->assertSame(get_class($header), get_class($clone)); + $this->assertNotSame($header, $clone); + } public function testCreateFromOneStringValue() { $headers = ['Newauth realm="apps", type=1, title="Login to \\"apps\\"", Basic realm="simple"']; - $header = (new Header('WWW-Authenticate'))->addArray($headers); + $header = (new Header('WWW-Authenticate'))->withValues($headers); $this->assertSame('WWW-Authenticate', $header->getName()); $this->assertSame($headers, $header->getStrings()); @@ -62,7 +81,7 @@ public function testCreateFromFewStringValues() '*/*', ]; - $header = (new Header('accept'))->addArray($headers); + $header = (new Header('accept'))->withValues($headers); $this->assertSame('accept', $header->getName()); $this->assertSame($headers, $header->getStrings()); @@ -73,24 +92,24 @@ public function testAddObject() 'text/*;q=0.3', 'text/html;q=0.7', ]; - $header = (new Header(Accept::class))->addArray($headers); + $header = (new Header(Accept::class))->withValues($headers); - $header->add(new Accept('*/*')); + $header = $header->withValue(new Accept('*/*')); $this->assertSame(Accept::NAME, $header->getName()); $this->assertSame(['text/*;q=0.3', 'text/html;q=0.7', '*/*'], $header->getStrings()); } - public function testExceptionWhenAddOtherObject() + public function testExceptionWhenAddOtherClassObject() { $headers = [ 'text/*;q=0.3', 'text/html;q=0.7', ]; - $header = (new Header(Accept::class))->addArray($headers); + $header = (new Header(Accept::class))->withValues($headers); $this->expectException(InvalidArgumentException::class); - $header->add(new Date('*/*')); + $header->withValue(new Date('*/*')); } public function valueAndParametersDataProvider(): array @@ -166,8 +185,7 @@ public function testParsingAndRepackOfValueAndParams( array $params, string $output = null ): void { - $header = new Header(WithParamsHeaderValue::class); - $header->add($input); + $header = (new Header(WithParamsHeaderValue::class))->withValue($input); /** @var WithParamsHeaderValue $headerValue */ $headerValue = $header->getValues(true)[0]; @@ -218,8 +236,7 @@ public function testParsingAndRepackOfIncorrectValueAndParams( array $params, string $output = null ): void { - $header = new Header(WithParamsHeaderValue::class); - $header->add($input); + $header = (new Header(WithParamsHeaderValue::class))->withValue($input); /** @var WithParamsHeaderValue $headerValue */ $headerValue = $header->getValues(false)[0]; @@ -283,8 +300,7 @@ public function listedValuesDataProvider(): array */ public function testParsingAndRepackListedValues(string $input, array $values): void { - $header = new Header(ListedValuesHeaderValue::class); - $header->add($input); + $header = (new Header(ListedValuesHeaderValue::class))->withValue($input); $strings = $header->getStrings(true); $this->assertSame($values, $strings); } @@ -335,9 +351,8 @@ public function listedValuesWithParamsDataProvider(): array */ public function testParsingAndRepackListedValuesWithParams(string $input, array $valueParams): void { - $header = new Header(ListedValuesWithParamsHeaderValue::class); + $header = (new Header(ListedValuesWithParamsHeaderValue::class))->withValue($input); $result = []; - $header->add($input); foreach ($header->getValues(true) as $value) { $result[] = [$value->getValue(), $value->getParams()]; } diff --git a/tests/Header/SortableHeaderTest.php b/tests/Header/SortableHeaderTest.php index 24f4a17..9f91036 100644 --- a/tests/Header/SortableHeaderTest.php +++ b/tests/Header/SortableHeaderTest.php @@ -5,7 +5,7 @@ use InvalidArgumentException; use PHPUnit\Framework\TestCase; use Yiisoft\Http\Header\SortableHeader; -use Yiisoft\Http\Header\Value\Date\Date; +use Yiisoft\Http\Header\Value\Date; use Yiisoft\Http\Tests\Header\Value\Stub\SortedHeaderValue; class SortableHeaderTest extends TestCase @@ -23,7 +23,7 @@ public function testErrorWithHeaderClass() } public function testCreateFromStringValues() { - $header = (new SortableHeader('Accept'))->add('*/*'); + $header = (new SortableHeader('Accept'))->withValue('*/*'); $this->assertSame('Accept', $header->getName()); $this->assertSame(['*/*'], $header->getStrings()); @@ -37,7 +37,7 @@ public function testCreateFromFewStringValues() 'text/plain;q=0.5', ]; - $header = (new SortableHeader('Accept-Test'))->addArray($headers); + $header = (new SortableHeader('Accept-Test'))->withValues($headers); $this->assertSame('Accept-Test', $header->getName()); $this->assertSame( @@ -64,7 +64,7 @@ public function testCreateFromManyStringValues() '*/*', ]; - $header = (new SortableHeader('Accept'))->addArray($headers); + $header = (new SortableHeader('Accept'))->withValues($headers); $this->assertSame('Accept', $header->getName()); $this->assertSame( From fadb06c2996b62008010330add91a7feaebecbe7 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 7 Mar 2020 17:07:44 +0300 Subject: [PATCH 14/26] Added ETag header --- src/Header/Value/BaseHeaderValue.php | 6 +- src/Header/Value/Condition/ETag.php | 60 +++++++++++++++ .../Value/Condition/IfModifiedSince.php | 2 +- .../Value/Condition/IfUnmodifiedSince.php | 2 +- .../{Cache => Condition}/LastModified.php | 2 +- tests/Header/Value/BaseHeaderValueTest.php | 5 ++ tests/Header/Value/Condition/ETagTest.php | 75 +++++++++++++++++++ 7 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 src/Header/Value/Condition/ETag.php rename src/Header/Value/{Cache => Condition}/LastModified.php (82%) create mode 100644 tests/Header/Value/Condition/ETagTest.php diff --git a/src/Header/Value/BaseHeaderValue.php b/src/Header/Value/BaseHeaderValue.php index 110b973..d4cb270 100644 --- a/src/Header/Value/BaseHeaderValue.php +++ b/src/Header/Value/BaseHeaderValue.php @@ -61,7 +61,7 @@ public static function createHeader(): Header public function withValue(string $value): self { $clone = clone $this; - $clone->value = $value; + $clone->setValue($value); return $clone; } public function getValue(): string @@ -133,4 +133,8 @@ protected function setQuality(string $q): bool $this->quality = rtrim($q, '0.') ?: '0'; return true; } + protected function setValue(string $value): void + { + $this->value = $value; + } } diff --git a/src/Header/Value/Condition/ETag.php b/src/Header/Value/Condition/ETag.php new file mode 100644 index 0000000..825bd33 --- /dev/null +++ b/src/Header/Value/Condition/ETag.php @@ -0,0 +1,60 @@ +toStringFromTag) { + return ($this->weak ? 'W/' : '') . '"' . $this->tag . '"'; + } else { + return $this->value; + } + } + + public function getTag(): string + { + return $this->tag; + } + + public function isWeak(): bool + { + return $this->weak; + } + + public function withTag(string $tag, bool $weak = true): self + { + $clone = clone $this; + $clone->tag = $tag; + $clone->weak = $weak; + $clone->toStringFromTag = true; + return $clone; + } + + protected function setValue(string $value): void + { + $this->value = trim($value); + $this->toStringFromTag = false; + if (preg_match('/^(?W\\/)?"(?[^"\\x00-\\x20\\x7F]+)"$/', $this->value, $matches) === 1) { + $this->tag = $matches['etagc']; + $this->weak = $matches['weak'] === 'W/'; + } else { + $this->error = new ParsingException($value, 0, 'Invalid ETag value format', 0, $this->error); + } + } +} diff --git a/src/Header/Value/Condition/IfModifiedSince.php b/src/Header/Value/Condition/IfModifiedSince.php index f8be0f2..059b015 100644 --- a/src/Header/Value/Condition/IfModifiedSince.php +++ b/src/Header/Value/Condition/IfModifiedSince.php @@ -4,7 +4,7 @@ namespace Yiisoft\Http\Header\Value\Condition; -use Yiisoft\Http\Header\Value\Cache; +use Yiisoft\Http\Header\Value\Date; /** * @see https://tools.ietf.org/html/rfc7232#section-3.3 diff --git a/src/Header/Value/Condition/IfUnmodifiedSince.php b/src/Header/Value/Condition/IfUnmodifiedSince.php index 8b80829..3576d3b 100644 --- a/src/Header/Value/Condition/IfUnmodifiedSince.php +++ b/src/Header/Value/Condition/IfUnmodifiedSince.php @@ -4,7 +4,7 @@ namespace Yiisoft\Http\Header\Value\Condition; -use Yiisoft\Http\Header\Value\Cache; +use Yiisoft\Http\Header\Value\Date; /** * @see https://tools.ietf.org/html/rfc7232#section-3.4 diff --git a/src/Header/Value/Cache/LastModified.php b/src/Header/Value/Condition/LastModified.php similarity index 82% rename from src/Header/Value/Cache/LastModified.php rename to src/Header/Value/Condition/LastModified.php index 00ec6dc..8ef021c 100644 --- a/src/Header/Value/Cache/LastModified.php +++ b/src/Header/Value/Condition/LastModified.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Yiisoft\Http\Header\Value\Cache; +namespace Yiisoft\Http\Header\Value\Condition; use Yiisoft\Http\Header\Value\Date; diff --git a/tests/Header/Value/BaseHeaderValueTest.php b/tests/Header/Value/BaseHeaderValueTest.php index 55761c3..2244c50 100644 --- a/tests/Header/Value/BaseHeaderValueTest.php +++ b/tests/Header/Value/BaseHeaderValueTest.php @@ -16,6 +16,11 @@ public function testWithValueImmutability() $clone = $value->withValue('test'); + // the default value of the original object has not changed + $this->assertSame('', $value->getValue()); + // changes applied + $this->assertSame('test', $clone->getValue()); + // immutability $this->assertSame(get_class($value), get_class($clone)); $this->assertNotSame($value, $clone); } diff --git a/tests/Header/Value/Condition/ETagTest.php b/tests/Header/Value/Condition/ETagTest.php new file mode 100644 index 0000000..8318a6e --- /dev/null +++ b/tests/Header/Value/Condition/ETagTest.php @@ -0,0 +1,75 @@ +withTag('test', false); + + // the default values of the original object have not changed + $this->assertSame('', $origin->getTag()); + $this->assertTrue($origin->isWeak()); + // changes applied + $this->assertSame('test', $clone->getTag()); + $this->assertFalse($clone->isWeak()); + // immutability + $this->assertSame(get_class($origin), get_class($clone)); + $this->assertNotSame($origin, $clone); + } + public function testToStringFromTag() + { + $origin = (new ETag())->withValue('"origin-tag"'); + + $clone = $origin->withTag('new-tag', true); + + $this->assertSame('W/"new-tag"', (string)$clone); + } + public function testToStringFromValue() + { + $origin = (new ETag())->withTag('origin-tag', false); + + $clone = $origin->withValue('W/"new-tag"'); + + $this->assertSame('W/"new-tag"', (string)$clone); + } + public function testToStringFromIncorrectValue() + { + $origin = (new ETag())->withTag('origin-tag', false); + + $clone = $origin->withValue('some-incorrect-value'); + + $this->assertSame('some-incorrect-value', (string)$clone); + } + + public function withValueDataProvider(): array + { + return [ + 'weak1' => ['W/"value"', false, true, 'value'], + 'weak2' => ['"value"', false, false, 'value'], + 'backslashes' => ['"\\this-is-not\\\\-a-quoted-pair\\"', false, false, '\\this-is-not\\\\-a-quoted-pair\\'], + 'wo-quotes' => ['value', true, true, ''], + 'spaced' => ['"spaced string"', true, true, ''], + 'with-quotes' => ['""quoted""', true, true, ''], + 'with-escaped-quotes' => ['"\\"quoted\\""', true, true, ''], + 'hard-space' => ['"' . chr(127) . '"', true, true, ''], + ]; + } + /** + * @dataProvider withValueDataProvider + */ + public function testValueParsing(string $input, bool $hasError, bool $isWeak, string $tag) + { + $headerValue = (new ETag())->withValue($input); + + $this->assertSame($tag, $headerValue->getTag()); + $this->assertSame($isWeak, $headerValue->isWeak()); + $this->assertSame($hasError, $headerValue->hasError()); + } +} From 18f6ee9f51c7c27efef166b927cd1b248c563f32 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 9 Mar 2020 22:47:09 +0300 Subject: [PATCH 15/26] added logic for CacheControl and Pragma headers --- src/Header/DateHeader.php | 2 +- src/Header/Value/BaseHeaderValue.php | 34 ++-- src/Header/Value/Cache/CacheControl.php | 186 +++++++++++++++++- src/Header/Value/Cache/Pragma.php | 4 +- tests/Header/Value/Cache/CacheControlTest.php | 178 +++++++++++++++++ 5 files changed, 384 insertions(+), 20 deletions(-) create mode 100644 tests/Header/Value/Cache/CacheControlTest.php diff --git a/src/Header/DateHeader.php b/src/Header/DateHeader.php index 7397139..180bf40 100644 --- a/src/Header/DateHeader.php +++ b/src/Header/DateHeader.php @@ -6,7 +6,7 @@ use DateTimeInterface; use Yiisoft\Http\Header\Value\BaseHeaderValue; -use Yiisoft\Http\Header\Value\Cache; +use Yiisoft\Http\Header\Value\Date; /** * @method $this withValue(string|DateTimeInterface|BaseHeaderValue $value) diff --git a/src/Header/Value/BaseHeaderValue.php b/src/Header/Value/BaseHeaderValue.php index d4cb270..61b2a74 100644 --- a/src/Header/Value/BaseHeaderValue.php +++ b/src/Header/Value/BaseHeaderValue.php @@ -77,21 +77,7 @@ public function getValue(): string public function withParams(array $params): self { $clone = clone $this; - $clone->params = []; - foreach ($params as $key => $value) { - $key = strtolower($key); - if (!key_exists($key, $clone->params)) { - $clone->params[$key] = $value; - } - } - if ($clone instanceof WithQualityParam) { - if (key_exists('q', $clone->params)) { - $clone->setQuality($clone->params['q']); - unset($clone->params['q']); - } else { - $clone->setQuality('1'); - } - } + $clone->setParams($params); return $clone; } public function getParams(): array @@ -137,4 +123,22 @@ protected function setValue(string $value): void { $this->value = $value; } + protected function setParams(array $params): void + { + $this->params = []; + foreach ($params as $key => $value) { + $key = strtolower($key); + if (!key_exists($key, $this->params)) { + $this->params[$key] = $value; + } + } + if ($this instanceof WithQualityParam) { + if (key_exists('q', $this->params)) { + $this->setQuality($this->params['q']); + unset($this->params['q']); + } else { + $this->setQuality('1'); + } + } + } } diff --git a/src/Header/Value/Cache/CacheControl.php b/src/Header/Value/Cache/CacheControl.php index 34382cf..ae0c766 100644 --- a/src/Header/Value/Cache/CacheControl.php +++ b/src/Header/Value/Cache/CacheControl.php @@ -4,12 +4,196 @@ namespace Yiisoft\Http\Header\Value\Cache; +use InvalidArgumentException; +use Yiisoft\Http\Header\ListedValues; use Yiisoft\Http\Header\Value\BaseHeaderValue; +use Yiisoft\Http\Header\WithParams; /** * @see https://tools.ietf.org/html/rfc7234#section-5.2 */ -final class CacheControl extends BaseHeaderValue +class CacheControl extends BaseHeaderValue implements ListedValues, WithParams { public const NAME = 'Cache-Control'; + + // Request Directives + public const D_MAX_AGE = 'max-age'; + public const D_MAX_STALE = 'max-stale'; + public const D_MIN_FRESH = 'min-fresh'; + public const D_NO_CACHE = 'no-cache'; + public const D_NO_STORE = 'no-store'; + public const D_NO_TRANSFORM = 'no-transform'; + public const D_ONLY_IF_CACHED = 'only-if-cached'; + public const D_MUST_REVALIDATE = 'must-revalidate'; + public const D_PUBLIC = 'public'; + public const D_PRIVATE = 'private'; + public const D_PROXY_REVALIDATE = 'proxy-revalidate'; + public const D_S_MAXAGE = 's-maxage'; + + /** + * Request Directives + * @see https://tools.ietf.org/html/rfc7234#section-5.2.1 + */ + public const REQUEST_DIRECTIVES = [ + self::D_MAX_AGE => self::ARG_DELTA_SECONDS, + self::D_MAX_STALE => self::ARG_DELTA_SECONDS, + self::D_MIN_FRESH => self::ARG_DELTA_SECONDS, + self::D_NO_CACHE => self::ARG_EMPTY, + self::D_NO_STORE => self::ARG_EMPTY, + self::D_NO_TRANSFORM => self::ARG_EMPTY, + self::D_ONLY_IF_CACHED => self::ARG_EMPTY, + ]; + /** + * Response Directives + * @see https://tools.ietf.org/html/rfc7234#section-5.2.2 + */ + public const RESPONSE_DIRECTIVES = [ + self::D_MUST_REVALIDATE => self::ARG_EMPTY, + self::D_NO_CACHE => self::ARG_HEADERS_LIST | self::ARG_EMPTY, + self::D_NO_STORE => self::ARG_EMPTY, + self::D_NO_TRANSFORM => self::ARG_EMPTY, + self::D_PUBLIC => self::ARG_EMPTY, + self::D_PRIVATE => self::ARG_HEADERS_LIST | self::ARG_EMPTY, + self::D_PROXY_REVALIDATE => self::ARG_EMPTY, + self::D_MAX_AGE => self::ARG_DELTA_SECONDS, + self::D_S_MAXAGE => self::ARG_DELTA_SECONDS, + ]; + + protected const ARG_EMPTY = 1; + protected const ARG_DELTA_SECONDS = 2; + protected const ARG_HEADERS_LIST = 4; + protected const ARG_CUSTOM = 8; + + protected ?string $directive = null; + protected ?string $argument = null; + protected int $argumentType = self::ARG_EMPTY; + + final public function __toString(): string + { + if ($this->directive === null) { + return ''; + } + if ($this->argument === null) { + return $this->directive; + } + if ($this->argumentType === self::ARG_HEADERS_LIST) { + return "{$this->directive}=\"{$this->argument}\""; + } + if ($this->argumentType === self::ARG_CUSTOM) { + $argument = preg_replace('/([\\\\"])/', '\\\\$1', $this->argument); + if ( + $argument === '' + || strlen($argument) !== strlen($this->argument) + || preg_match('/[^a-z0-9_]/i', $argument) === 1 + ) { + $argument = '"' . $argument . '"'; + } + return "{$this->directive}={$argument}"; + } + return "{$this->directive}={$this->argument}"; + } + + /** + * @return string|null Returns null if the directive is not defined or cannot be parsed without error + */ + final public function getDirective(): ?string + { + return $this->directive; + } + + final public function hasArgument(): bool + { + return $this->argument !== null; + } + + final public function getArgument(): ?string + { + return $this->argument; + } + + final public function getArgumentList(): array + { + return $this->argument === null ? [] : explode(',', $this->argument); + } + + final public function withDirective(string $directive, string $argument = null): self + { + $clone = clone $this; + $clone->setDirective($directive, $argument, true); + return $clone; + } + + final protected function setValue(string $value): void + { + if ($value !== '') { + $this->setDirective($value); + } + parent::setValue($value); + } + + final protected function setParams(array $params): void + { + $key = array_key_first($params); + if ($key !== null) { + $this->setDirective($key, $params[$key]); + } + parent::setParams($params); + } + + final private function setDirective(string $value, string $argument = null, bool $trowError = false): bool + { + $name = strtolower($value); + + $fromRequestType = static::REQUEST_DIRECTIVES[$name] ?? null; + $fromResponseType = static::RESPONSE_DIRECTIVES[$name] ?? null; + + $directiveExists = $fromRequestType !== null || $fromResponseType; + $argumentType = $directiveExists ? $fromRequestType | $fromResponseType : self::ARG_CUSTOM; + + $writeProperties = function (\Exception $err = null, string $arg = null, int $type = self::ARG_EMPTY) use ( + $name, + $trowError + ) { + if ($trowError && $err !== null) { + throw $err; + } + $this->directive = $name; + $this->argument = $arg; + $this->argumentType = $type; + $this->error = $err; + return $this->error === null; + }; + + if ($argument === null && ($argumentType & self::ARG_EMPTY) === self::ARG_EMPTY) { + return $writeProperties(); + } elseif ($argumentType === self::ARG_EMPTY) { + if ($argument !== null) { + return $writeProperties(new InvalidArgumentException("{$name} directive should not have an argument")); + } + return $writeProperties(); + } elseif (($argumentType & self::ARG_HEADERS_LIST) === self::ARG_HEADERS_LIST) { + # Validate headers list + $argument = $argument === null ? null : trim($argument); + if ($argument === null || preg_match('/^[\\w\\-]+(?:(?:\\s*,\\s*)[\\w\\-]+)*$/', $argument) !== 1) { + return $writeProperties( + new InvalidArgumentException( + "{$name} directive should have an argument as a comma separated headers name list" + ) + ); + } + return $writeProperties(null, $argument, self::ARG_HEADERS_LIST); + } elseif (($argumentType & self::ARG_DELTA_SECONDS) === self::ARG_DELTA_SECONDS) { + $this->argumentType = self::ARG_DELTA_SECONDS; + // Validate number + if ($argument === null || preg_match('/^\\d+$/', $argument) !== 1) { + return $writeProperties( + new InvalidArgumentException("{$name} directive should have numeric argument"), + '0', + self::ARG_DELTA_SECONDS + ); + } + return $writeProperties(null, $argument, self::ARG_DELTA_SECONDS); + } + return $writeProperties(null, $argument, $argumentType); + } } diff --git a/src/Header/Value/Cache/Pragma.php b/src/Header/Value/Cache/Pragma.php index 4c42464..b34f1a6 100644 --- a/src/Header/Value/Cache/Pragma.php +++ b/src/Header/Value/Cache/Pragma.php @@ -4,12 +4,10 @@ namespace Yiisoft\Http\Header\Value\Cache; -use Yiisoft\Http\Header\Value\BaseHeaderValue; - /** * @see https://tools.ietf.org/html/rfc7234#section-5.4 */ -final class Pragma extends BaseHeaderValue +final class Pragma extends CacheControl { public const NAME = 'Pragma'; } diff --git a/tests/Header/Value/Cache/CacheControlTest.php b/tests/Header/Value/Cache/CacheControlTest.php new file mode 100644 index 0000000..0df9a64 --- /dev/null +++ b/tests/Header/Value/Cache/CacheControlTest.php @@ -0,0 +1,178 @@ +withDirective('no-cache', null); + + // the default values of the original object have not changed + $this->assertNull($origin->getDirective()); + $this->assertNull($origin->getArgument()); + // changes applied + $this->assertSame('no-cache', $clone->getDirective()); + $this->assertNull($origin->getArgument()); + // immutability + $this->assertSame(get_class($origin), get_class($clone)); + $this->assertNotSame($origin, $clone); + } + public function testToStringWithoutArgument() + { + $headerValue = (new CacheControl())->withDirective(CacheControl::D_NO_CACHE); + + $this->assertSame('no-cache', (string)$headerValue); + } + public function testToStringNumericArgument() + { + $headerValue = (new CacheControl())->withDirective(CacheControl::D_MAX_AGE, '1560'); + + $this->assertSame('max-age=1560', (string)$headerValue); + } + public function testToStringEmptyListedArgument() + { + $headerValue = (new CacheControl())->withDirective(CacheControl::D_PRIVATE); + + $this->assertSame('private', (string)$headerValue); + } + public function testToStringListedArgument() + { + $headerValue = (new CacheControl())->withDirective(CacheControl::D_PRIVATE, 'etag'); + + $this->assertSame('private="etag"', (string)$headerValue); + } + public function testCustomDirective() + { + $headerValue = (new CacheControl())->withDirective('custom-name', 'custom_value'); + + $this->assertSame('custom-name=custom_value', (string)$headerValue); + } + public function testToStringEmptyDirective() + { + $headerValue = (new CacheControl()); + + $this->assertSame('', (string)$headerValue); + } + public function testWithValue() + { + $headerValue = (new CacheControl())->withValue('test-directive'); + + $this->assertSame('test-directive', $headerValue->getDirective()); + $this->assertSame('test-directive', (string)$headerValue); + $this->assertFalse($headerValue->hasError()); + } + public function testWithParams() + { + $headerValue = (new CacheControl())->withParams(['test-name' => 'test-value']); + + $this->assertSame('test-name', $headerValue->getDirective()); + $this->assertSame('test-value', $headerValue->getArgument()); + $this->assertSame('test-name="test-value"', (string)$headerValue); + $this->assertFalse($headerValue->hasError()); + } + public function testWithEmptyParams() + { + $headerValue = (new CacheControl())->withParams([]); + + $this->assertSame(null, $headerValue->getDirective()); + $this->assertSame(null, $headerValue->getArgument()); + $this->assertSame('', (string)$headerValue); + $this->assertFalse($headerValue->hasError()); + } + public function testWithMultipleParams() + { + $headerValue = (new CacheControl())->withParams(['test-name' => 'test-value', 'foo' => 'bar']); + + $this->assertSame('test-name', $headerValue->getDirective()); + $this->assertSame('test-value', $headerValue->getArgument()); + $this->assertSame('test-name="test-value"', (string)$headerValue); + $this->assertFalse($headerValue->hasError()); + } + public function testWithValueRewritesByWithParams() + { + $headerValue = (new CacheControl())->withValue('test-directive')->withParams(['test-name' => 'test-value']); + + $this->assertSame('test-name', $headerValue->getDirective()); + $this->assertSame('test-value', $headerValue->getArgument()); + $this->assertSame('test-name="test-value"', (string)$headerValue); + $this->assertFalse($headerValue->hasError()); + } + + public function withDirectiveDataProvider(): array + { + return [ + 'nullable-argument' => ['no-store', null, 'no-store', null, 'no-store'], + 'case-1' => ['No-CAche', null, 'no-cache', null, 'no-cache'], + 'case-2' => ['No-CAche', 'eTaG', 'no-cache', 'eTaG', 'no-cache="eTaG"'], + 'case-3' => ['test-DIrective', 'CaSe', 'test-directive', 'CaSe', 'test-directive=CaSe'], + 'case-4' => ['test-DIrective', 'Ca Se', 'test-directive', 'Ca Se', 'test-directive="Ca Se"'], + 'ext-directive-case' => ['Test-directive', null, 'test-directive', null, 'test-directive'], + 'ext-directive-argument' => ['test-directive', 'null', 'test-directive', 'null', 'test-directive=null'], + 'ext-directive-arg-char-0' => ['test-directive', '', 'test-directive', '', 'test-directive=""'], + 'ext-directive-arg-char-1' => ['test-directive', ' ', 'test-directive', ' ', 'test-directive=" "'], + 'ext-directive-arg-char-2' => ['test-directive', '"', 'test-directive', '"', 'test-directive="\\""'], + 'ext-directive-arg-char-3' => ['test-directive', '\\', 'test-directive', '\\', 'test-directive="\\\\"'], + 'ext-directive-arg-char-4' => ['test-directive', '-', 'test-directive', '-', 'test-directive="-"'], + 'double-type-1' => ['no-cache', null, 'no-cache', null, 'no-cache'], + 'double-type-2' => ['no-cache', 'Content-Length, ETag', 'no-cache', 'Content-Length, ETag', 'no-cache="Content-Length, ETag"'], + 'numeric-arg-0' => ['max-age', '0', 'max-age', '0', 'max-age=0'], + 'numeric-arg-1' => ['max-age', '123', 'max-age', '123', 'max-age=123'], + 'numeric-arg-2' => ['max-age', '0123', 'max-age', '0123', 'max-age=0123'], + 'header-list-arg-1' => ['private', 'test', 'private', 'test', 'private="test"'], + 'header-list-arg-2' => ['private', 'test , test', 'private', 'test , test', 'private="test , test"'], + 'header-list-arg-3' => ['private', ' test , test ', 'private', 'test , test', 'private="test , test"'], + ]; + } + /** + * @dataProvider withDirectiveDataProvider + */ + public function testWithDirectiveCorrect( + string $inputDirective, + ?string $imputArgument, + ?string $directive, + ?string $argument, + string $output + ) { + $headerValue = (new CacheControl())->withDirective($inputDirective, $imputArgument); + + $this->assertFalse($headerValue->hasError()); + $this->assertSame($directive, $headerValue->getDirective()); + $this->assertSame($argument, $headerValue->getArgument()); + $this->assertSame($output, (string)$headerValue); + } + + public function withDirectiveIncorrectDataProvider(): array + { + return [ + 'header-list-arg-0' => ['private', '!'], + 'header-list-arg-1' => ['private', ''], + 'header-list-arg-2' => ['private', ' '], + 'header-list-arg-3' => ['private', ',,'], + 'header-list-arg-4' => ['private', 'test , , test'], + 'numeric-arg-0' => ['max-age', null], + 'numeric-arg-1' => ['max-age', 'test'], + 'numeric-arg-2' => ['max-age', '123test'], + 'numeric-arg-3' => ['max-age', '0x123'], + 'numeric-arg-4' => ['max-age', '-123'], + 'argument-should-be-empty' => ['No-Store', 'null'], + ]; + } + /** + * @dataProvider withDirectiveIncorrectDataProvider + */ + public function testWithDirectiveIncorrectCases( + string $inputDirective, + ?string $imputArgument + ) { + $this->expectException(\InvalidArgumentException::class); + (new CacheControl())->withDirective($inputDirective, $imputArgument); + } +} From 52649108c20780bdf7277a0c0c2838b252904870 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 12 Mar 2020 20:56:45 +0300 Subject: [PATCH 16/26] Added CacheControlHeader; docs updated; Date improvements --- docs/ru/README.md | 40 ++++++++++- src/Header/CacheControlHeader.php | 55 ++++++++++++++++ src/Header/Value/BaseHeaderValue.php | 3 +- src/Header/Value/Cache/CacheControl.php | 12 ++++ src/Header/Value/Condition/ETag.php | 1 + src/Header/Value/Date.php | 27 ++++++-- tests/Header/Value/Cache/CacheControlTest.php | 8 +++ tests/Header/Value/DateTest.php | 66 +++++++++++++++++++ 8 files changed, 203 insertions(+), 9 deletions(-) create mode 100644 src/Header/CacheControlHeader.php create mode 100644 tests/Header/Value/DateTest.php diff --git a/docs/ru/README.md b/docs/ru/README.md index e38a214..129c440 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -123,9 +123,46 @@ $dateHeader = \Yiisoft\Http\Header\Value\Cache\Expires::createHeader() ->inject($response); ``` +### Заголовки кеширования [RFC7234](https://tools.ietf.org/html/rfc7234) + +Сюда относятся заголовки: + +- `Age` - простой заоловок, в котором задаётся количество секунд с момента модификации ресурса. +- `Cache-Control` - список директив правил кеширования. +- `Expires` - дата истечения срока актуальности сущности. Класс заголовка `Expires` наследуется от класса `Date`. +- `Pragma` - устаревший заголовок из HTTP/1.0, использующийся для обратной совместимости. + Класс `Pragma` наследуется от класса `CacheControl`. +- `Warning` - заголовок для дополнительной информации об ошибках. + +#### Cache-Control + +```php +$header = \Yiisoft\Http\Header\Value\Cache\CacheControl::createHeader() + ->withDirective('max-age', '27000') + ->withDirective('no-transform') + ->withDirective('foo', 'bar'); +``` + +Для всех стандартных директив, которые описаны в [главе 5.2 RFC7234](https://tools.ietf.org/html/rfc7234#section-5.2), +присваиваемые значения (аргументы) будут проходить этап валидации. Если вы указываете директиву вручную, будьте готовы к +тому, что метод `withDirective()` может выбросить исключение. + +```php +use Yiisoft\Http\Header\Value\Cache\CacheControl; +// исключения будут брошены в каждой строчке ниже +(new CacheControl())->withDirective('max-age'); // у директивы max-age должно быть аргумент +(new CacheControl())->withDirective('max-age', 'not numeric'); // аргумент директивы max-age должен быть числовым +(new CacheControl())->withDirective('max-age', '-456'); // допускаются только цифры +(new CacheControl())->withDirective('private', 'ETag,'); // нарушение синтаксиса списка заголовков +(new CacheControl())->withDirective('no-store', 'yes'); // директива no-store не принимает аргумент +``` + +### Пользовательские заголовки + Если вы не хотите описывать заголовок в отдельном классе, то можете использовать заготовленные классы: ```php -withValues(['foo', 'bar', 'baz']); @@ -133,5 +170,4 @@ $myListHeader = \Yiisoft\Http\Header\Value\ListedValue::createHeader('My-List') \Yiisoft\Http\Header\Value\SortedValue::createHeader('My-Sorted-List') ->withValues(['foo', 'bar;q=0.5', 'baz;q=0']) ->inject($response); -?> ``` diff --git a/src/Header/CacheControlHeader.php b/src/Header/CacheControlHeader.php new file mode 100644 index 0000000..22b1af1 --- /dev/null +++ b/src/Header/CacheControlHeader.php @@ -0,0 +1,55 @@ +headerClass, CacheControl::class, true)) { + throw new InvalidArgumentException( + sprintf("%s class is not an instance of %s", $this->headerClass, CacheControl::class) + ); + } + } + + /** + * @param string $directive + * @param string|null $argument + * @return $this + * @throws InvalidArgumentException + */ + public function withDirective(string $directive, string $argument = null): self + { + $clone = clone $this; + /** @var CacheControl $headerValue */ + $headerValue = new $this->headerClass(); + $clone->addValue($headerValue->withDirective($directive, $argument)); + return $clone; + } + + /** + * @param bool $ignoreIncorrect + * @return string[]|null[] Returns array where keys are directives and values are arguments + */ + public function getDirectives(bool $ignoreIncorrect = true): array + { + $result = []; + /** @var CacheControl $header */ + foreach ($this->collection as $header) { + if ($ignoreIncorrect && $header->hasError()) { + continue; + } + $result[] = [$header->getDirective() => $header->getArgument()]; + } + return $result; + } +} diff --git a/src/Header/Value/BaseHeaderValue.php b/src/Header/Value/BaseHeaderValue.php index 61b2a74..66654da 100644 --- a/src/Header/Value/BaseHeaderValue.php +++ b/src/Header/Value/BaseHeaderValue.php @@ -30,7 +30,7 @@ abstract class BaseHeaderValue public function __construct(string $value = '') { - $this->value = $value; + $this->setValue($value); } public function __toString(): string { @@ -127,6 +127,7 @@ protected function setParams(array $params): void { $this->params = []; foreach ($params as $key => $value) { + # todo decide: what about numeric keys? $key = strtolower($key); if (!key_exists($key, $this->params)) { $this->params[$key] = $value; diff --git a/src/Header/Value/Cache/CacheControl.php b/src/Header/Value/Cache/CacheControl.php index ae0c766..42cc56a 100644 --- a/src/Header/Value/Cache/CacheControl.php +++ b/src/Header/Value/Cache/CacheControl.php @@ -5,6 +5,7 @@ namespace Yiisoft\Http\Header\Value\Cache; use InvalidArgumentException; +use Yiisoft\Http\Header\CacheControlHeader; use Yiisoft\Http\Header\ListedValues; use Yiisoft\Http\Header\Value\BaseHeaderValue; use Yiisoft\Http\Header\WithParams; @@ -93,6 +94,11 @@ final public function __toString(): string return "{$this->directive}={$this->argument}"; } + final public static function createHeader(): CacheControlHeader + { + return new CacheControlHeader(static::class); + } + /** * @return string|null Returns null if the directive is not defined or cannot be parsed without error */ @@ -116,6 +122,12 @@ final public function getArgumentList(): array return $this->argument === null ? [] : explode(',', $this->argument); } + /** + * @param string $directive + * @param string|null $argument + * @return $this + * @throws InvalidArgumentException + */ final public function withDirective(string $directive, string $argument = null): self { $clone = clone $this; diff --git a/src/Header/Value/Condition/ETag.php b/src/Header/Value/Condition/ETag.php index 825bd33..c2e1249 100644 --- a/src/Header/Value/Condition/ETag.php +++ b/src/Header/Value/Condition/ETag.php @@ -53,6 +53,7 @@ protected function setValue(string $value): void if (preg_match('/^(?W\\/)?"(?[^"\\x00-\\x20\\x7F]+)"$/', $this->value, $matches) === 1) { $this->tag = $matches['etagc']; $this->weak = $matches['weak'] === 'W/'; + $this->error = null; } else { $this->error = new ParsingException($value, 0, 'Invalid ETag value format', 0, $this->error); } diff --git a/src/Header/Value/Date.php b/src/Header/Value/Date.php index f94de54..cfac42c 100644 --- a/src/Header/Value/Date.php +++ b/src/Header/Value/Date.php @@ -13,6 +13,8 @@ class Date extends BaseHeaderValue { public const NAME = 'Date'; + private ?DateTimeImmutable $datetimeObject = null; + /** * Date constructor. * @param DateTimeInterface|string $value @@ -25,6 +27,13 @@ public function __construct($value = '') parent::__construct($value); } + public function __toString(): string + { + return $this->datetimeObject === null + ? parent::__toString() + : $this->datetimeObject->format(DateTimeInterface::RFC7231); + } + final public static function createHeader(): DateHeader { return new DateHeader(static::class); @@ -32,16 +41,22 @@ final public static function createHeader(): DateHeader final public function getDatetimeValue(): ?DateTimeImmutable { - try { - return new DateTimeImmutable($this->value); - } catch (Exception $e) { - $this->error = $e; - return null; - } + return $this->datetimeObject; } final public function withValueFromDatetime(DateTimeInterface $date): self { return $this->withValue($date->format(DateTimeInterface::RFC7231)); } + + final protected function setValue(string $value): void + { + try { + $this->datetimeObject = new DateTimeImmutable($value); + } catch (Exception $e) { + $this->datetimeObject = null; + $this->error = $e; + } + parent::setValue($value); + } } diff --git a/tests/Header/Value/Cache/CacheControlTest.php b/tests/Header/Value/Cache/CacheControlTest.php index 0df9a64..a0e7a52 100644 --- a/tests/Header/Value/Cache/CacheControlTest.php +++ b/tests/Header/Value/Cache/CacheControlTest.php @@ -5,10 +5,16 @@ namespace Yiisoft\Http\Tests\Header\Value\Cache; use PHPUnit\Framework\TestCase; +use Yiisoft\Http\Header\CacheControlHeader; use Yiisoft\Http\Header\Value\Cache\CacheControl; class CacheControlTest extends TestCase { + public function testCreateHeader() + { + $header = CacheControl::createHeader(); + $this->assertInstanceOf(CacheControlHeader::class, $header); + } public function testWithDirectiveImmutability() { $origin = new CacheControl(); @@ -157,11 +163,13 @@ public function withDirectiveIncorrectDataProvider(): array 'header-list-arg-2' => ['private', ' '], 'header-list-arg-3' => ['private', ',,'], 'header-list-arg-4' => ['private', 'test , , test'], + 'header-list-arg-5' => ['private', 'ETag,'], 'numeric-arg-0' => ['max-age', null], 'numeric-arg-1' => ['max-age', 'test'], 'numeric-arg-2' => ['max-age', '123test'], 'numeric-arg-3' => ['max-age', '0x123'], 'numeric-arg-4' => ['max-age', '-123'], + 'numeric-arg-5' => ['max-age', '12 34'], 'argument-should-be-empty' => ['No-Store', 'null'], ]; } diff --git a/tests/Header/Value/DateTest.php b/tests/Header/Value/DateTest.php new file mode 100644 index 0000000..f4c44f5 --- /dev/null +++ b/tests/Header/Value/DateTest.php @@ -0,0 +1,66 @@ +withValueFromDatetime($date); + + // the default value of the original object has not changed + $this->assertSame('', $value->getValue()); + // changes applied + $this->assertSame('Wed, 01 Jan 2020 00:00:00 GMT', $clone->getValue()); + // immutability + $this->assertSame(get_class($value), get_class($clone)); + $this->assertNotSame($value, $clone); + } + public function testToString() + { + $dateStr = '2020-01-01 00:00:00 +0000'; + $value = (new Date())->withValue($dateStr); + + $this->assertSame('Wed, 01 Jan 2020 00:00:00 GMT', (string)$value); + } + public function testToStringIncorrect() + { + $dateStr = 'not a date'; + $value = (new Date())->withValue($dateStr); + + $this->assertTrue($value->hasError()); + $this->assertSame('not a date', (string)$value); + } + public function testGetDatetimeValue() + { + $dateStr = '2020-01-01 00:00:00 +0000'; + $value = new Date($dateStr); + + $this->assertInstanceOf(\DateTimeImmutable::class, $value->getDatetimeValue()); + } + public function testConstructWithDatetimeInterface() + { + $date = new \DateTime(); + $value = (new Date($date)); + $this->assertInstanceOf(\DateTimeImmutable::class, $value->getDatetimeValue()); + } + public function testGetDatetimeValueFromEmpty() + { + $value = (new Date()); + $this->assertInstanceOf(\DateTimeImmutable::class, $value->getDatetimeValue()); + } + public function testGetDatetimeValueFromIncorrect() + { + $value = (new Date())->withValue('not a date'); + + $this->assertTrue($value->hasError()); + $this->assertNull($value->getDatetimeValue()); + $this->assertSame('not a date', (string)$value); + } +} From e0a5dd6f11e0d8fc1dca50c74dac86c053473954 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 16 Mar 2020 16:57:56 +0300 Subject: [PATCH 17/26] Docs updated; Added methods CONNECT and TRACE; Interfaces moved to `Rule` subfolder; --- docs/en/README.md | 60 ++--- docs/en/http-start-line.md | 49 ++++ docs/ru/README.md | 187 ++----------- docs/ru/http-headers.md | 248 ++++++++++++++++++ src/Header/Header.php | 10 +- src/Header/ListedValues.php | 7 - src/Header/Rule/ListedValues.php | 7 + src/Header/{ => Rule}/WithParams.php | 2 +- src/Header/{ => Rule}/WithQualityParam.php | 2 +- src/Header/SortableHeader.php | 1 + src/Header/Value/Accept/Accept.php | 2 +- src/Header/Value/Allow.php | 2 +- src/Header/Value/BaseHeaderValue.php | 4 +- src/Header/Value/Cache/CacheControl.php | 60 ++--- src/Header/Value/Connection.php | 2 +- src/Header/Value/Forwarded.php | 4 +- src/Header/Value/ListedValue.php | 2 +- src/Header/Value/SortedValue.php | 2 +- src/Method.php | 29 ++ tests/Header/Value/Cache/CacheControlTest.php | 8 +- tests/Header/Value/Stub/DummyHeaderValue.php | 1 - .../Value/Stub/ListedValuesHeaderValue.php | 3 +- .../ListedValuesWithParamsHeaderValue.php | 4 +- tests/Header/Value/Stub/SortedHeaderValue.php | 2 +- .../Value/Stub/WithParamsHeaderValue.php | 2 +- 25 files changed, 422 insertions(+), 278 deletions(-) create mode 100644 docs/en/http-start-line.md create mode 100644 docs/ru/http-headers.md delete mode 100644 src/Header/ListedValues.php create mode 100644 src/Header/Rule/ListedValues.php rename src/Header/{ => Rule}/WithParams.php (87%) rename src/Header/{ => Rule}/WithQualityParam.php (87%) diff --git a/docs/en/README.md b/docs/en/README.md index b4a1648..ec249fa 100644 --- a/docs/en/README.md +++ b/docs/en/README.md @@ -1,46 +1,18 @@ # HTTP Package -## Method constants - -Individual HTTP methods could be referenced as - -```php -use Yiisoft\Http\Method; - -Method::GET; -Method::POST; -Method::PUT; -Method::DELETE; -Method::PATCH; -Method::HEAD; -Method::OPTIONS; -``` - -To have a list of these, use: - -```php -use Yiisoft\Http\Method; - -Method::ANY; -``` - -## HTTP status codes - -Status codes could be referenced by name as: - -```php -use Yiisoft\Http\Status; - -Status::NOT_FOUND; -``` - -Status text could be obtained as the following: - -```php -use Yiisoft\Http\Status; - -Status::TEXTS[Status::NOT_FOUND]; -``` - -## HTTP headers - +All HTTP/1.1 messages consist of a start-line followed by a sequence of octets in a format similar to the Internet +Message Format [RFC5322](https://tools.ietf.org/html/rfc5322): zero or more header fields (collectively referred to as +the "headers" or the "header section"), an empty line indicating the end of the header section, and an optional message +body. + +- [HTTP start line](http-start-line.md) (Method constants and status codes) +- [HTTP заголовки](http-headers.md) + +Related links to RFC: + +* [RFC7230](https://tools.ietf.org/html/rfc7230) HTTP/1.1: Message Syntax and Routing +* [RFC7231](https://tools.ietf.org/html/rfc7231) HTTP/1.1: Semantics and Content +* [RFC7232](https://tools.ietf.org/html/rfc7232) HTTP/1.1: Conditional Requests +* [RFC7233](https://tools.ietf.org/html/rfc7233) HTTP/1.1: Range Requests +* [RFC7234](https://tools.ietf.org/html/rfc7234) HTTP/1.1: Caching +* [RFC7235](https://tools.ietf.org/html/rfc7235) HTTP/1.1: Authentication diff --git a/docs/en/http-start-line.md b/docs/en/http-start-line.md new file mode 100644 index 0000000..9f81e46 --- /dev/null +++ b/docs/en/http-start-line.md @@ -0,0 +1,49 @@ +# HTTP start line + +An HTTP message can be either a request from client to server or a response from server to client. Syntactically, the +two types of message differ in the start-line, which is either a request-line (for requests) or a status-line +(for responses). + +## Method constants + +Individual HTTP methods could be referenced as + +```php +use Yiisoft\Http\Method; + +Method::GET; +Method::POST; +Method::PUT; +Method::DELETE; +Method::PATCH; +Method::HEAD; +Method::OPTIONS; +Method::CONNECT; +Method::TRACE; +``` + +To have a list of content related methods, use: + +```php +use Yiisoft\Http\Method; + +Method::ANY; +``` + +## HTTP status codes + +Status codes could be referenced by name as: + +```php +use Yiisoft\Http\Status; + +Status::NOT_FOUND; +``` + +Status text could be obtained as the following: + +```php +use Yiisoft\Http\Status; + +Status::TEXTS[Status::NOT_FOUND]; +``` diff --git a/docs/ru/README.md b/docs/ru/README.md index 129c440..177c043 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -1,173 +1,18 @@ # HTTP Package -## Method constants - -Individual HTTP methods could be referenced as - -```php -use Yiisoft\Http\Method; - -Method::GET; -Method::POST; -Method::PUT; -Method::DELETE; -Method::PATCH; -Method::HEAD; -Method::OPTIONS; -``` - -To have a list of these, use: - -```php -use Yiisoft\Http\Method; - -Method::ANY; -``` - -## HTTP status codes - -Status codes could be referenced by name as: - -```php -use Yiisoft\Http\Status; - -Status::NOT_FOUND; -``` - -Status text could be obtained as the following: - -```php -use Yiisoft\Http\Status; - -Status::TEXTS[Status::NOT_FOUND]; -``` - -## HTTP заголовки - -Из совокупности всех рассмотренных HTTP заголовков были выделены некоторые важные особенности: - -- Некоторые заголовки могут иметь не одно значение, а целый список. Значения таких заголовков могут быть перечислены - через запятую в одном заголовке или нескольких одноимённых заголовках. -- Некоторые заголовки могут иметь параметры. Параметры отделяются символом ";" от значения. -- Заголовки со множественными значениями могут иметь особый тип параметра "q" (quality), который может означает -приоритет, "качество" или "вес" значения. -- Существуют заголовки, не имеющие вышеперечисленные качества, либо имеющие особый формат. - -Указанные свойства прививаются заголовкам с помощью интерфейсов. Например: - -```php -/** @see https://tools.ietf.org/html/rfc7231#section-7.4.1 */ -final class Allow extends BaseHeaderValue implements ListedValues {} -/** @see https://tools.ietf.org/html/rfc7239 */ -final class Forwarded extends BaseHeaderValue implements WithParams, ListedValues {} -``` - -Указание интерфейсов влияет только на процессы парсинга и генерирования строки поля значения заголовка. -Таким образом, при отсутствующем интерфейсе `WithParams` вам будут доступны методы `withParams()` и `getParams()`, но -при генерировании строки параметры будут игнорироваться. - -Рассморим поведение парсинга на примере: - -```php -$header = XHeader::createHeader()->add('foo;param1=test;q=0.5, bar'); -``` -- Если класс `XHeader` не будет иметь интерфейс `ListedValues`, то вся строка станет единственным значением заголовка. -- При наличии только интерфейса `ListedValues` у заголовка будет два значения: `foo;param1=test;q=0.5` и `bar`. -- `ListedValues` и `WithParams` вместе дадут ожидаемый разбор на значения и параметры, однако параметр `q` не будет - учитываться в качестве параметра сортировки, а метод `getQuality()` будет всегда возвращать "1". -- Введение интерфейса `WithQualityParam` добавит автоматическую сортировку значений, метод `getQuality()` будет зависеть - от параметра `q`. - -Любой заголовок при вводе/выводе может быть перечислен несколько раз, поэтому работа с любым заголовком подразумевается -как с коллекцией значений. Базовым классом для коллекции значений заголовков является `\Yiisoft\Http\Header\Header`. \ -Для заголовков, поддерживающих множество значений, важно соблюдение последовательности этих значений. Поэтому для -сортируемых списков значений выделена отдельная коллекция `\Yiisoft\Http\Header\SortableHeader`, а также специальная -коллекция `\Yiisoft\Http\Header\AcceptHeader` для семейства заголовков `Accept`. - -Рассмотрим пример с заголовком Forwarded [[RFC](https://tools.ietf.org/html/rfc7239)].\ -Заголовок представляет собой список из пустых значений с возможными параметрами `by`, `for`, `host` и `proto`: - -``` -Forwarded: for=192.0.2.43,for="[2001:db8:cafe::17]",for=unknown -Forwarded: for=192.0.2.60;proto=http;by=203.0.113.43 -``` - -Следующий код выводит все значения параметров `for`: - -```php -extract($request); - -foreach ($header->getValues() as $headerValue) { - $paramFor = $headerValue->getParams()['for'] ?? null; - if ($paramFor === null) { - continue; - } - echo $paramFor . "\r\n"; -} -?> -``` - - - -```php -/** @var \Psr\Http\Message\ResponseInterface $response */ -$dateHeader = \Yiisoft\Http\Header\Value\Date::createHeader() - ->withValue(new DateTimeImmutable()) - ->inject($response); -$dateHeader = \Yiisoft\Http\Header\Value\Cache\Expires::createHeader() - ->withValue(new DateTimeImmutable('+1 day')) - ->inject($response); -``` - -### Заголовки кеширования [RFC7234](https://tools.ietf.org/html/rfc7234) - -Сюда относятся заголовки: - -- `Age` - простой заоловок, в котором задаётся количество секунд с момента модификации ресурса. -- `Cache-Control` - список директив правил кеширования. -- `Expires` - дата истечения срока актуальности сущности. Класс заголовка `Expires` наследуется от класса `Date`. -- `Pragma` - устаревший заголовок из HTTP/1.0, использующийся для обратной совместимости. - Класс `Pragma` наследуется от класса `CacheControl`. -- `Warning` - заголовок для дополнительной информации об ошибках. - -#### Cache-Control - -```php -$header = \Yiisoft\Http\Header\Value\Cache\CacheControl::createHeader() - ->withDirective('max-age', '27000') - ->withDirective('no-transform') - ->withDirective('foo', 'bar'); -``` - -Для всех стандартных директив, которые описаны в [главе 5.2 RFC7234](https://tools.ietf.org/html/rfc7234#section-5.2), -присваиваемые значения (аргументы) будут проходить этап валидации. Если вы указываете директиву вручную, будьте готовы к -тому, что метод `withDirective()` может выбросить исключение. - -```php -use Yiisoft\Http\Header\Value\Cache\CacheControl; -// исключения будут брошены в каждой строчке ниже -(new CacheControl())->withDirective('max-age'); // у директивы max-age должно быть аргумент -(new CacheControl())->withDirective('max-age', 'not numeric'); // аргумент директивы max-age должен быть числовым -(new CacheControl())->withDirective('max-age', '-456'); // допускаются только цифры -(new CacheControl())->withDirective('private', 'ETag,'); // нарушение синтаксиса списка заголовков -(new CacheControl())->withDirective('no-store', 'yes'); // директива no-store не принимает аргумент -``` - -### Пользовательские заголовки - -Если вы не хотите описывать заголовок в отдельном классе, то можете использовать заготовленные классы: -```php -/** @var \Psr\Http\Message\ResponseInterface $response */ - -// создать заголовок с перечисляемыми значениями -$myListHeader = \Yiisoft\Http\Header\Value\ListedValue::createHeader('My-List') - ->withValues(['foo', 'bar', 'baz']); -// создать заголовок с перечисляемыми сортируемыми значениями -\Yiisoft\Http\Header\Value\SortedValue::createHeader('My-Sorted-List') - ->withValues(['foo', 'bar;q=0.5', 'baz;q=0']) - ->inject($response); -``` +All HTTP/1.1 messages consist of a start-line followed by a sequence of octets in a format similar to the Internet +Message Format [RFC5322](https://tools.ietf.org/html/rfc5322): zero or more header fields (collectively referred to as +the "headers" or the "header section"), an empty line indicating the end of the header section, and an optional message +body. + +- [HTTP start line](http-start-line.md) (Method constants and status codes) +- [HTTP заголовки](http-headers.md) + +Ссылки на RFC: + +* [RFC7230](https://tools.ietf.org/html/rfc7230) HTTP/1.1: Message Syntax and Routing +* [RFC7231](https://tools.ietf.org/html/rfc7231) HTTP/1.1: Semantics and Content +* [RFC7232](https://tools.ietf.org/html/rfc7232) HTTP/1.1: Conditional Requests +* [RFC7233](https://tools.ietf.org/html/rfc7233) HTTP/1.1: Range Requests +* [RFC7234](https://tools.ietf.org/html/rfc7234) HTTP/1.1: Caching +* [RFC7235](https://tools.ietf.org/html/rfc7235) HTTP/1.1: Authentication diff --git a/docs/ru/http-headers.md b/docs/ru/http-headers.md new file mode 100644 index 0000000..0f13be5 --- /dev/null +++ b/docs/ru/http-headers.md @@ -0,0 +1,248 @@ +# HTTP заголовки + +Пакет `yiisoft/http` предоставляет инструменты для парсинга и генерирования заголовков с учётом их особенностей + +## Правила парсинга + +Каждый заголовок состоит из нечувствительного к регистру имени заголовка, двоеточия и затем поля значения, которое может +быть обособлено необязательными пробелами. + +В поле значения может находиться как одно, так и несколько значений заголовка, как с параметрами, так и без них. +Существуют также заголовки без значений, но с параметрами (Forwarded, Keep-Alive), либо с директивами (Forwarded). + +Пример реальных заголовков, использующихся в сообщениях запросов и ответов: + +```text +Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng +Accept: */*;q=0.8,application/signed-exchange;v=b3;q=0.9 +Allow: GET, POST, HEAD +Content-Range: bytes 88080384-160993791/160993792 +Connection: Keep-Alive +Keep-Alive: timeout=5, max=100 +Date: Mon, 16 Mar 2020 09:59:58 GMT +ETag: W/"3d19ee-418a3-5a0d6b89d613d;5a0f5e1acb07d" +Content-Disposition: attachment; filename="filename.jpg" +``` + +С целью выделения общих правил парсинга в контексте пакета `yiisoft/http` HTTP-заголовки разбиты на несколько частично +пересекающихся групп: + +1. Заголовки, имеющие одно значение (Date, ETag) или несколько, но в особом формате (Content-Range). +2. Заголовки, которые позволяют иметь множество значений (список). Значения таких заголовков могут быть перечислены в + одном заголовке через запятую или в нескольких одноимённых заголовках. Это такие заголовки, как Accept, Allow, + Keep-Alive и т.д. +3. Заголовки, которые могут иметь параметры. Параметры отделяются символом ";" от значения. В примере выше это заголовки + Accept, Keep-Alive и Content-Disposition. +4. Группа заголовков со множественными значениями и параметрами, среди которых присутствует особый параметр "q" + (quality), который может означает приоритет, "качество" или "вес" значения. В приведённом примере это заголовок + Allow. + +Правила обработки каждого заголовка описываются в отдельных классах, наследующихся от класса +`\Yiisoft\Http\Header\Value\BaseHeaderValue`. +Для того, чтобы отнести заголовок к той или иной группе из списка выше, в описываемом заголовок классе следует указать +интерфейс, соответствующий этой группе. +По умолчанию, если класс заголовка не имеет интерфейсов, задающих правила парсинга, то он обрабатывается как заголовок +первой группы. + +Пример: + +```php +use \Yiisoft\Http\Header\Rule\ListedValues; +use \Yiisoft\Http\Header\Rule\WithParams; +use \Yiisoft\Http\Header\Value\BaseHeaderValue; + +# В поле значения заголовка передаётся только одно значение +final class Date extends BaseHeaderValue {} +# Заголовок со списком значений +final class Allow extends BaseHeaderValue implements ListedValues {} +# Со списком значений и параметрами +final class Forwarded extends BaseHeaderValue implements WithParams, ListedValues {} +``` + +Указание интерфейсов влияет только на процессы парсинга и генерирования поля значения заголовка. Таким образом, при +отсутствующем интерфейсе `WithParams` вам будут доступны методы `withParams()` и `getParams()`, но при генерировании +строки параметры будут игнорироваться, если метод генератора не переопределён. + +Рассмотрим поведение парсера на примере произвольного заголовка `X-Header`: + +```php +$header = XHeader::createHeader()->add('foo;param1=test;q=0.5, bar'); +``` + - Если класс `XHeader` не будет иметь интерфейс `ListedValues`, то вся строка, передаваемая в метод `add()` станет + единственным значением заголовка. + - При наличии только одного интерфейса `ListedValues` у заголовка будет два значения: `foo;param1=test;q=0.5` и `bar`. + - `ListedValues` и `WithParams` вместе дадут ожидаемый разбор на значения и параметры, однако параметр `q` не будет + учитываться в качестве параметра сортировки, а метод `getQuality()` будет возвращать "1", как значение по умолчанию. + - Указание интерфейса `WithQualityParam` добавит автоматическую сортировку значений, а метод `getQuality()` будет + зависеть от параметра `q`. + +## Класс Header + +Абсолютно любой заголовок в поле заголовков HTTP-сообщения может быть указан несколько раз, даже если это не разрешается +правилами самого заголовка. Поэтому работа с любым заголовком происходит как с коллекцией валидных и не валидных +значений. + +Базовым классом для коллекции значений заголовков является `\Yiisoft\Http\Header\Header`. \ +Для заголовков, поддерживающих множество значений, бывает важно соблюдение последовательности этих значений. Поэтому для +сортируемых списков значений выделена отдельная коллекция `\Yiisoft\Http\Header\SortableHeader`, а также специальная +коллекция `\Yiisoft\Http\Header\AcceptHeader` для семейства заголовков `Accept`. В сортируемых коллекциях значения сразу +занимают места по порядку согласно параметру `q` или иным правилам, описанным в коллекции. + +Рассмотрим пример с заголовком `Forwarded` [RFC7239](https://tools.ietf.org/html/rfc7239).\ +Заголовок представляет собой список из пустых значений с возможными параметрами `by`, `for`, `host` и `proto`: + +``` +Forwarded: for=192.0.2.43,for="[2001:db8:cafe::17]",for=unknown +Forwarded: for=192.0.2.60;proto=http;by=203.0.113.43 +``` + +Следующий код выводит все значения параметров `for`: + +```php +use \Yiisoft\Http\Header\Value\Forwarded; + +/** @var \Psr\Http\Message\ServerRequestInterface $request */ +$header = Forwarded::createHeader()->withValues($request->getHeader('Forwarded')); + +foreach ($header->getValues() as $headerValue) { + $paramFor = $headerValue->getParams()['for'] ?? null; + if ($paramFor === null) { + continue; + } + echo "{$paramFor}\n"; +} +``` + +Каждый объект коллекции `Header` привязывается к указанному при создании классу заголовка. В коллекцию одного заголовка +нельзя передать значения другого заголовка, даже если один из них является наследником другого: + +```php +use \Yiisoft\Http\Header\Value\Date; +use \Yiisoft\Http\Header\Value\Forwarded; + +$header = Forwarded::createHeader(); + +// Будет брошено исключение +$header = $header->withValue(new Date(new DateTimeImmutable())); +``` + +Для взаимодействия с объектами PSR7, реализующими интерфейс `\Psr\Http\Message\MessageInterface`, в классе `Header` +предусмотрены методы импорта и экспорта заголовков: `exctract()` и `inject()` + +```php +use \Yiisoft\Http\Header\Value\Allow; +/** @var \Psr\Http\Message\ServerRequestInterface $request */ +/** @var \Psr\Http\Message\ResponseInterface $response */ + +// Получить коллекцию значений заголовка Allow из объекта запроса +$header = Allow::createHeader()->extract($request); + +// Записать заголовки в объект ответа +$response = $header->inject($response, $replace = true); +``` + +## Примеры + + + +## Заголовки + +### Date + +Для заголовков `Date` и других, значением которых является дата, выделен отдельный класс-коллекция `DateHeader`, +который поддерживает работу со значениями, реализующими интерфейс `\DateTimeInterface`. + +```php +use \Yiisoft\Http\Header\Value\Date; +/** @var \Psr\Http\Message\ResponseInterface $response */ + +$response = Date::createHeader() + ->withValue(new DateTimeImmutable()) + ->inject($response); +``` + +Вы можете использовать класс `Date` для конвертирования объекта `\DateTimeInterface` в строку формата времени HTTP: + +```php +use \Yiisoft\Http\Header\Value\Date; + +// В $date будет записано "Wed, 01 Jan 2020 00:00:00 GMT" +$date = (string)(new Date(new DateTimeImmutable('2020-01-01 00:00:00 +0000'))); +``` + +## Заголовки кеширования + +[RFC7234](https://tools.ietf.org/html/rfc7234) + +### Age + +В заголовке `Age` задаётся количество секунд с момента модификации ресурса. Поэтому все значения, имеющие символы, +отличные от десятичных цифр, будут помечены как не валидные. + +### Expires + +Дата истечения срока актуальности сущности. Класс заголовка `Expires` наследуется от класса `Date`. + +```php +$dateHeader = \Yiisoft\Http\Header\Value\Cache\Expires::createHeader() + ->withValue(new DateTimeImmutable('+1 day')); +``` + +### Warning + +Заголовок для дополнительной информации об ошибках. + +### Cache-Control + +Заголовок `Cache-Control` задаёт правила кеширования. Его особенность заключается в том, что вместо значений и их +параметров используются директивы. С целью переиспользования кода парсера директива без аргумента парсится как значение, +а директива с аргументом — как параметр без значения. Однако для пользователя выделен отдельный метод для записи +директивы и аргумента `withDirective(string $directive, string $argument = null)` + +```php +$header = \Yiisoft\Http\Header\Value\Cache\CacheControl::createHeader() + ->withDirective('max-age', '27000') + ->withDirective('no-transform') + ->withDirective('foo', 'bar'); +``` + +Для всех стандартных директив, которые описаны в [главе 5.2 RFC7234](https://tools.ietf.org/html/rfc7234#section-5.2), +аргументы будут проходить этап валидации. Если вы указываете директиву вручную, будьте готовы к тому, что метод +`withDirective()` может выбросить исключение. + +```php +use Yiisoft\Http\Header\Value\Cache\CacheControl; + +// исключения будут брошены в каждой строчке ниже +(new CacheControl())->withDirective('max-stale'); // у директивы max-stale должен быть аргумент +(new CacheControl())->withDirective('max-age', 'not numeric'); // аргумент директивы max-age должен быть числовым +(new CacheControl())->withDirective('max-age', '-456'); // допускаются только цифры +(new CacheControl())->withDirective('private', 'ETag,'); // нарушение синтаксиса списка заголовков +(new CacheControl())->withDirective('no-store', 'yes'); // директива no-store не принимает аргумент + +// Исключение выброшено не будет, однако все элементы коллекции будут не валидными +CacheControl::createHeader()->withValue('max-stale, max-age=test, private="ETag,", no-store=yes'); +``` + +### Pragma + +Устаревший заголовок из HTTP/1.0, использующийся для обратной совместимости. +Класс заголовка `Pragma` наследуется от класса `CacheControl`. + +## Пользовательские заголовки + +Если вы не хотите описывать заголовок в отдельном классе, либо целевой заголовок не имеет заранее определённое имя, то +вы можете использовать заготовленные классы: + +```php +/** @var \Psr\Http\Message\ServerRequestInterface $request */ +/** @var \Psr\Http\Message\ResponseInterface $response */ + +// извлечь из запроса заголовок с перечисляемыми значениями +$listed = \Yiisoft\Http\Header\Value\ListedValue::createHeader('My-List') + ->extract($request); + +// создать заголовок с перечисляемыми сортируемыми значениями +$sorted = \Yiisoft\Http\Header\Value\SortedValue::createHeader('My-Sorted-List') + ->withValues(['foo', 'bar;q=0.5', 'baz;q=0']); +``` diff --git a/src/Header/Header.php b/src/Header/Header.php index c3f3f1f..7ccda81 100644 --- a/src/Header/Header.php +++ b/src/Header/Header.php @@ -6,6 +6,8 @@ use InvalidArgumentException; use Psr\Http\Message\MessageInterface; +use Yiisoft\Http\Header\Rule\ListedValues; +use Yiisoft\Http\Header\Rule\WithParams; use Yiisoft\Http\Header\Value\SimpleValue; use Yiisoft\Http\Header\Value\BaseHeaderValue; @@ -125,7 +127,7 @@ public function withValue($value): self } /** - * Export header values into Request or Response + * Export header values into HTTP message * @param MessageInterface $message Request or Response instance * @param bool $replace Replace existing headers * @param bool $ignoreIncorrect Don't export values that have error @@ -148,14 +150,14 @@ public function inject( return $message; } /** - * Import header values from Request or Response + * Import header values from HTTP message * @param MessageInterface $message Request or Response instance * @return $this */ public function extract(MessageInterface $message): self { - $this->withValues($message->getHeader($this->headerName)); - return $this; + #todo test it + return $this->withValues($message->getHeader($this->headerName)); } /** diff --git a/src/Header/ListedValues.php b/src/Header/ListedValues.php deleted file mode 100644 index 92730f8..0000000 --- a/src/Header/ListedValues.php +++ /dev/null @@ -1,7 +0,0 @@ - self::ARG_DELTA_SECONDS, - self::D_MAX_STALE => self::ARG_DELTA_SECONDS, - self::D_MIN_FRESH => self::ARG_DELTA_SECONDS, - self::D_NO_CACHE => self::ARG_EMPTY, - self::D_NO_STORE => self::ARG_EMPTY, - self::D_NO_TRANSFORM => self::ARG_EMPTY, - self::D_ONLY_IF_CACHED => self::ARG_EMPTY, + self::MAX_AGE => self::ARG_DELTA_SECONDS, + self::MAX_STALE => self::ARG_DELTA_SECONDS, + self::MIN_FRESH => self::ARG_DELTA_SECONDS, + self::NO_CACHE => self::ARG_EMPTY, + self::NO_STORE => self::ARG_EMPTY, + self::NO_TRANSFORM => self::ARG_EMPTY, + self::ONLY_IF_CACHED => self::ARG_EMPTY, ]; /** * Response Directives * @see https://tools.ietf.org/html/rfc7234#section-5.2.2 */ public const RESPONSE_DIRECTIVES = [ - self::D_MUST_REVALIDATE => self::ARG_EMPTY, - self::D_NO_CACHE => self::ARG_HEADERS_LIST | self::ARG_EMPTY, - self::D_NO_STORE => self::ARG_EMPTY, - self::D_NO_TRANSFORM => self::ARG_EMPTY, - self::D_PUBLIC => self::ARG_EMPTY, - self::D_PRIVATE => self::ARG_HEADERS_LIST | self::ARG_EMPTY, - self::D_PROXY_REVALIDATE => self::ARG_EMPTY, - self::D_MAX_AGE => self::ARG_DELTA_SECONDS, - self::D_S_MAXAGE => self::ARG_DELTA_SECONDS, + self::MUST_REVALIDATE => self::ARG_EMPTY, + self::NO_CACHE => self::ARG_HEADERS_LIST | self::ARG_EMPTY, + self::NO_STORE => self::ARG_EMPTY, + self::NO_TRANSFORM => self::ARG_EMPTY, + self::PUBLIC => self::ARG_EMPTY, + self::PRIVATE => self::ARG_HEADERS_LIST | self::ARG_EMPTY, + self::PROXY_REVALIDATE => self::ARG_EMPTY, + self::MAX_AGE => self::ARG_DELTA_SECONDS, + self::S_MAXAGE => self::ARG_DELTA_SECONDS, ]; protected const ARG_EMPTY = 1; diff --git a/src/Header/Value/Connection.php b/src/Header/Value/Connection.php index 856f108..59e1abb 100644 --- a/src/Header/Value/Connection.php +++ b/src/Header/Value/Connection.php @@ -4,7 +4,7 @@ namespace Yiisoft\Http\Header\Value; -use Yiisoft\Http\Header\ListedValues; +use Yiisoft\Http\Header\Rule\ListedValues; class Connection extends BaseHeaderValue implements ListedValues { diff --git a/src/Header/Value/Forwarded.php b/src/Header/Value/Forwarded.php index ae7a993..41a621d 100644 --- a/src/Header/Value/Forwarded.php +++ b/src/Header/Value/Forwarded.php @@ -4,8 +4,8 @@ namespace Yiisoft\Http\Header\Value; -use Yiisoft\Http\Header\ListedValues; -use Yiisoft\Http\Header\WithParams; +use Yiisoft\Http\Header\Rule\ListedValues; +use Yiisoft\Http\Header\Rule\WithParams; /** * @see https://tools.ietf.org/html/rfc7239 diff --git a/src/Header/Value/ListedValue.php b/src/Header/Value/ListedValue.php index 1c6b277..c3d3d85 100644 --- a/src/Header/Value/ListedValue.php +++ b/src/Header/Value/ListedValue.php @@ -5,7 +5,7 @@ namespace Yiisoft\Http\Header\Value; use Yiisoft\Http\Header\Header; -use Yiisoft\Http\Header\ListedValues; +use Yiisoft\Http\Header\Rule\ListedValues; final class ListedValue extends BaseHeaderValue implements ListedValues { diff --git a/src/Header/Value/SortedValue.php b/src/Header/Value/SortedValue.php index 685b427..b975651 100644 --- a/src/Header/Value/SortedValue.php +++ b/src/Header/Value/SortedValue.php @@ -4,8 +4,8 @@ namespace Yiisoft\Http\Header\Value; +use Yiisoft\Http\Header\Rule\WithQualityParam; use Yiisoft\Http\Header\SortableHeader; -use Yiisoft\Http\Header\WithQualityParam; final class SortedValue extends BaseHeaderValue implements WithQualityParam { diff --git a/src/Method.php b/src/Method.php index 2572bff..85ac800 100644 --- a/src/Method.php +++ b/src/Method.php @@ -84,6 +84,35 @@ final class Method */ public const OPTIONS = 'OPTIONS'; + /** + * The TRACE method requests a remote, application-level loop-back of + * the request message.The final recipient of the request SHOULD + * reflect the message received, excluding some fields, + * back to the client as the message body of a 200 (OK) response with a + * Content-Type of "message/http" (Section 8.3.1 of [RFC7230]). The + * final recipient is either the origin server or the first server to + * receive a Max-Forwards value of zero (0) in the request + * + * @link https://tools.ietf.org/html/rfc7231#section-4.3.8 + */ + public const TRACE = 'TRACE'; + + /** + * The CONNECT method requests that the recipient establish a tunnel to + * the destination origin server identified by the request-target and, + * if successful, thereafter restrict its behavior to blind forwarding + * of packets, in both directions, until the tunnel is closed. Tunnels + * are commonly used to create an end-to-end virtual connection, through + * one or more proxies, which can then be secured using TLS (Transport + * Layer Security, [RFC5246]). + * + * @link https://tools.ietf.org/html/rfc7231#section-4.3.6 + */ + public const CONNECT = 'CONNECT'; + + /** + * Array of content related methods + */ public const ANY = [ self::GET, self::POST, diff --git a/tests/Header/Value/Cache/CacheControlTest.php b/tests/Header/Value/Cache/CacheControlTest.php index a0e7a52..236eafe 100644 --- a/tests/Header/Value/Cache/CacheControlTest.php +++ b/tests/Header/Value/Cache/CacheControlTest.php @@ -33,25 +33,25 @@ public function testWithDirectiveImmutability() } public function testToStringWithoutArgument() { - $headerValue = (new CacheControl())->withDirective(CacheControl::D_NO_CACHE); + $headerValue = (new CacheControl())->withDirective(CacheControl::NO_CACHE); $this->assertSame('no-cache', (string)$headerValue); } public function testToStringNumericArgument() { - $headerValue = (new CacheControl())->withDirective(CacheControl::D_MAX_AGE, '1560'); + $headerValue = (new CacheControl())->withDirective(CacheControl::MAX_AGE, '1560'); $this->assertSame('max-age=1560', (string)$headerValue); } public function testToStringEmptyListedArgument() { - $headerValue = (new CacheControl())->withDirective(CacheControl::D_PRIVATE); + $headerValue = (new CacheControl())->withDirective(CacheControl::PRIVATE); $this->assertSame('private', (string)$headerValue); } public function testToStringListedArgument() { - $headerValue = (new CacheControl())->withDirective(CacheControl::D_PRIVATE, 'etag'); + $headerValue = (new CacheControl())->withDirective(CacheControl::PRIVATE, 'etag'); $this->assertSame('private="etag"', (string)$headerValue); } diff --git a/tests/Header/Value/Stub/DummyHeaderValue.php b/tests/Header/Value/Stub/DummyHeaderValue.php index a7f517a..6f56a92 100644 --- a/tests/Header/Value/Stub/DummyHeaderValue.php +++ b/tests/Header/Value/Stub/DummyHeaderValue.php @@ -3,7 +3,6 @@ namespace Yiisoft\Http\Tests\Header\Value\Stub; use Yiisoft\Http\Header\Value\BaseHeaderValue; -use Yiisoft\Http\Header\WithParams; final class DummyHeaderValue extends BaseHeaderValue { diff --git a/tests/Header/Value/Stub/ListedValuesHeaderValue.php b/tests/Header/Value/Stub/ListedValuesHeaderValue.php index d175417..6fad554 100644 --- a/tests/Header/Value/Stub/ListedValuesHeaderValue.php +++ b/tests/Header/Value/Stub/ListedValuesHeaderValue.php @@ -2,9 +2,8 @@ namespace Yiisoft\Http\Tests\Header\Value\Stub; +use Yiisoft\Http\Header\Rule\ListedValues; use Yiisoft\Http\Header\Value\BaseHeaderValue; -use Yiisoft\Http\Header\ListedValues; -use Yiisoft\Http\Header\WithParams; final class ListedValuesHeaderValue extends BaseHeaderValue implements ListedValues { diff --git a/tests/Header/Value/Stub/ListedValuesWithParamsHeaderValue.php b/tests/Header/Value/Stub/ListedValuesWithParamsHeaderValue.php index f865e96..799c74f 100644 --- a/tests/Header/Value/Stub/ListedValuesWithParamsHeaderValue.php +++ b/tests/Header/Value/Stub/ListedValuesWithParamsHeaderValue.php @@ -2,9 +2,9 @@ namespace Yiisoft\Http\Tests\Header\Value\Stub; +use Yiisoft\Http\Header\Rule\ListedValues; +use Yiisoft\Http\Header\Rule\WithParams; use Yiisoft\Http\Header\Value\BaseHeaderValue; -use Yiisoft\Http\Header\ListedValues; -use Yiisoft\Http\Header\WithParams; final class ListedValuesWithParamsHeaderValue extends BaseHeaderValue implements ListedValues, WithParams { diff --git a/tests/Header/Value/Stub/SortedHeaderValue.php b/tests/Header/Value/Stub/SortedHeaderValue.php index cc77019..5b716ac 100644 --- a/tests/Header/Value/Stub/SortedHeaderValue.php +++ b/tests/Header/Value/Stub/SortedHeaderValue.php @@ -2,7 +2,7 @@ namespace Yiisoft\Http\Tests\Header\Value\Stub; -use Yiisoft\Http\Header\WithQualityParam; +use Yiisoft\Http\Header\Rule\WithQualityParam; final class SortedHeaderValue extends \Yiisoft\Http\Header\Value\BaseHeaderValue implements WithQualityParam { diff --git a/tests/Header/Value/Stub/WithParamsHeaderValue.php b/tests/Header/Value/Stub/WithParamsHeaderValue.php index b10f7b3..1844e55 100644 --- a/tests/Header/Value/Stub/WithParamsHeaderValue.php +++ b/tests/Header/Value/Stub/WithParamsHeaderValue.php @@ -2,8 +2,8 @@ namespace Yiisoft\Http\Tests\Header\Value\Stub; +use Yiisoft\Http\Header\Rule\WithParams; use Yiisoft\Http\Header\Value\BaseHeaderValue; -use Yiisoft\Http\Header\WithParams; final class WithParamsHeaderValue extends BaseHeaderValue implements WithParams { From 99090e106069f6d7e9e1c1cabf94d7a5a25690af Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 20 Mar 2020 00:54:14 +0300 Subject: [PATCH 18/26] Added logic into Age and Warning headers; @see replaced to @link --- docs/ru/http-headers.md | 16 +- src/Header/Header.php | 2 +- src/Header/Rule/WithQualityParam.php | 2 +- src/Header/Value/Accept/Accept.php | 2 +- src/Header/Value/Accept/AcceptCharset.php | 2 +- src/Header/Value/Accept/AcceptEncoding.php | 2 +- src/Header/Value/Accept/AcceptLanguage.php | 2 +- src/Header/Value/Allow.php | 2 +- src/Header/Value/BaseHeaderValue.php | 6 +- src/Header/Value/Cache/Age.php | 26 ++- src/Header/Value/Cache/CacheControl.php | 8 +- src/Header/Value/Cache/Pragma.php | 2 +- src/Header/Value/Cache/Warning.php | 149 +++++++++++++++++- src/Header/Value/Condition/ETag.php | 2 +- .../Value/Condition/IfModifiedSince.php | 2 +- .../Value/Condition/IfUnmodifiedSince.php | 2 +- src/Header/Value/Condition/LastModified.php | 2 +- src/Header/Value/Forwarded.php | 2 +- tests/Header/Value/Cache/AgeTest.php | 79 ++++++++++ tests/Header/Value/Cache/CacheControlTest.php | 2 +- tests/Header/Value/Cache/WarningTest.php | 120 ++++++++++++++ 21 files changed, 408 insertions(+), 24 deletions(-) create mode 100644 tests/Header/Value/Cache/AgeTest.php create mode 100644 tests/Header/Value/Cache/WarningTest.php diff --git a/docs/ru/http-headers.md b/docs/ru/http-headers.md index 0f13be5..62992ec 100644 --- a/docs/ru/http-headers.md +++ b/docs/ru/http-headers.md @@ -51,11 +51,11 @@ use \Yiisoft\Http\Header\Rule\ListedValues; use \Yiisoft\Http\Header\Rule\WithParams; use \Yiisoft\Http\Header\Value\BaseHeaderValue; -# В поле значения заголовка передаётся только одно значение +// В поле значения заголовка передаётся только одно значение final class Date extends BaseHeaderValue {} -# Заголовок со списком значений +// Заголовок со списком значений final class Allow extends BaseHeaderValue implements ListedValues {} -# Со списком значений и параметрами +// Со списком значений и параметрами final class Forwarded extends BaseHeaderValue implements WithParams, ListedValues {} ``` @@ -170,6 +170,10 @@ use \Yiisoft\Http\Header\Value\Date; $date = (string)(new Date(new DateTimeImmutable('2020-01-01 00:00:00 +0000'))); ``` +### ETag + +... + ## Заголовки кеширования [RFC7234](https://tools.ietf.org/html/rfc7234) @@ -191,6 +195,12 @@ $dateHeader = \Yiisoft\Http\Header\Value\Cache\Expires::createHeader() ### Warning Заголовок для дополнительной информации об ошибках. +```php +use \Yiisoft\Http\Header\Value\Cache\Warning; + +$dateHeader = Warning::createHeader() + ->withValue((new Warning())->withDataset(Warning::RESPONSE_IS_STALE, '-', 'Response is stale')); +``` ### Cache-Control diff --git a/src/Header/Header.php b/src/Header/Header.php index 7ccda81..328f20a 100644 --- a/src/Header/Header.php +++ b/src/Header/Header.php @@ -219,7 +219,7 @@ private function parseAndCollect(string $body): void }; $added = 0; try { - /** @see https://tools.ietf.org/html/rfc7230#section-3.2.6 */ + /** @link https://tools.ietf.org/html/rfc7230#section-3.2.6 */ for ($pos = 0, $length = strlen($body); $pos < $length; ++$pos) { $s = $body[$pos]; if ($part === self::READ_VALUE) { diff --git a/src/Header/Rule/WithQualityParam.php b/src/Header/Rule/WithQualityParam.php index ccd49aa..ba093bd 100644 --- a/src/Header/Rule/WithQualityParam.php +++ b/src/Header/Rule/WithQualityParam.php @@ -3,7 +3,7 @@ namespace Yiisoft\Http\Header\Rule; /** - * @see https://tools.ietf.org/html/rfc7231#section-5.3.1 + * @link https://tools.ietf.org/html/rfc7231#section-5.3.1 */ interface WithQualityParam extends ListedValues, WithParams { diff --git a/src/Header/Value/Accept/Accept.php b/src/Header/Value/Accept/Accept.php index 5bc543b..5eac38a 100644 --- a/src/Header/Value/Accept/Accept.php +++ b/src/Header/Value/Accept/Accept.php @@ -9,7 +9,7 @@ use Yiisoft\Http\Header\Value\BaseHeaderValue; /** - * @see https://tools.ietf.org/html/rfc7231#section-5.3.2 + * @link https://tools.ietf.org/html/rfc7231#section-5.3.2 */ class Accept extends BaseHeaderValue implements WithQualityParam { diff --git a/src/Header/Value/Accept/AcceptCharset.php b/src/Header/Value/Accept/AcceptCharset.php index 3f3bbbc..d976c22 100644 --- a/src/Header/Value/Accept/AcceptCharset.php +++ b/src/Header/Value/Accept/AcceptCharset.php @@ -5,7 +5,7 @@ namespace Yiisoft\Http\Header\Value\Accept; /** - * @see https://tools.ietf.org/html/rfc7231#section-5.3.4 + * @link https://tools.ietf.org/html/rfc7231#section-5.3.4 */ final class AcceptCharset extends Accept { diff --git a/src/Header/Value/Accept/AcceptEncoding.php b/src/Header/Value/Accept/AcceptEncoding.php index cacdc5c..47b1aaa 100644 --- a/src/Header/Value/Accept/AcceptEncoding.php +++ b/src/Header/Value/Accept/AcceptEncoding.php @@ -5,7 +5,7 @@ namespace Yiisoft\Http\Header\Value\Accept; /** - * @see https://tools.ietf.org/html/rfc7231#section-5.3.4 + * @link https://tools.ietf.org/html/rfc7231#section-5.3.4 */ final class AcceptEncoding extends Accept { diff --git a/src/Header/Value/Accept/AcceptLanguage.php b/src/Header/Value/Accept/AcceptLanguage.php index dbbeff2..18b84c4 100644 --- a/src/Header/Value/Accept/AcceptLanguage.php +++ b/src/Header/Value/Accept/AcceptLanguage.php @@ -5,7 +5,7 @@ namespace Yiisoft\Http\Header\Value\Accept; /** - * @see https://tools.ietf.org/html/rfc7231#section-5.3.5 + * @link https://tools.ietf.org/html/rfc7231#section-5.3.5 */ final class AcceptLanguage extends Accept { diff --git a/src/Header/Value/Allow.php b/src/Header/Value/Allow.php index 945601c..fd933f5 100644 --- a/src/Header/Value/Allow.php +++ b/src/Header/Value/Allow.php @@ -7,7 +7,7 @@ use Yiisoft\Http\Header\Rule\ListedValues; /** - * @see https://tools.ietf.org/html/rfc7231#section-7.4.1 + * @link https://tools.ietf.org/html/rfc7231#section-7.4.1 */ final class Allow extends BaseHeaderValue implements ListedValues { diff --git a/src/Header/Value/BaseHeaderValue.php b/src/Header/Value/BaseHeaderValue.php index 73c37f1..a273ba8 100644 --- a/src/Header/Value/BaseHeaderValue.php +++ b/src/Header/Value/BaseHeaderValue.php @@ -42,7 +42,7 @@ public function __toString(): string continue; } } - $escaped = preg_replace('/([\\\\"])/', '\\\\$1', $value); + $escaped = $this->encodeQuotedString($value); $quoted = $value === '' || strlen($escaped) !== strlen($value) || preg_match('/[\\s,;()\\/:<=>?@\\[\\\\\\]{}]/', $value) === 1; @@ -142,4 +142,8 @@ protected function setParams(array $params): void } } } + protected function encodeQuotedString(string $string): string + { + return preg_replace('/([\\\\"])/', '\\\\$1', $string); + } } diff --git a/src/Header/Value/Cache/Age.php b/src/Header/Value/Cache/Age.php index e559356..28a701f 100644 --- a/src/Header/Value/Cache/Age.php +++ b/src/Header/Value/Cache/Age.php @@ -4,12 +4,36 @@ namespace Yiisoft\Http\Header\Value\Cache; +use InvalidArgumentException; +use Yiisoft\Http\Header\ParsingException; use Yiisoft\Http\Header\Value\BaseHeaderValue; /** - * @see https://tools.ietf.org/html/rfc7234#section-5.1 + * @link https://tools.ietf.org/html/rfc7234#section-5.1 */ final class Age extends BaseHeaderValue { public const NAME = 'Age'; + + /** + * @param int|string $value + * @return BaseHeaderValue + */ + public function withValue($value): BaseHeaderValue + { + if (is_int($value)) { + $value = (string)$value; + } + return parent::withValue($value); + } + + protected function setValue(string $value): void + { + if (preg_match('/^\\d+$/', $value) !== 1) { + $this->error = new ParsingException($value, 0, 'The value must consist of digits only.'); + } else { + $this->error = null; + } + parent::setValue($value); + } } diff --git a/src/Header/Value/Cache/CacheControl.php b/src/Header/Value/Cache/CacheControl.php index ed05f57..5b8d505 100644 --- a/src/Header/Value/Cache/CacheControl.php +++ b/src/Header/Value/Cache/CacheControl.php @@ -11,7 +11,7 @@ use Yiisoft\Http\Header\Value\BaseHeaderValue; /** - * @see https://tools.ietf.org/html/rfc7234#section-5.2 + * @link https://tools.ietf.org/html/rfc7234#section-5.2 */ class CacheControl extends BaseHeaderValue implements ListedValues, WithParams { @@ -33,7 +33,7 @@ class CacheControl extends BaseHeaderValue implements ListedValues, WithParams /** * Request Directives - * @see https://tools.ietf.org/html/rfc7234#section-5.2.1 + * @link https://tools.ietf.org/html/rfc7234#section-5.2.1 */ public const REQUEST_DIRECTIVES = [ self::MAX_AGE => self::ARG_DELTA_SECONDS, @@ -46,7 +46,7 @@ class CacheControl extends BaseHeaderValue implements ListedValues, WithParams ]; /** * Response Directives - * @see https://tools.ietf.org/html/rfc7234#section-5.2.2 + * @link https://tools.ietf.org/html/rfc7234#section-5.2.2 */ public const RESPONSE_DIRECTIVES = [ self::MUST_REVALIDATE => self::ARG_EMPTY, @@ -81,7 +81,7 @@ final public function __toString(): string return "{$this->directive}=\"{$this->argument}\""; } if ($this->argumentType === self::ARG_CUSTOM) { - $argument = preg_replace('/([\\\\"])/', '\\\\$1', $this->argument); + $argument = $this->encodeQuotedString($this->argument); if ( $argument === '' || strlen($argument) !== strlen($this->argument) diff --git a/src/Header/Value/Cache/Pragma.php b/src/Header/Value/Cache/Pragma.php index b34f1a6..eae2b81 100644 --- a/src/Header/Value/Cache/Pragma.php +++ b/src/Header/Value/Cache/Pragma.php @@ -5,7 +5,7 @@ namespace Yiisoft\Http\Header\Value\Cache; /** - * @see https://tools.ietf.org/html/rfc7234#section-5.4 + * @link https://tools.ietf.org/html/rfc7234#section-5.4 */ final class Pragma extends CacheControl { diff --git a/src/Header/Value/Cache/Warning.php b/src/Header/Value/Cache/Warning.php index 1f99fe1..2f16d8d 100644 --- a/src/Header/Value/Cache/Warning.php +++ b/src/Header/Value/Cache/Warning.php @@ -4,12 +4,159 @@ namespace Yiisoft\Http\Header\Value\Cache; +use DateTimeImmutable; +use DateTimeInterface; +use InvalidArgumentException; +use Yiisoft\Http\Header\ParsingException; use Yiisoft\Http\Header\Value\BaseHeaderValue; /** - * @see https://tools.ietf.org/html/rfc7234#section-5.5 + * @link https://tools.ietf.org/html/rfc7234#section-5.5 + * @link https://developer.mozilla.org/docs/Web/HTTP/Headers/Warning */ final class Warning extends BaseHeaderValue { public const NAME = 'Warning'; + + /** + * A cache SHOULD generate this whenever the sent response is stale. + * @link https://tools.ietf.org/html/rfc7234#section-5.5.1 + */ + public const RESPONSE_IS_STALE = 110; + /** + * A cache SHOULD generate this when sending a stale response because an attempt to validate the response failed, + * due to an inability to reach the server. + * @link https://tools.ietf.org/html/rfc7234#section-5.5.2 + */ + public const REVALIDATION_FAILED = 111; + /** + * A cache SHOULD generate this if it is intentionally disconnected from the rest of the network for a period of + * time. + * @link https://tools.ietf.org/html/rfc7234#section-5.5.3 + */ + public const DISCONNECTED_OPERATION = 112; + /** + * A cache SHOULD generate this if it heuristically chose a freshness lifetime greater than 24 hours and the + * response's age is greater than 24 hours. + * @link https://tools.ietf.org/html/rfc7234#section-5.5.4 + */ + public const HEURISTIC_EXPIRATION = 113; + /** + * The warning text can include arbitrary information to be presented to a human user or logged. A system receiving + * this warning MUST NOT take any automated action, besides presenting the warning to the user. + * @link https://tools.ietf.org/html/rfc7234#section-5.5.5 + */ + public const MISCELLANEOUS_WARNING = 199; + /** + * This Warning code MUST be added by a proxy if it applies any transformation to the representation, such as + * changing the content-coding, media-type, or modifying the representation data, unless this Warning code already + * appears in the response. + * @link https://tools.ietf.org/html/rfc7234#section-5.5.6 + */ + public const TRANSFORMATION_APPLIED = 214; + /** + * The warning text can include arbitrary information to be presented to a human user or logged. A system receiving + * this warning MUST NOT take any automated action. + * @link https://tools.ietf.org/html/rfc7234#section-5.5.7 + */ + public const MISCELLANEOUS_PERSISTENT_WARNING = 299; + + private bool $useDataset = false; + private int $code; + private string $agent; + private string $text; + private ?DateTimeImmutable $date = null; + + public function __toString(): string + { + if ($this->useDataset) { + $result = "{$this->code} $this->agent \"" . $this->encodeQuotedString($this->text) . "\""; + if ($this->date !== null) { + $result .= ' "' . $this->date->format(DateTimeInterface::RFC7231) . '"'; + } + return $result; + } + return parent::__toString(); + } + + public function getCode(): ?int + { + return $this->useDataset ? $this->code : null; + } + public function getAgent(): ?string + { + return $this->useDataset ? $this->agent : null; + } + public function getText(): ?string + { + return $this->useDataset ? $this->text : null; + } + public function getDate(): ?DateTimeImmutable + { + return $this->useDataset ? $this->date : null; + } + + public function withDataset(int $code, string $agent = '-', string $text = '', DateTimeImmutable $date = null): self + { + $clone = clone $this; + $clone->code = $code; + $clone->agent = $agent; + $clone->text = $text; + $clone->date = $date; + $clone->useDataset = true; + return $clone; + } + + protected function setValue(string $value): void + { + $value = trim($value); + $parts = preg_split('/\\s+/', $value, 3); + $this->resetValues(); + try { + // code + if (preg_match('/^[1-9]\\d{2}$/', $parts[0]) !== 1) { + throw new ParsingException($parts[0], 0, 'Incorrect code value.'); + } + $this->code = (int)$parts[0]; + // agent + if (!isset($parts[1])) { + throw new ParsingException($value, 0, 'Agent value not defined.'); + } + $this->agent = $parts[1]; + // text + if (!isset($parts[2])) { + throw new ParsingException($value, 0, 'Text not defined.'); + } + if (preg_match( + '/^"(?(?:(?:\\\\.)+|[^\\\\"]+)*)"(?:(?:\\s+"(?[a-zA-Z0-9, \\-:]+)")?|\\s*)$/', + $parts[2], + $matches + ) !== 1) { + throw new ParsingException($parts[2], 0, 'Bad quoted string format.'); + } + $this->text = preg_replace("/\\\\(.)/", '$1', $matches['text']); + // date + if (isset($matches['date'])) { + if (preg_match( + '/^\\w{3,}, [0-3]?\\d[ \\-]\\w{3}[ \\-]\\d+ [0-2]\\d:[0-5]\\d:[0-5]\\d \\w+|' + . '\\w{3} \\w{3} [0-3]?\\d [0-2]\\d:[0-5]\\d:[0-5]\\d \\d+$/i', + $matches['date'] + ) !== 1) { + throw new ParsingException($matches['date'], 0, 'Incorrect datetime format.'); + } + $this->date = new DateTimeImmutable($matches['date']); + } + $this->useDataset = true; + $this->error = null; + } catch (\Exception $e) { + $this->error = $e; + } + parent::setValue($value); + } + + final private function resetValues() + { + $this->useDataset = false; + $this->date = null; + } } diff --git a/src/Header/Value/Condition/ETag.php b/src/Header/Value/Condition/ETag.php index c2e1249..d31bc6e 100644 --- a/src/Header/Value/Condition/ETag.php +++ b/src/Header/Value/Condition/ETag.php @@ -8,7 +8,7 @@ use Yiisoft\Http\Header\Value\BaseHeaderValue; /** - * @see https://tools.ietf.org/html/rfc7232#section-2.3 + * @link https://tools.ietf.org/html/rfc7232#section-2.3 */ final class ETag extends BaseHeaderValue { diff --git a/src/Header/Value/Condition/IfModifiedSince.php b/src/Header/Value/Condition/IfModifiedSince.php index 059b015..7b06bd2 100644 --- a/src/Header/Value/Condition/IfModifiedSince.php +++ b/src/Header/Value/Condition/IfModifiedSince.php @@ -7,7 +7,7 @@ use Yiisoft\Http\Header\Value\Date; /** - * @see https://tools.ietf.org/html/rfc7232#section-3.3 + * @link https://tools.ietf.org/html/rfc7232#section-3.3 */ final class IfModifiedSince extends Date { diff --git a/src/Header/Value/Condition/IfUnmodifiedSince.php b/src/Header/Value/Condition/IfUnmodifiedSince.php index 3576d3b..1c01e62 100644 --- a/src/Header/Value/Condition/IfUnmodifiedSince.php +++ b/src/Header/Value/Condition/IfUnmodifiedSince.php @@ -7,7 +7,7 @@ use Yiisoft\Http\Header\Value\Date; /** - * @see https://tools.ietf.org/html/rfc7232#section-3.4 + * @link https://tools.ietf.org/html/rfc7232#section-3.4 */ final class IfUnmodifiedSince extends Date { diff --git a/src/Header/Value/Condition/LastModified.php b/src/Header/Value/Condition/LastModified.php index 8ef021c..7f2a87d 100644 --- a/src/Header/Value/Condition/LastModified.php +++ b/src/Header/Value/Condition/LastModified.php @@ -7,7 +7,7 @@ use Yiisoft\Http\Header\Value\Date; /** - * @see https://tools.ietf.org/html/rfc7232#section-2.2 + * @link https://tools.ietf.org/html/rfc7232#section-2.2 */ final class LastModified extends Date { diff --git a/src/Header/Value/Forwarded.php b/src/Header/Value/Forwarded.php index 41a621d..4f7c886 100644 --- a/src/Header/Value/Forwarded.php +++ b/src/Header/Value/Forwarded.php @@ -8,7 +8,7 @@ use Yiisoft\Http\Header\Rule\WithParams; /** - * @see https://tools.ietf.org/html/rfc7239 + * @link https://tools.ietf.org/html/rfc7239 */ final class Forwarded extends BaseHeaderValue implements WithParams, ListedValues { diff --git a/tests/Header/Value/Cache/AgeTest.php b/tests/Header/Value/Cache/AgeTest.php new file mode 100644 index 0000000..b103315 --- /dev/null +++ b/tests/Header/Value/Cache/AgeTest.php @@ -0,0 +1,79 @@ +withValue(0); + + $this->assertFalse($value->hasError()); + $this->assertSame('0', $value->getValue()); + } + public function testWithValueMaxInteger() + { + $value = (new Age())->withValue(PHP_INT_MAX); + + $this->assertFalse($value->hasError()); + $this->assertSame(strval(PHP_INT_MAX), $value->getValue()); + } + public function testWithValueMinInteger() + { + $value = (new Age())->withValue(PHP_INT_MIN); + + $this->assertTrue($value->hasError()); + } + + public function withValueCorrectDataProvider(): array + { + return [ + ['0'], + ['1'], + ['000001'], + ['123456'], + ]; + } + /** + * @dataProvider withValueCorrectDataProvider + */ + public function testWithValueCorrectCases($input) + { + $value = (new Age())->withValue($input); + + $this->assertFalse($value->hasError()); + $this->assertSame($input, $value->getValue()); + } + + public function withValueIncorrectDataProvider(): array + { + return [ + 'empty' => [''], + 'space' => [' '], + 'signed-1' => ['-1'], + 'signed-2' => ['+1'], + 'word' => ['hello'], + 'words' => ['hello word'], + 'numbers' => ['1 2'], + 'hex-1' => ['0xff'], + 'hex-2' => ['12ab'], + 'hex-3' => ['12AB'], + 'exp' => ['3e5'], + ]; + } + /** + * @dataProvider withValueIncorrectDataProvider + */ + public function testWithValueIncorrectCases($input) + { + $value = (new Age())->withValue($input); + + $this->assertTrue($value->hasError()); + $this->assertSame($input, $value->getValue()); + } +} diff --git a/tests/Header/Value/Cache/CacheControlTest.php b/tests/Header/Value/Cache/CacheControlTest.php index 236eafe..493aa36 100644 --- a/tests/Header/Value/Cache/CacheControlTest.php +++ b/tests/Header/Value/Cache/CacheControlTest.php @@ -26,7 +26,7 @@ public function testWithDirectiveImmutability() $this->assertNull($origin->getArgument()); // changes applied $this->assertSame('no-cache', $clone->getDirective()); - $this->assertNull($origin->getArgument()); + $this->assertNull($clone->getArgument()); // immutability $this->assertSame(get_class($origin), get_class($clone)); $this->assertNotSame($origin, $clone); diff --git a/tests/Header/Value/Cache/WarningTest.php b/tests/Header/Value/Cache/WarningTest.php new file mode 100644 index 0000000..6148979 --- /dev/null +++ b/tests/Header/Value/Cache/WarningTest.php @@ -0,0 +1,120 @@ +withDataset(100, 'localhost', 'test', new DateTimeImmutable()); + + // the default values of the original object have not changed + $this->assertNull($origin->getCode()); + $this->assertNull($origin->getAgent()); + $this->assertNull($origin->getText()); + $this->assertNull($origin->getDate()); + // changes applied + $this->assertSame(100, $clone->getCode()); + $this->assertSame('localhost', $clone->getAgent()); + $this->assertSame('test', $clone->getText()); + $this->assertNotNull($clone->getDate()); + // immutability + $this->assertSame(get_class($origin), get_class($clone)); + $this->assertNotSame($origin, $clone); + } + public function testToStringWithoutDate() + { + $headerValue = (new Warning())->withDataset(100, '-', 'test'); + + $this->assertSame('100 - "test"', (string)$headerValue); + } + public function testWithValueParsing() + { + $headerValue = (new Warning())->withValue('100 localhost "test" "Wed, 01 Jan 2020 00:00:00 GMT"'); + + $this->assertSame(100, $headerValue->getCode()); + $this->assertSame('localhost', $headerValue->getAgent()); + $this->assertSame('test', $headerValue->getText()); + $this->assertNotNull($headerValue->getDate()); + $this->assertSame('100 localhost "test" "Wed, 01 Jan 2020 00:00:00 GMT"', (string)$headerValue); + $this->assertFalse($headerValue->hasError()); + } + + public function withValueDataProvider(): array + { + return [ + 'spaces' => [' 100 - "" ', 100, '-', ''], + 'spaces+time' => [' 100 - "" "Wed, 01 Jan 2020 00:00:00 GMT" ', 100, '-', '', '2020-01-01-00-00-00'], + 'agent-url' => ['100 www.example.com ""', 100, 'www.example.com', ''], + 'agent-url:port' => ['100 www.example.com:80 ""', 100, 'www.example.com:80', ''], + 'text-empty' => ['100 - ""', 100, '-', ''], + 'text-spaced' => ['100 - " test text "', 100, '-', ' test text '], + 'text-quote' => ['100 - "\\""', 100, '-', '"'], + 'text-bs' => ['100 - "\\\\"', 100, '-', '\\'], + 'time-RFC-7231' => ['100 - "" "Fri, 04 Jul 2008 08:42:36 GMT"', 100, '-', '', '2008-07-04-08-42-36'], + 'time-RFC-850' => ['100 - "" "Friday, 04-Jul-08 08:42:36 GMT"', 100, '-', '', '2008-07-04-08-42-36'], + 'time-deprecated' => ['100 - "" "Fri Jul 4 08:42:36 2008"', 100, '-', '', '2008-07-04-08-42-36'], + ]; + } + /** + * @dataProvider withValueDataProvider + */ + public function testwithValueCorrect( + string $input, + int $code, + string $agent, + string $text, + string $date = null + ) { + $headerValue = (new Warning())->withValue($input); + $this->assertFalse($headerValue->hasError()); + $this->assertSame($code, $headerValue->getCode()); + $this->assertSame($agent, $headerValue->getAgent()); + $this->assertSame($text, $headerValue->getText()); + $this->assertSame($date, $headerValue->getDate() === null + ? null + : $headerValue->getDate()->format('Y-m-d-H-i-s') + ); + } + + public function withValueIncorrectDataProvider(): array + { + return [ + 'no-code' => [' - ""'], + 'no-agent' => ['100 ""'], + 'no-text' => ['100 -'], + 'code-first-zero' => ['012 - ""'], + 'code-zero' => ['000 - ""'], + 'code-hex-1' => ['0xff - ""'], + 'code-hex-2' => ['12A - ""'], + 'code-quoted' => ['"100" - ""'], + 'text-unescaped-quote' => ['100 - """'], + 'text-end-bs' => ['100 - "\\"'], + 'datetime-1' => ['100 - "" "phrase"'], + 'datetime-2' => ['100 - "" "now"'], + 'datetime-3' => ['100 - "" now'], + 'datetime-RFC-7231-1' => ['100 - "" "Fri, 99 Jul 2008 08:42:36 GMT"'], + 'datetime-RFC-7231-2' => ['100 - "" "Fri, 01 Jul 2008 30:42:36 GMT"'], + 'datetime-RFC-850-1' => ['100 - "" "Friday, 99-Jul-08 08:42:36 GMT"'], + 'datetime-RFC-850-2' => ['100 - "" "Friday, 01-Jul-08 30:42:36 GMT"'], + 'datetime-deprecated-1' => ['100 - "" "Fri Jul 99 08:42:36 2008"'], + ]; + } + /** + * @dataProvider withValueIncorrectDataProvider + */ + public function testWithValueIncorrectCases( + string $input + ) { + $headerValue = (new Warning())->withValue($input); + $this->assertTrue($headerValue->hasError()); + } +} From 784bd801d951275a8d8986636aa1e22a9bc0cda4 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 1 Apr 2020 17:48:38 +0300 Subject: [PATCH 19/26] Refactoring: removed parsing interfaces; parsing params declared in constants; create some specific base classes; added Directives group --- docs/ru/http-headers.md | 52 +---- src/Header/AcceptHeader.php | 2 +- src/Header/DateHeader.php | 2 +- ...eControlHeader.php => DirectiveHeader.php} | 15 +- src/Header/Header.php | 40 ++-- src/Header/Internal/BaseHeaderValue.php | 90 +++++++++ src/Header/Internal/DirectivesHeaderValue.php | 147 ++++++++++++++ src/Header/Internal/WithParamsHeaderValue.php | 100 ++++++++++ src/Header/Rule/ListedValues.php | 7 - src/Header/Rule/WithParams.php | 16 -- src/Header/Rule/WithQualityParam.php | 14 -- src/Header/SortableHeader.php | 8 +- src/Header/Value/Accept/Accept.php | 8 +- src/Header/Value/Allow.php | 6 +- src/Header/Value/BaseHeaderValue.php | 149 -------------- src/Header/Value/Cache/Age.php | 3 +- src/Header/Value/Cache/CacheControl.php | 183 ++--------------- src/Header/Value/Cache/Warning.php | 9 +- src/Header/Value/Condition/ETag.php | 2 +- src/Header/Value/Connection.php | 6 +- src/Header/Value/Date.php | 5 + src/Header/Value/DirectiveValue.php | 16 ++ src/Header/Value/Forwarded.php | 7 +- src/Header/Value/ListedValue.php | 6 +- src/Header/Value/SimpleValue.php | 1 + src/Header/Value/SortedValue.php | 7 +- tests/Header/HeaderTest.php | 2 +- tests/Header/Internal/BaseHeaderValueTest.php | 41 ++++ .../Internal/DirectivesHeaderValueTest.php | 181 +++++++++++++++++ .../WithParamsHeaderValueTest.php} | 53 ++--- tests/Header/Value/Cache/CacheControlTest.php | 186 ------------------ tests/Header/Value/DateTest.php | 30 ++- .../Value/Stub/DirectivesHeaderValue.php | 22 +++ tests/Header/Value/Stub/DummyHeaderValue.php | 2 +- .../Value/Stub/ListedValuesHeaderValue.php | 7 +- .../ListedValuesWithParamsHeaderValue.php | 8 +- tests/Header/Value/Stub/SortedHeaderValue.php | 8 +- .../Value/Stub/WithParamsHeaderValue.php | 5 +- 38 files changed, 741 insertions(+), 705 deletions(-) rename src/Header/{CacheControlHeader.php => DirectiveHeader.php} (74%) create mode 100644 src/Header/Internal/BaseHeaderValue.php create mode 100644 src/Header/Internal/DirectivesHeaderValue.php create mode 100644 src/Header/Internal/WithParamsHeaderValue.php delete mode 100644 src/Header/Rule/ListedValues.php delete mode 100644 src/Header/Rule/WithParams.php delete mode 100644 src/Header/Rule/WithQualityParam.php delete mode 100644 src/Header/Value/BaseHeaderValue.php create mode 100644 src/Header/Value/DirectiveValue.php create mode 100644 tests/Header/Internal/BaseHeaderValueTest.php create mode 100644 tests/Header/Internal/DirectivesHeaderValueTest.php rename tests/Header/{Value/BaseHeaderValueTest.php => Internal/WithParamsHeaderValueTest.php} (51%) delete mode 100644 tests/Header/Value/Cache/CacheControlTest.php create mode 100644 tests/Header/Value/Stub/DirectivesHeaderValue.php diff --git a/docs/ru/http-headers.md b/docs/ru/http-headers.md index 62992ec..c9458bd 100644 --- a/docs/ru/http-headers.md +++ b/docs/ru/http-headers.md @@ -24,57 +24,7 @@ ETag: W/"3d19ee-418a3-5a0d6b89d613d;5a0f5e1acb07d" Content-Disposition: attachment; filename="filename.jpg" ``` -С целью выделения общих правил парсинга в контексте пакета `yiisoft/http` HTTP-заголовки разбиты на несколько частично -пересекающихся групп: - -1. Заголовки, имеющие одно значение (Date, ETag) или несколько, но в особом формате (Content-Range). -2. Заголовки, которые позволяют иметь множество значений (список). Значения таких заголовков могут быть перечислены в - одном заголовке через запятую или в нескольких одноимённых заголовках. Это такие заголовки, как Accept, Allow, - Keep-Alive и т.д. -3. Заголовки, которые могут иметь параметры. Параметры отделяются символом ";" от значения. В примере выше это заголовки - Accept, Keep-Alive и Content-Disposition. -4. Группа заголовков со множественными значениями и параметрами, среди которых присутствует особый параметр "q" - (quality), который может означает приоритет, "качество" или "вес" значения. В приведённом примере это заголовок - Allow. - -Правила обработки каждого заголовка описываются в отдельных классах, наследующихся от класса -`\Yiisoft\Http\Header\Value\BaseHeaderValue`. -Для того, чтобы отнести заголовок к той или иной группе из списка выше, в описываемом заголовок классе следует указать -интерфейс, соответствующий этой группе. -По умолчанию, если класс заголовка не имеет интерфейсов, задающих правила парсинга, то он обрабатывается как заголовок -первой группы. - -Пример: - -```php -use \Yiisoft\Http\Header\Rule\ListedValues; -use \Yiisoft\Http\Header\Rule\WithParams; -use \Yiisoft\Http\Header\Value\BaseHeaderValue; - -// В поле значения заголовка передаётся только одно значение -final class Date extends BaseHeaderValue {} -// Заголовок со списком значений -final class Allow extends BaseHeaderValue implements ListedValues {} -// Со списком значений и параметрами -final class Forwarded extends BaseHeaderValue implements WithParams, ListedValues {} -``` - -Указание интерфейсов влияет только на процессы парсинга и генерирования поля значения заголовка. Таким образом, при -отсутствующем интерфейсе `WithParams` вам будут доступны методы `withParams()` и `getParams()`, но при генерировании -строки параметры будут игнорироваться, если метод генератора не переопределён. - -Рассмотрим поведение парсера на примере произвольного заголовка `X-Header`: - -```php -$header = XHeader::createHeader()->add('foo;param1=test;q=0.5, bar'); -``` - - Если класс `XHeader` не будет иметь интерфейс `ListedValues`, то вся строка, передаваемая в метод `add()` станет - единственным значением заголовка. - - При наличии только одного интерфейса `ListedValues` у заголовка будет два значения: `foo;param1=test;q=0.5` и `bar`. - - `ListedValues` и `WithParams` вместе дадут ожидаемый разбор на значения и параметры, однако параметр `q` не будет - учитываться в качестве параметра сортировки, а метод `getQuality()` будет возвращать "1", как значение по умолчанию. - - Указание интерфейса `WithQualityParam` добавит автоматическую сортировку значений, а метод `getQuality()` будет - зависеть от параметра `q`. +Все заголовки разные и со своими особенностями... ## Класс Header diff --git a/src/Header/AcceptHeader.php b/src/Header/AcceptHeader.php index 92f02ed..facd98a 100644 --- a/src/Header/AcceptHeader.php +++ b/src/Header/AcceptHeader.php @@ -6,7 +6,7 @@ use InvalidArgumentException; use Yiisoft\Http\Header\Value\Accept\Accept; -use Yiisoft\Http\Header\Value\BaseHeaderValue; +use Yiisoft\Http\Header\Internal\BaseHeaderValue; final class AcceptHeader extends Header { diff --git a/src/Header/DateHeader.php b/src/Header/DateHeader.php index 180bf40..fd56e26 100644 --- a/src/Header/DateHeader.php +++ b/src/Header/DateHeader.php @@ -5,7 +5,7 @@ namespace Yiisoft\Http\Header; use DateTimeInterface; -use Yiisoft\Http\Header\Value\BaseHeaderValue; +use Yiisoft\Http\Header\Internal\BaseHeaderValue; use Yiisoft\Http\Header\Value\Date; /** diff --git a/src/Header/CacheControlHeader.php b/src/Header/DirectiveHeader.php similarity index 74% rename from src/Header/CacheControlHeader.php rename to src/Header/DirectiveHeader.php index 22b1af1..77ad6d9 100644 --- a/src/Header/CacheControlHeader.php +++ b/src/Header/DirectiveHeader.php @@ -5,18 +5,19 @@ namespace Yiisoft\Http\Header; use InvalidArgumentException; -use Yiisoft\Http\Header\Value\Cache\CacheControl; +use Yiisoft\Http\Header\Internal\DirectivesHeaderValue; +use Yiisoft\Http\Header\Value\DirectiveValue; -final class CacheControlHeader extends Header +final class DirectiveHeader extends Header { - protected const DEFAULT_VALUE_CLASS = CacheControl::class; + protected const DEFAULT_VALUE_CLASS = DirectiveValue::class; public function __construct(string $nameOrClass) { parent::__construct($nameOrClass); - if (!is_a($this->headerClass, CacheControl::class, true)) { + if (!is_a($this->headerClass, DirectivesHeaderValue::class, true)) { throw new InvalidArgumentException( - sprintf("%s class is not an instance of %s", $this->headerClass, CacheControl::class) + sprintf("%s class is not an instance of %s", $this->headerClass, DirectivesHeaderValue::class) ); } } @@ -30,7 +31,7 @@ public function __construct(string $nameOrClass) public function withDirective(string $directive, string $argument = null): self { $clone = clone $this; - /** @var CacheControl $headerValue */ + /** @var DirectivesHeaderValue $headerValue */ $headerValue = new $this->headerClass(); $clone->addValue($headerValue->withDirective($directive, $argument)); return $clone; @@ -43,7 +44,7 @@ public function withDirective(string $directive, string $argument = null): self public function getDirectives(bool $ignoreIncorrect = true): array { $result = []; - /** @var CacheControl $header */ + /** @var DirectivesHeaderValue $header */ foreach ($this->collection as $header) { if ($ignoreIncorrect && $header->hasError()) { continue; diff --git a/src/Header/Header.php b/src/Header/Header.php index 328f20a..9f154c6 100644 --- a/src/Header/Header.php +++ b/src/Header/Header.php @@ -6,21 +6,23 @@ use InvalidArgumentException; use Psr\Http\Message\MessageInterface; -use Yiisoft\Http\Header\Rule\ListedValues; -use Yiisoft\Http\Header\Rule\WithParams; +use Yiisoft\Http\Header\Internal\DirectivesHeaderValue; +use Yiisoft\Http\Header\Internal\WithParamsHeaderValue; use Yiisoft\Http\Header\Value\SimpleValue; -use Yiisoft\Http\Header\Value\BaseHeaderValue; +use Yiisoft\Http\Header\Internal\BaseHeaderValue; class Header implements \IteratorAggregate, \Countable { - protected bool $listedValues = false; - protected bool $withParams = false; protected string $headerClass; protected string $headerName; /** @var BaseHeaderValue[] */ protected array $collection = []; protected const DEFAULT_VALUE_CLASS = SimpleValue::class; + // Parsing params + private bool $listedValues = false; + private bool $withParams = false; + private bool $directives = false; // Parsing's constants private const DELIMITERS = '"(),/:;<=>?@[\\]{}', @@ -51,8 +53,10 @@ public function __construct(string $nameOrClass) $this->headerName = $nameOrClass; $this->headerClass = static::DEFAULT_VALUE_CLASS; } - $this->listedValues = is_subclass_of($this->headerClass, ListedValues::class, true); - $this->withParams = is_subclass_of($this->headerClass, WithParams::class, true); + $parsing = $this->headerClass::getParsingParams(); + $this->listedValues = $parsing['list']; + $this->withParams = $parsing['params']; + $this->directives = is_subclass_of($this->headerClass, DirectivesHeaderValue::class, true); } public function getIterator(): iterable @@ -188,7 +192,7 @@ protected function collect(BaseHeaderValue $value): void } private function parseAndCollect(string $body): void { - if (!$this->listedValues && !$this->withParams) { + if (!$this->listedValues && !$this->withParams && !$this->directives) { $this->collect(new $this->headerClass(trim($body))); return; } @@ -198,15 +202,19 @@ private function parseAndCollect(string $body): void $value = ''; $params = []; $error = null; + $parseList = $this->listedValues || $this->directives; + $parseParams = $this->withParams || $this->directives; $addParam = static function ($key, $value) use (&$params) { if (!key_exists($key, $params)) { $params[$key] = $value; } }; $collectHeaderValue = function () use (&$key, &$value, &$buffer, &$params, &$added, &$error) { - /** @var BaseHeaderValue $item */ + /** @var DirectivesHeaderValue|BaseHeaderValue $item */ $item = new $this->headerClass($value); - if ($this->withParams) { + if ($this->directives) { + // $item->withDirective($value, ); + } elseif ($this->withParams) { $item = $item->withParams($params); } if ($error !== null) { @@ -223,7 +231,7 @@ private function parseAndCollect(string $body): void for ($pos = 0, $length = strlen($body); $pos < $length; ++$pos) { $s = $body[$pos]; if ($part === self::READ_VALUE) { - if ($s === '=' && $this->withParams) { + if ($s === '=' && $parseParams) { $key = ltrim($buffer); $buffer = ''; if (preg_match('/\s/', $key) === 0) { @@ -239,11 +247,11 @@ private function parseAndCollect(string $body): void } $part = self::READ_PARAM_VALUE; [$value, $key] = $chunks; - } elseif ($s === ';' && $this->withParams) { + } elseif ($s === ';' && $parseParams) { $part = self::READ_PARAM_NAME; $value = trim($buffer); $buffer = ''; - } elseif ($s === ',' && $this->listedValues) { + } elseif ($s === ',' && $parseList) { $value = trim($buffer); $collectHeaderValue(); } else { @@ -288,7 +296,7 @@ private function parseAndCollect(string $body): void $part = self::READ_PARAM_NAME; $addParam($key, $buffer); $key = $buffer = ''; - } elseif ($s === ',' && $this->listedValues) { + } elseif ($s === ',' && $parseList) { $part = self::READ_VALUE; $addParam($key, $buffer); $collectHeaderValue(); @@ -317,9 +325,9 @@ private function parseAndCollect(string $body): void if ($part === self::READ_NONE) { if (ord($s) <= 32) { continue; - } elseif ($s === ';' && $this->withParams) { + } elseif ($s === ';' && $parseParams) { $part = self::READ_PARAM_NAME; - } elseif ($s === ',' && $this->listedValues) { + } elseif ($s === ',' && $parseList) { $part = self::READ_VALUE; $collectHeaderValue(); } else { diff --git a/src/Header/Internal/BaseHeaderValue.php b/src/Header/Internal/BaseHeaderValue.php new file mode 100644 index 0000000..898eab5 --- /dev/null +++ b/src/Header/Internal/BaseHeaderValue.php @@ -0,0 +1,90 @@ +setValue($value); + } + public function __toString(): string + { + return $this->value; + } + + public static function createHeader(): Header + { + return new Header(static::class); + } + + public function withValue(string $value): self + { + $clone = clone $this; + $clone->setValue($value); + return $clone; + } + public function getValue(): string + { + return $this->value; + } + + /** + * @param Exception|null $error + * @return $this + */ + final public function withError(?Exception $error): self + { + $clone = clone $this; + $clone->error = $error; + return $clone; + } + final public function hasError(): bool + { + return $this->error !== null; + } + final public function getError(): ?Exception + { + return $this->error; + } + + final public static function getParsingParams(): array + { + return [ + 'list' => static::PARSING_LIST, + 'params' => static::PARSING_PARAMS, + 'q' => static::PARSING_Q_PARAM, + ]; + } + + protected function setValue(string $value): void + { + $this->value = $value; + } + final protected function encodeQuotedString(string $string): string + { + return preg_replace('/([\\\\"])/', '\\\\$1', $string); + } + final protected function validateDateTime(string $value): bool + { + return preg_match( + '/^\\w{3,}, [0-3]?\\d[ \\-]\\w{3}[ \\-]\\d+ [0-2]\\d:[0-5]\\d:[0-5]\\d \\w+|' + . '\\w{3} \\w{3} [0-3]?\\d [0-2]\\d:[0-5]\\d:[0-5]\\d \\d+$/i', + trim($value) + ) === 1; + } +} diff --git a/src/Header/Internal/DirectivesHeaderValue.php b/src/Header/Internal/DirectivesHeaderValue.php new file mode 100644 index 0000000..56f5f94 --- /dev/null +++ b/src/Header/Internal/DirectivesHeaderValue.php @@ -0,0 +1,147 @@ +directive === '') { + return ''; + } + if ($this->argument === null) { + return $this->directive; + } + if ($this->argumentType === self::ARG_HEADERS_LIST) { + return "{$this->directive}=\"{$this->argument}\""; + } + if ($this->argumentType === self::ARG_CUSTOM) { + $argument = $this->encodeQuotedString($this->argument); + if ( + $argument === '' + || strlen($argument) !== strlen($this->argument) + || preg_match('/[^a-z0-9_]/i', $argument) === 1 + ) { + $argument = '"' . $argument . '"'; + } + return "{$this->directive}={$argument}"; + } + return "{$this->directive}={$this->argument}"; + } + + public static function createHeader(): DirectiveHeader + { + return new DirectiveHeader(static::class); + } + + /** + * @return string|null Returns null if the directive is not defined or cannot be parsed without error + */ + public function getDirective(): string + { + return $this->directive; + } + public function getValue(): string + { + return $this->getDirective(); + } + + public function hasArgument(): bool + { + return $this->argument !== null; + } + public function getArgument(): ?string + { + return $this->argument; + } + public function getArgumentList(): array + { + return $this->argument === null ? [] : explode(',', $this->argument); + } + + /** + * @param string $directive + * @param string|null $argument + * @return $this + * @throws InvalidArgumentException + */ + public function withDirective(string $directive, string $argument = null): self + { + $clone = clone $this; + $clone->setDirective($directive, $argument, true); + return $clone; + } + + protected function setValue(string $value): void + { + $this->setDirective($value); + } + private function setDirective(string $value, string $argument = null, bool $trowError = false): bool + { + $name = strtolower($value); + + $argumentType = static::DIRECTIVES[$name] ?? self::ARG_CUSTOM; + + $writeProperties = function (\Exception $err = null, string $arg = null, int $type = self::ARG_EMPTY) use ( + $name, + $trowError + ) { + if ($trowError && $err !== null) { + throw $err; + } + $this->directive = $name; + $this->argument = $arg; + $this->argumentType = $type; + $this->error = $err; + return $this->error === null; + }; + + if ($argument === null && ($argumentType & self::ARG_EMPTY) === self::ARG_EMPTY) { + return $writeProperties(); + } elseif ($argumentType === self::ARG_EMPTY) { + if ($argument !== null) { + return $writeProperties(new InvalidArgumentException("{$name} directive should not have an argument")); + } + return $writeProperties(); + } elseif (($argumentType & self::ARG_HEADERS_LIST) === self::ARG_HEADERS_LIST) { + // Validate headers list + $argument = $argument === null ? null : trim($argument); + if ($argument === null || preg_match('/^[\\w\\-]+(?:(?:\\s*,\\s*)[\\w\\-]+)*$/', $argument) !== 1) { + return $writeProperties( + new InvalidArgumentException( + "{$name} directive should have an argument as a comma separated headers name list" + ) + ); + } + return $writeProperties(null, $argument, self::ARG_HEADERS_LIST); + } elseif (($argumentType & self::ARG_DELTA_SECONDS) === self::ARG_DELTA_SECONDS) { + $this->argumentType = self::ARG_DELTA_SECONDS; + // Validate number + if ($argument === null || preg_match('/^\\d+$/', $argument) !== 1) { + return $writeProperties( + new InvalidArgumentException("{$name} directive should have numeric argument"), + '0', + self::ARG_DELTA_SECONDS + ); + } + return $writeProperties(null, $argument, self::ARG_DELTA_SECONDS); + } + return $writeProperties(null, $argument, $argumentType); + } +} diff --git a/src/Header/Internal/WithParamsHeaderValue.php b/src/Header/Internal/WithParamsHeaderValue.php new file mode 100644 index 0000000..d8ade58 --- /dev/null +++ b/src/Header/Internal/WithParamsHeaderValue.php @@ -0,0 +1,100 @@ + + */ + private array $params = []; + /** + * @link https://tools.ietf.org/html/rfc7231#section-5.3.1 + * @var string value between 0.000 and 1.000 + */ + private string $quality = '1'; + + protected const PARSING_PARAMS = true; + + public function __toString(): string + { + $params = []; + foreach ($this->getParams() as $key => $value) { + if ($key === 'q' && static::PARSING_Q_PARAM) { + if ($value === '1') { + continue; + } + } + $escaped = $this->encodeQuotedString($value); + $quoted = $value === '' + || strlen($escaped) !== strlen($value) + || preg_match('/[\\s,;()\\/:<=>?@\\[\\\\\\]{}]/', $value) === 1; + $params[] = $key . '=' . ($quoted ? "\"{$escaped}\"" : $value); + } + return $this->value === '' ? implode(';', $params) : implode(';', [$this->value, ...$params]); + } + + public static function createHeader(): Header + { + $class = static::PARSING_Q_PARAM ? SortableHeader::class : Header::class; + return new $class(static::class); + } + + /** + * It makes sense to use only for HeaderValues that implement the WithParams interface + * @param array $params + * @return $this + */ + public function withParams(array $params): self + { + $clone = clone $this; + $clone->setParams($params); + return $clone; + } + public function getParams(): array + { + $result = $this->params; + if (static::PARSING_Q_PARAM) { + $result['q'] = $this->quality; + } + return $result; + } + public function getQuality(): string + { + return $this->quality; + } + + protected function setQuality(string $q): bool + { + if (preg_match('/^0(?:\\.\\d{1,3})?$|^1(?:\\.0{1,3})?$/', $q) !== 1) { + return false; + } + $this->quality = rtrim($q, '0.') ?: '0'; + return true; + } + protected function setParams(array $params): void + { + $this->params = []; + foreach ($params as $key => $value) { + # todo decide: what about numeric keys? + $key = strtolower($key); + if (!key_exists($key, $this->params)) { + $this->params[$key] = $value; + } + } + if (static::PARSING_Q_PARAM) { + if (key_exists('q', $this->params)) { + $this->setQuality($this->params['q']); + unset($this->params['q']); + } else { + $this->setQuality('1'); + } + } + } +} diff --git a/src/Header/Rule/ListedValues.php b/src/Header/Rule/ListedValues.php deleted file mode 100644 index c28034c..0000000 --- a/src/Header/Rule/ListedValues.php +++ /dev/null @@ -1,7 +0,0 @@ - - */ - public function getParams(): array; - /** - * @param array $params - * @return $this - */ - public function withParams(array $params); -} diff --git a/src/Header/Rule/WithQualityParam.php b/src/Header/Rule/WithQualityParam.php deleted file mode 100644 index ba093bd..0000000 --- a/src/Header/Rule/WithQualityParam.php +++ /dev/null @@ -1,14 +0,0 @@ -headerClass, WithQualityParam::class, true)) { + if (!is_subclass_of($this->headerClass, WithParamsHeaderValue::class, true)) { throw new InvalidArgumentException( - sprintf("%s class does not implement %s", $this->headerClass, WithQualityParam::class) + sprintf("%s class does not implement %s", $this->headerClass, WithParamsHeaderValue::class) ); } } diff --git a/src/Header/Value/Accept/Accept.php b/src/Header/Value/Accept/Accept.php index 5eac38a..17ba749 100644 --- a/src/Header/Value/Accept/Accept.php +++ b/src/Header/Value/Accept/Accept.php @@ -5,17 +5,19 @@ namespace Yiisoft\Http\Header\Value\Accept; use Yiisoft\Http\Header\AcceptHeader; -use Yiisoft\Http\Header\Rule\WithQualityParam; -use Yiisoft\Http\Header\Value\BaseHeaderValue; +use Yiisoft\Http\Header\Internal\WithParamsHeaderValue; /** * @link https://tools.ietf.org/html/rfc7231#section-5.3.2 */ -class Accept extends BaseHeaderValue implements WithQualityParam +class Accept extends WithParamsHeaderValue { public const NAME = 'Accept'; public const VALUE_SEPARATOR = '/'; + protected const PARSING_LIST = true; + protected const PARSING_Q_PARAM = true; + final public static function createHeader(): AcceptHeader { return new AcceptHeader(static::class); diff --git a/src/Header/Value/Allow.php b/src/Header/Value/Allow.php index fd933f5..3263bda 100644 --- a/src/Header/Value/Allow.php +++ b/src/Header/Value/Allow.php @@ -4,12 +4,14 @@ namespace Yiisoft\Http\Header\Value; -use Yiisoft\Http\Header\Rule\ListedValues; +use Yiisoft\Http\Header\Internal\BaseHeaderValue; /** * @link https://tools.ietf.org/html/rfc7231#section-7.4.1 */ -final class Allow extends BaseHeaderValue implements ListedValues +final class Allow extends BaseHeaderValue { public const NAME = 'Allow'; + + protected const PARSING_LIST = true; } diff --git a/src/Header/Value/BaseHeaderValue.php b/src/Header/Value/BaseHeaderValue.php deleted file mode 100644 index a273ba8..0000000 --- a/src/Header/Value/BaseHeaderValue.php +++ /dev/null @@ -1,149 +0,0 @@ - - */ - private array $params = []; - /** - * @see WithQualityParam - * @var string - */ - private string $quality = '1'; - protected ?Exception $error = null; - protected const HTTP_DATETIME_FORMAT = 'D, d M Y H:i:s \\G\\M\\T'; - - public function __construct(string $value = '') - { - $this->setValue($value); - } - public function __toString(): string - { - $params = []; - if ($this instanceof WithParams) { - foreach ($this->getParams() as $key => $value) { - if ($key === 'q' && $this instanceof WithQualityParam) { - if ($value === '1') { - continue; - } - } - $escaped = $this->encodeQuotedString($value); - $quoted = $value === '' - || strlen($escaped) !== strlen($value) - || preg_match('/[\\s,;()\\/:<=>?@\\[\\\\\\]{}]/', $value) === 1; - $params[] = $key . '=' . ($quoted ? "\"{$escaped}\"" : $value); - } - } - return $this->value === '' ? implode(';', $params) : implode(';', [$this->value, ...$params]); - } - - public static function createHeader(): Header - { - $class = is_subclass_of(static::class, WithQualityParam::class) ? SortableHeader::class : Header::class; - return new $class(static::class); - } - - public function withValue(string $value): self - { - $clone = clone $this; - $clone->setValue($value); - return $clone; - } - public function getValue(): string - { - return $this->value; - } - - /** - * It makes sense to use only for HeaderValues that implement the WithParams interface - * @param array $params - * @return $this - */ - public function withParams(array $params): self - { - $clone = clone $this; - $clone->setParams($params); - return $clone; - } - public function getParams(): array - { - $result = $this->params; - if ($this instanceof WithQualityParam) { - $result['q'] = $this->quality; - } - return $result; - } - public function getQuality(): string - { - return $this->quality; - } - - /** - * @param Exception|null $error - * @return $this - */ - public function withError(?Exception $error): self - { - $clone = clone $this; - $clone->error = $error; - return $clone; - } - public function hasError(): bool - { - return $this->error !== null; - } - public function getError(): ?Exception - { - return $this->error; - } - protected function setQuality(string $q): bool - { - if (preg_match('/^0(?:\\.\\d{1,3})?$|^1(?:\\.0{1,3})?$/', $q) !== 1) { - return false; - } - $this->quality = rtrim($q, '0.') ?: '0'; - return true; - } - protected function setValue(string $value): void - { - $this->value = $value; - } - protected function setParams(array $params): void - { - $this->params = []; - foreach ($params as $key => $value) { - # todo decide: what about numeric keys? - $key = strtolower($key); - if (!key_exists($key, $this->params)) { - $this->params[$key] = $value; - } - } - if ($this instanceof WithQualityParam) { - if (key_exists('q', $this->params)) { - $this->setQuality($this->params['q']); - unset($this->params['q']); - } else { - $this->setQuality('1'); - } - } - } - protected function encodeQuotedString(string $string): string - { - return preg_replace('/([\\\\"])/', '\\\\$1', $string); - } -} diff --git a/src/Header/Value/Cache/Age.php b/src/Header/Value/Cache/Age.php index 28a701f..c6e0ffe 100644 --- a/src/Header/Value/Cache/Age.php +++ b/src/Header/Value/Cache/Age.php @@ -4,9 +4,8 @@ namespace Yiisoft\Http\Header\Value\Cache; -use InvalidArgumentException; use Yiisoft\Http\Header\ParsingException; -use Yiisoft\Http\Header\Value\BaseHeaderValue; +use Yiisoft\Http\Header\Internal\BaseHeaderValue; /** * @link https://tools.ietf.org/html/rfc7234#section-5.1 diff --git a/src/Header/Value/Cache/CacheControl.php b/src/Header/Value/Cache/CacheControl.php index 5b8d505..20ea520 100644 --- a/src/Header/Value/Cache/CacheControl.php +++ b/src/Header/Value/Cache/CacheControl.php @@ -4,16 +4,12 @@ namespace Yiisoft\Http\Header\Value\Cache; -use InvalidArgumentException; -use Yiisoft\Http\Header\CacheControlHeader; -use Yiisoft\Http\Header\Rule\ListedValues; -use Yiisoft\Http\Header\Rule\WithParams; -use Yiisoft\Http\Header\Value\BaseHeaderValue; +use Yiisoft\Http\Header\Internal\DirectivesHeaderValue; /** * @link https://tools.ietf.org/html/rfc7234#section-5.2 */ -class CacheControl extends BaseHeaderValue implements ListedValues, WithParams +class CacheControl extends DirectivesHeaderValue { public const NAME = 'Cache-Control'; @@ -32,180 +28,25 @@ class CacheControl extends BaseHeaderValue implements ListedValues, WithParams public const S_MAXAGE = 's-maxage'; /** - * Request Directives + * Request and Response Directives: * @link https://tools.ietf.org/html/rfc7234#section-5.2.1 + * @link https://tools.ietf.org/html/rfc7234#section-5.2.2 */ - public const REQUEST_DIRECTIVES = [ - self::MAX_AGE => self::ARG_DELTA_SECONDS, + public const DIRECTIVES = [ + // Request Directives self::MAX_STALE => self::ARG_DELTA_SECONDS, self::MIN_FRESH => self::ARG_DELTA_SECONDS, - self::NO_CACHE => self::ARG_EMPTY, - self::NO_STORE => self::ARG_EMPTY, - self::NO_TRANSFORM => self::ARG_EMPTY, self::ONLY_IF_CACHED => self::ARG_EMPTY, - ]; - /** - * Response Directives - * @link https://tools.ietf.org/html/rfc7234#section-5.2.2 - */ - public const RESPONSE_DIRECTIVES = [ + // Response Directives self::MUST_REVALIDATE => self::ARG_EMPTY, - self::NO_CACHE => self::ARG_HEADERS_LIST | self::ARG_EMPTY, - self::NO_STORE => self::ARG_EMPTY, - self::NO_TRANSFORM => self::ARG_EMPTY, self::PUBLIC => self::ARG_EMPTY, self::PRIVATE => self::ARG_HEADERS_LIST | self::ARG_EMPTY, self::PROXY_REVALIDATE => self::ARG_EMPTY, - self::MAX_AGE => self::ARG_DELTA_SECONDS, self::S_MAXAGE => self::ARG_DELTA_SECONDS, + // Both + self::NO_CACHE => self::ARG_HEADERS_LIST | self::ARG_EMPTY, + self::NO_STORE => self::ARG_EMPTY, + self::NO_TRANSFORM => self::ARG_EMPTY, + self::MAX_AGE => self::ARG_DELTA_SECONDS, ]; - - protected const ARG_EMPTY = 1; - protected const ARG_DELTA_SECONDS = 2; - protected const ARG_HEADERS_LIST = 4; - protected const ARG_CUSTOM = 8; - - protected ?string $directive = null; - protected ?string $argument = null; - protected int $argumentType = self::ARG_EMPTY; - - final public function __toString(): string - { - if ($this->directive === null) { - return ''; - } - if ($this->argument === null) { - return $this->directive; - } - if ($this->argumentType === self::ARG_HEADERS_LIST) { - return "{$this->directive}=\"{$this->argument}\""; - } - if ($this->argumentType === self::ARG_CUSTOM) { - $argument = $this->encodeQuotedString($this->argument); - if ( - $argument === '' - || strlen($argument) !== strlen($this->argument) - || preg_match('/[^a-z0-9_]/i', $argument) === 1 - ) { - $argument = '"' . $argument . '"'; - } - return "{$this->directive}={$argument}"; - } - return "{$this->directive}={$this->argument}"; - } - - final public static function createHeader(): CacheControlHeader - { - return new CacheControlHeader(static::class); - } - - /** - * @return string|null Returns null if the directive is not defined or cannot be parsed without error - */ - final public function getDirective(): ?string - { - return $this->directive; - } - - final public function hasArgument(): bool - { - return $this->argument !== null; - } - - final public function getArgument(): ?string - { - return $this->argument; - } - - final public function getArgumentList(): array - { - return $this->argument === null ? [] : explode(',', $this->argument); - } - - /** - * @param string $directive - * @param string|null $argument - * @return $this - * @throws InvalidArgumentException - */ - final public function withDirective(string $directive, string $argument = null): self - { - $clone = clone $this; - $clone->setDirective($directive, $argument, true); - return $clone; - } - - final protected function setValue(string $value): void - { - if ($value !== '') { - $this->setDirective($value); - } - parent::setValue($value); - } - - final protected function setParams(array $params): void - { - $key = array_key_first($params); - if ($key !== null) { - $this->setDirective($key, $params[$key]); - } - parent::setParams($params); - } - - final private function setDirective(string $value, string $argument = null, bool $trowError = false): bool - { - $name = strtolower($value); - - $fromRequestType = static::REQUEST_DIRECTIVES[$name] ?? null; - $fromResponseType = static::RESPONSE_DIRECTIVES[$name] ?? null; - - $directiveExists = $fromRequestType !== null || $fromResponseType; - $argumentType = $directiveExists ? $fromRequestType | $fromResponseType : self::ARG_CUSTOM; - - $writeProperties = function (\Exception $err = null, string $arg = null, int $type = self::ARG_EMPTY) use ( - $name, - $trowError - ) { - if ($trowError && $err !== null) { - throw $err; - } - $this->directive = $name; - $this->argument = $arg; - $this->argumentType = $type; - $this->error = $err; - return $this->error === null; - }; - - if ($argument === null && ($argumentType & self::ARG_EMPTY) === self::ARG_EMPTY) { - return $writeProperties(); - } elseif ($argumentType === self::ARG_EMPTY) { - if ($argument !== null) { - return $writeProperties(new InvalidArgumentException("{$name} directive should not have an argument")); - } - return $writeProperties(); - } elseif (($argumentType & self::ARG_HEADERS_LIST) === self::ARG_HEADERS_LIST) { - # Validate headers list - $argument = $argument === null ? null : trim($argument); - if ($argument === null || preg_match('/^[\\w\\-]+(?:(?:\\s*,\\s*)[\\w\\-]+)*$/', $argument) !== 1) { - return $writeProperties( - new InvalidArgumentException( - "{$name} directive should have an argument as a comma separated headers name list" - ) - ); - } - return $writeProperties(null, $argument, self::ARG_HEADERS_LIST); - } elseif (($argumentType & self::ARG_DELTA_SECONDS) === self::ARG_DELTA_SECONDS) { - $this->argumentType = self::ARG_DELTA_SECONDS; - // Validate number - if ($argument === null || preg_match('/^\\d+$/', $argument) !== 1) { - return $writeProperties( - new InvalidArgumentException("{$name} directive should have numeric argument"), - '0', - self::ARG_DELTA_SECONDS - ); - } - return $writeProperties(null, $argument, self::ARG_DELTA_SECONDS); - } - return $writeProperties(null, $argument, $argumentType); - } } diff --git a/src/Header/Value/Cache/Warning.php b/src/Header/Value/Cache/Warning.php index 2f16d8d..8ed0ca3 100644 --- a/src/Header/Value/Cache/Warning.php +++ b/src/Header/Value/Cache/Warning.php @@ -6,9 +6,8 @@ use DateTimeImmutable; use DateTimeInterface; -use InvalidArgumentException; use Yiisoft\Http\Header\ParsingException; -use Yiisoft\Http\Header\Value\BaseHeaderValue; +use Yiisoft\Http\Header\Internal\BaseHeaderValue; /** * @link https://tools.ietf.org/html/rfc7234#section-5.5 @@ -137,11 +136,7 @@ protected function setValue(string $value): void $this->text = preg_replace("/\\\\(.)/", '$1', $matches['text']); // date if (isset($matches['date'])) { - if (preg_match( - '/^\\w{3,}, [0-3]?\\d[ \\-]\\w{3}[ \\-]\\d+ [0-2]\\d:[0-5]\\d:[0-5]\\d \\w+|' - . '\\w{3} \\w{3} [0-3]?\\d [0-2]\\d:[0-5]\\d:[0-5]\\d \\d+$/i', - $matches['date'] - ) !== 1) { + if (!$this->validateDateTime($matches['date'])) { throw new ParsingException($matches['date'], 0, 'Incorrect datetime format.'); } $this->date = new DateTimeImmutable($matches['date']); diff --git a/src/Header/Value/Condition/ETag.php b/src/Header/Value/Condition/ETag.php index d31bc6e..06fd9e3 100644 --- a/src/Header/Value/Condition/ETag.php +++ b/src/Header/Value/Condition/ETag.php @@ -5,7 +5,7 @@ namespace Yiisoft\Http\Header\Value\Condition; use Yiisoft\Http\Header\ParsingException; -use Yiisoft\Http\Header\Value\BaseHeaderValue; +use Yiisoft\Http\Header\Internal\BaseHeaderValue; /** * @link https://tools.ietf.org/html/rfc7232#section-2.3 diff --git a/src/Header/Value/Connection.php b/src/Header/Value/Connection.php index 59e1abb..76fa0d2 100644 --- a/src/Header/Value/Connection.php +++ b/src/Header/Value/Connection.php @@ -4,9 +4,11 @@ namespace Yiisoft\Http\Header\Value; -use Yiisoft\Http\Header\Rule\ListedValues; +use Yiisoft\Http\Header\Internal\BaseHeaderValue; -class Connection extends BaseHeaderValue implements ListedValues +class Connection extends BaseHeaderValue { public const NAME = 'Connection'; + + protected const PARSING_LIST = true; } diff --git a/src/Header/Value/Date.php b/src/Header/Value/Date.php index cfac42c..dda2ad6 100644 --- a/src/Header/Value/Date.php +++ b/src/Header/Value/Date.php @@ -8,6 +8,7 @@ use DateTimeInterface; use Exception; use Yiisoft\Http\Header\DateHeader; +use Yiisoft\Http\Header\Internal\BaseHeaderValue; class Date extends BaseHeaderValue { @@ -52,7 +53,11 @@ final public function withValueFromDatetime(DateTimeInterface $date): self final protected function setValue(string $value): void { try { + if ($value !== '' && !$this->validateDateTime($value)) { + throw new \InvalidArgumentException('Invalid date format.'); + } $this->datetimeObject = new DateTimeImmutable($value); + $this->error = null; } catch (Exception $e) { $this->datetimeObject = null; $this->error = $e; diff --git a/src/Header/Value/DirectiveValue.php b/src/Header/Value/DirectiveValue.php new file mode 100644 index 0000000..ceee471 --- /dev/null +++ b/src/Header/Value/DirectiveValue.php @@ -0,0 +1,16 @@ +withValue('test'); + + // the default value of the original object has not changed + $this->assertSame('', $value->getValue()); + // changes applied + $this->assertSame('test', $clone->getValue()); + // immutability + $this->assertSame(get_class($value), get_class($clone)); + $this->assertNotSame($value, $clone); + } + public function testWithErrorImmutability() + { + $value = new DummyHeaderValue(); + + $clone = $value->withError(null); + + $this->assertSame(get_class($value), get_class($clone)); + $this->assertNotSame($value, $clone); + } + public function testCreateHeader() + { + $header = DummyHeaderValue::createHeader(); + + $this->assertInstanceOf(Header::class, $header); + $this->assertSame(DummyHeaderValue::NAME, $header->getName()); + } +} diff --git a/tests/Header/Internal/DirectivesHeaderValueTest.php b/tests/Header/Internal/DirectivesHeaderValueTest.php new file mode 100644 index 0000000..25a7714 --- /dev/null +++ b/tests/Header/Internal/DirectivesHeaderValueTest.php @@ -0,0 +1,181 @@ +withDirective('test'); + + // the default value of the original object has not changed + $this->assertSame('', $origin->getDirective()); + $this->assertNull($origin->getArgument()); + // changes applied + $this->assertSame('test', $clone->getDirective()); + $this->assertNull($clone->getArgument()); + // immutability + $this->assertSame(get_class($origin), get_class($clone)); + $this->assertNotSame($origin, $clone); + } + public function testWithDirectiveArg() + { + $value = (new DirectivesHeaderValue()) + ->withDirective('foo', 'bar'); + + + $this->assertFalse($value->hasError()); + $this->assertSame('foo=bar', (string)$value); + $this->assertSame('foo', $value->getDirective()); + $this->assertSame('bar', $value->getArgument()); + } + public function testCreateHeader() + { + $header = DirectivesHeaderValue::createHeader(); + + $this->assertInstanceOf(DirectiveHeader::class, $header); + $this->assertSame(DirectivesHeaderValue::NAME, $header->getName()); + } + public function testToStringWithoutArgument() + { + $headerValue = (new DirectivesHeaderValue())->withDirective(DirectivesHeaderValue::EMPTY); + + $this->assertSame('empty', (string)$headerValue); + } + public function testToStringNumericArgument() + { + $headerValue = (new DirectivesHeaderValue())->withDirective(DirectivesHeaderValue::NUMERIC, '1560'); + + $this->assertSame('numeric=1560', (string)$headerValue); + } + public function testToStringEmptyListedArgument() + { + $headerValue = (new DirectivesHeaderValue())->withDirective(DirectivesHeaderValue::LIST_OR_EMPTY); + + $this->assertSame('list-or-empty', (string)$headerValue); + } + public function testToStringListedArgument() + { + $headerValue = (new DirectivesHeaderValue())->withDirective(DirectivesHeaderValue::HEADER_LIST, 'etag'); + + $this->assertSame('header-list="etag"', (string)$headerValue); + } + + public function testCustomDirective() + { + $headerValue = (new DirectivesHeaderValue())->withDirective('custom-directive-name', 'custom_value'); + + $this->assertSame('custom-directive-name=custom_value', (string)$headerValue); + } + public function testToStringEmptyDirective() + { + $headerValue = (new DirectivesHeaderValue()); + + $this->assertSame('', (string)$headerValue); + } + public function testWithValue() + { + $headerValue = (new DirectivesHeaderValue())->withValue('test-value-to-directive'); + + $this->assertSame('test-value-to-directive', $headerValue->getValue()); + $this->assertSame('test-value-to-directive', $headerValue->getDirective()); + $this->assertSame('test-value-to-directive', (string)$headerValue); + $this->assertFalse($headerValue->hasError()); + } + + public function withDirectiveDataProvider(): array + { + return [ + 'nullable-argument' => ['empty', null, 'empty', null, 'empty'], + 'case-1' => ['emPTy', null, 'empty', null, 'empty'], + 'case-2' => ['hEAder-LIst', 'eTaG', 'header-list', 'eTaG', 'header-list="eTaG"'], + 'case-3' => ['test-DIrective', 'CaSe', 'test-directive', 'CaSe', 'test-directive=CaSe'], + 'case-4' => ['test-DIrective', 'Ca Se', 'test-directive', 'Ca Se', 'test-directive="Ca Se"'], + 'ext-directive-case' => ['Test-directive', null, 'test-directive', null, 'test-directive'], + 'ext-directive-argument' => ['test-directive', 'null', 'test-directive', 'null', 'test-directive=null'], + 'ext-directive-arg-char-0' => ['test-directive', '', 'test-directive', '', 'test-directive=""'], + 'ext-directive-arg-char-1' => ['test-directive', ' ', 'test-directive', ' ', 'test-directive=" "'], + 'ext-directive-arg-char-2' => ['test-directive', '"', 'test-directive', '"', 'test-directive="\\""'], + 'ext-directive-arg-char-3' => ['test-directive', '\\', 'test-directive', '\\', 'test-directive="\\\\"'], + 'ext-directive-arg-char-4' => ['test-directive', '-', 'test-directive', '-', 'test-directive="-"'], + 'double-type-1' => ['list-or-empty', null, 'list-or-empty', null, 'list-or-empty'], + 'double-type-2' => [ + 'list-or-empty', + 'Content-Length, ETag', + 'list-or-empty', + 'Content-Length, ETag', + 'list-or-empty="Content-Length, ETag"', + ], + 'numeric-arg-0' => ['numeric', '0', 'numeric', '0', 'numeric=0'], + 'numeric-arg-1' => ['numeric', '123', 'numeric', '123', 'numeric=123'], + 'numeric-arg-2' => ['numeric', '0123', 'numeric', '0123', 'numeric=0123'], + 'header-list-arg-1' => ['header-list', 'test', 'header-list', 'test', 'header-list="test"'], + 'header-list-arg-2' => [ + 'header-list', + 'test , test', + 'header-list', + 'test , test', + 'header-list="test , test"', + ], + 'header-list-arg-3' => [ + 'header-list', + ' test , test ', + 'header-list', + 'test , test', + 'header-list="test , test"', + ], + ]; + } + /** + * @dataProvider withDirectiveDataProvider + */ + public function testWithDirectiveCorrect( + string $inputDirective, + ?string $imputArgument, + ?string $directive, + ?string $argument, + string $output + ) { + $headerValue = (new DirectivesHeaderValue())->withDirective($inputDirective, $imputArgument); + + $this->assertFalse($headerValue->hasError()); + $this->assertSame($directive, $headerValue->getDirective()); + $this->assertSame($argument, $headerValue->getArgument()); + $this->assertSame($output, (string)$headerValue); + } + + public function withDirectiveIncorrectDataProvider(): array + { + return [ + 'header-list-arg-0' => ['header-list', '!'], + 'header-list-arg-1' => ['header-list', ''], + 'header-list-arg-2' => ['header-list', ' '], + 'header-list-arg-3' => ['header-list', ',,'], + 'header-list-arg-4' => ['header-list', 'test , , test'], + 'header-list-arg-5' => ['header-list', 'ETag,'], + 'numeric-arg-0' => ['numeric', null], + 'numeric-arg-1' => ['numeric', 'test'], + 'numeric-arg-2' => ['numeric', '123test'], + 'numeric-arg-3' => ['numeric', '0x123'], + 'numeric-arg-4' => ['numeric', '-123'], + 'numeric-arg-5' => ['numeric', '12 34'], + 'argument-should-be-empty' => ['empty', 'null'], + ]; + } + /** + * @dataProvider withDirectiveIncorrectDataProvider + */ + public function testWithDirectiveIncorrectCases( + string $inputDirective, + ?string $imputArgument + ) { + $this->expectException(\InvalidArgumentException::class); + (new DirectivesHeaderValue())->withDirective($inputDirective, $imputArgument); + } +} diff --git a/tests/Header/Value/BaseHeaderValueTest.php b/tests/Header/Internal/WithParamsHeaderValueTest.php similarity index 51% rename from tests/Header/Value/BaseHeaderValueTest.php rename to tests/Header/Internal/WithParamsHeaderValueTest.php index 2244c50..182c9db 100644 --- a/tests/Header/Value/BaseHeaderValueTest.php +++ b/tests/Header/Internal/WithParamsHeaderValueTest.php @@ -1,28 +1,28 @@ withValue('test'); + $this->assertInstanceOf(Header::class, $header); + $this->assertSame(WithParamsHeaderValue::NAME, $header->getName()); + } + public function testCreateHeaderQ() + { + $header = SortedHeaderValue::createHeader(); - // the default value of the original object has not changed - $this->assertSame('', $value->getValue()); - // changes applied - $this->assertSame('test', $clone->getValue()); - // immutability - $this->assertSame(get_class($value), get_class($clone)); - $this->assertNotSame($value, $clone); + $this->assertInstanceOf(SortableHeader::class, $header); + $this->assertSame(SortedHeaderValue::NAME, $header->getName()); } public function testWithParamsImmutability() { @@ -33,33 +33,6 @@ public function testWithParamsImmutability() $this->assertSame(get_class($value), get_class($clone)); $this->assertNotSame($value, $clone); } - public function testWithErrorImmutability() - { - $value = new DummyHeaderValue(); - - $clone = $value->withError(null); - - $this->assertSame(get_class($value), get_class($clone)); - $this->assertNotSame($value, $clone); - } - public function testCreateHeader() - { - $header = DummyHeaderValue::createHeader(); - - $this->assertInstanceOf(Header::class, $header); - $this->assertSame(DummyHeaderValue::NAME, $header->getName()); - } - public function testBehaviorWithoutParams() - { - $params = ['q' => '0.4', 'param' => 'test', 'foo' => 'bar']; - $value = (new DummyHeaderValue('foo')) - ->withParams($params); - - $this->assertFalse($value->hasError()); - $this->assertSame('foo', (string)$value); - $this->assertSame('1', $value->getQuality()); - $this->assertEquals($params, $value->getParams()); - } public function testBehaviorWithParams() { $params = ['q' => '0.4', 'param' => 'test', 'foo' => 'bar']; diff --git a/tests/Header/Value/Cache/CacheControlTest.php b/tests/Header/Value/Cache/CacheControlTest.php deleted file mode 100644 index 493aa36..0000000 --- a/tests/Header/Value/Cache/CacheControlTest.php +++ /dev/null @@ -1,186 +0,0 @@ -assertInstanceOf(CacheControlHeader::class, $header); - } - public function testWithDirectiveImmutability() - { - $origin = new CacheControl(); - - $clone = $origin->withDirective('no-cache', null); - - // the default values of the original object have not changed - $this->assertNull($origin->getDirective()); - $this->assertNull($origin->getArgument()); - // changes applied - $this->assertSame('no-cache', $clone->getDirective()); - $this->assertNull($clone->getArgument()); - // immutability - $this->assertSame(get_class($origin), get_class($clone)); - $this->assertNotSame($origin, $clone); - } - public function testToStringWithoutArgument() - { - $headerValue = (new CacheControl())->withDirective(CacheControl::NO_CACHE); - - $this->assertSame('no-cache', (string)$headerValue); - } - public function testToStringNumericArgument() - { - $headerValue = (new CacheControl())->withDirective(CacheControl::MAX_AGE, '1560'); - - $this->assertSame('max-age=1560', (string)$headerValue); - } - public function testToStringEmptyListedArgument() - { - $headerValue = (new CacheControl())->withDirective(CacheControl::PRIVATE); - - $this->assertSame('private', (string)$headerValue); - } - public function testToStringListedArgument() - { - $headerValue = (new CacheControl())->withDirective(CacheControl::PRIVATE, 'etag'); - - $this->assertSame('private="etag"', (string)$headerValue); - } - public function testCustomDirective() - { - $headerValue = (new CacheControl())->withDirective('custom-name', 'custom_value'); - - $this->assertSame('custom-name=custom_value', (string)$headerValue); - } - public function testToStringEmptyDirective() - { - $headerValue = (new CacheControl()); - - $this->assertSame('', (string)$headerValue); - } - public function testWithValue() - { - $headerValue = (new CacheControl())->withValue('test-directive'); - - $this->assertSame('test-directive', $headerValue->getDirective()); - $this->assertSame('test-directive', (string)$headerValue); - $this->assertFalse($headerValue->hasError()); - } - public function testWithParams() - { - $headerValue = (new CacheControl())->withParams(['test-name' => 'test-value']); - - $this->assertSame('test-name', $headerValue->getDirective()); - $this->assertSame('test-value', $headerValue->getArgument()); - $this->assertSame('test-name="test-value"', (string)$headerValue); - $this->assertFalse($headerValue->hasError()); - } - public function testWithEmptyParams() - { - $headerValue = (new CacheControl())->withParams([]); - - $this->assertSame(null, $headerValue->getDirective()); - $this->assertSame(null, $headerValue->getArgument()); - $this->assertSame('', (string)$headerValue); - $this->assertFalse($headerValue->hasError()); - } - public function testWithMultipleParams() - { - $headerValue = (new CacheControl())->withParams(['test-name' => 'test-value', 'foo' => 'bar']); - - $this->assertSame('test-name', $headerValue->getDirective()); - $this->assertSame('test-value', $headerValue->getArgument()); - $this->assertSame('test-name="test-value"', (string)$headerValue); - $this->assertFalse($headerValue->hasError()); - } - public function testWithValueRewritesByWithParams() - { - $headerValue = (new CacheControl())->withValue('test-directive')->withParams(['test-name' => 'test-value']); - - $this->assertSame('test-name', $headerValue->getDirective()); - $this->assertSame('test-value', $headerValue->getArgument()); - $this->assertSame('test-name="test-value"', (string)$headerValue); - $this->assertFalse($headerValue->hasError()); - } - - public function withDirectiveDataProvider(): array - { - return [ - 'nullable-argument' => ['no-store', null, 'no-store', null, 'no-store'], - 'case-1' => ['No-CAche', null, 'no-cache', null, 'no-cache'], - 'case-2' => ['No-CAche', 'eTaG', 'no-cache', 'eTaG', 'no-cache="eTaG"'], - 'case-3' => ['test-DIrective', 'CaSe', 'test-directive', 'CaSe', 'test-directive=CaSe'], - 'case-4' => ['test-DIrective', 'Ca Se', 'test-directive', 'Ca Se', 'test-directive="Ca Se"'], - 'ext-directive-case' => ['Test-directive', null, 'test-directive', null, 'test-directive'], - 'ext-directive-argument' => ['test-directive', 'null', 'test-directive', 'null', 'test-directive=null'], - 'ext-directive-arg-char-0' => ['test-directive', '', 'test-directive', '', 'test-directive=""'], - 'ext-directive-arg-char-1' => ['test-directive', ' ', 'test-directive', ' ', 'test-directive=" "'], - 'ext-directive-arg-char-2' => ['test-directive', '"', 'test-directive', '"', 'test-directive="\\""'], - 'ext-directive-arg-char-3' => ['test-directive', '\\', 'test-directive', '\\', 'test-directive="\\\\"'], - 'ext-directive-arg-char-4' => ['test-directive', '-', 'test-directive', '-', 'test-directive="-"'], - 'double-type-1' => ['no-cache', null, 'no-cache', null, 'no-cache'], - 'double-type-2' => ['no-cache', 'Content-Length, ETag', 'no-cache', 'Content-Length, ETag', 'no-cache="Content-Length, ETag"'], - 'numeric-arg-0' => ['max-age', '0', 'max-age', '0', 'max-age=0'], - 'numeric-arg-1' => ['max-age', '123', 'max-age', '123', 'max-age=123'], - 'numeric-arg-2' => ['max-age', '0123', 'max-age', '0123', 'max-age=0123'], - 'header-list-arg-1' => ['private', 'test', 'private', 'test', 'private="test"'], - 'header-list-arg-2' => ['private', 'test , test', 'private', 'test , test', 'private="test , test"'], - 'header-list-arg-3' => ['private', ' test , test ', 'private', 'test , test', 'private="test , test"'], - ]; - } - /** - * @dataProvider withDirectiveDataProvider - */ - public function testWithDirectiveCorrect( - string $inputDirective, - ?string $imputArgument, - ?string $directive, - ?string $argument, - string $output - ) { - $headerValue = (new CacheControl())->withDirective($inputDirective, $imputArgument); - - $this->assertFalse($headerValue->hasError()); - $this->assertSame($directive, $headerValue->getDirective()); - $this->assertSame($argument, $headerValue->getArgument()); - $this->assertSame($output, (string)$headerValue); - } - - public function withDirectiveIncorrectDataProvider(): array - { - return [ - 'header-list-arg-0' => ['private', '!'], - 'header-list-arg-1' => ['private', ''], - 'header-list-arg-2' => ['private', ' '], - 'header-list-arg-3' => ['private', ',,'], - 'header-list-arg-4' => ['private', 'test , , test'], - 'header-list-arg-5' => ['private', 'ETag,'], - 'numeric-arg-0' => ['max-age', null], - 'numeric-arg-1' => ['max-age', 'test'], - 'numeric-arg-2' => ['max-age', '123test'], - 'numeric-arg-3' => ['max-age', '0x123'], - 'numeric-arg-4' => ['max-age', '-123'], - 'numeric-arg-5' => ['max-age', '12 34'], - 'argument-should-be-empty' => ['No-Store', 'null'], - ]; - } - /** - * @dataProvider withDirectiveIncorrectDataProvider - */ - public function testWithDirectiveIncorrectCases( - string $inputDirective, - ?string $imputArgument - ) { - $this->expectException(\InvalidArgumentException::class); - (new CacheControl())->withDirective($inputDirective, $imputArgument); - } -} diff --git a/tests/Header/Value/DateTest.php b/tests/Header/Value/DateTest.php index f4c44f5..cb66329 100644 --- a/tests/Header/Value/DateTest.php +++ b/tests/Header/Value/DateTest.php @@ -24,10 +24,10 @@ public function testWithValueFromDatetimeImmutability() } public function testToString() { - $dateStr = '2020-01-01 00:00:00 +0000'; + $dateStr = 'Friday, 04-Jul-08 08:42:36 GMT'; $value = (new Date())->withValue($dateStr); - $this->assertSame('Wed, 01 Jan 2020 00:00:00 GMT', (string)$value); + $this->assertSame('Fri, 04 Jul 2008 08:42:36 GMT', (string)$value); } public function testToStringIncorrect() { @@ -39,11 +39,27 @@ public function testToStringIncorrect() } public function testGetDatetimeValue() { - $dateStr = '2020-01-01 00:00:00 +0000'; + $dateStr = 'Fri Jul 4 08:42:36 2008'; $value = new Date($dateStr); $this->assertInstanceOf(\DateTimeImmutable::class, $value->getDatetimeValue()); } + public function testRFC7231() + { + $dateStr = 'Fri, 04 Jul 2008 08:42:36 GMT'; + $value = new Date($dateStr); + + $this->assertInstanceOf(\DateTimeImmutable::class, $value->getDatetimeValue()); + $this->assertSame('Fri, 04 Jul 2008 08:42:36 GMT', (string)$value); + } + public function testRFC850() + { + $dateStr = 'Friday, 04-Jul-08 08:42:36 GMT'; + $value = new Date($dateStr); + + $this->assertInstanceOf(\DateTimeImmutable::class, $value->getDatetimeValue()); + $this->assertSame('Fri, 04 Jul 2008 08:42:36 GMT', (string)$value); + } public function testConstructWithDatetimeInterface() { $date = new \DateTime(); @@ -63,4 +79,12 @@ public function testGetDatetimeValueFromIncorrect() $this->assertNull($value->getDatetimeValue()); $this->assertSame('not a date', (string)$value); } + public function testGetDatetimeValueFromIncorrectForHttp() + { + $value = (new Date())->withValue('2008-07-12 10:15'); + + $this->assertTrue($value->hasError()); + $this->assertNull($value->getDatetimeValue()); + $this->assertSame('2008-07-12 10:15', (string)$value); + } } diff --git a/tests/Header/Value/Stub/DirectivesHeaderValue.php b/tests/Header/Value/Stub/DirectivesHeaderValue.php new file mode 100644 index 0000000..8842483 --- /dev/null +++ b/tests/Header/Value/Stub/DirectivesHeaderValue.php @@ -0,0 +1,22 @@ + self::ARG_DELTA_SECONDS, + self::EMPTY => self::ARG_EMPTY, + self::HEADER_LIST => self::ARG_HEADERS_LIST, + self::LIST_OR_EMPTY => self::ARG_HEADERS_LIST | self::ARG_EMPTY, + ]; + + protected const PARSING_LIST = true; +} diff --git a/tests/Header/Value/Stub/DummyHeaderValue.php b/tests/Header/Value/Stub/DummyHeaderValue.php index 6f56a92..97b29f9 100644 --- a/tests/Header/Value/Stub/DummyHeaderValue.php +++ b/tests/Header/Value/Stub/DummyHeaderValue.php @@ -2,7 +2,7 @@ namespace Yiisoft\Http\Tests\Header\Value\Stub; -use Yiisoft\Http\Header\Value\BaseHeaderValue; +use Yiisoft\Http\Header\Internal\BaseHeaderValue; final class DummyHeaderValue extends BaseHeaderValue { diff --git a/tests/Header/Value/Stub/ListedValuesHeaderValue.php b/tests/Header/Value/Stub/ListedValuesHeaderValue.php index 6fad554..84a724c 100644 --- a/tests/Header/Value/Stub/ListedValuesHeaderValue.php +++ b/tests/Header/Value/Stub/ListedValuesHeaderValue.php @@ -2,10 +2,11 @@ namespace Yiisoft\Http\Tests\Header\Value\Stub; -use Yiisoft\Http\Header\Rule\ListedValues; -use Yiisoft\Http\Header\Value\BaseHeaderValue; +use Yiisoft\Http\Header\Internal\BaseHeaderValue; -final class ListedValuesHeaderValue extends BaseHeaderValue implements ListedValues +final class ListedValuesHeaderValue extends BaseHeaderValue { public const NAME = 'Test-Listed'; + + protected const PARSING_LIST = true; } diff --git a/tests/Header/Value/Stub/ListedValuesWithParamsHeaderValue.php b/tests/Header/Value/Stub/ListedValuesWithParamsHeaderValue.php index 799c74f..0e0d093 100644 --- a/tests/Header/Value/Stub/ListedValuesWithParamsHeaderValue.php +++ b/tests/Header/Value/Stub/ListedValuesWithParamsHeaderValue.php @@ -2,11 +2,11 @@ namespace Yiisoft\Http\Tests\Header\Value\Stub; -use Yiisoft\Http\Header\Rule\ListedValues; -use Yiisoft\Http\Header\Rule\WithParams; -use Yiisoft\Http\Header\Value\BaseHeaderValue; +use Yiisoft\Http\Header\Internal\WithParamsHeaderValue; -final class ListedValuesWithParamsHeaderValue extends BaseHeaderValue implements ListedValues, WithParams +final class ListedValuesWithParamsHeaderValue extends WithParamsHeaderValue { public const NAME = 'Test-Listed-Params'; + + protected const PARSING_LIST = true; } diff --git a/tests/Header/Value/Stub/SortedHeaderValue.php b/tests/Header/Value/Stub/SortedHeaderValue.php index 5b716ac..e0a88e4 100644 --- a/tests/Header/Value/Stub/SortedHeaderValue.php +++ b/tests/Header/Value/Stub/SortedHeaderValue.php @@ -2,11 +2,15 @@ namespace Yiisoft\Http\Tests\Header\Value\Stub; -use Yiisoft\Http\Header\Rule\WithQualityParam; +use Yiisoft\Http\Header\Internal\WithParamsHeaderValue; -final class SortedHeaderValue extends \Yiisoft\Http\Header\Value\BaseHeaderValue implements WithQualityParam +final class SortedHeaderValue extends WithParamsHeaderValue { public const NAME = 'Test-Quality'; + + protected const PARSING_LIST = true; + protected const PARSING_Q_PARAM = true; + public function setQuality(string $q): bool { return parent::setQuality($q); diff --git a/tests/Header/Value/Stub/WithParamsHeaderValue.php b/tests/Header/Value/Stub/WithParamsHeaderValue.php index 1844e55..45d9115 100644 --- a/tests/Header/Value/Stub/WithParamsHeaderValue.php +++ b/tests/Header/Value/Stub/WithParamsHeaderValue.php @@ -2,10 +2,7 @@ namespace Yiisoft\Http\Tests\Header\Value\Stub; -use Yiisoft\Http\Header\Rule\WithParams; -use Yiisoft\Http\Header\Value\BaseHeaderValue; - -final class WithParamsHeaderValue extends BaseHeaderValue implements WithParams +final class WithParamsHeaderValue extends \Yiisoft\Http\Header\Internal\WithParamsHeaderValue { public const NAME = 'Test-Params'; } From 44717d653d68ee4ab68ea66bfd8459111a343136 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 2 Apr 2020 00:33:53 +0300 Subject: [PATCH 20/26] Refactoring: unnamed header values moved to Unnamed namespace; Parser and related parameters moved to separate classes --- docs/ru/http-headers.md | 4 +- src/Header/DirectiveHeader.php | 2 +- src/Header/Header.php | 187 ++---------------- src/Header/Internal/BaseHeaderValue.php | 14 +- src/Header/Parser/HeaderParsingParams.php | 13 ++ src/Header/{ => Parser}/ParsingException.php | 2 +- src/Header/Parser/ValueFieldParser.php | 174 ++++++++++++++++ src/Header/Parser/ValueFieldState.php | 36 ++++ src/Header/SortableHeader.php | 2 +- src/Header/Value/Cache/Age.php | 2 +- src/Header/Value/Cache/Warning.php | 2 +- src/Header/Value/Condition/ETag.php | 2 +- .../Value/{ => Unnamed}/DirectiveValue.php | 2 +- .../Value/{ => Unnamed}/ListedValue.php | 2 +- .../Value/{ => Unnamed}/SimpleValue.php | 2 +- .../Value/{ => Unnamed}/SortedValue.php | 2 +- tests/Header/DirectiveHeaderTest.php | 57 ++++++ tests/Header/HeaderTest.php | 2 +- tests/Header/SortableHeaderTest.php | 4 +- 19 files changed, 315 insertions(+), 196 deletions(-) create mode 100644 src/Header/Parser/HeaderParsingParams.php rename src/Header/{ => Parser}/ParsingException.php (91%) create mode 100644 src/Header/Parser/ValueFieldParser.php create mode 100644 src/Header/Parser/ValueFieldState.php rename src/Header/Value/{ => Unnamed}/DirectiveValue.php (88%) rename src/Header/Value/{ => Unnamed}/ListedValue.php (88%) rename src/Header/Value/{ => Unnamed}/SimpleValue.php (87%) rename src/Header/Value/{ => Unnamed}/SortedValue.php (90%) create mode 100644 tests/Header/DirectiveHeaderTest.php diff --git a/docs/ru/http-headers.md b/docs/ru/http-headers.md index c9458bd..e45efb4 100644 --- a/docs/ru/http-headers.md +++ b/docs/ru/http-headers.md @@ -199,10 +199,10 @@ CacheControl::createHeader()->withValue('max-stale, max-age=test, private="ETag, /** @var \Psr\Http\Message\ResponseInterface $response */ // извлечь из запроса заголовок с перечисляемыми значениями -$listed = \Yiisoft\Http\Header\Value\ListedValue::createHeader('My-List') +$listed = \Yiisoft\Http\Header\Value\Unnamed\ListedValue::createHeader('My-List') ->extract($request); // создать заголовок с перечисляемыми сортируемыми значениями -$sorted = \Yiisoft\Http\Header\Value\SortedValue::createHeader('My-Sorted-List') +$sorted = \Yiisoft\Http\Header\Value\Unnamed\SortedValue::createHeader('My-Sorted-List') ->withValues(['foo', 'bar;q=0.5', 'baz;q=0']); ``` diff --git a/src/Header/DirectiveHeader.php b/src/Header/DirectiveHeader.php index 77ad6d9..d2a60da 100644 --- a/src/Header/DirectiveHeader.php +++ b/src/Header/DirectiveHeader.php @@ -6,7 +6,7 @@ use InvalidArgumentException; use Yiisoft\Http\Header\Internal\DirectivesHeaderValue; -use Yiisoft\Http\Header\Value\DirectiveValue; +use Yiisoft\Http\Header\Value\Unnamed\DirectiveValue; final class DirectiveHeader extends Header { diff --git a/src/Header/Header.php b/src/Header/Header.php index 9f154c6..a1f851f 100644 --- a/src/Header/Header.php +++ b/src/Header/Header.php @@ -6,9 +6,9 @@ use InvalidArgumentException; use Psr\Http\Message\MessageInterface; -use Yiisoft\Http\Header\Internal\DirectivesHeaderValue; -use Yiisoft\Http\Header\Internal\WithParamsHeaderValue; -use Yiisoft\Http\Header\Value\SimpleValue; +use Yiisoft\Http\Header\Parser\HeaderParsingParams; +use Yiisoft\Http\Header\Parser\ValueFieldParser; +use Yiisoft\Http\Header\Value\Unnamed\SimpleValue; use Yiisoft\Http\Header\Internal\BaseHeaderValue; class Header implements \IteratorAggregate, \Countable @@ -19,18 +19,6 @@ class Header implements \IteratorAggregate, \Countable protected array $collection = []; protected const DEFAULT_VALUE_CLASS = SimpleValue::class; - // Parsing params - private bool $listedValues = false; - private bool $withParams = false; - private bool $directives = false; - // Parsing's constants - private const - DELIMITERS = '"(),/:;<=>?@[\\]{}', - READ_NONE = 0, - READ_VALUE = 1, - READ_PARAM_NAME = 2, - READ_PARAM_QUOTED_VALUE = 3, - READ_PARAM_VALUE = 4; /** * @param string $nameOrClass Header name or header value class @@ -53,10 +41,6 @@ public function __construct(string $nameOrClass) $this->headerName = $nameOrClass; $this->headerClass = static::DEFAULT_VALUE_CLASS; } - $parsing = $this->headerClass::getParsingParams(); - $this->listedValues = $parsing['list']; - $this->withParams = $parsing['params']; - $this->directives = is_subclass_of($this->headerClass, DirectivesHeaderValue::class, true); } public function getIterator(): iterable @@ -192,162 +176,15 @@ protected function collect(BaseHeaderValue $value): void } private function parseAndCollect(string $body): void { - if (!$this->listedValues && !$this->withParams && !$this->directives) { - $this->collect(new $this->headerClass(trim($body))); - return; - } - $part = self::READ_VALUE; - $buffer = ''; - $key = ''; - $value = ''; - $params = []; - $error = null; - $parseList = $this->listedValues || $this->directives; - $parseParams = $this->withParams || $this->directives; - $addParam = static function ($key, $value) use (&$params) { - if (!key_exists($key, $params)) { - $params[$key] = $value; - } - }; - $collectHeaderValue = function () use (&$key, &$value, &$buffer, &$params, &$added, &$error) { - /** @var DirectivesHeaderValue|BaseHeaderValue $item */ - $item = new $this->headerClass($value); - if ($this->directives) { - // $item->withDirective($value, ); - } elseif ($this->withParams) { - $item = $item->withParams($params); - } - if ($error !== null) { - $item = $item->withError($error); - } - $this->collect($item); - $key = $buffer = $value = ''; - $params = []; - ++$added; - }; - $added = 0; - try { - /** @link https://tools.ietf.org/html/rfc7230#section-3.2.6 */ - for ($pos = 0, $length = strlen($body); $pos < $length; ++$pos) { - $s = $body[$pos]; - if ($part === self::READ_VALUE) { - if ($s === '=' && $parseParams) { - $key = ltrim($buffer); - $buffer = ''; - if (preg_match('/\s/', $key) === 0) { - $part = self::READ_PARAM_VALUE; - continue; - } - $key = preg_replace('/\s+/', ' ', $key); - $chunks = explode(' ', $key); - if (count($chunks) > 2 || preg_match('/\s$/', $key) === 1) { - array_pop($chunks); - $buffer = implode(' ', $chunks); - throw new ParsingException($body, $pos, 'Syntax error'); - } - $part = self::READ_PARAM_VALUE; - [$value, $key] = $chunks; - } elseif ($s === ';' && $parseParams) { - $part = self::READ_PARAM_NAME; - $value = trim($buffer); - $buffer = ''; - } elseif ($s === ',' && $parseList) { - $value = trim($buffer); - $collectHeaderValue(); - } else { - $buffer .= $s; - } - continue; - } - if ($part === self::READ_PARAM_NAME) { - if ($s === '=') { - $key = $buffer; - $buffer = ''; - $part = self::READ_PARAM_VALUE; - } elseif (strpos(self::DELIMITERS, $s) !== false) { - throw new ParsingException($body, $pos, 'Delimiter char in a param name'); - } elseif (ord($s) <= 32) { - if ($buffer !== '') { - throw new ParsingException($body, $pos, 'Space in a param name'); - } - } else { - $buffer .= $s; - } - continue; - } - if ($part === self::READ_PARAM_VALUE) { - if ($buffer === '') { - if ($s === '"') { - $part = self::READ_PARAM_QUOTED_VALUE; - } elseif (ord($s) <= 32) { - continue; - } elseif (strpos(self::DELIMITERS, $s) === false) { - $buffer .= $s; - } else { - throw new ParsingException($body, $pos, 'Delimiter char in a unquoted param value'); - } - } elseif (ord($s) <= 32) { - $part = self::READ_NONE; - $addParam($key, $buffer); - $key = $buffer = ''; - } elseif (strpos(self::DELIMITERS, $s) === false) { - $buffer .= $s; - } elseif ($s === ';') { - $part = self::READ_PARAM_NAME; - $addParam($key, $buffer); - $key = $buffer = ''; - } elseif ($s === ',' && $parseList) { - $part = self::READ_VALUE; - $addParam($key, $buffer); - $collectHeaderValue(); - } else { - $buffer = ''; - throw new ParsingException($body, $pos, 'Delimiter char in a unquoted param value'); - } - continue; - } - if ($part === self::READ_PARAM_QUOTED_VALUE) { - if ($s === '\\') { // quoted pair - if (++$pos >= $length) { - throw new ParsingException($body, $pos, 'Incorrect quoted pair'); - } else { - $buffer .= $body[$pos]; - } - } elseif ($s === '"') { // end - $part = self::READ_NONE; - $addParam($key, $buffer); - $key = $buffer = ''; - } else { - $buffer .= $s; - } - continue; - } - if ($part === self::READ_NONE) { - if (ord($s) <= 32) { - continue; - } elseif ($s === ';' && $parseParams) { - $part = self::READ_PARAM_NAME; - } elseif ($s === ',' && $parseList) { - $part = self::READ_VALUE; - $collectHeaderValue(); - } else { - throw new ParsingException($body, $pos, 'Expected Separator'); - } - } - } - } catch (ParsingException $e) { - $error = $e; - } - /** @var BaseHeaderValue $item */ - if ($part === self::READ_VALUE) { - $value = trim($buffer); - } elseif (in_array($part, [self::READ_PARAM_VALUE, self::READ_PARAM_QUOTED_VALUE], true)) { - if ($buffer === '') { - $error = $error ?? new ParsingException($body, $pos, 'Empty value should be quoted'); - } else { - $addParam($key, $buffer); - } + /** + * @var HeaderParsingParams $parsingParams + * @see \Yiisoft\Http\Header\Internal\BaseHeaderValue::getParsingParams + */ + $parsingParams = $this->headerClass::getParsingParams(); + + // var_dump($body, $this->headerClass, $parsingParams); die; + foreach (ValueFieldParser::parse($body, $this->headerClass, $parsingParams) as $value) { + $this->collect($value); } - $collectHeaderValue(); } } diff --git a/src/Header/Internal/BaseHeaderValue.php b/src/Header/Internal/BaseHeaderValue.php index 898eab5..7794683 100644 --- a/src/Header/Internal/BaseHeaderValue.php +++ b/src/Header/Internal/BaseHeaderValue.php @@ -6,6 +6,7 @@ use Exception; use Yiisoft\Http\Header\Header; +use Yiisoft\Http\Header\Parser\HeaderParsingParams; abstract class BaseHeaderValue { @@ -62,13 +63,14 @@ final public function getError(): ?Exception return $this->error; } - final public static function getParsingParams(): array + final public static function getParsingParams(): HeaderParsingParams { - return [ - 'list' => static::PARSING_LIST, - 'params' => static::PARSING_PARAMS, - 'q' => static::PARSING_Q_PARAM, - ]; + $params = new HeaderParsingParams(); + $params->directives = is_subclass_of(static::class, DirectivesHeaderValue::class, true); + $params->valuesList = $params->directives || static::PARSING_LIST; + $params->withParams = $params->directives || static::PARSING_PARAMS; + $params->q = static::PARSING_Q_PARAM; + return $params; } protected function setValue(string $value): void diff --git a/src/Header/Parser/HeaderParsingParams.php b/src/Header/Parser/HeaderParsingParams.php new file mode 100644 index 0000000..6c1f0f7 --- /dev/null +++ b/src/Header/Parser/HeaderParsingParams.php @@ -0,0 +1,13 @@ +?@[\\]{}', + READ_NONE = 0, + READ_VALUE = 1, + READ_PARAM_NAME = 2, + READ_PARAM_QUOTED_VALUE = 3, + READ_PARAM_VALUE = 4; + + public static function parse(string $body, string $class, HeaderParsingParams $params): Generator + { + if (!$params->valuesList && !$params->withParams && !$params->directives) { + yield new $class(trim($body)); + return; + } + + $state = new ValueFieldState(); + $state->part = self::READ_VALUE; + try { + /** @link https://tools.ietf.org/html/rfc7230#section-3.2.6 */ + for ($pos = 0, $length = strlen($body); $pos < $length; ++$pos) { + $sym = $body[$pos]; + if ($state->part === self::READ_VALUE) { + if ($sym === '=' && $params->withParams) { + $state->key = ltrim($state->buffer); + $state->buffer = ''; + if (preg_match('/\s/', $state->key) === 0) { + $state->part = self::READ_PARAM_VALUE; + continue; + } + $state->key = preg_replace('/\s+/', ' ', $state->key); + $chunks = explode(' ', $state->key); + if (count($chunks) > 2 || preg_match('/\s$/', $state->key) === 1) { + array_pop($chunks); + $state->buffer = implode(' ', $chunks); + throw new ParsingException($body, $pos, 'Syntax error'); + } + $state->part = self::READ_PARAM_VALUE; + [$state->value, $state->key] = $chunks; + } elseif ($sym === ';' && $params->withParams) { + $state->part = self::READ_PARAM_NAME; + $state->value = trim($state->buffer); + $state->buffer = ''; + } elseif ($sym === ',' && $params->valuesList) { + $state->value = trim($state->buffer); + yield static::createHeaderValue($class, $params, $state); + } else { + $state->buffer .= $sym; + } + continue; + } + if ($state->part === self::READ_PARAM_NAME) { + if ($sym === '=') { + $state->key = $state->buffer; + $state->buffer = ''; + $state->part = self::READ_PARAM_VALUE; + } elseif (strpos(self::DELIMITERS, $sym) !== false) { + throw new ParsingException($body, $pos, 'Delimiter char in a param name'); + } elseif (ord($sym) <= 32) { + if ($state->buffer !== '') { + throw new ParsingException($body, $pos, 'Space in a param name'); + } + } else { + $state->buffer .= $sym; + } + continue; + } + if ($state->part === self::READ_PARAM_VALUE) { + if ($state->buffer === '') { + if ($sym === '"') { + $state->part = self::READ_PARAM_QUOTED_VALUE; + } elseif (ord($sym) <= 32) { + continue; + } elseif (strpos(self::DELIMITERS, $sym) === false) { + $state->buffer .= $sym; + } else { + throw new ParsingException($body, $pos, 'Delimiter char in a unquoted param value'); + } + } elseif (ord($sym) <= 32) { + $state->part = self::READ_NONE; + $state->addParamFromBuffer(); + } elseif (strpos(self::DELIMITERS, $sym) === false) { + $state->buffer .= $sym; + } elseif ($sym === ';') { + $state->part = self::READ_PARAM_NAME; + $state->addParamFromBuffer(); + } elseif ($sym === ',' && $params->valuesList) { + $state->part = self::READ_VALUE; + $state->addParamFromBuffer(); + yield static::createHeaderValue($class, $params, $state); + } else { + $state->buffer = ''; + throw new ParsingException($body, $pos, 'Delimiter char in a unquoted param value'); + } + continue; + } + if ($state->part === self::READ_PARAM_QUOTED_VALUE) { + if ($sym === '\\') { // quoted pair + if (++$pos >= $length) { + throw new ParsingException($body, $pos, 'Incorrect quoted pair'); + } else { + $state->buffer .= $body[$pos]; + } + } elseif ($sym === '"') { // end + $state->part = self::READ_NONE; + $state->addParamFromBuffer(); + } else { + $state->buffer .= $sym; + } + continue; + } + if ($state->part === self::READ_NONE) { + if (ord($sym) <= 32) { + continue; + } elseif ($sym === ';' && $params->withParams) { + $state->part = self::READ_PARAM_NAME; + } elseif ($sym === ',' && $params->valuesList) { + $state->part = self::READ_VALUE; + yield static::createHeaderValue($class, $params, $state); + } else { + throw new ParsingException($body, $pos, 'Expected Separator'); + } + } + } + } catch (ParsingException $e) { + $state->error = $e; + } + /** @var BaseHeaderValue $item */ + if ($state->part === self::READ_VALUE) { + $state->value = trim($state->buffer); + } elseif (in_array($state->part, [self::READ_PARAM_VALUE, self::READ_PARAM_QUOTED_VALUE], true)) { + if ($state->buffer === '') { + $state->error = $state->error ?? new ParsingException($body, $pos, 'Empty value should be quoted'); + } else { + $state->addParamFromBuffer(); + } + } + yield static::createHeaderValue($class, $params, $state); + } + + protected static function createHeaderValue( + string $class, + HeaderParsingParams $params, + ValueFieldState $state + ): BaseHeaderValue { + /** @var DirectivesHeaderValue|BaseHeaderValue $item */ + $item = new $class($state->value); + if ($params->directives) { + if ($state->value === '' && count($state->params) > 0) { + $item = $item->withDirective(key($state->params), current($state->params)); + } + } elseif ($params->withParams) { + $item = $item->withParams($state->params); + } + if ($state->error !== null) { + $item = $item->withError($state->error); + } + $state->clear(); + return $item; + } +} diff --git a/src/Header/Parser/ValueFieldState.php b/src/Header/Parser/ValueFieldState.php new file mode 100644 index 0000000..50f595e --- /dev/null +++ b/src/Header/Parser/ValueFieldState.php @@ -0,0 +1,36 @@ +params)) { + $this->params[$key] = $value; + } + } + public function addParamFromBuffer(): void + { + $this->addParam($this->key, $this->buffer); + $this->key = $this->buffer = ''; + } + public function clear(): void + { + $this->key = $this->buffer = $this->value = ''; + $this->params = []; + $this->error = null; + } +} diff --git a/src/Header/SortableHeader.php b/src/Header/SortableHeader.php index d1ee4f6..fb92f48 100644 --- a/src/Header/SortableHeader.php +++ b/src/Header/SortableHeader.php @@ -7,7 +7,7 @@ use InvalidArgumentException; use Yiisoft\Http\Header\Internal\BaseHeaderValue; use Yiisoft\Http\Header\Internal\WithParamsHeaderValue; -use Yiisoft\Http\Header\Value\SortedValue; +use Yiisoft\Http\Header\Value\Unnamed\SortedValue; final class SortableHeader extends Header { diff --git a/src/Header/Value/Cache/Age.php b/src/Header/Value/Cache/Age.php index c6e0ffe..cf474e6 100644 --- a/src/Header/Value/Cache/Age.php +++ b/src/Header/Value/Cache/Age.php @@ -4,7 +4,7 @@ namespace Yiisoft\Http\Header\Value\Cache; -use Yiisoft\Http\Header\ParsingException; +use Yiisoft\Http\Header\Parser\ParsingException; use Yiisoft\Http\Header\Internal\BaseHeaderValue; /** diff --git a/src/Header/Value/Cache/Warning.php b/src/Header/Value/Cache/Warning.php index 8ed0ca3..ef424ad 100644 --- a/src/Header/Value/Cache/Warning.php +++ b/src/Header/Value/Cache/Warning.php @@ -6,7 +6,7 @@ use DateTimeImmutable; use DateTimeInterface; -use Yiisoft\Http\Header\ParsingException; +use Yiisoft\Http\Header\Parser\ParsingException; use Yiisoft\Http\Header\Internal\BaseHeaderValue; /** diff --git a/src/Header/Value/Condition/ETag.php b/src/Header/Value/Condition/ETag.php index 06fd9e3..e35c86d 100644 --- a/src/Header/Value/Condition/ETag.php +++ b/src/Header/Value/Condition/ETag.php @@ -4,7 +4,7 @@ namespace Yiisoft\Http\Header\Value\Condition; -use Yiisoft\Http\Header\ParsingException; +use Yiisoft\Http\Header\Parser\ParsingException; use Yiisoft\Http\Header\Internal\BaseHeaderValue; /** diff --git a/src/Header/Value/DirectiveValue.php b/src/Header/Value/Unnamed/DirectiveValue.php similarity index 88% rename from src/Header/Value/DirectiveValue.php rename to src/Header/Value/Unnamed/DirectiveValue.php index ceee471..705528a 100644 --- a/src/Header/Value/DirectiveValue.php +++ b/src/Header/Value/Unnamed/DirectiveValue.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Yiisoft\Http\Header\Value; +namespace Yiisoft\Http\Header\Value\Unnamed; use Yiisoft\Http\Header\DirectiveHeader; use Yiisoft\Http\Header\Internal\DirectivesHeaderValue; diff --git a/src/Header/Value/ListedValue.php b/src/Header/Value/Unnamed/ListedValue.php similarity index 88% rename from src/Header/Value/ListedValue.php rename to src/Header/Value/Unnamed/ListedValue.php index 28c7adf..3123204 100644 --- a/src/Header/Value/ListedValue.php +++ b/src/Header/Value/Unnamed/ListedValue.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Yiisoft\Http\Header\Value; +namespace Yiisoft\Http\Header\Value\Unnamed; use Yiisoft\Http\Header\Header; use Yiisoft\Http\Header\Internal\BaseHeaderValue; diff --git a/src/Header/Value/SimpleValue.php b/src/Header/Value/Unnamed/SimpleValue.php similarity index 87% rename from src/Header/Value/SimpleValue.php rename to src/Header/Value/Unnamed/SimpleValue.php index 485bf2c..be1a7d9 100644 --- a/src/Header/Value/SimpleValue.php +++ b/src/Header/Value/Unnamed/SimpleValue.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Yiisoft\Http\Header\Value; +namespace Yiisoft\Http\Header\Value\Unnamed; use Yiisoft\Http\Header\Header; use Yiisoft\Http\Header\Internal\BaseHeaderValue; diff --git a/src/Header/Value/SortedValue.php b/src/Header/Value/Unnamed/SortedValue.php similarity index 90% rename from src/Header/Value/SortedValue.php rename to src/Header/Value/Unnamed/SortedValue.php index 57aed97..788308f 100644 --- a/src/Header/Value/SortedValue.php +++ b/src/Header/Value/Unnamed/SortedValue.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Yiisoft\Http\Header\Value; +namespace Yiisoft\Http\Header\Value\Unnamed; use Yiisoft\Http\Header\SortableHeader; use Yiisoft\Http\Header\Internal\WithParamsHeaderValue; diff --git a/tests/Header/DirectiveHeaderTest.php b/tests/Header/DirectiveHeaderTest.php new file mode 100644 index 0000000..d7e3106 --- /dev/null +++ b/tests/Header/DirectiveHeaderTest.php @@ -0,0 +1,57 @@ +assertSame('Test-Directives', $values->getName()); + $this->assertSame(DirectivesHeaderValue::class, $values->getValueClass()); + } + public function testErrorWithHeaderClass() + { + $this->expectException(InvalidArgumentException::class); + new DirectiveHeader(DummyHeaderValue::class); + } + public function testCreateFromStringValue() + { + $header = (new DirectiveHeader('Directive-Test'))->withValue('value'); + + $this->assertSame('Directive-Test', $header->getName()); + $this->assertSame(['value'], $header->getStrings()); + } + public function testCreateFromStringValueWithArgument() + { + $header = (new DirectiveHeader('Directive-Test'))->withValue('value=tEst'); + + $this->assertSame('Directive-Test', $header->getName()); + $this->assertSame(['value=tEst'], $header->getStrings()); + } + public function testCreateDirectivesFromFewStringValues() + { + $headers = [ + 'max-age=123', + 'no-store', + 'proxy-revalidate, private="Connect, Host"', + ]; + $header = (new DirectiveHeader('Cache-Control'))->withValues($headers); + $this->assertSame('Cache-Control', $header->getName()); + $this->assertSame( + [ + 'max-age=123', + 'no-store', + 'proxy-revalidate', + 'private="Connect, Host"', + ], + $header->getStrings() + ); + } +} diff --git a/tests/Header/HeaderTest.php b/tests/Header/HeaderTest.php index b183764..c1f147d 100644 --- a/tests/Header/HeaderTest.php +++ b/tests/Header/HeaderTest.php @@ -8,7 +8,7 @@ use Yiisoft\Http\Header\Value\Accept\Accept; use Yiisoft\Http\Header\Internal\BaseHeaderValue; use Yiisoft\Http\Header\Value\Date; -use Yiisoft\Http\Header\Value\SimpleValue; +use Yiisoft\Http\Header\Value\Unnamed\SimpleValue; use Yiisoft\Http\Tests\Header\Value\Stub\DummyHeaderValue; use Yiisoft\Http\Tests\Header\Value\Stub\ListedValuesHeaderValue; use Yiisoft\Http\Tests\Header\Value\Stub\ListedValuesWithParamsHeaderValue; diff --git a/tests/Header/SortableHeaderTest.php b/tests/Header/SortableHeaderTest.php index 9f91036..45d2c50 100644 --- a/tests/Header/SortableHeaderTest.php +++ b/tests/Header/SortableHeaderTest.php @@ -5,7 +5,7 @@ use InvalidArgumentException; use PHPUnit\Framework\TestCase; use Yiisoft\Http\Header\SortableHeader; -use Yiisoft\Http\Header\Value\Date; +use Yiisoft\Http\Tests\Header\Value\Stub\DummyHeaderValue; use Yiisoft\Http\Tests\Header\Value\Stub\SortedHeaderValue; class SortableHeaderTest extends TestCase @@ -19,7 +19,7 @@ public function testHeaderValueClassPassed() public function testErrorWithHeaderClass() { $this->expectException(InvalidArgumentException::class); - new SortableHeader(Date::class); + new SortableHeader(DummyHeaderValue::class); } public function testCreateFromStringValues() { From 710a8cdb73c3f7ae48f8f4749a22270893e4e084 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 2 Apr 2020 15:06:05 +0300 Subject: [PATCH 21/26] Added `inject` method in the `BaseHeaderValue` class --- docs/ru/http-headers.md | 69 +++++++++++++------------ src/Header/Internal/BaseHeaderValue.php | 12 +++++ 2 files changed, 47 insertions(+), 34 deletions(-) diff --git a/docs/ru/http-headers.md b/docs/ru/http-headers.md index e45efb4..1bfcf46 100644 --- a/docs/ru/http-headers.md +++ b/docs/ru/http-headers.md @@ -29,14 +29,13 @@ Content-Disposition: attachment; filename="filename.jpg" ## Класс Header Абсолютно любой заголовок в поле заголовков HTTP-сообщения может быть указан несколько раз, даже если это не разрешается -правилами самого заголовка. Поэтому работа с любым заголовком происходит как с коллекцией валидных и не валидных -значений. +правилами самого заголовка. Поэтому в результате парсинга любого заголовка вы получите коллекцию значений. -Базовым классом для коллекции значений заголовков является `\Yiisoft\Http\Header\Header`. \ -Для заголовков, поддерживающих множество значений, бывает важно соблюдение последовательности этих значений. Поэтому для -сортируемых списков значений выделена отдельная коллекция `\Yiisoft\Http\Header\SortableHeader`, а также специальная -коллекция `\Yiisoft\Http\Header\AcceptHeader` для семейства заголовков `Accept`. В сортируемых коллекциях значения сразу -занимают места по порядку согласно параметру `q` или иным правилам, описанным в коллекции. +Базовым классом для коллекции значений заголовков является `\Yiisoft\Http\Header\Header`. Он подходит для заголовков, +в которых важно соблюдение порядка значений в заданной последовательности. Для сортируемых списков значений выделена +отдельная коллекция `\Yiisoft\Http\Header\SortableHeader`, а также специальная коллекция +`\Yiisoft\Http\Header\AcceptHeader` для семейства заголовков `Accept`. В сортируемых коллекциях значения сразу занимают +места по порядку согласно параметру `q` или иным правилам, описанным в коллекции. Рассмотрим пример с заголовком `Forwarded` [RFC7239](https://tools.ietf.org/html/rfc7239).\ Заголовок представляет собой список из пустых значений с возможными параметрами `by`, `for`, `host` и `proto`: @@ -91,38 +90,33 @@ $header = Allow::createHeader()->extract($request); $response = $header->inject($response, $replace = true); ``` -## Примеры - - - ## Заголовки ### Date -Для заголовков `Date` и других, значением которых является дата, выделен отдельный класс-коллекция `DateHeader`, -который поддерживает работу со значениями, реализующими интерфейс `\DateTimeInterface`. +Вы можете использовать класс `Date` для конвертирования объекта `\DateTimeInterface` в строку формата времени HTTP. ```php use \Yiisoft\Http\Header\Value\Date; -/** @var \Psr\Http\Message\ResponseInterface $response */ - -$response = Date::createHeader() - ->withValue(new DateTimeImmutable()) - ->inject($response); -``` -Вы можете использовать класс `Date` для конвертирования объекта `\DateTimeInterface` в строку формата времени HTTP: +$date = new Date(new DateTimeImmutable('2020-01-01 00:00:00 +0000')); -```php -use \Yiisoft\Http\Header\Value\Date; +echo new Date(new DateTimeImmutable('2020-01-01 00:00:00 +0000')); +// Wed, 01 Jan 2020 00:00:00 GMT -// В $date будет записано "Wed, 01 Jan 2020 00:00:00 GMT" -$date = (string)(new Date(new DateTimeImmutable('2020-01-01 00:00:00 +0000'))); +// Внедрить в ответ качестве заголовка +/** @var \Psr\Http\Message\ResponseInterface $response */ +$response = $date->inject($response); ``` ### ETag -... +```php +use Yiisoft\Http\Header\Value\Condition\ETag; +/** @var \Psr\Http\Message\ResponseInterface $response */ + +$etag = (new ETag())->withTag('56d-9989200-1132c580', true)->inject($response); +``` ## Заголовки кеширования @@ -138,8 +132,11 @@ $date = (string)(new Date(new DateTimeImmutable('2020-01-01 00:00:00 +0000'))); Дата истечения срока актуальности сущности. Класс заголовка `Expires` наследуется от класса `Date`. ```php -$dateHeader = \Yiisoft\Http\Header\Value\Cache\Expires::createHeader() - ->withValue(new DateTimeImmutable('+1 day')); +use Yiisoft\Http\Header\Value\Cache\Expires; +/** @var \Psr\Http\Message\ResponseInterface $response */ + +$dateHeader = (new Expires(new DateTimeImmutable('+1 day'))) + ->inject($response); ``` ### Warning @@ -147,9 +144,12 @@ $dateHeader = \Yiisoft\Http\Header\Value\Cache\Expires::createHeader() Заголовок для дополнительной информации об ошибках. ```php use \Yiisoft\Http\Header\Value\Cache\Warning; +/** @var \Psr\Http\Message\ResponseInterface $response */ $dateHeader = Warning::createHeader() - ->withValue((new Warning())->withDataset(Warning::RESPONSE_IS_STALE, '-', 'Response is stale')); + ->withValue((new Warning())->withDataset(Warning::RESPONSE_IS_STALE, '-', 'Response is stale')) + ->withValue((new Warning())->withDataset(Warning::REVALIDATION_FAILED, '-', 'Revalidation failed')) + ->inject($response); ``` ### Cache-Control @@ -191,18 +191,19 @@ CacheControl::createHeader()->withValue('max-stale, max-age=test, private="ETag, ## Пользовательские заголовки -Если вы не хотите описывать заголовок в отдельном классе, либо целевой заголовок не имеет заранее определённое имя, то -вы можете использовать заготовленные классы: +Если для требуемого заголовка вы не нашли среди классов подходящий, либо целевой заголовок не имеет заранее определённое +имя, то вы можете также использовать классы безымянных заголовков с предопределёнными правилами парсинга: ```php /** @var \Psr\Http\Message\ServerRequestInterface $request */ /** @var \Psr\Http\Message\ResponseInterface $response */ -// извлечь из запроса заголовок с перечисляемыми значениями -$listed = \Yiisoft\Http\Header\Value\Unnamed\ListedValue::createHeader('My-List') - ->extract($request); +// извлечь из запроса заголовок My-List с перечисляемыми значениями +$values = \Yiisoft\Http\Header\Value\Unnamed\ListedValue::createHeader('My-List') + ->extract($request) + ->getValues(); -// создать заголовок с перечисляемыми сортируемыми значениями +// создать заголовок My-Sorted-List с перечисляемыми сортируемыми значениями $sorted = \Yiisoft\Http\Header\Value\Unnamed\SortedValue::createHeader('My-Sorted-List') ->withValues(['foo', 'bar;q=0.5', 'baz;q=0']); ``` diff --git a/src/Header/Internal/BaseHeaderValue.php b/src/Header/Internal/BaseHeaderValue.php index 7794683..7ea38f3 100644 --- a/src/Header/Internal/BaseHeaderValue.php +++ b/src/Header/Internal/BaseHeaderValue.php @@ -5,6 +5,8 @@ namespace Yiisoft\Http\Header\Internal; use Exception; +use http\Exception\RuntimeException; +use Psr\Http\Message\MessageInterface; use Yiisoft\Http\Header\Header; use Yiisoft\Http\Header\Parser\HeaderParsingParams; @@ -27,6 +29,16 @@ public function __toString(): string { return $this->value; } + final public function inject(MessageInterface $message, bool $replace = true): MessageInterface + { + if (static::NAME === null) { + throw new \RuntimeException('Can not inject unnamed header value'); + } + if ($replace) { + $message = $message->withoutHeader(static::NAME); + } + return $message->withAddedHeader(static::NAME, $this->__toString()); + } public static function createHeader(): Header { From 8d7c3910f6fcd3a08c7ad4d5a51630845f910ecd Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sun, 6 Dec 2020 02:02:44 +0300 Subject: [PATCH 22/26] Fix doc --- docs/ru/http-headers.md | 25 ++----------------------- src/Header/Header.php | 2 +- 2 files changed, 3 insertions(+), 24 deletions(-) diff --git a/docs/ru/http-headers.md b/docs/ru/http-headers.md index 1bfcf46..4948ce7 100644 --- a/docs/ru/http-headers.md +++ b/docs/ru/http-headers.md @@ -1,30 +1,9 @@ # HTTP заголовки -Пакет `yiisoft/http` предоставляет инструменты для парсинга и генерирования заголовков с учётом их особенностей - -## Правила парсинга - -Каждый заголовок состоит из нечувствительного к регистру имени заголовка, двоеточия и затем поля значения, которое может -быть обособлено необязательными пробелами. - -В поле значения может находиться как одно, так и несколько значений заголовка, как с параметрами, так и без них. -Существуют также заголовки без значений, но с параметрами (Forwarded, Keep-Alive), либо с директивами (Forwarded). +## Список HTTP заголовков -Пример реальных заголовков, использующихся в сообщениях запросов и ответов: -```text -Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng -Accept: */*;q=0.8,application/signed-exchange;v=b3;q=0.9 -Allow: GET, POST, HEAD -Content-Range: bytes 88080384-160993791/160993792 -Connection: Keep-Alive -Keep-Alive: timeout=5, max=100 -Date: Mon, 16 Mar 2020 09:59:58 GMT -ETag: W/"3d19ee-418a3-5a0d6b89d613d;5a0f5e1acb07d" -Content-Disposition: attachment; filename="filename.jpg" -``` - -Все заголовки разные и со своими особенностями... +Пакет `yiisoft/http` предоставляет инструменты для парсинга и генерирования заголовков с учётом их особенностей ## Класс Header diff --git a/src/Header/Header.php b/src/Header/Header.php index a1f851f..f0946fe 100644 --- a/src/Header/Header.php +++ b/src/Header/Header.php @@ -45,7 +45,7 @@ public function __construct(string $nameOrClass) public function getIterator(): iterable { - return $this->getValues(true); + yield from $this->getValues(true); } public function count(): int { From 3ae591581efb3f4cda79f0c6a25a3d710702c44a Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 25 Feb 2021 14:27:44 +0300 Subject: [PATCH 23/26] Add psr/http-message again; cleanup --- composer.json | 1 + docs/ru/http-headers.md | 35 +++++++++++-------- src/Header/DirectiveHeader.php | 2 +- src/Header/Header.php | 35 +++++++++++-------- src/Header/Internal/BaseHeaderValue.php | 7 ++-- src/Header/Internal/DirectivesHeaderValue.php | 2 +- src/Header/Internal/WithParamsHeaderValue.php | 11 ++++-- src/Header/Parser/ValueFieldParser.php | 4 +++ 8 files changed, 60 insertions(+), 37 deletions(-) diff --git a/composer.json b/composer.json index d66e9cd..10bddbc 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,7 @@ }, "require": { "php": "^7.4|^8.0", + "psr/http-message": "^1.0", "yiisoft/strings": "^2.0" }, "autoload": { diff --git a/docs/ru/http-headers.md b/docs/ru/http-headers.md index 4948ce7..fa12abc 100644 --- a/docs/ru/http-headers.md +++ b/docs/ru/http-headers.md @@ -3,12 +3,12 @@ ## Список HTTP заголовков -Пакет `yiisoft/http` предоставляет инструменты для парсинга и генерирования заголовков с учётом их особенностей +Пакет `yiisoft/http` предоставляет инструменты для парсинга и генерирования HTTP заголовков с учётом их особенностей ## Класс Header Абсолютно любой заголовок в поле заголовков HTTP-сообщения может быть указан несколько раз, даже если это не разрешается -правилами самого заголовка. Поэтому в результате парсинга любого заголовка вы получите коллекцию значений. +в RFC самого заголовка. Поэтому в результате парсинга любого заголовка вы получите коллекцию значений. Базовым классом для коллекции значений заголовков является `\Yiisoft\Http\Header\Header`. Он подходит для заголовков, в которых важно соблюдение порядка значений в заданной последовательности. Для сортируемых списков значений выделена @@ -64,6 +64,8 @@ use \Yiisoft\Http\Header\Value\Allow; // Получить коллекцию значений заголовка Allow из объекта запроса $header = Allow::createHeader()->extract($request); +// Или +$header = Allow::extract($request); // Записать заголовки в объект ответа $response = $header->inject($response, $replace = true); @@ -76,16 +78,10 @@ $response = $header->inject($response, $replace = true); Вы можете использовать класс `Date` для конвертирования объекта `\DateTimeInterface` в строку формата времени HTTP. ```php -use \Yiisoft\Http\Header\Value\Date; - -$date = new Date(new DateTimeImmutable('2020-01-01 00:00:00 +0000')); +$date = new \Yiisoft\Http\Header\Value\Date(new DateTimeImmutable('2020-01-01 00:00:00 +0000')); -echo new Date(new DateTimeImmutable('2020-01-01 00:00:00 +0000')); +echo $date; // Wed, 01 Jan 2020 00:00:00 GMT - -// Внедрить в ответ качестве заголовка -/** @var \Psr\Http\Message\ResponseInterface $response */ -$response = $date->inject($response); ``` ### ETag @@ -104,7 +100,7 @@ $etag = (new ETag())->withTag('56d-9989200-1132c580', true)->inject($response); ### Age В заголовке `Age` задаётся количество секунд с момента модификации ресурса. Поэтому все значения, имеющие символы, -отличные от десятичных цифр, будут помечены как не валидные. +отличные от десятичных цифр, будут помечены как невалидные. ### Expires @@ -152,14 +148,14 @@ $header = \Yiisoft\Http\Header\Value\Cache\CacheControl::createHeader() ```php use Yiisoft\Http\Header\Value\Cache\CacheControl; -// исключения будут брошены в каждой строчке ниже +// исключения будут брошены в каждом из этих случаев: (new CacheControl())->withDirective('max-stale'); // у директивы max-stale должен быть аргумент (new CacheControl())->withDirective('max-age', 'not numeric'); // аргумент директивы max-age должен быть числовым (new CacheControl())->withDirective('max-age', '-456'); // допускаются только цифры (new CacheControl())->withDirective('private', 'ETag,'); // нарушение синтаксиса списка заголовков (new CacheControl())->withDirective('no-store', 'yes'); // директива no-store не принимает аргумент -// Исключение выброшено не будет, однако все элементы коллекции будут не валидными +// Исключение выброшено не будет, однако все элементы коллекции будут невалидными CacheControl::createHeader()->withValue('max-stale, max-age=test, private="ETag,", no-store=yes'); ``` @@ -170,8 +166,8 @@ CacheControl::createHeader()->withValue('max-stale, max-age=test, private="ETag, ## Пользовательские заголовки -Если для требуемого заголовка вы не нашли среди классов подходящий, либо целевой заголовок не имеет заранее определённое -имя, то вы можете также использовать классы безымянных заголовков с предопределёнными правилами парсинга: +Если для требуемого заголовка не нашёлся подходящий, либо целевой заголовок не имеет заранее определённое имя, +то вы можете также использовать классы безымянных заголовков с предопределёнными правилами парсинга: ```php /** @var \Psr\Http\Message\ServerRequestInterface $request */ @@ -185,4 +181,13 @@ $values = \Yiisoft\Http\Header\Value\Unnamed\ListedValue::createHeader('My-List' // создать заголовок My-Sorted-List с перечисляемыми сортируемыми значениями $sorted = \Yiisoft\Http\Header\Value\Unnamed\SortedValue::createHeader('My-Sorted-List') ->withValues(['foo', 'bar;q=0.5', 'baz;q=0']); + +// создать заголовок My-Directives с форматом директив +$directives = \Yiisoft\Http\Header\Value\Unnamed\DirectiveValue::createHeader('My-Directives') + ->withValues(['foo', 'bar=baz']) + ->withDirective('answer', '42'); + +// создать заголовок My-Simple-Header, к которому не будут применяться правила парсинга +$simple = \Yiisoft\Http\Header\Value\Unnamed\SimpleValue::createHeader('My-Simple-Header') + ->withValues(['foo', 'bar=baz', 'answer=42']); ``` diff --git a/src/Header/DirectiveHeader.php b/src/Header/DirectiveHeader.php index d2a60da..dcda11d 100644 --- a/src/Header/DirectiveHeader.php +++ b/src/Header/DirectiveHeader.php @@ -25,7 +25,7 @@ public function __construct(string $nameOrClass) /** * @param string $directive * @param string|null $argument - * @return $this + * * @throws InvalidArgumentException */ public function withDirective(string $directive, string $argument = null): self diff --git a/src/Header/Header.php b/src/Header/Header.php index f0946fe..f1bf27c 100644 --- a/src/Header/Header.php +++ b/src/Header/Header.php @@ -13,15 +13,17 @@ class Header implements \IteratorAggregate, \Countable { + /** @psalm-var class-string */ protected string $headerClass; protected string $headerName; - /** @var BaseHeaderValue[] */ + /** @var array */ protected array $collection = []; protected const DEFAULT_VALUE_CLASS = SimpleValue::class; /** * @param string $nameOrClass Header name or header value class + * @psalm-param class-string $nameOrClass */ public function __construct(string $nameOrClass) { @@ -43,23 +45,26 @@ public function __construct(string $nameOrClass) } } - public function getIterator(): iterable + final public function getIterator(): iterable { yield from $this->getValues(true); } - public function count(): int + + final public function count(): int { return count($this->collection); } - public function getName(): string + final public function getName(): string { return $this->headerName; } - public function getValueClass(): string + + final public function getValueClass(): string { return $this->headerClass; } + /** * @param bool $ignoreIncorrect * @return BaseHeaderValue[] @@ -75,6 +80,7 @@ public function getValues(bool $ignoreIncorrect = true): array } return $result; } + /** * @param bool $ignoreIncorrect * @return string[] @@ -93,9 +99,8 @@ public function getStrings(bool $ignoreIncorrect = true): array /** * @param string[]|BaseHeaderValue[] $values - * @return $this */ - public function withValues(array $values): self + final public function withValues(array $values): self { $clone = clone $this; foreach ($values as $value) { @@ -103,11 +108,11 @@ public function withValues(array $values): self } return $clone; } + /** * @param string|BaseHeaderValue $value - * @return $this */ - public function withValue($value): self + final public function withValue($value): self { $clone = clone $this; $clone->addValue($value); @@ -121,7 +126,7 @@ public function withValue($value): self * @param bool $ignoreIncorrect Don't export values that have error * @return MessageInterface */ - public function inject( + final public function inject( MessageInterface $message, bool $replace = true, bool $ignoreIncorrect = true @@ -137,14 +142,13 @@ public function inject( } return $message; } + /** * Import header values from HTTP message * @param MessageInterface $message Request or Response instance - * @return $this */ - public function extract(MessageInterface $message): self + final public function extract(MessageInterface $message): self { - #todo test it return $this->withValues($message->getHeader($this->headerName)); } @@ -170,19 +174,20 @@ protected function addValue($value): void sprintf('The value must be an instance of %s or string', $this->headerClass) ); } + protected function collect(BaseHeaderValue $value): void { $this->collection[] = $value; } + private function parseAndCollect(string $body): void { /** * @var HeaderParsingParams $parsingParams - * @see \Yiisoft\Http\Header\Internal\BaseHeaderValue::getParsingParams + * @see BaseHeaderValue::getParsingParams */ $parsingParams = $this->headerClass::getParsingParams(); - // var_dump($body, $this->headerClass, $parsingParams); die; foreach (ValueFieldParser::parse($body, $this->headerClass, $parsingParams) as $value) { $this->collect($value); } diff --git a/src/Header/Internal/BaseHeaderValue.php b/src/Header/Internal/BaseHeaderValue.php index 7ea38f3..4f90322 100644 --- a/src/Header/Internal/BaseHeaderValue.php +++ b/src/Header/Internal/BaseHeaderValue.php @@ -5,7 +5,6 @@ namespace Yiisoft\Http\Header\Internal; use Exception; -use http\Exception\RuntimeException; use Psr\Http\Message\MessageInterface; use Yiisoft\Http\Header\Header; use Yiisoft\Http\Header\Parser\HeaderParsingParams; @@ -45,6 +44,11 @@ public static function createHeader(): Header return new Header(static::class); } + public static function extract(MessageInterface $message): Header + { + return (new Header(static::class))->extract($message); + } + public function withValue(string $value): self { $clone = clone $this; @@ -58,7 +62,6 @@ public function getValue(): string /** * @param Exception|null $error - * @return $this */ final public function withError(?Exception $error): self { diff --git a/src/Header/Internal/DirectivesHeaderValue.php b/src/Header/Internal/DirectivesHeaderValue.php index 56f5f94..a745e37 100644 --- a/src/Header/Internal/DirectivesHeaderValue.php +++ b/src/Header/Internal/DirectivesHeaderValue.php @@ -78,7 +78,7 @@ public function getArgumentList(): array /** * @param string $directive * @param string|null $argument - * @return $this + * * @throws InvalidArgumentException */ public function withDirective(string $directive, string $argument = null): self diff --git a/src/Header/Internal/WithParamsHeaderValue.php b/src/Header/Internal/WithParamsHeaderValue.php index d8ade58..dd41d04 100644 --- a/src/Header/Internal/WithParamsHeaderValue.php +++ b/src/Header/Internal/WithParamsHeaderValue.php @@ -49,7 +49,6 @@ public static function createHeader(): Header /** * It makes sense to use only for HeaderValues that implement the WithParams interface * @param array $params - * @return $this */ public function withParams(array $params): self { @@ -57,6 +56,10 @@ public function withParams(array $params): self $clone->setParams($params); return $clone; } + + /** + * @return array + */ public function getParams(): array { $result = $this->params; @@ -65,6 +68,7 @@ public function getParams(): array } return $result; } + public function getQuality(): string { return $this->quality; @@ -78,18 +82,19 @@ protected function setQuality(string $q): bool $this->quality = rtrim($q, '0.') ?: '0'; return true; } + protected function setParams(array $params): void { $this->params = []; foreach ($params as $key => $value) { # todo decide: what about numeric keys? $key = strtolower($key); - if (!key_exists($key, $this->params)) { + if (!array_key_exists($key, $this->params)) { $this->params[$key] = $value; } } if (static::PARSING_Q_PARAM) { - if (key_exists('q', $this->params)) { + if (array_key_exists('q', $this->params)) { $this->setQuality($this->params['q']); unset($this->params['q']); } else { diff --git a/src/Header/Parser/ValueFieldParser.php b/src/Header/Parser/ValueFieldParser.php index 979428e..a875208 100644 --- a/src/Header/Parser/ValueFieldParser.php +++ b/src/Header/Parser/ValueFieldParser.php @@ -20,6 +20,10 @@ class ValueFieldParser READ_PARAM_QUOTED_VALUE = 3, READ_PARAM_VALUE = 4; + /** + * @param string $class + * @psalm-return Generator + */ public static function parse(string $body, string $class, HeaderParsingParams $params): Generator { if (!$params->valuesList && !$params->withParams && !$params->directives) { From 27609a490ed6965c1d1857dabfe7bada09356d7a Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 25 Feb 2021 15:49:02 +0300 Subject: [PATCH 24/26] Cleanup --- src/Header/Parser/ValueFieldParser.php | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/Header/Parser/ValueFieldParser.php b/src/Header/Parser/ValueFieldParser.php index a875208..4d7ab04 100644 --- a/src/Header/Parser/ValueFieldParser.php +++ b/src/Header/Parser/ValueFieldParser.php @@ -5,11 +5,11 @@ namespace Yiisoft\Http\Header\Parser; use Generator; +use InvalidArgumentException; use Yiisoft\Http\Header\Internal\BaseHeaderValue; use Yiisoft\Http\Header\Internal\DirectivesHeaderValue; -use Yiisoft\Http\Header\Parser\ParsingException; -class ValueFieldParser +final class ValueFieldParser { // Parsing's constants private const @@ -21,11 +21,14 @@ class ValueFieldParser READ_PARAM_VALUE = 4; /** - * @param string $class + * @psalm-param class-string $class * @psalm-return Generator */ public static function parse(string $body, string $class, HeaderParsingParams $params): Generator { + if (!is_a($class, BaseHeaderValue::class, true)) { + throw new InvalidArgumentException('$class should be instance of BaseHeaderValue.'); + } if (!$params->valuesList && !$params->withParams && !$params->directives) { yield new $class(trim($body)); return; @@ -115,9 +118,8 @@ public static function parse(string $body, string $class, HeaderParsingParams $p if ($sym === '\\') { // quoted pair if (++$pos >= $length) { throw new ParsingException($body, $pos, 'Incorrect quoted pair'); - } else { - $state->buffer .= $body[$pos]; } + $state->buffer .= $body[$pos]; } elseif ($sym === '"') { // end $state->part = self::READ_NONE; $state->addParamFromBuffer(); @@ -129,7 +131,8 @@ public static function parse(string $body, string $class, HeaderParsingParams $p if ($state->part === self::READ_NONE) { if (ord($sym) <= 32) { continue; - } elseif ($sym === ';' && $params->withParams) { + } + if ($sym === ';' && $params->withParams) { $state->part = self::READ_PARAM_NAME; } elseif ($sym === ',' && $params->valuesList) { $state->part = self::READ_VALUE; @@ -142,7 +145,6 @@ public static function parse(string $body, string $class, HeaderParsingParams $p } catch (ParsingException $e) { $state->error = $e; } - /** @var BaseHeaderValue $item */ if ($state->part === self::READ_VALUE) { $state->value = trim($state->buffer); } elseif (in_array($state->part, [self::READ_PARAM_VALUE, self::READ_PARAM_QUOTED_VALUE], true)) { @@ -155,6 +157,9 @@ public static function parse(string $body, string $class, HeaderParsingParams $p yield static::createHeaderValue($class, $params, $state); } + /** + * @psalm-param class-string $class + */ protected static function createHeaderValue( string $class, HeaderParsingParams $params, @@ -162,7 +167,7 @@ protected static function createHeaderValue( ): BaseHeaderValue { /** @var DirectivesHeaderValue|BaseHeaderValue $item */ $item = new $class($state->value); - if ($params->directives) { + if ($params->directives && $item instanceof DirectivesHeaderValue) { if ($state->value === '' && count($state->params) > 0) { $item = $item->withDirective(key($state->params), current($state->params)); } From afeaf21fa73423c377605629c4195b51aa1372f3 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 26 Feb 2021 13:24:26 +0300 Subject: [PATCH 25/26] Fix some codestyle --- src/Header/AcceptHeader.php | 13 +++++++------ src/Header/DateHeader.php | 4 ++-- src/Header/DirectiveHeader.php | 11 +++-------- src/Header/Header.php | 10 ++++++---- src/Header/Internal/DirectivesHeaderValue.php | 3 --- 5 files changed, 18 insertions(+), 23 deletions(-) diff --git a/src/Header/AcceptHeader.php b/src/Header/AcceptHeader.php index facd98a..026756e 100644 --- a/src/Header/AcceptHeader.php +++ b/src/Header/AcceptHeader.php @@ -18,7 +18,7 @@ public function __construct(string $nameOrClass) parent::__construct($nameOrClass); if (!is_a($this->headerClass, Accept::class, true)) { throw new InvalidArgumentException( - sprintf("%s class is not an instance of %s", $this->headerClass, Accept::class) + sprintf('%s class is not an instance of %s', $this->headerClass, Accept::class) ); } } @@ -37,7 +37,8 @@ protected function collect(BaseHeaderValue $value): void $result = (float)$item->getQuality() <=> (float)$value->getQuality(); if ($result > 0) { break; - } elseif ($result === 0) { + } + if ($result === 0) { $separator = $this->headerClass::VALUE_SEPARATOR; if ($separator !== '') { $itemTypes = array_reverse(explode($separator, $item->getValue())); @@ -49,15 +50,15 @@ protected function collect(BaseHeaderValue $value): void $result = count($itemTypes) <=> count($valueTypes); if ($result > 0) { break; - } elseif ($result === 0) { + } + if ($result === 0) { foreach ($itemTypes as $part => $itemType) { if ($itemType === '*' xor $valueTypes[$part] === '*') { if ($itemType !== '*') { break 2; - } else { - $this->collection[$pos + 1] = $item; - continue 2; } + $this->collection[$pos + 1] = $item; + continue 2; } } if (count($item->getParams()) >= count($value->getParams())) { diff --git a/src/Header/DateHeader.php b/src/Header/DateHeader.php index fd56e26..1bb8a64 100644 --- a/src/Header/DateHeader.php +++ b/src/Header/DateHeader.php @@ -9,8 +9,8 @@ use Yiisoft\Http\Header\Value\Date; /** - * @method $this withValue(string|DateTimeInterface|BaseHeaderValue $value) - * @method $this withValues(string[]|DateTimeInterface[]|BaseHeaderValue[] $value) + * @method $this withValue(BaseHeaderValue|DateTimeInterface|string $value) + * @method $this withValues(BaseHeaderValue[]|DateTimeInterface[]|string[] $value) */ final class DateHeader extends Header { diff --git a/src/Header/DirectiveHeader.php b/src/Header/DirectiveHeader.php index dcda11d..31d3be3 100644 --- a/src/Header/DirectiveHeader.php +++ b/src/Header/DirectiveHeader.php @@ -17,17 +17,11 @@ public function __construct(string $nameOrClass) parent::__construct($nameOrClass); if (!is_a($this->headerClass, DirectivesHeaderValue::class, true)) { throw new InvalidArgumentException( - sprintf("%s class is not an instance of %s", $this->headerClass, DirectivesHeaderValue::class) + sprintf('%s class is not an instance of %s', $this->headerClass, DirectivesHeaderValue::class) ); } } - /** - * @param string $directive - * @param string|null $argument - * - * @throws InvalidArgumentException - */ public function withDirective(string $directive, string $argument = null): self { $clone = clone $this; @@ -39,7 +33,8 @@ public function withDirective(string $directive, string $argument = null): self /** * @param bool $ignoreIncorrect - * @return string[]|null[] Returns array where keys are directives and values are arguments + * @return null[][]|string[][] Returns array of array directive value> + * @psalm-return array>|array */ public function getDirectives(bool $ignoreIncorrect = true): array { diff --git a/src/Header/Header.php b/src/Header/Header.php index f1bf27c..53bf898 100644 --- a/src/Header/Header.php +++ b/src/Header/Header.php @@ -22,23 +22,25 @@ class Header implements \IteratorAggregate, \Countable protected const DEFAULT_VALUE_CLASS = SimpleValue::class; /** - * @param string $nameOrClass Header name or header value class + * @param string|class-string $nameOrClass Header name or header value class * @psalm-param class-string $nameOrClass */ public function __construct(string $nameOrClass) { - $this->headerClass = $nameOrClass; if (class_exists($nameOrClass)) { if (!is_subclass_of($nameOrClass, BaseHeaderValue::class, true)) { throw new InvalidArgumentException("{$nameOrClass} is not a header."); } + /** @var class-string $nameOrClass */ + $this->headerClass = $nameOrClass; if (empty($nameOrClass::NAME)) { throw new InvalidArgumentException("{$nameOrClass} has no header name."); } $this->headerName = $nameOrClass::NAME; } else { + /** @var string $nameOrClass */ if ($nameOrClass === '') { - throw new InvalidArgumentException("Empty header name."); + throw new InvalidArgumentException('Empty header name.'); } $this->headerName = $nameOrClass; $this->headerClass = static::DEFAULT_VALUE_CLASS; @@ -160,7 +162,7 @@ protected function addValue($value): void if ($value instanceof BaseHeaderValue) { if (get_class($value) !== $this->headerClass) { throw new InvalidArgumentException( - sprintf('The value must be an instance of %s, %s given', $this->headerClass, get_class($value)) + sprintf('The value must be an instance of %s, %s given.', $this->headerClass, get_class($value)) ); } $this->collect($value); diff --git a/src/Header/Internal/DirectivesHeaderValue.php b/src/Header/Internal/DirectivesHeaderValue.php index a745e37..5dcea7d 100644 --- a/src/Header/Internal/DirectivesHeaderValue.php +++ b/src/Header/Internal/DirectivesHeaderValue.php @@ -50,9 +50,6 @@ public static function createHeader(): DirectiveHeader return new DirectiveHeader(static::class); } - /** - * @return string|null Returns null if the directive is not defined or cannot be parsed without error - */ public function getDirective(): string { return $this->directive; From 3ae1418adbd563399ddb0a2006a597f951639331 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 26 Feb 2021 13:29:15 +0300 Subject: [PATCH 26/26] Apply cs diff --- src/Header/DirectiveHeader.php | 1 + src/Header/Header.php | 12 +++++++--- src/Header/Internal/BaseHeaderValue.php | 13 +++++++--- src/Header/Internal/DirectivesHeaderValue.php | 13 +++++++--- src/Header/Internal/WithParamsHeaderValue.php | 5 +++- src/Header/Parser/ParsingException.php | 1 + src/Header/Parser/ValueFieldParser.php | 10 ++++---- src/Header/Parser/ValueFieldState.php | 6 ++--- src/Header/SortableHeader.php | 2 +- src/Header/Value/Cache/Age.php | 1 + src/Header/Value/Cache/CacheControl.php | 1 + src/Header/Value/Cache/Warning.php | 24 +++++++++++++------ src/Header/Value/Condition/ETag.php | 3 +-- src/Header/Value/Date.php | 1 + tests/Header/AcceptHeaderTest.php | 11 +++++++++ tests/Header/DirectiveHeaderTest.php | 6 +++++ tests/Header/HeaderTest.php | 19 +++++++++++++-- tests/Header/Internal/BaseHeaderValueTest.php | 4 ++++ .../Internal/DirectivesHeaderValueTest.php | 12 ++++++++++ .../Internal/WithParamsHeaderValueTest.php | 6 +++++ tests/Header/SortableHeaderTest.php | 6 +++++ tests/Header/Value/Cache/AgeTest.php | 6 ++++- tests/Header/Value/Cache/WarningTest.php | 8 ++++++- tests/Header/Value/Condition/ETagTest.php | 6 +++++ tests/Header/Value/DateTest.php | 11 +++++++++ .../Value/Stub/DirectivesHeaderValue.php | 2 ++ tests/Header/Value/Stub/DummyHeaderValue.php | 2 ++ .../Value/Stub/ListedValuesHeaderValue.php | 2 ++ .../ListedValuesWithParamsHeaderValue.php | 2 ++ tests/Header/Value/Stub/SortedHeaderValue.php | 2 ++ .../Value/Stub/WithParamsHeaderValue.php | 2 ++ 31 files changed, 168 insertions(+), 32 deletions(-) diff --git a/src/Header/DirectiveHeader.php b/src/Header/DirectiveHeader.php index 31d3be3..9bcabce 100644 --- a/src/Header/DirectiveHeader.php +++ b/src/Header/DirectiveHeader.php @@ -33,6 +33,7 @@ public function withDirective(string $directive, string $argument = null): self /** * @param bool $ignoreIncorrect + * * @return null[][]|string[][] Returns array of array directive value> * @psalm-return array>|array */ diff --git a/src/Header/Header.php b/src/Header/Header.php index 53bf898..736ac32 100644 --- a/src/Header/Header.php +++ b/src/Header/Header.php @@ -69,6 +69,7 @@ final public function getValueClass(): string /** * @param bool $ignoreIncorrect + * * @return BaseHeaderValue[] */ public function getValues(bool $ignoreIncorrect = true): array @@ -85,6 +86,7 @@ public function getValues(bool $ignoreIncorrect = true): array /** * @param bool $ignoreIncorrect + * * @return string[] */ public function getStrings(bool $ignoreIncorrect = true): array @@ -100,7 +102,7 @@ public function getStrings(bool $ignoreIncorrect = true): array } /** - * @param string[]|BaseHeaderValue[] $values + * @param BaseHeaderValue[]|string[] $values */ final public function withValues(array $values): self { @@ -112,7 +114,7 @@ final public function withValues(array $values): self } /** - * @param string|BaseHeaderValue $value + * @param BaseHeaderValue|string $value */ final public function withValue($value): self { @@ -123,9 +125,11 @@ final public function withValue($value): self /** * Export header values into HTTP message + * * @param MessageInterface $message Request or Response instance * @param bool $replace Replace existing headers * @param bool $ignoreIncorrect Don't export values that have error + * * @return MessageInterface */ final public function inject( @@ -147,6 +151,7 @@ final public function inject( /** * Import header values from HTTP message + * * @param MessageInterface $message Request or Response instance */ final public function extract(MessageInterface $message): self @@ -155,7 +160,7 @@ final public function extract(MessageInterface $message): self } /** - * @param string|BaseHeaderValue $value + * @param BaseHeaderValue|string $value */ protected function addValue($value): void { @@ -186,6 +191,7 @@ private function parseAndCollect(string $body): void { /** * @var HeaderParsingParams $parsingParams + * * @see BaseHeaderValue::getParsingParams */ $parsingParams = $this->headerClass::getParsingParams(); diff --git a/src/Header/Internal/BaseHeaderValue.php b/src/Header/Internal/BaseHeaderValue.php index 4f90322..b558f1c 100644 --- a/src/Header/Internal/BaseHeaderValue.php +++ b/src/Header/Internal/BaseHeaderValue.php @@ -24,10 +24,12 @@ public function __construct(string $value = '') { $this->setValue($value); } + public function __toString(): string { return $this->value; } + final public function inject(MessageInterface $message, bool $replace = true): MessageInterface { if (static::NAME === null) { @@ -55,6 +57,7 @@ public function withValue(string $value): self $clone->setValue($value); return $clone; } + public function getValue(): string { return $this->value; @@ -69,10 +72,12 @@ final public function withError(?Exception $error): self $clone->error = $error; return $clone; } + final public function hasError(): bool { return $this->error !== null; } + final public function getError(): ?Exception { return $this->error; @@ -92,16 +97,18 @@ protected function setValue(string $value): void { $this->value = $value; } + final protected function encodeQuotedString(string $string): string { return preg_replace('/([\\\\"])/', '\\\\$1', $string); } + final protected function validateDateTime(string $value): bool { return preg_match( - '/^\\w{3,}, [0-3]?\\d[ \\-]\\w{3}[ \\-]\\d+ [0-2]\\d:[0-5]\\d:[0-5]\\d \\w+|' + '/^\\w{3,}, [0-3]?\\d[ \\-]\\w{3}[ \\-]\\d+ [0-2]\\d:[0-5]\\d:[0-5]\\d \\w+|' . '\\w{3} \\w{3} [0-3]?\\d [0-2]\\d:[0-5]\\d:[0-5]\\d \\d+$/i', - trim($value) - ) === 1; + trim($value) + ) === 1; } } diff --git a/src/Header/Internal/DirectivesHeaderValue.php b/src/Header/Internal/DirectivesHeaderValue.php index 5dcea7d..ad2fdce 100644 --- a/src/Header/Internal/DirectivesHeaderValue.php +++ b/src/Header/Internal/DirectivesHeaderValue.php @@ -54,6 +54,7 @@ public function getDirective(): string { return $this->directive; } + public function getValue(): string { return $this->getDirective(); @@ -63,10 +64,12 @@ public function hasArgument(): bool { return $this->argument !== null; } + public function getArgument(): ?string { return $this->argument; } + public function getArgumentList(): array { return $this->argument === null ? [] : explode(',', $this->argument); @@ -89,6 +92,7 @@ protected function setValue(string $value): void { $this->setDirective($value); } + private function setDirective(string $value, string $argument = null, bool $trowError = false): bool { $name = strtolower($value); @@ -111,12 +115,14 @@ private function setDirective(string $value, string $argument = null, bool $trow if ($argument === null && ($argumentType & self::ARG_EMPTY) === self::ARG_EMPTY) { return $writeProperties(); - } elseif ($argumentType === self::ARG_EMPTY) { + } + if ($argumentType === self::ARG_EMPTY) { if ($argument !== null) { return $writeProperties(new InvalidArgumentException("{$name} directive should not have an argument")); } return $writeProperties(); - } elseif (($argumentType & self::ARG_HEADERS_LIST) === self::ARG_HEADERS_LIST) { + } + if (($argumentType & self::ARG_HEADERS_LIST) === self::ARG_HEADERS_LIST) { // Validate headers list $argument = $argument === null ? null : trim($argument); if ($argument === null || preg_match('/^[\\w\\-]+(?:(?:\\s*,\\s*)[\\w\\-]+)*$/', $argument) !== 1) { @@ -127,7 +133,8 @@ private function setDirective(string $value, string $argument = null, bool $trow ); } return $writeProperties(null, $argument, self::ARG_HEADERS_LIST); - } elseif (($argumentType & self::ARG_DELTA_SECONDS) === self::ARG_DELTA_SECONDS) { + } + if (($argumentType & self::ARG_DELTA_SECONDS) === self::ARG_DELTA_SECONDS) { $this->argumentType = self::ARG_DELTA_SECONDS; // Validate number if ($argument === null || preg_match('/^\\d+$/', $argument) !== 1) { diff --git a/src/Header/Internal/WithParamsHeaderValue.php b/src/Header/Internal/WithParamsHeaderValue.php index dd41d04..a470b4b 100644 --- a/src/Header/Internal/WithParamsHeaderValue.php +++ b/src/Header/Internal/WithParamsHeaderValue.php @@ -11,11 +11,13 @@ abstract class WithParamsHeaderValue extends BaseHeaderValue { /** * @see WithParams + * * @var array */ private array $params = []; /** * @link https://tools.ietf.org/html/rfc7231#section-5.3.1 + * * @var string value between 0.000 and 1.000 */ private string $quality = '1'; @@ -48,6 +50,7 @@ public static function createHeader(): Header /** * It makes sense to use only for HeaderValues that implement the WithParams interface + * * @param array $params */ public function withParams(array $params): self @@ -87,7 +90,7 @@ protected function setParams(array $params): void { $this->params = []; foreach ($params as $key => $value) { - # todo decide: what about numeric keys? + // todo decide: what about numeric keys? $key = strtolower($key); if (!array_key_exists($key, $this->params)) { $this->params[$key] = $value; diff --git a/src/Header/Parser/ParsingException.php b/src/Header/Parser/ParsingException.php index 3a130bb..f956663 100644 --- a/src/Header/Parser/ParsingException.php +++ b/src/Header/Parser/ParsingException.php @@ -10,6 +10,7 @@ final class ParsingException extends \Exception { private string $value; private int $position; + public function __construct(string $value, int $position, $message = '', $code = 0, Throwable $previous = null) { $this->value = $value; diff --git a/src/Header/Parser/ValueFieldParser.php b/src/Header/Parser/ValueFieldParser.php index 4d7ab04..d3bd314 100644 --- a/src/Header/Parser/ValueFieldParser.php +++ b/src/Header/Parser/ValueFieldParser.php @@ -63,7 +63,7 @@ public static function parse(string $body, string $class, HeaderParsingParams $p $state->buffer = ''; } elseif ($sym === ',' && $params->valuesList) { $state->value = trim($state->buffer); - yield static::createHeaderValue($class, $params, $state); + yield self::createHeaderValue($class, $params, $state); } else { $state->buffer .= $sym; } @@ -107,7 +107,7 @@ public static function parse(string $body, string $class, HeaderParsingParams $p } elseif ($sym === ',' && $params->valuesList) { $state->part = self::READ_VALUE; $state->addParamFromBuffer(); - yield static::createHeaderValue($class, $params, $state); + yield self::createHeaderValue($class, $params, $state); } else { $state->buffer = ''; throw new ParsingException($body, $pos, 'Delimiter char in a unquoted param value'); @@ -136,7 +136,7 @@ public static function parse(string $body, string $class, HeaderParsingParams $p $state->part = self::READ_PARAM_NAME; } elseif ($sym === ',' && $params->valuesList) { $state->part = self::READ_VALUE; - yield static::createHeaderValue($class, $params, $state); + yield self::createHeaderValue($class, $params, $state); } else { throw new ParsingException($body, $pos, 'Expected Separator'); } @@ -154,7 +154,7 @@ public static function parse(string $body, string $class, HeaderParsingParams $p $state->addParamFromBuffer(); } } - yield static::createHeaderValue($class, $params, $state); + yield self::createHeaderValue($class, $params, $state); } /** @@ -165,7 +165,7 @@ protected static function createHeaderValue( HeaderParsingParams $params, ValueFieldState $state ): BaseHeaderValue { - /** @var DirectivesHeaderValue|BaseHeaderValue $item */ + /** @var BaseHeaderValue|DirectivesHeaderValue $item */ $item = new $class($state->value); if ($params->directives && $item instanceof DirectivesHeaderValue) { if ($state->value === '' && count($state->params) > 0) { diff --git a/src/Header/Parser/ValueFieldState.php b/src/Header/Parser/ValueFieldState.php index 50f595e..b9b93c5 100644 --- a/src/Header/Parser/ValueFieldState.php +++ b/src/Header/Parser/ValueFieldState.php @@ -4,8 +4,6 @@ namespace Yiisoft\Http\Header\Parser; -use Yiisoft\Http\Header\Parser\ParsingException; - class ValueFieldState { public int $part = 0; @@ -18,15 +16,17 @@ class ValueFieldState public function addParam($key, $value): void { - if (!key_exists($key, $this->params)) { + if (!array_key_exists($key, $this->params)) { $this->params[$key] = $value; } } + public function addParamFromBuffer(): void { $this->addParam($this->key, $this->buffer); $this->key = $this->buffer = ''; } + public function clear(): void { $this->key = $this->buffer = $this->value = ''; diff --git a/src/Header/SortableHeader.php b/src/Header/SortableHeader.php index fb92f48..77d7acf 100644 --- a/src/Header/SortableHeader.php +++ b/src/Header/SortableHeader.php @@ -18,7 +18,7 @@ public function __construct(string $nameOrClass) parent::__construct($nameOrClass); if (!is_subclass_of($this->headerClass, WithParamsHeaderValue::class, true)) { throw new InvalidArgumentException( - sprintf("%s class does not implement %s", $this->headerClass, WithParamsHeaderValue::class) + sprintf('%s class does not implement %s', $this->headerClass, WithParamsHeaderValue::class) ); } } diff --git a/src/Header/Value/Cache/Age.php b/src/Header/Value/Cache/Age.php index cf474e6..d0f2062 100644 --- a/src/Header/Value/Cache/Age.php +++ b/src/Header/Value/Cache/Age.php @@ -16,6 +16,7 @@ final class Age extends BaseHeaderValue /** * @param int|string $value + * * @return BaseHeaderValue */ public function withValue($value): BaseHeaderValue diff --git a/src/Header/Value/Cache/CacheControl.php b/src/Header/Value/Cache/CacheControl.php index 20ea520..4641a45 100644 --- a/src/Header/Value/Cache/CacheControl.php +++ b/src/Header/Value/Cache/CacheControl.php @@ -29,6 +29,7 @@ class CacheControl extends DirectivesHeaderValue /** * Request and Response Directives: + * * @link https://tools.ietf.org/html/rfc7234#section-5.2.1 * @link https://tools.ietf.org/html/rfc7234#section-5.2.2 */ diff --git a/src/Header/Value/Cache/Warning.php b/src/Header/Value/Cache/Warning.php index ef424ad..5185dea 100644 --- a/src/Header/Value/Cache/Warning.php +++ b/src/Header/Value/Cache/Warning.php @@ -19,30 +19,35 @@ final class Warning extends BaseHeaderValue /** * A cache SHOULD generate this whenever the sent response is stale. + * * @link https://tools.ietf.org/html/rfc7234#section-5.5.1 */ public const RESPONSE_IS_STALE = 110; /** * A cache SHOULD generate this when sending a stale response because an attempt to validate the response failed, * due to an inability to reach the server. + * * @link https://tools.ietf.org/html/rfc7234#section-5.5.2 */ public const REVALIDATION_FAILED = 111; /** * A cache SHOULD generate this if it is intentionally disconnected from the rest of the network for a period of * time. + * * @link https://tools.ietf.org/html/rfc7234#section-5.5.3 */ public const DISCONNECTED_OPERATION = 112; /** * A cache SHOULD generate this if it heuristically chose a freshness lifetime greater than 24 hours and the * response's age is greater than 24 hours. + * * @link https://tools.ietf.org/html/rfc7234#section-5.5.4 */ public const HEURISTIC_EXPIRATION = 113; /** * The warning text can include arbitrary information to be presented to a human user or logged. A system receiving * this warning MUST NOT take any automated action, besides presenting the warning to the user. + * * @link https://tools.ietf.org/html/rfc7234#section-5.5.5 */ public const MISCELLANEOUS_WARNING = 199; @@ -50,12 +55,14 @@ final class Warning extends BaseHeaderValue * This Warning code MUST be added by a proxy if it applies any transformation to the representation, such as * changing the content-coding, media-type, or modifying the representation data, unless this Warning code already * appears in the response. + * * @link https://tools.ietf.org/html/rfc7234#section-5.5.6 */ public const TRANSFORMATION_APPLIED = 214; /** * The warning text can include arbitrary information to be presented to a human user or logged. A system receiving * this warning MUST NOT take any automated action. + * * @link https://tools.ietf.org/html/rfc7234#section-5.5.7 */ public const MISCELLANEOUS_PERSISTENT_WARNING = 299; @@ -69,7 +76,7 @@ final class Warning extends BaseHeaderValue public function __toString(): string { if ($this->useDataset) { - $result = "{$this->code} $this->agent \"" . $this->encodeQuotedString($this->text) . "\""; + $result = "{$this->code} $this->agent \"" . $this->encodeQuotedString($this->text) . '"'; if ($this->date !== null) { $result .= ' "' . $this->date->format(DateTimeInterface::RFC7231) . '"'; } @@ -82,14 +89,17 @@ public function getCode(): ?int { return $this->useDataset ? $this->code : null; } + public function getAgent(): ?string { return $this->useDataset ? $this->agent : null; } + public function getText(): ?string { return $this->useDataset ? $this->text : null; } + public function getDate(): ?DateTimeImmutable { return $this->useDataset ? $this->date : null; @@ -127,13 +137,13 @@ protected function setValue(string $value): void throw new ParsingException($value, 0, 'Text not defined.'); } if (preg_match( - '/^"(?(?:(?:\\\\.)+|[^\\\\"]+)*)"(?:(?:\\s+"(?[a-zA-Z0-9, \\-:]+)")?|\\s*)$/', - $parts[2], - $matches - ) !== 1) { + '/^"(?(?:(?:\\\\.)+|[^\\\\"]+)*)"(?:(?:\\s+"(?[a-zA-Z0-9, \\-:]+)")?|\\s*)$/', + $parts[2], + $matches + ) !== 1) { throw new ParsingException($parts[2], 0, 'Bad quoted string format.'); } - $this->text = preg_replace("/\\\\(.)/", '$1', $matches['text']); + $this->text = preg_replace('/\\\\(.)/', '$1', $matches['text']); // date if (isset($matches['date'])) { if (!$this->validateDateTime($matches['date'])) { @@ -149,7 +159,7 @@ protected function setValue(string $value): void parent::setValue($value); } - final private function resetValues() + private function resetValues() { $this->useDataset = false; $this->date = null; diff --git a/src/Header/Value/Condition/ETag.php b/src/Header/Value/Condition/ETag.php index e35c86d..194c9aa 100644 --- a/src/Header/Value/Condition/ETag.php +++ b/src/Header/Value/Condition/ETag.php @@ -22,9 +22,8 @@ public function __toString(): string { if ($this->toStringFromTag) { return ($this->weak ? 'W/' : '') . '"' . $this->tag . '"'; - } else { - return $this->value; } + return $this->value; } public function getTag(): string diff --git a/src/Header/Value/Date.php b/src/Header/Value/Date.php index dda2ad6..25b160c 100644 --- a/src/Header/Value/Date.php +++ b/src/Header/Value/Date.php @@ -18,6 +18,7 @@ class Date extends BaseHeaderValue /** * Date constructor. + * * @param DateTimeInterface|string $value */ public function __construct($value = '') diff --git a/tests/Header/AcceptHeaderTest.php b/tests/Header/AcceptHeaderTest.php index 29806a6..f5f5229 100644 --- a/tests/Header/AcceptHeaderTest.php +++ b/tests/Header/AcceptHeaderTest.php @@ -1,5 +1,7 @@ assertSame('Accept', $values->getName()); $this->assertSame(Accept::class, $values->getValueClass()); } + public function testErrorWithHeaderClass() { $this->expectException(InvalidArgumentException::class); new AcceptHeader(SortedHeaderValue::class); } + public function testCreateFromStringValues() { $header = (new AcceptHeader('Accept'))->withValue('*/*'); @@ -31,6 +35,7 @@ public function testCreateFromStringValues() $this->assertSame('Accept', $header->getName()); $this->assertSame(['*/*'], $header->getStrings()); } + public function testCreateFromFewStringValues() { $headers = [ @@ -53,6 +58,7 @@ public function testCreateFromFewStringValues() $header->getStrings() ); } + public function testParamsPrioritySortingWithSameQuality() { $headers = [ @@ -97,6 +103,7 @@ public function testAcceptPrioritySortingWithoutParams() $header->getStrings() ); } + public function testAcceptPrioritySortingOfIncorrectValuesDataWithoutParams() { $headers = 'foo/bar/*, foo/bar/baz, */bar, */*/*, foo/*/*, foo/*/baz, foo/bar'; @@ -117,6 +124,7 @@ public function testAcceptPrioritySortingOfIncorrectValuesDataWithoutParams() $header->getStrings() ); } + public function testAcceptCreateFromManyMixedStringValues() { $headers = [ @@ -166,6 +174,7 @@ public function testAcceptCharsetPrioritySortingWithoutParams() $header->getStrings() ); } + public function testAcceptCharsetSortingManyValues() { $headers = ['iso-8859-5, unicode-1-1;q=0.8, utf-8, undef/ned, *;q=0']; @@ -202,6 +211,7 @@ public function testAcceptEncodingPrioritySortingWithoutParams() $header->getStrings() ); } + public function testAcceptEncodingSortingManyValues() { $headers = [ @@ -248,6 +258,7 @@ public function testAcceptLanguagePrioritySortingWithoutParams() $header->getStrings() ); } + public function testAcceptLanguageCreateFromManyMixedStringValues() { $headers = [ diff --git a/tests/Header/DirectiveHeaderTest.php b/tests/Header/DirectiveHeaderTest.php index d7e3106..0ac1241 100644 --- a/tests/Header/DirectiveHeaderTest.php +++ b/tests/Header/DirectiveHeaderTest.php @@ -1,5 +1,7 @@ assertSame('Test-Directives', $values->getName()); $this->assertSame(DirectivesHeaderValue::class, $values->getValueClass()); } + public function testErrorWithHeaderClass() { $this->expectException(InvalidArgumentException::class); new DirectiveHeader(DummyHeaderValue::class); } + public function testCreateFromStringValue() { $header = (new DirectiveHeader('Directive-Test'))->withValue('value'); @@ -28,6 +32,7 @@ public function testCreateFromStringValue() $this->assertSame('Directive-Test', $header->getName()); $this->assertSame(['value'], $header->getStrings()); } + public function testCreateFromStringValueWithArgument() { $header = (new DirectiveHeader('Directive-Test'))->withValue('value=tEst'); @@ -35,6 +40,7 @@ public function testCreateFromStringValueWithArgument() $this->assertSame('Directive-Test', $header->getName()); $this->assertSame(['value=tEst'], $header->getStrings()); } + public function testCreateDirectivesFromFewStringValues() { $headers = [ diff --git a/tests/Header/HeaderTest.php b/tests/Header/HeaderTest.php index c1f147d..47b7746 100644 --- a/tests/Header/HeaderTest.php +++ b/tests/Header/HeaderTest.php @@ -1,5 +1,7 @@ assertSame('Date', $values->getName()); $this->assertSame(Date::class, $values->getValueClass()); } + public function testErrorWhenHeaderValueHasNoHeaderName() { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessageMatches('/no header name/'); new Header(SimpleValue::class); } + public function testErrorWithHeaderClass() { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessageMatches('/not a header/'); new Header(BaseHeaderValue::class); } + public function testErrorIfNotHeaderClass() { $this->expectException(InvalidArgumentException::class); new Header(\DateTimeImmutable::class); } + public function testWithValueImmutability() { $header = new Header('WWW-Authenticate'); @@ -49,6 +54,7 @@ public function testWithValueImmutability() $this->assertSame(get_class($header), get_class($clone)); $this->assertNotSame($header, $clone); } + public function testWithValuesImmutability() { $header = new Header('WWW-Authenticate'); @@ -58,6 +64,7 @@ public function testWithValuesImmutability() $this->assertSame(get_class($header), get_class($clone)); $this->assertNotSame($header, $clone); } + public function testCreateFromOneStringValue() { $headers = ['Newauth realm="apps", type=1, title="Login to \\"apps\\"", Basic realm="simple"']; @@ -67,6 +74,7 @@ public function testCreateFromOneStringValue() $this->assertSame('WWW-Authenticate', $header->getName()); $this->assertSame($headers, $header->getStrings()); } + public function testCreateFromFewStringValues() { $headers = [ @@ -86,6 +94,7 @@ public function testCreateFromFewStringValues() $this->assertSame('accept', $header->getName()); $this->assertSame($headers, $header->getStrings()); } + public function testAddObject() { $headers = [ @@ -99,6 +108,7 @@ public function testAddObject() $this->assertSame(Accept::NAME, $header->getName()); $this->assertSame(['text/*;q=0.3', 'text/html;q=0.7', '*/*'], $header->getStrings()); } + public function testExceptionWhenAddOtherClassObject() { $headers = [ @@ -176,6 +186,7 @@ public function valueAndParametersDataProvider(): array ], ]; } + /** * @dataProvider valueAndParametersDataProvider */ @@ -222,10 +233,11 @@ public function incorrectValueAndParametersDataProvider(): array 'brokenSyntax1' => ['a==b', true, '', [], ''], 'brokenSyntax2' => ['value; a *=b', true, 'value', [], 'value'], 'brokenSyntax3' => ['value;a *=b', true, 'value', [], 'value'], - # Invalid syntax but most browsers accept the umlaut with warn + // Invalid syntax but most browsers accept the umlaut with warn 'brokenToken2' => ['a=foo-ä.html', false, '', ['a' => 'foo-ä.html']], ]; } + /** * @dataProvider incorrectValueAndParametersDataProvider */ @@ -268,6 +280,7 @@ public function qualityParametersDataProvider(): array 'q0,05' => ['0,05', false], ]; } + /** * @dataProvider qualityParametersDataProvider */ @@ -295,6 +308,7 @@ public function listedValuesDataProvider(): array 'chars' => [',!@# $%^&*()!"№;%:=-?.,', ['', '!@# $%^&*()!"№;%:=-?.', '']], ]; } + /** * @dataProvider listedValuesDataProvider */ @@ -346,6 +360,7 @@ public function listedValuesWithParamsDataProvider(): array 'badSyntax2' => [';,', []], // no values added ]; } + /** * @dataProvider listedValuesWithParamsDataProvider */ diff --git a/tests/Header/Internal/BaseHeaderValueTest.php b/tests/Header/Internal/BaseHeaderValueTest.php index 216cc7f..6704680 100644 --- a/tests/Header/Internal/BaseHeaderValueTest.php +++ b/tests/Header/Internal/BaseHeaderValueTest.php @@ -1,5 +1,7 @@ assertSame(get_class($value), get_class($clone)); $this->assertNotSame($value, $clone); } + public function testWithErrorImmutability() { $value = new DummyHeaderValue(); @@ -31,6 +34,7 @@ public function testWithErrorImmutability() $this->assertSame(get_class($value), get_class($clone)); $this->assertNotSame($value, $clone); } + public function testCreateHeader() { $header = DummyHeaderValue::createHeader(); diff --git a/tests/Header/Internal/DirectivesHeaderValueTest.php b/tests/Header/Internal/DirectivesHeaderValueTest.php index 25a7714..63fc37e 100644 --- a/tests/Header/Internal/DirectivesHeaderValueTest.php +++ b/tests/Header/Internal/DirectivesHeaderValueTest.php @@ -1,5 +1,7 @@ assertSame(get_class($origin), get_class($clone)); $this->assertNotSame($origin, $clone); } + public function testWithDirectiveArg() { $value = (new DirectivesHeaderValue()) @@ -35,6 +38,7 @@ public function testWithDirectiveArg() $this->assertSame('foo', $value->getDirective()); $this->assertSame('bar', $value->getArgument()); } + public function testCreateHeader() { $header = DirectivesHeaderValue::createHeader(); @@ -42,24 +46,28 @@ public function testCreateHeader() $this->assertInstanceOf(DirectiveHeader::class, $header); $this->assertSame(DirectivesHeaderValue::NAME, $header->getName()); } + public function testToStringWithoutArgument() { $headerValue = (new DirectivesHeaderValue())->withDirective(DirectivesHeaderValue::EMPTY); $this->assertSame('empty', (string)$headerValue); } + public function testToStringNumericArgument() { $headerValue = (new DirectivesHeaderValue())->withDirective(DirectivesHeaderValue::NUMERIC, '1560'); $this->assertSame('numeric=1560', (string)$headerValue); } + public function testToStringEmptyListedArgument() { $headerValue = (new DirectivesHeaderValue())->withDirective(DirectivesHeaderValue::LIST_OR_EMPTY); $this->assertSame('list-or-empty', (string)$headerValue); } + public function testToStringListedArgument() { $headerValue = (new DirectivesHeaderValue())->withDirective(DirectivesHeaderValue::HEADER_LIST, 'etag'); @@ -73,12 +81,14 @@ public function testCustomDirective() $this->assertSame('custom-directive-name=custom_value', (string)$headerValue); } + public function testToStringEmptyDirective() { $headerValue = (new DirectivesHeaderValue()); $this->assertSame('', (string)$headerValue); } + public function testWithValue() { $headerValue = (new DirectivesHeaderValue())->withValue('test-value-to-directive'); @@ -132,6 +142,7 @@ public function withDirectiveDataProvider(): array ], ]; } + /** * @dataProvider withDirectiveDataProvider */ @@ -168,6 +179,7 @@ public function withDirectiveIncorrectDataProvider(): array 'argument-should-be-empty' => ['empty', 'null'], ]; } + /** * @dataProvider withDirectiveIncorrectDataProvider */ diff --git a/tests/Header/Internal/WithParamsHeaderValueTest.php b/tests/Header/Internal/WithParamsHeaderValueTest.php index 182c9db..17e36d1 100644 --- a/tests/Header/Internal/WithParamsHeaderValueTest.php +++ b/tests/Header/Internal/WithParamsHeaderValueTest.php @@ -1,5 +1,7 @@ assertInstanceOf(Header::class, $header); $this->assertSame(WithParamsHeaderValue::NAME, $header->getName()); } + public function testCreateHeaderQ() { $header = SortedHeaderValue::createHeader(); @@ -24,6 +27,7 @@ public function testCreateHeaderQ() $this->assertInstanceOf(SortableHeader::class, $header); $this->assertSame(SortedHeaderValue::NAME, $header->getName()); } + public function testWithParamsImmutability() { $value = new WithParamsHeaderValue(); @@ -33,6 +37,7 @@ public function testWithParamsImmutability() $this->assertSame(get_class($value), get_class($clone)); $this->assertNotSame($value, $clone); } + public function testBehaviorWithParams() { $params = ['q' => '0.4', 'param' => 'test', 'foo' => 'bar']; @@ -44,6 +49,7 @@ public function testBehaviorWithParams() $this->assertSame('1', $value->getQuality()); $this->assertEquals($params, $value->getParams()); } + public function testBehaviorWithQualityParam() { $params = ['param' => 'test', 'foo' => 'bar', 'q' => '0.4']; diff --git a/tests/Header/SortableHeaderTest.php b/tests/Header/SortableHeaderTest.php index 45d2c50..eb76913 100644 --- a/tests/Header/SortableHeaderTest.php +++ b/tests/Header/SortableHeaderTest.php @@ -1,5 +1,7 @@ assertSame('Test-Quality', $values->getName()); $this->assertSame(SortedHeaderValue::class, $values->getValueClass()); } + public function testErrorWithHeaderClass() { $this->expectException(InvalidArgumentException::class); new SortableHeader(DummyHeaderValue::class); } + public function testCreateFromStringValues() { $header = (new SortableHeader('Accept'))->withValue('*/*'); @@ -28,6 +32,7 @@ public function testCreateFromStringValues() $this->assertSame('Accept', $header->getName()); $this->assertSame(['*/*'], $header->getStrings()); } + public function testCreateFromFewStringValues() { $headers = [ @@ -50,6 +55,7 @@ public function testCreateFromFewStringValues() $header->getStrings() ); } + public function testCreateFromManyStringValues() { $headers = [ diff --git a/tests/Header/Value/Cache/AgeTest.php b/tests/Header/Value/Cache/AgeTest.php index b103315..9f89c01 100644 --- a/tests/Header/Value/Cache/AgeTest.php +++ b/tests/Header/Value/Cache/AgeTest.php @@ -16,13 +16,15 @@ public function testWithValueIntegerZero() $this->assertFalse($value->hasError()); $this->assertSame('0', $value->getValue()); } + public function testWithValueMaxInteger() { $value = (new Age())->withValue(PHP_INT_MAX); $this->assertFalse($value->hasError()); - $this->assertSame(strval(PHP_INT_MAX), $value->getValue()); + $this->assertSame((string) PHP_INT_MAX, $value->getValue()); } + public function testWithValueMinInteger() { $value = (new Age())->withValue(PHP_INT_MIN); @@ -39,6 +41,7 @@ public function withValueCorrectDataProvider(): array ['123456'], ]; } + /** * @dataProvider withValueCorrectDataProvider */ @@ -66,6 +69,7 @@ public function withValueIncorrectDataProvider(): array 'exp' => ['3e5'], ]; } + /** * @dataProvider withValueIncorrectDataProvider */ diff --git a/tests/Header/Value/Cache/WarningTest.php b/tests/Header/Value/Cache/WarningTest.php index 6148979..11ef07c 100644 --- a/tests/Header/Value/Cache/WarningTest.php +++ b/tests/Header/Value/Cache/WarningTest.php @@ -30,12 +30,14 @@ public function testWithDatasetImmutability() $this->assertSame(get_class($origin), get_class($clone)); $this->assertNotSame($origin, $clone); } + public function testToStringWithoutDate() { $headerValue = (new Warning())->withDataset(100, '-', 'test'); $this->assertSame('100 - "test"', (string)$headerValue); } + public function testWithValueParsing() { $headerValue = (new Warning())->withValue('100 localhost "test" "Wed, 01 Jan 2020 00:00:00 GMT"'); @@ -64,6 +66,7 @@ public function withValueDataProvider(): array 'time-deprecated' => ['100 - "" "Fri Jul 4 08:42:36 2008"', 100, '-', '', '2008-07-04-08-42-36'], ]; } + /** * @dataProvider withValueDataProvider */ @@ -79,7 +82,9 @@ public function testwithValueCorrect( $this->assertSame($code, $headerValue->getCode()); $this->assertSame($agent, $headerValue->getAgent()); $this->assertSame($text, $headerValue->getText()); - $this->assertSame($date, $headerValue->getDate() === null + $this->assertSame( + $date, + $headerValue->getDate() === null ? null : $headerValue->getDate()->format('Y-m-d-H-i-s') ); @@ -108,6 +113,7 @@ public function withValueIncorrectDataProvider(): array 'datetime-deprecated-1' => ['100 - "" "Fri Jul 99 08:42:36 2008"'], ]; } + /** * @dataProvider withValueIncorrectDataProvider */ diff --git a/tests/Header/Value/Condition/ETagTest.php b/tests/Header/Value/Condition/ETagTest.php index 8318a6e..13596f1 100644 --- a/tests/Header/Value/Condition/ETagTest.php +++ b/tests/Header/Value/Condition/ETagTest.php @@ -1,5 +1,7 @@ assertSame(get_class($origin), get_class($clone)); $this->assertNotSame($origin, $clone); } + public function testToStringFromTag() { $origin = (new ETag())->withValue('"origin-tag"'); @@ -31,6 +34,7 @@ public function testToStringFromTag() $this->assertSame('W/"new-tag"', (string)$clone); } + public function testToStringFromValue() { $origin = (new ETag())->withTag('origin-tag', false); @@ -39,6 +43,7 @@ public function testToStringFromValue() $this->assertSame('W/"new-tag"', (string)$clone); } + public function testToStringFromIncorrectValue() { $origin = (new ETag())->withTag('origin-tag', false); @@ -61,6 +66,7 @@ public function withValueDataProvider(): array 'hard-space' => ['"' . chr(127) . '"', true, true, ''], ]; } + /** * @dataProvider withValueDataProvider */ diff --git a/tests/Header/Value/DateTest.php b/tests/Header/Value/DateTest.php index cb66329..161c4f4 100644 --- a/tests/Header/Value/DateTest.php +++ b/tests/Header/Value/DateTest.php @@ -1,5 +1,7 @@ assertSame(get_class($value), get_class($clone)); $this->assertNotSame($value, $clone); } + public function testToString() { $dateStr = 'Friday, 04-Jul-08 08:42:36 GMT'; @@ -29,6 +32,7 @@ public function testToString() $this->assertSame('Fri, 04 Jul 2008 08:42:36 GMT', (string)$value); } + public function testToStringIncorrect() { $dateStr = 'not a date'; @@ -37,6 +41,7 @@ public function testToStringIncorrect() $this->assertTrue($value->hasError()); $this->assertSame('not a date', (string)$value); } + public function testGetDatetimeValue() { $dateStr = 'Fri Jul 4 08:42:36 2008'; @@ -44,6 +49,7 @@ public function testGetDatetimeValue() $this->assertInstanceOf(\DateTimeImmutable::class, $value->getDatetimeValue()); } + public function testRFC7231() { $dateStr = 'Fri, 04 Jul 2008 08:42:36 GMT'; @@ -52,6 +58,7 @@ public function testRFC7231() $this->assertInstanceOf(\DateTimeImmutable::class, $value->getDatetimeValue()); $this->assertSame('Fri, 04 Jul 2008 08:42:36 GMT', (string)$value); } + public function testRFC850() { $dateStr = 'Friday, 04-Jul-08 08:42:36 GMT'; @@ -60,17 +67,20 @@ public function testRFC850() $this->assertInstanceOf(\DateTimeImmutable::class, $value->getDatetimeValue()); $this->assertSame('Fri, 04 Jul 2008 08:42:36 GMT', (string)$value); } + public function testConstructWithDatetimeInterface() { $date = new \DateTime(); $value = (new Date($date)); $this->assertInstanceOf(\DateTimeImmutable::class, $value->getDatetimeValue()); } + public function testGetDatetimeValueFromEmpty() { $value = (new Date()); $this->assertInstanceOf(\DateTimeImmutable::class, $value->getDatetimeValue()); } + public function testGetDatetimeValueFromIncorrect() { $value = (new Date())->withValue('not a date'); @@ -79,6 +89,7 @@ public function testGetDatetimeValueFromIncorrect() $this->assertNull($value->getDatetimeValue()); $this->assertSame('not a date', (string)$value); } + public function testGetDatetimeValueFromIncorrectForHttp() { $value = (new Date())->withValue('2008-07-12 10:15'); diff --git a/tests/Header/Value/Stub/DirectivesHeaderValue.php b/tests/Header/Value/Stub/DirectivesHeaderValue.php index 8842483..c4d333f 100644 --- a/tests/Header/Value/Stub/DirectivesHeaderValue.php +++ b/tests/Header/Value/Stub/DirectivesHeaderValue.php @@ -1,5 +1,7 @@