diff --git a/README.md b/README.md index 56a21a2..10d5c8f 100644 --- a/README.md +++ b/README.md @@ -20,87 +20,10 @@ The package provides: - PSR-7, PSR-17 PhpStorm meta for HTTP protocol headers, methods and statuses. - `ContentDispositionHeader` that has static methods to generate `Content-Disposition` header name and value. -## 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::ALL; -``` - -## 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]; -``` - -## `ContentDispositionHeader` usage - -`ContentDispositionHeader` methods are static so usage is like the following: - -```php -$name = \Yiisoft\Http\ContentDispositionHeader::name(); - -$value = \Yiisoft\Http\ContentDispositionHeader::value( - \Yiisoft\Http\ContentDispositionHeader::INLINE, - 'avatar.png' -); - -$value = \Yiisoft\Http\ContentDispositionHeader::inline('document.pdf'); - -$value = \Yiisoft\Http\ContentDispositionHeader::attachment('document.pdf'); -``` - -## PSR-7 and PSR-17 PhpStorm meta - -The package includes PhpStorm meta-files that help IDE to provide values when completing code in cases such as: - -```php -use Psr\Http\Message\ResponseFactoryInterface; -use Psr\Http\Message\ResponseInterface; -use Yiisoft\Http\Header; -use Yiisoft\Http\Status; - -class StaticController -{ - private ResponseFactoryInterface $responseFactory; - - public function actionIndex(): ResponseInterface - { - return $this->responseFactory->createResponse() - ->withStatus(Status::OK) - ->withoutHeader(Header::ACCEPT); - } -} -``` +- [English](docs/en/README.md) +- [Russian](docs/ru/README.md) ## Testing 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/en/README.md b/docs/en/README.md new file mode 100644 index 0000000..5e21008 --- /dev/null +++ b/docs/en/README.md @@ -0,0 +1,18 @@ +# HTTP Package + +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 headers](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..a7cd352 --- /dev/null +++ b/docs/en/http-start-line.md @@ -0,0 +1,87 @@ +# 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; +``` + +To have a list of these, use: + +```php +use Yiisoft\Http\Method; + +Method::ALL; +``` + +## 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]; +``` + +# ContentDispositionHeader usage + +`ContentDispositionHeader` methods are static so usage is like the following: + +```php +$name = \Yiisoft\Http\ContentDispositionHeader::name(); + +$value = \Yiisoft\Http\ContentDispositionHeader::value( + \Yiisoft\Http\ContentDispositionHeader::INLINE, + 'avatar.png' +); + +$value = \Yiisoft\Http\ContentDispositionHeader::inline('document.pdf'); + +$value = \Yiisoft\Http\ContentDispositionHeader::attachment('document.pdf'); +``` + +# PSR-7 and PSR-17 PhpStorm meta + +The package includes PhpStorm meta-files that help IDE to provide values when completing code in cases such as: + +```php +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\ResponseInterface; +use Yiisoft\Http\Header; +use Yiisoft\Http\Status; + +class StaticController +{ + private ResponseFactoryInterface $responseFactory; + + public function actionIndex(): ResponseInterface + { + return $this->responseFactory->createResponse() + ->withStatus(Status::OK) + ->withoutHeader(Header::ACCEPT); + } +} +``` diff --git a/docs/ru/README.md b/docs/ru/README.md new file mode 100644 index 0000000..177c043 --- /dev/null +++ b/docs/ru/README.md @@ -0,0 +1,18 @@ +# HTTP Package + +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..fa12abc --- /dev/null +++ b/docs/ru/http-headers.md @@ -0,0 +1,193 @@ +# HTTP заголовки + +## Список HTTP заголовков + + +Пакет `yiisoft/http` предоставляет инструменты для парсинга и генерирования HTTP заголовков с учётом их особенностей + +## Класс Header + +Абсолютно любой заголовок в поле заголовков HTTP-сообщения может быть указан несколько раз, даже если это не разрешается +в RFC самого заголовка. Поэтому в результате парсинга любого заголовка вы получите коллекцию значений. + +Базовым классом для коллекции значений заголовков является `\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); +// Или +$header = Allow::extract($request); + +// Записать заголовки в объект ответа +$response = $header->inject($response, $replace = true); +``` + +## Заголовки + +### Date + +Вы можете использовать класс `Date` для конвертирования объекта `\DateTimeInterface` в строку формата времени HTTP. + +```php +$date = new \Yiisoft\Http\Header\Value\Date(new DateTimeImmutable('2020-01-01 00:00:00 +0000')); + +echo $date; +// Wed, 01 Jan 2020 00:00:00 GMT +``` + +### 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); +``` + +## Заголовки кеширования + +[RFC7234](https://tools.ietf.org/html/rfc7234) + +### Age + +В заголовке `Age` задаётся количество секунд с момента модификации ресурса. Поэтому все значения, имеющие символы, +отличные от десятичных цифр, будут помечены как невалидные. + +### Expires + +Дата истечения срока актуальности сущности. Класс заголовка `Expires` наследуется от класса `Date`. + +```php +use Yiisoft\Http\Header\Value\Cache\Expires; +/** @var \Psr\Http\Message\ResponseInterface $response */ + +$dateHeader = (new Expires(new DateTimeImmutable('+1 day'))) + ->inject($response); +``` + +### Warning + +Заголовок для дополнительной информации об ошибках. +```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::REVALIDATION_FAILED, '-', 'Revalidation failed')) + ->inject($response); +``` + +### 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 */ + +// извлечь из запроса заголовок 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']); + +// создать заголовок 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/AcceptHeader.php b/src/Header/AcceptHeader.php new file mode 100644 index 0000000..026756e --- /dev/null +++ b/src/Header/AcceptHeader.php @@ -0,0 +1,73 @@ +headerClass, Accept::class, true)) { + throw new InvalidArgumentException( + sprintf('%s class is not an instance of %s', $this->headerClass, Accept::class) + ); + } + } + + /** + * Add value in order + */ + protected function collect(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; + } + if ($result === 0) { + $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; + } + if ($result === 0) { + foreach ($itemTypes as $part => $itemType) { + if ($itemType === '*' xor $valueTypes[$part] === '*') { + if ($itemType !== '*') { + break 2; + } + $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/DateHeader.php b/src/Header/DateHeader.php new file mode 100644 index 0000000..1bb8a64 --- /dev/null +++ b/src/Header/DateHeader.php @@ -0,0 +1,26 @@ +format(DateTimeInterface::RFC7231); + } + parent::addValue($value); + } +} diff --git a/src/Header/DirectiveHeader.php b/src/Header/DirectiveHeader.php new file mode 100644 index 0000000..9bcabce --- /dev/null +++ b/src/Header/DirectiveHeader.php @@ -0,0 +1,52 @@ +headerClass, DirectivesHeaderValue::class, true)) { + throw new InvalidArgumentException( + sprintf('%s class is not an instance of %s', $this->headerClass, DirectivesHeaderValue::class) + ); + } + } + + public function withDirective(string $directive, string $argument = null): self + { + $clone = clone $this; + /** @var DirectivesHeaderValue $headerValue */ + $headerValue = new $this->headerClass(); + $clone->addValue($headerValue->withDirective($directive, $argument)); + return $clone; + } + + /** + * @param bool $ignoreIncorrect + * + * @return null[][]|string[][] Returns array of array directive value> + * @psalm-return array>|array + */ + public function getDirectives(bool $ignoreIncorrect = true): array + { + $result = []; + /** @var DirectivesHeaderValue $header */ + foreach ($this->collection as $header) { + if ($ignoreIncorrect && $header->hasError()) { + continue; + } + $result[] = [$header->getDirective() => $header->getArgument()]; + } + return $result; + } +} diff --git a/src/Header/Header.php b/src/Header/Header.php new file mode 100644 index 0000000..736ac32 --- /dev/null +++ b/src/Header/Header.php @@ -0,0 +1,203 @@ + */ + protected string $headerClass; + protected string $headerName; + /** @var array */ + protected array $collection = []; + + protected const DEFAULT_VALUE_CLASS = SimpleValue::class; + + /** + * @param string|class-string $nameOrClass Header name or header value class + * @psalm-param class-string $nameOrClass + */ + public function __construct(string $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.'); + } + $this->headerName = $nameOrClass; + $this->headerClass = static::DEFAULT_VALUE_CLASS; + } + } + + final public function getIterator(): iterable + { + yield from $this->getValues(true); + } + + final public function count(): int + { + return count($this->collection); + } + + final public function getName(): string + { + return $this->headerName; + } + + final public function getValueClass(): string + { + return $this->headerClass; + } + + /** + * @param bool $ignoreIncorrect + * + * @return BaseHeaderValue[] + */ + public function getValues(bool $ignoreIncorrect = true): array + { + $result = []; + foreach ($this->collection as $header) { + if ($ignoreIncorrect && $header->hasError()) { + continue; + } + $result[] = $header; + } + return $result; + } + + /** + * @param bool $ignoreIncorrect + * + * @return string[] + */ + public function getStrings(bool $ignoreIncorrect = true): array + { + $result = []; + foreach ($this->collection as $header) { + if ($ignoreIncorrect && $header->hasError()) { + continue; + } + $result[] = $header->__toString(); + } + return $result; + } + + /** + * @param BaseHeaderValue[]|string[] $values + */ + final public function withValues(array $values): self + { + $clone = clone $this; + foreach ($values as $value) { + $clone->addValue($value); + } + return $clone; + } + + /** + * @param BaseHeaderValue|string $value + */ + final public function withValue($value): self + { + $clone = clone $this; + $clone->addValue($value); + return $clone; + } + + /** + * 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( + 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; + } + + /** + * Import header values from HTTP message + * + * @param MessageInterface $message Request or Response instance + */ + final public function extract(MessageInterface $message): self + { + return $this->withValues($message->getHeader($this->headerName)); + } + + /** + * @param BaseHeaderValue|string $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 + { + /** + * @var HeaderParsingParams $parsingParams + * + * @see BaseHeaderValue::getParsingParams + */ + $parsingParams = $this->headerClass::getParsingParams(); + + 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 new file mode 100644 index 0000000..b558f1c --- /dev/null +++ b/src/Header/Internal/BaseHeaderValue.php @@ -0,0 +1,114 @@ +setValue($value); + } + + 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 + { + 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; + $clone->setValue($value); + return $clone; + } + + public function getValue(): string + { + return $this->value; + } + + /** + * @param Exception|null $error + */ + 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(): HeaderParsingParams + { + $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 + { + $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..ad2fdce --- /dev/null +++ b/src/Header/Internal/DirectivesHeaderValue.php @@ -0,0 +1,151 @@ +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); + } + + 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 + * + * @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(); + } + if ($argumentType === self::ARG_EMPTY) { + if ($argument !== null) { + return $writeProperties(new InvalidArgumentException("{$name} directive should not have an argument")); + } + return $writeProperties(); + } + 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) { + 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); + } + 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) { + 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..a470b4b --- /dev/null +++ b/src/Header/Internal/WithParamsHeaderValue.php @@ -0,0 +1,108 @@ + + */ + 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 + */ + public function withParams(array $params): self + { + $clone = clone $this; + $clone->setParams($params); + return $clone; + } + + /** + * @return array + */ + 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 (!array_key_exists($key, $this->params)) { + $this->params[$key] = $value; + } + } + if (static::PARSING_Q_PARAM) { + if (array_key_exists('q', $this->params)) { + $this->setQuality($this->params['q']); + unset($this->params['q']); + } else { + $this->setQuality('1'); + } + } + } +} 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 @@ +value = $value; + $this->position = $position; + parent::__construct($message, $code, $previous); + } +} diff --git a/src/Header/Parser/ValueFieldParser.php b/src/Header/Parser/ValueFieldParser.php new file mode 100644 index 0000000..d3bd314 --- /dev/null +++ b/src/Header/Parser/ValueFieldParser.php @@ -0,0 +1,183 @@ +?@[\\]{}', + READ_NONE = 0, + READ_VALUE = 1, + READ_PARAM_NAME = 2, + READ_PARAM_QUOTED_VALUE = 3, + READ_PARAM_VALUE = 4; + + /** + * @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; + } + + $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 self::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 self::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'); + } + $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; + } + if ($sym === ';' && $params->withParams) { + $state->part = self::READ_PARAM_NAME; + } elseif ($sym === ',' && $params->valuesList) { + $state->part = self::READ_VALUE; + yield self::createHeaderValue($class, $params, $state); + } else { + throw new ParsingException($body, $pos, 'Expected Separator'); + } + } + } + } catch (ParsingException $e) { + $state->error = $e; + } + 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 self::createHeaderValue($class, $params, $state); + } + + /** + * @psalm-param class-string $class + */ + protected static function createHeaderValue( + string $class, + HeaderParsingParams $params, + ValueFieldState $state + ): BaseHeaderValue { + /** @var BaseHeaderValue|DirectivesHeaderValue $item */ + $item = new $class($state->value); + if ($params->directives && $item instanceof DirectivesHeaderValue) { + 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..b9b93c5 --- /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 new file mode 100644 index 0000000..77d7acf --- /dev/null +++ b/src/Header/SortableHeader.php @@ -0,0 +1,46 @@ +headerClass, WithParamsHeaderValue::class, true)) { + throw new InvalidArgumentException( + sprintf('%s class does not implement %s', $this->headerClass, WithParamsHeaderValue::class) + ); + } + } + + /** + * Add value in order + */ + protected function collect(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) { + $this->collection[$pos + 1] = $value; + return; + } + $this->collection[$pos + 1] = $item; + } + $this->collection[0] = $value; + } +} diff --git a/src/Header/Value/Accept/Accept.php b/src/Header/Value/Accept/Accept.php new file mode 100644 index 0000000..17ba749 --- /dev/null +++ b/src/Header/Value/Accept/Accept.php @@ -0,0 +1,25 @@ +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 new file mode 100644 index 0000000..4641a45 --- /dev/null +++ b/src/Header/Value/Cache/CacheControl.php @@ -0,0 +1,53 @@ + self::ARG_DELTA_SECONDS, + self::MIN_FRESH => self::ARG_DELTA_SECONDS, + self::ONLY_IF_CACHED => self::ARG_EMPTY, + // Response Directives + self::MUST_REVALIDATE => self::ARG_EMPTY, + self::PUBLIC => self::ARG_EMPTY, + self::PRIVATE => self::ARG_HEADERS_LIST | self::ARG_EMPTY, + self::PROXY_REVALIDATE => self::ARG_EMPTY, + 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, + ]; +} diff --git a/src/Header/Value/Cache/Expires.php b/src/Header/Value/Cache/Expires.php new file mode 100644 index 0000000..a42f85d --- /dev/null +++ b/src/Header/Value/Cache/Expires.php @@ -0,0 +1,12 @@ +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 (!$this->validateDateTime($matches['date'])) { + 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); + } + + 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 new file mode 100644 index 0000000..194c9aa --- /dev/null +++ b/src/Header/Value/Condition/ETag.php @@ -0,0 +1,60 @@ +toStringFromTag) { + return ($this->weak ? 'W/' : '') . '"' . $this->tag . '"'; + } + 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/'; + $this->error = null; + } 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 new file mode 100644 index 0000000..7b06bd2 --- /dev/null +++ b/src/Header/Value/Condition/IfModifiedSince.php @@ -0,0 +1,15 @@ +format(DateTimeInterface::RFC7231); + } + 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); + } + + final public function getDatetimeValue(): ?DateTimeImmutable + { + 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 { + 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; + } + parent::setValue($value); + } +} diff --git a/src/Header/Value/Forwarded.php b/src/Header/Value/Forwarded.php new file mode 100644 index 0000000..68500bd --- /dev/null +++ b/src/Header/Value/Forwarded.php @@ -0,0 +1,17 @@ +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('*/*'); + + $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'))->withValues($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'))->withValues($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 testAcceptPrioritySortingWithoutParams() + { + $headers = ' text/*, text/plain, text/plain;format=flowed, */*'; + + $header = (new AcceptHeader('Accept'))->withValue($headers); + + $this->assertSame('Accept', $header->getName()); + $this->assertSame( + [ + 'text/plain;format=flowed', + 'text/plain', + 'text/*', + '*/*', + ], + $header->getStrings() + ); + } + + public function testAcceptPrioritySortingOfIncorrectValuesDataWithoutParams() + { + $headers = 'foo/bar/*, foo/bar/baz, */bar, */*/*, foo/*/*, foo/*/baz, foo/bar'; + + $header = (new AcceptHeader('Accept'))->withValue($headers); + + $this->assertSame('Accept', $header->getName()); + $this->assertSame( + [ + 'foo/bar/baz', + 'foo/*/baz', + 'foo/bar/*', + 'foo/*/*', + '*/*/*', + 'foo/bar', + '*/bar', + ], + $header->getStrings() + ); + } + + public function testAcceptCreateFromManyMixedStringValues() + { + $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'))->withValues($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() + ); + } + + public function testAcceptCharsetPrioritySortingWithoutParams() + { + $headers = '*, utf-8, iso-8859-5'; + + $header = AcceptCharset::createHeader()->withValue($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()->withValues($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()->withValue($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()->withValues($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()->withValues($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()->withValues($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/DirectiveHeaderTest.php b/tests/Header/DirectiveHeaderTest.php new file mode 100644 index 0000000..0ac1241 --- /dev/null +++ b/tests/Header/DirectiveHeaderTest.php @@ -0,0 +1,63 @@ +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 new file mode 100644 index 0000000..47b7746 --- /dev/null +++ b/tests/Header/HeaderTest.php @@ -0,0 +1,376 @@ +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'); + + $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'))->withValues($headers); + + $this->assertSame('WWW-Authenticate', $header->getName()); + $this->assertSame($headers, $header->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', + '*/*', + ]; + + $header = (new Header('accept'))->withValues($headers); + + $this->assertSame('accept', $header->getName()); + $this->assertSame($headers, $header->getStrings()); + } + + public function testAddObject() + { + $headers = [ + 'text/*;q=0.3', + 'text/html;q=0.7', + ]; + $header = (new Header(Accept::class))->withValues($headers); + + $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 testExceptionWhenAddOtherClassObject() + { + $headers = [ + 'text/*;q=0.3', + 'text/html;q=0.7', + ]; + $header = (new Header(Accept::class))->withValues($headers); + + $this->expectException(InvalidArgumentException::class); + + $header->withValue(new Date('*/*')); + } + + public function valueAndParametersDataProvider(): array + { + return [ + 'empty' => ['', '', []], + 'noParams' => ['test', 'test', []], + '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']], + 'simple4' => ['*;q=0', '*', ['q' => '0']], + 'quotedParameter' => [ + 'test;noquote=test;quote="test2"', + 'test', + ['noquote' => 'test', 'quote' => 'test2'], + 'test;noquote=test;quote=test2', + ], + '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="\\"\\""', + ], + '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', + ['q' => '1.0', 'version' => '2'], + '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', + ], + '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', + ], + ]; + } + + /** + * @dataProvider valueAndParametersDataProvider + */ + public function testParsingAndRepackOfValueAndParams( + string $input, + string $value, + array $params, + string $output = null + ): void { + $header = (new Header(WithParamsHeaderValue::class))->withValue($input); + /** @var WithParamsHeaderValue $headerValue */ + $headerValue = $header->getValues(true)[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'], + '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 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'], + '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, '', [], ''], + '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']], + ]; + } + + /** + * @dataProvider incorrectValueAndParametersDataProvider + */ + public function testParsingAndRepackOfIncorrectValueAndParams( + string $input, + bool $withError, + string $value, + array $params, + string $output = null + ): void { + $header = (new Header(WithParamsHeaderValue::class))->withValue($input); + /** @var WithParamsHeaderValue $headerValue */ + $headerValue = $header->getValues(false)[0]; + + $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 + { + 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 SortedHeaderValue())->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))->withValue($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))->withValue($input); + $result = []; + foreach ($header->getValues(true) as $value) { + $result[] = [$value->getValue(), $value->getParams()]; + } + $this->assertSame($valueParams, $result); + } +} diff --git a/tests/Header/Internal/BaseHeaderValueTest.php b/tests/Header/Internal/BaseHeaderValueTest.php new file mode 100644 index 0000000..6704680 --- /dev/null +++ b/tests/Header/Internal/BaseHeaderValueTest.php @@ -0,0 +1,45 @@ +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..63fc37e --- /dev/null +++ b/tests/Header/Internal/DirectivesHeaderValueTest.php @@ -0,0 +1,193 @@ +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/Internal/WithParamsHeaderValueTest.php b/tests/Header/Internal/WithParamsHeaderValueTest.php new file mode 100644 index 0000000..17e36d1 --- /dev/null +++ b/tests/Header/Internal/WithParamsHeaderValueTest.php @@ -0,0 +1,64 @@ +assertInstanceOf(Header::class, $header); + $this->assertSame(WithParamsHeaderValue::NAME, $header->getName()); + } + + public function testCreateHeaderQ() + { + $header = SortedHeaderValue::createHeader(); + + $this->assertInstanceOf(SortableHeader::class, $header); + $this->assertSame(SortedHeaderValue::NAME, $header->getName()); + } + + public function testWithParamsImmutability() + { + $value = new WithParamsHeaderValue(); + + $clone = $value->withParams([]); + + $this->assertSame(get_class($value), get_class($clone)); + $this->assertNotSame($value, $clone); + } + + 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/SortableHeaderTest.php b/tests/Header/SortableHeaderTest.php new file mode 100644 index 0000000..eb76913 --- /dev/null +++ b/tests/Header/SortableHeaderTest.php @@ -0,0 +1,91 @@ +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('*/*'); + + $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'))->withValues($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'))->withValues($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() + ); + } +} diff --git a/tests/Header/Value/Cache/AgeTest.php b/tests/Header/Value/Cache/AgeTest.php new file mode 100644 index 0000000..9f89c01 --- /dev/null +++ b/tests/Header/Value/Cache/AgeTest.php @@ -0,0 +1,83 @@ +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((string) 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/WarningTest.php b/tests/Header/Value/Cache/WarningTest.php new file mode 100644 index 0000000..11ef07c --- /dev/null +++ b/tests/Header/Value/Cache/WarningTest.php @@ -0,0 +1,126 @@ +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()); + } +} diff --git a/tests/Header/Value/Condition/ETagTest.php b/tests/Header/Value/Condition/ETagTest.php new file mode 100644 index 0000000..13596f1 --- /dev/null +++ b/tests/Header/Value/Condition/ETagTest.php @@ -0,0 +1,81 @@ +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()); + } +} diff --git a/tests/Header/Value/DateTest.php b/tests/Header/Value/DateTest.php new file mode 100644 index 0000000..161c4f4 --- /dev/null +++ b/tests/Header/Value/DateTest.php @@ -0,0 +1,101 @@ +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 = 'Friday, 04-Jul-08 08:42:36 GMT'; + $value = (new Date())->withValue($dateStr); + + $this->assertSame('Fri, 04 Jul 2008 08:42:36 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 = '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(); + $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); + } + + 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..c4d333f --- /dev/null +++ b/tests/Header/Value/Stub/DirectivesHeaderValue.php @@ -0,0 +1,24 @@ + 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 new file mode 100644 index 0000000..4506d32 --- /dev/null +++ b/tests/Header/Value/Stub/DummyHeaderValue.php @@ -0,0 +1,12 @@ +