diff options
Diffstat (limited to 'vendor/bakame/http-structured-fields')
32 files changed, 5282 insertions, 0 deletions
diff --git a/vendor/bakame/http-structured-fields/LICENSE b/vendor/bakame/http-structured-fields/LICENSE new file mode 100644 index 000000000..225c502ed --- /dev/null +++ b/vendor/bakame/http-structured-fields/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Nyamagana Butera + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/bakame/http-structured-fields/composer.json b/vendor/bakame/http-structured-fields/composer.json new file mode 100644 index 000000000..9a8241ee2 --- /dev/null +++ b/vendor/bakame/http-structured-fields/composer.json @@ -0,0 +1,106 @@ +{ + "name": "bakame/http-structured-fields", + "description": "A PHP library that parses, validates and serializes HTTP structured fields according to RFC9561 and RFC8941", + "type": "library", + "keywords": [ + "http", + "http headers", + "http trailers", + "headers", + "trailers", + "structured fields", + "structured headers", + "structured trailers", + "structured values", + "parser", + "serializer", + "validation", + "rfc8941", + "rfc9651" + ], + "license": "MIT", + "authors": [ + { + "name" : "Ignace Nyamagana Butera", + "email" : "nyamsprod@gmail.com", + "homepage" : "https://github.com/nyamsprod/", + "role" : "Developer" + } + ], + "support": { + "docs": "https://github.com/bakame-php/http-structured-fields", + "issues": "https://github.com/bakame-php/http-structured-fields/issues", + "source": "https://github.com/bakame-php/http-structured-fields" + }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nyamsprod" + } + ], + "require": { + "php" : "^8.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.65.0", + "httpwg/structured-field-tests": "*@dev", + "phpstan/phpstan": "^2.0.3", + "phpstan/phpstan-strict-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0.1", + "phpstan/phpstan-deprecation-rules": "^2.0.1", + "phpunit/phpunit": "^10.5.38 || ^11.5.0", + "symfony/var-dumper": "^6.4.15 || ^v7.2.0", + "bakame/aide-base32": "dev-main", + "phpbench/phpbench": "^1.3.1" + }, + "autoload": { + "psr-4": { + "Bakame\\Http\\StructuredFields\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Bakame\\Http\\StructuredFields\\": "tests/" + } + }, + "scripts": { + "benchmark": "phpbench run --report=default", + "phpcs": "php-cs-fixer fix --dry-run --diff -vvv --allow-risky=yes --ansi", + "phpcs:fix": "php-cs-fixer fix -vvv --allow-risky=yes --ansi", + "phpstan": "phpstan analyse -c phpstan.neon --ansi --memory-limit 192M", + "phpunit": "XDEBUG_MODE=coverage phpunit --coverage-text", + "phpunit:min": "phpunit --no-coverage", + "test": [ + "@phpunit", + "@phpstan", + "@phpcs" + ] + }, + "scripts-descriptions": { + "benchmark": "Runs parser benchmark", + "phpstan": "Runs complete codebase static analysis", + "phpunit": "Runs unit and functional testing", + "phpcs": "Runs coding style testing", + "phpcs:fix": "Fix coding style issues", + "test": "Runs all tests" + }, + "repositories": [ + { + "type": "package", + "package": { + "name": "httpwg/structured-field-tests", + "version": "dev-main", + "source": { + "url": "https://github.com/httpwg/structured-field-tests.git", + "type": "git", + "reference": "main" + } + } + } + ], + "extra": { + "branch-alias": { + "dev-develop": "1.x-dev" + } + } +} diff --git a/vendor/bakame/http-structured-fields/src/Bytes.php b/vendor/bakame/http-structured-fields/src/Bytes.php new file mode 100644 index 000000000..aa747b76b --- /dev/null +++ b/vendor/bakame/http-structured-fields/src/Bytes.php @@ -0,0 +1,83 @@ +<?php + +declare(strict_types=1); + +namespace Bakame\Http\StructuredFields; + +use Stringable; +use Throwable; + +use function base64_decode; +use function base64_encode; +use function preg_match; + +/** + * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-3.3.5 + */ +final class Bytes +{ + private function __construct(private readonly string $value) + { + } + + /** + * Returns a new instance from a Base64 encoded string. + */ + public static function fromEncoded(Stringable|string $encoded): self + { + $encoded = (string) $encoded; + if (1 !== preg_match('/^[a-z\d+\/=]*$/i', $encoded)) { + throw new SyntaxError('The byte sequence '.$encoded.' contains invalid characters.'); + } + + $decoded = base64_decode($encoded, true); + if (false === $decoded) { + throw new SyntaxError('Unable to base64 decode the byte sequence '.$encoded); + } + + return new self($decoded); + } + + public static function tryFromEncoded(Stringable|string $encoded): ?self + { + try { + return self::fromEncoded($encoded); + } catch (Throwable) { + return null; + } + } + + /** + * Returns a new instance from a raw decoded string. + */ + public static function fromDecoded(Stringable|string $decoded): self + { + return new self((string) $decoded); + } + + /** + * Returns the decoded string. + */ + public function decoded(): string + { + return $this->value; + } + + /** + * Returns the base64 encoded string. + */ + public function encoded(): string + { + return base64_encode($this->value); + } + + public function equals(mixed $other): bool + { + return $other instanceof self && $other->value === $this->value; + } + + public function type(): Type + { + return Type::Bytes; + } +} diff --git a/vendor/bakame/http-structured-fields/src/DataType.php b/vendor/bakame/http-structured-fields/src/DataType.php new file mode 100644 index 000000000..8cc5f5395 --- /dev/null +++ b/vendor/bakame/http-structured-fields/src/DataType.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +namespace Bakame\Http\StructuredFields; + +use Exception; +use Stringable; + +enum DataType: string +{ + case List = 'list'; + case InnerList = 'innerlist'; + case Parameters = 'parameters'; + case Dictionary = 'dictionary'; + case Item = 'item'; + + /** + * @throws SyntaxError|Exception + */ + public function parse(Stringable|string $httpValue, Ietf $rfc = Ietf::Rfc9651): OuterList|InnerList|Parameters|Dictionary|Item + { + return match ($this) { + self::List => OuterList::fromHttpValue($httpValue, $rfc), + self::Dictionary => Dictionary::fromHttpValue($httpValue, $rfc), + self::Item => Item::fromHttpValue($httpValue, $rfc), + self::InnerList => InnerList::fromHttpValue($httpValue, $rfc), + self::Parameters => Parameters::fromHttpValue($httpValue, $rfc), + }; + } + + /** + * @throws SyntaxError|Exception + */ + public function serialize(iterable $data, Ietf $rfc = Ietf::Rfc9651): string + { + return (match ($this) { + self::List => OuterList::fromPairs($data), + self::Dictionary => Dictionary::fromPairs($data), + self::Item => Item::fromPair([...$data]), + self::InnerList => InnerList::fromPair([...$data]), + self::Parameters => Parameters::fromPairs($data), + })->toHttpValue($rfc); + } +} diff --git a/vendor/bakame/http-structured-fields/src/Dictionary.php b/vendor/bakame/http-structured-fields/src/Dictionary.php new file mode 100644 index 000000000..8a318695c --- /dev/null +++ b/vendor/bakame/http-structured-fields/src/Dictionary.php @@ -0,0 +1,788 @@ +<?php + +declare(strict_types=1); + +namespace Bakame\Http\StructuredFields; + +use ArrayAccess; +use Bakame\Http\StructuredFields\Validation\Violation; +use CallbackFilterIterator; +use Countable; +use DateTimeInterface; +use Exception; +use Iterator; +use IteratorAggregate; +use Stringable; +use Throwable; + +use function array_key_exists; +use function array_keys; +use function array_map; +use function count; +use function implode; +use function is_bool; +use function is_int; +use function is_iterable; +use function is_string; +use function uasort; + +/** + * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-3.2 + * + * @phpstan-import-type SfMemberInput from StructuredFieldProvider + * + * @implements ArrayAccess<string, InnerList|Item> + * @implements IteratorAggregate<int, array{0:string, 1:InnerList|Item}> + */ +final class Dictionary implements ArrayAccess, Countable, IteratorAggregate +{ + /** @var array<string, InnerList|Item> */ + private readonly array $members; + + /** + * @param iterable<string, SfMemberInput> $members + */ + private function __construct(iterable $members = []) + { + $filteredMembers = []; + foreach ($members as $key => $member) { + $filteredMembers[Key::from($key)->value] = Member::innerListOrItem($member); + } + + $this->members = $filteredMembers; + } + + /** + * Returns a new instance. + */ + public static function new(): self + { + return new self(); + } + + /** + * Returns a new instance from an associative iterable construct. + * + * its keys represent the dictionary entry name + * its values represent the dictionary entry value + * + * @param StructuredFieldProvider|iterable<string, SfMemberInput> $members + */ + public static function fromAssociative(StructuredFieldProvider|iterable $members): self + { + if ($members instanceof StructuredFieldProvider) { + $structuredField = $members->toStructuredField(); + + return match (true) { + $structuredField instanceof Dictionary, + $structuredField instanceof Parameters => new self($structuredField->toAssociative()), + default => throw new InvalidArgument('The '.StructuredFieldProvider::class.' must provide a structured field container; '.$structuredField::class.' given.'), + }; + } + + return new self($members); + } + + /** + * Returns a new instance from a pair iterable construct. + * + * Each member is composed of an array with two elements + * the first member represents the instance entry key + * the second member represents the instance entry value + * + * @param StructuredFieldProvider|Dictionary|Parameters|iterable<array{0:string, 1?:SfMemberInput}> $pairs + */ + public static function fromPairs(StructuredFieldProvider|Dictionary|Parameters|iterable $pairs): self + { + if ($pairs instanceof StructuredFieldProvider) { + $pairs = $pairs->toStructuredField(); + } + + if (!is_iterable($pairs)) { + throw new InvalidArgument('The "'.$pairs::class.'" instance can not be used for creating a .'.self::class.' structured field.'); + } + + return match (true) { + $pairs instanceof Dictionary, + $pairs instanceof Parameters => new self($pairs->toAssociative()), + default => new self((function (iterable $pairs) { + foreach ($pairs as [$key, $member]) { + yield $key => Member::innerListOrItemFromPair($member); + } + })($pairs)), + }; + } + + /** + * Returns an instance from an HTTP textual representation compliant with RFC9651. + * + * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-3.2 + * + * @throws StructuredFieldError|Throwable + */ + public static function fromRfc9651(Stringable|string $httpValue): self + { + return self::fromHttpValue($httpValue, Ietf::Rfc9651); + } + + /** + * Returns an instance from an HTTP textual representation compliant with RFC8941. + * + * @see https://www.rfc-editor.org/rfc/rfc8941.html#section-3.2 + * + * @throws StructuredFieldError|Throwable + */ + public static function fromRfc8941(Stringable|string $httpValue): self + { + return self::fromHttpValue($httpValue, Ietf::Rfc8941); + } + + /** + * Returns an instance from an HTTP textual representation. + * + * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-3.2 + * + * @throws StructuredFieldError|Exception If the string is not a valid + */ + public static function fromHttpValue(Stringable|string $httpValue, Ietf $rfc = Ietf::Rfc9651): self + { + return self::fromPairs((new Parser($rfc))->parseDictionary($httpValue)); /* @phpstan-ignore-line */ + } + + public function toRfc9651(): string + { + return $this->toHttpValue(Ietf::Rfc9651); + } + + public function toRfc8941(): string + { + return $this->toHttpValue(Ietf::Rfc8941); + } + + public function toHttpValue(Ietf $rfc = Ietf::Rfc9651): string + { + $formatter = static fn (Item|InnerList $member, string $offset): string => match (true) { + $member instanceof Item && true === $member->value() => $offset.$member->parameters()->toHttpValue($rfc), + default => $offset.'='.$member->toHttpValue($rfc), + }; + + return implode(', ', array_map($formatter, $this->members, array_keys($this->members))); + } + + public function __toString(): string + { + return $this->toHttpValue(); + } + + public function equals(mixed $other): bool + { + return $other instanceof self && $other->toHttpValue() === $this->toHttpValue(); + } + + /** + * Apply the callback if the given "condition" is (or resolves to) true. + * + * @param (callable($this): bool)|bool $condition + * @param callable($this): (self|null) $onSuccess + * @param ?callable($this): (self|null) $onFail + */ + public function when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null): self + { + if (!is_bool($condition)) { + $condition = $condition($this); + } + + return match (true) { + $condition => $onSuccess($this), + null !== $onFail => $onFail($this), + default => $this, + } ?? $this; + } + + public function count(): int + { + return count($this->members); + } + + /** + * Tells whether the instance contains no members. + */ + public function isEmpty(): bool + { + return !$this->isNotEmpty(); + } + + /** + * Tells whether the instance contains any members. + */ + public function isNotEmpty(): bool + { + return [] !== $this->members; + } + + /** + * @return Iterator<string, InnerList|Item> + */ + public function toAssociative(): Iterator + { + yield from $this->members; + } + + /** + * Returns an iterable construct of dictionary pairs. + * + * @return Iterator<int, array{0:string, 1:InnerList|Item}> + */ + public function getIterator(): Iterator + { + foreach ($this->members as $index => $member) { + yield [$index, $member]; + } + } + + /** + * Returns an ordered list of the instance keys. + * + * @return array<string> + */ + public function keys(): array + { + return array_keys($this->members); + } + + /** + * @return array<int> + */ + public function indices(): array + { + return array_keys($this->keys()); + } + + /** + * Tells whether the instance contain a members at the specified offsets. + */ + public function hasKeys(string ...$keys): bool + { + foreach ($keys as $key) { + if (!array_key_exists($key, $this->members)) { + return false; + } + } + + return [] !== $keys; + } + + /** + * Returns true only if the instance only contains the listed keys, false otherwise. + * + * @param array<string> $keys + */ + public function allowedKeys(array $keys): bool + { + foreach ($this->members as $key => $member) { + if (!in_array($key, $keys, true)) { + return false; + } + } + + return [] !== $keys; + } + + /** + * @param ?callable(Item|InnerList): (bool|string) $validate + * + * @throws InvalidOffset|Violation|StructuredFieldError + */ + public function getByKey(string $key, ?callable $validate = null): Item|InnerList + { + $value = $this->members[$key] ?? throw InvalidOffset::dueToKeyNotFound($key); + if (null === $validate || true === ($exceptionMessage = $validate($value))) { + return $value; + } + + if (!is_string($exceptionMessage) || '' === trim($exceptionMessage)) { + $exceptionMessage = "The parameter '{key}' whose value is '{value}' failed validation."; + } + + throw new Violation(strtr($exceptionMessage, ['{key}' => $key, '{value}' => $value->toHttpValue()])); + } + + /** + * Tells whether a pair is attached to the given index position. + */ + public function hasIndices(int ...$indexes): bool + { + $max = count($this->members); + foreach ($indexes as $index) { + if (null === $this->filterIndex($index, $max)) { + return false; + } + } + + return [] !== $indexes; + } + + /** + * Filters and format instance index. + */ + private function filterIndex(int $index, ?int $max = null): ?int + { + $max ??= count($this->members); + + return match (true) { + [] === $this->members, + 0 > $max + $index, + 0 > $max - $index - 1 => null, + 0 > $index => $max + $index, + default => $index, + }; + } + + /** + * Returns the item or the inner-list and its key as attached to the given + * collection according to their index position otherwise throw. + * + * @param ?callable(Item|InnerList, string): (bool|string) $validate + * + * @throws InvalidOffset|Violation|StructuredFieldError + * + * @return array{0:string, 1:InnerList|Item} + */ + public function getByIndex(int $index, ?callable $validate = null): array + { + $foundOffset = $this->filterIndex($index) ?? throw InvalidOffset::dueToIndexNotFound($index); + foreach ($this as $offset => $pair) { + if ($offset === $foundOffset) { + break; + } + } + + if (!isset($pair)) { + throw InvalidOffset::dueToIndexNotFound($index); + } + + if (null === $validate || true === ($exceptionMessage = $validate($pair[1], $pair[0]))) { + return $pair; + } + + if (!is_string($exceptionMessage) || '' === trim($exceptionMessage)) { + $exceptionMessage = "The member at position '{index}' whose key is '{key}' with the value '{value}' failed validation."; + } + + throw new Violation(strtr($exceptionMessage, ['{index}' => $index, '{key}' => $pair[0], '{value}' => $pair[1]->toHttpValue()])); + } + + /** + * Returns the key associated with the given index or null otherwise. + */ + public function indexByKey(string $key): ?int + { + foreach ($this as $index => $member) { + if ($key === $member[0]) { + return $index; + } + } + + return null; + } + + /** + * Returns the index associated with the given key or null otherwise. + */ + public function keyByIndex(int $index): ?string + { + $index = $this->filterIndex($index); + if (null === $index) { + return null; + } + + foreach ($this as $offset => $member) { + if ($offset === $index) { + return $member[0]; + } + } + + return null; + } + + /** + * Returns the first member whether it is an item or an inner-list and its key as attached to the given + * collection according to their index position otherwise returns an empty array. + * + * @return array{0:string, 1:InnerList|Item}|array{} + */ + public function first(): array + { + try { + return $this->getByIndex(0); + } catch (StructuredFieldError) { + return []; + } + } + + /** + * Returns the first member whether it is an item or an inner-list and its key as attached to the given + * collection according to their index position otherwise returns an empty array. + * + * @return array{0:string, 1:InnerList|Item}|array{} + */ + public function last(): array + { + try { + return $this->getByIndex(-1); + } catch (StructuredFieldError) { + return []; + } + } + + /** + * Adds a member at the end of the instance otherwise updates the value associated with the key if already present. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified changes. + * + * @param SfMemberInput $member + * + * @throws SyntaxError If the string key is not a valid + */ + public function add( + string $key, + iterable|StructuredFieldProvider|Dictionary|Parameters|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool $member + ): self { + $members = $this->members; + $members[Key::from($key)->value] = Member::innerListOrItem($member); + + return $this->newInstance($members); + } + + /** + * @param array<string, InnerList|Item> $members + */ + private function newInstance(array $members): self + { + foreach ($members as $offset => $member) { + if (!isset($this->members[$offset]) || !$this->members[$offset]->equals($member)) { + return new self($members); + } + } + + return $this; + } + + /** + * Deletes members associated with the list of submitted keys. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified changes. + */ + private function remove(string|int ...$offsets): self + { + if ([] === $this->members || [] === $offsets) { + return $this; + } + + $keys = array_keys($this->members); + $max = count($keys); + $reducer = fn (array $carry, string|int $key): array => match (true) { + is_string($key) && (false !== ($position = array_search($key, $keys, true))), + is_int($key) && (null !== ($position = $this->filterIndex($key, $max))) => [$position => true] + $carry, + default => $carry, + }; + + $indices = array_reduce($offsets, $reducer, []); + + return match (true) { + [] === $indices => $this, + $max === count($indices) => self::new(), + default => self::fromPairs((function (array $offsets) { + foreach ($this->getIterator() as $offset => $pair) { + if (!array_key_exists($offset, $offsets)) { + yield $pair; + } + } + })($indices)), + }; + } + + /** + * Deletes members associated with the list using the member pair offset. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified changes. + */ + public function removeByIndices(int ...$indices): self + { + return $this->remove(...$indices); + } + + /** + * Deletes members associated with the list using the member key. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified changes. + */ + public function removeByKeys(string ...$keys): self + { + return $this->remove(...$keys); + } + + /** + * Adds a member at the end of the instance and deletes any previous reference to the key if present. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified changes. + * + * @param SfMemberInput $member + * @throws SyntaxError If the string key is not a valid + */ + public function append( + string $key, + iterable|StructuredFieldProvider|Dictionary|Parameters|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool $member + ): self { + $members = $this->members; + unset($members[$key]); + + return $this->newInstance([...$members, Key::from($key)->value => Member::innerListOrItem($member)]); + } + + /** + * Adds a member at the beginning of the instance and deletes any previous reference to the key if present. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified changes. + * + * @param SfMemberInput $member + * + * @throws SyntaxError If the string key is not a valid + */ + public function prepend( + string $key, + iterable|StructuredFieldProvider|Dictionary|Parameters|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool $member + ): self { + $members = $this->members; + unset($members[$key]); + + return $this->newInstance([Key::from($key)->value => Member::innerListOrItem($member), ...$members]); + } + + /** + * Inserts pairs at the end of the container. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified changes. + * + * @param array{0:string, 1:SfMemberInput} ...$pairs + */ + public function push(array ...$pairs): self + { + return match (true) { + [] === $pairs => $this, + default => self::fromPairs((function (iterable $pairs) { + yield from $this->getIterator(); + yield from $pairs; + })($pairs)), + }; + } + + /** + * Inserts pairs at the beginning of the container. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified changes. + * + * @param array{0:string, 1:SfMemberInput} ...$pairs + */ + public function unshift(array ...$pairs): self + { + return match (true) { + [] === $pairs => $this, + default => self::fromPairs((function (iterable $pairs) { + yield from $pairs; + yield from $this->getIterator(); + })($pairs)), + }; + } + + /** + * Insert a member pair using its offset. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified changes. + * + * @param array{0:string, 1:SfMemberInput} ...$members + */ + public function insert(int $index, array ...$members): self + { + $offset = $this->filterIndex($index) ?? throw InvalidOffset::dueToIndexNotFound($index); + + return match (true) { + [] === $members => $this, + 0 === $offset => $this->unshift(...$members), + count($this->members) === $offset => $this->push(...$members), + default => (function (Iterator $newMembers) use ($offset, $members) { + $newMembers = iterator_to_array($newMembers); + array_splice($newMembers, $offset, 0, $members); + + return self::fromPairs($newMembers); + })($this->getIterator()), + }; + } + + /** + * Replace a member pair using its offset. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified changes. + * + * @param array{0:string, 1:SfMemberInput} $pair + */ + public function replace(int $index, array $pair): self + { + $offset = $this->filterIndex($index) ?? throw InvalidOffset::dueToIndexNotFound($index); + $pair[1] = Member::innerListOrItem($pair[1]); + $pairs = iterator_to_array($this->getIterator()); + + return match (true) { + $pairs[$offset][0] === $pair[0] && $pairs[$offset][1]->equals($pair[1]) => $this, + default => self::fromPairs(array_replace($pairs, [$offset => $pair])), + }; + } + + /** + * Merges multiple instances using iterable associative structures. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified changes. + * + * @param StructuredFieldProvider|Dictionary|Parameters|iterable<string, SfMemberInput> ...$others + */ + public function mergeAssociative(StructuredFieldProvider|iterable ...$others): self + { + $members = $this->members; + foreach ($others as $other) { + if ($other instanceof StructuredFieldProvider) { + $other = $other->toStructuredField(); + if (!is_iterable($other)) { + throw new InvalidArgument('The "'.$other::class.'" instance can not be used for creating a .'.self::class.' structured field.'); + } + } + + if ($other instanceof self || $other instanceof Parameters) { + $other = $other->toAssociative(); + } + + foreach ($other as $key => $value) { + $members[$key] = $value; + } + } + + return new self($members); + } + + /** + * Merges multiple instances using iterable pairs. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified changes. + * + * @param StructuredFieldProvider|Dictionary|Parameters|iterable<array{0:string, 1:SfMemberInput}> ...$others + */ + public function mergePairs(StructuredFieldProvider|Dictionary|Parameters|iterable ...$others): self + { + $members = $this->members; + foreach ($others as $other) { + if (!$other instanceof self) { + $other = self::fromPairs($other); + } + foreach ($other->toAssociative() as $key => $value) { + $members[$key] = $value; + } + } + + return new self($members); + } + + /** + * @param string $offset + */ + public function offsetExists(mixed $offset): bool + { + return $this->hasKeys($offset); + } + + /** + * @param string $offset + * + * @throws StructuredFieldError + */ + public function offsetGet(mixed $offset): InnerList|Item + { + return $this->getByKey($offset); + } + + public function offsetUnset(mixed $offset): void + { + throw new ForbiddenOperation(self::class.' instance can not be updated using '.ArrayAccess::class.' methods.'); + } + + public function offsetSet(mixed $offset, mixed $value): void + { + throw new ForbiddenOperation(self::class.' instance can not be updated using '.ArrayAccess::class.' methods.'); + } + + /** + * Run a map over each container members. + * + * @template TMap + * + * @param callable(array{0:string, 1:Item|InnerList}, int): TMap $callback + * + * @return Iterator<TMap> + */ + public function map(callable $callback): Iterator + { + foreach ($this as $offset => $member) { + yield ($callback)($member, $offset); + } + } + + /** + * @param callable(TInitial|null, array{0:string, 1:Item|InnerList}, int): TInitial $callback + * @param TInitial|null $initial + * + * @template TInitial + * + * @return TInitial|null + */ + public function reduce(callable $callback, mixed $initial = null): mixed + { + foreach ($this as $offset => $pair) { + $initial = $callback($initial, $pair, $offset); + } + + return $initial; + } + + /** + * Run a filter over each container members. + * + * @param callable(array{0:string, 1:InnerList|Item}, int): bool $callback + */ + public function filter(callable $callback): self + { + return self::fromPairs(new CallbackFilterIterator($this, $callback)); + } + + /** + * Sort a container by value using a callback. + * + * @param callable(array{0:string, 1:InnerList|Item}, array{0:string, 1:InnerList|Item}): int $callback + */ + public function sort(callable $callback): self + { + $members = iterator_to_array($this); + uasort($members, $callback); + + return self::fromPairs($members); + } +} diff --git a/vendor/bakame/http-structured-fields/src/DisplayString.php b/vendor/bakame/http-structured-fields/src/DisplayString.php new file mode 100644 index 000000000..361f91ec2 --- /dev/null +++ b/vendor/bakame/http-structured-fields/src/DisplayString.php @@ -0,0 +1,103 @@ +<?php + +declare(strict_types=1); + +namespace Bakame\Http\StructuredFields; + +use Stringable; +use Throwable; + +use function preg_match; +use function preg_replace_callback; +use function rawurldecode; +use function rawurlencode; +use function str_contains; + +/** + * @see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-sfbis#section-4.2.10 + */ +final class DisplayString +{ + private function __construct(private readonly string $value) + { + } + + public static function tryFromEncoded(Stringable|string $encoded): ?self + { + try { + return self::fromEncoded($encoded); + } catch (Throwable) { + return null; + } + } + + /** + * Returns a new instance from a Base64 encoded string. + */ + public static function fromEncoded(Stringable|string $encoded): self + { + $encoded = (string) $encoded; + + if (1 === preg_match('/[^\x20-\x7E]/i', $encoded)) { + throw new SyntaxError('The display string '.$encoded.' contains invalid characters.'); + } + + if (!str_contains($encoded, '%')) { + return new self($encoded); + } + + if (1 === preg_match('/%(?![0-9a-f]{2})/', $encoded)) { + throw new SyntaxError('The display string '.$encoded.' contains invalid utf-8 encoded sequence.'); + } + + $decoded = (string) preg_replace_callback( + ',%[a-f0-9]{2},', + fn (array $matches): string => rawurldecode($matches[0]), + $encoded + ); + + if (1 !== preg_match('//u', $decoded)) { + throw new SyntaxError('The display string '.$encoded.' contains invalid characters.'); + } + + return new self($decoded); + } + + /** + * Returns a new instance from a raw decoded string. + */ + public static function fromDecoded(Stringable|string $decoded): self + { + return new self((string) $decoded); + } + + /** + * Returns the decoded string. + */ + public function decoded(): string + { + return $this->value; + } + + /** + * Returns the base64 encoded string. + */ + public function encoded(): string + { + return (string) preg_replace_callback( + '/[%"\x00-\x1F\x7F-\xFF]/', + static fn (array $matches): string => strtolower(rawurlencode($matches[0])), + $this->value + ); + } + + public function equals(mixed $other): bool + { + return $other instanceof self && $other->value === $this->value; + } + + public function type(): Type + { + return Type::DisplayString; + } +} diff --git a/vendor/bakame/http-structured-fields/src/ForbiddenOperation.php b/vendor/bakame/http-structured-fields/src/ForbiddenOperation.php new file mode 100644 index 000000000..f1edbdef4 --- /dev/null +++ b/vendor/bakame/http-structured-fields/src/ForbiddenOperation.php @@ -0,0 +1,11 @@ +<?php + +declare(strict_types=1); + +namespace Bakame\Http\StructuredFields; + +use LogicException; + +final class ForbiddenOperation extends LogicException implements StructuredFieldError +{ +} diff --git a/vendor/bakame/http-structured-fields/src/Ietf.php b/vendor/bakame/http-structured-fields/src/Ietf.php new file mode 100644 index 000000000..a98e16e20 --- /dev/null +++ b/vendor/bakame/http-structured-fields/src/Ietf.php @@ -0,0 +1,73 @@ +<?php + +declare(strict_types=1); + +namespace Bakame\Http\StructuredFields; + +use DateTimeImmutable; +use DateTimeZone; + +enum Ietf +{ + case Rfc8941; + case Rfc9651; + + public function uri(): string + { + return match ($this) { + self::Rfc9651 => 'https://www.rfc-editor.org/rfc/rfc9651.html', + self::Rfc8941 => 'https://www.rfc-editor.org/rfc/rfc8941.html', + }; + } + + public function publishedAt(): DateTimeImmutable + { + return new DateTimeImmutable(match ($this) { + self::Rfc9651 => '2024-09-01', + self::Rfc8941 => '2021-02-01', + }, new DateTimeZone('UTC')); + } + + public function isActive(): bool + { + return self::Rfc9651 === $this; + } + + public function isObsolete(): bool + { + return !$this->isActive(); + } + + public function supports(mixed $value): bool + { + if ($value instanceof StructuredFieldProvider) { + $value = $value->toStructuredField(); + } + + if ($value instanceof OuterList || + $value instanceof InnerList || + $value instanceof Dictionary || + $value instanceof Parameters || + $value instanceof Item + ) { + try { + $value->toHttpValue($this); + + return true; + } catch (MissingFeature) { + return false; + } + } + + if (!$value instanceof Type) { + $value = Type::tryFromVariable($value); + } + + return match ($value) { + null => false, + Type::DisplayString, + Type::Date => self::Rfc8941 !== $this, + default => true, + }; + } +} diff --git a/vendor/bakame/http-structured-fields/src/InnerList.php b/vendor/bakame/http-structured-fields/src/InnerList.php new file mode 100644 index 000000000..8a9a3e734 --- /dev/null +++ b/vendor/bakame/http-structured-fields/src/InnerList.php @@ -0,0 +1,478 @@ +<?php + +declare(strict_types=1); + +namespace Bakame\Http\StructuredFields; + +use ArrayAccess; +use Bakame\Http\StructuredFields\Validation\Violation; +use Countable; +use DateTimeInterface; +use Iterator; +use IteratorAggregate; +use Stringable; + +use function array_filter; +use function array_is_list; +use function array_map; +use function array_replace; +use function array_splice; +use function array_values; +use function count; +use function implode; +use function uasort; + +use const ARRAY_FILTER_USE_BOTH; +use const ARRAY_FILTER_USE_KEY; + +/** + * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-3.1.1 + * + * @phpstan-import-type SfType from StructuredFieldProvider + * @phpstan-import-type SfTypeInput from StructuredFieldProvider + * @phpstan-import-type SfItemInput from StructuredFieldProvider + * @phpstan-import-type SfItemPair from StructuredFieldProvider + * @phpstan-import-type SfInnerListPair from StructuredFieldProvider + * @phpstan-import-type SfParameterInput from StructuredFieldProvider + * + * @implements ArrayAccess<int, Item> + * @implements IteratorAggregate<int, Item> + */ +final class InnerList implements ArrayAccess, Countable, IteratorAggregate +{ + use ParameterAccess; + + /** @var list<Item> */ + private readonly array $members; + private readonly Parameters $parameters; + + /** + * @param iterable<SfItemInput|SfItemPair> $members + */ + private function __construct(iterable $members, ?Parameters $parameters = null) + { + $this->members = array_map(Member::item(...), array_values([...$members])); + $this->parameters = $parameters ?? Parameters::new(); + } + + /** + * Returns an instance from an HTTP textual representation. + * + * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-3.1 + */ + public static function fromHttpValue(Stringable|string $httpValue, Ietf $rfc = Ietf::Rfc9651): self + { + return self::fromPair((new Parser($rfc))->parseInnerList($httpValue)); + } + + /** + * Returns a new instance with an iter. + * + * @param iterable<SfItemInput> $value + * @param Parameters|iterable<string, SfItemInput> $parameters + */ + public static function fromAssociative( + iterable $value, + StructuredFieldProvider|Parameters|iterable $parameters + ): self { + if ($parameters instanceof StructuredFieldProvider) { + $parameters = $parameters->toStructuredField(); + if (!$parameters instanceof Parameters) { + throw new InvalidArgument('The '.StructuredFieldProvider::class.' must provide a '.Parameters::class.'; '.$parameters::class.' given.'); + } + } + + if (!$parameters instanceof Parameters) { + return new self($value, Parameters::fromAssociative($parameters)); + } + + return new self($value, $parameters); + } + + /** + * @param array{0:iterable<SfItemInput>, 1?:Parameters|SfParameterInput}|array<mixed> $pair + */ + public static function fromPair(array $pair = []): self + { + if ([] === $pair) { + return self::new(); + } + + if (!array_is_list($pair) || 2 < count($pair)) { + throw new SyntaxError('The pair must be represented by an non-empty array as a list containing at most 2 members.'); + } + + if (1 === count($pair)) { + return new self($pair[0]); + } + + if ($pair[1] instanceof StructuredFieldProvider) { + $pair[1] = $pair[1]->toStructuredField(); + if (!$pair[1] instanceof Parameters) { + throw new InvalidArgument('The '.StructuredFieldProvider::class.' must provide a '.Parameters::class.'; '.$pair[1]::class.' given.'); + } + } + + if (!$pair[1] instanceof Parameters) { + return new self($pair[0], Parameters::fromPairs($pair[1])); + } + + return new self($pair[0], $pair[1]); + } + + /** + * Returns a new instance. + * + * @param StructuredFieldProvider|Item|SfTypeInput|SfItemPair ...$members + */ + public static function new( + StructuredFieldProvider|Item|Token|Bytes|DisplayString|DateTimeInterface|array|string|int|float|bool ...$members + ): self { + return new self($members); + } + + public static function fromRfc9651(Stringable|string $httpValue): self + { + return self::fromHttpValue($httpValue, Ietf::Rfc9651); + } + + public static function fromRfc8941(Stringable|string $httpValue): self + { + return self::fromHttpValue($httpValue, Ietf::Rfc8941); + } + + public function toHttpValue(Ietf $rfc = Ietf::Rfc9651): string + { + return '('.implode(' ', array_map(fn (Item $value): string => $value->toHttpValue($rfc), $this->members)).')'.$this->parameters->toHttpValue($rfc); + } + + public function toRfc9651(): string + { + return $this->toHttpValue(Ietf::Rfc9651); + } + + public function toRfc8941(): string + { + return $this->toHttpValue(Ietf::Rfc8941); + } + + public function __toString(): string + { + return $this->toHttpValue(); + } + + public function equals(mixed $other): bool + { + return $other instanceof self && $other->toHttpValue() === $this->toHttpValue(); + } + + /** + * Apply the callback if the given "condition" is (or resolves to) true. + * + * @param (callable($this): bool)|bool $condition + * @param callable($this): (self|null) $onSuccess + * @param ?callable($this): (self|null) $onFail + */ + public function when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null): self + { + if (!is_bool($condition)) { + $condition = $condition($this); + } + + return match (true) { + $condition => $onSuccess($this), + null !== $onFail => $onFail($this), + default => $this, + } ?? $this; + } + + /** + * @return array{0:list<Item>, 1:Parameters} + */ + public function toPair(): array + { + return [$this->members, $this->parameters]; + } + + public function getIterator(): Iterator + { + yield from $this->members; + } + + public function count(): int + { + return count($this->members); + } + + public function isEmpty(): bool + { + return !$this->isNotEmpty(); + } + + public function isNotEmpty(): bool + { + return [] !== $this->members; + } + + /** + * @return array<int> + */ + public function indices(): array + { + return array_keys($this->members); + } + + public function hasIndices(int ...$indices): bool + { + $max = count($this->members); + foreach ($indices as $offset) { + if (null === $this->filterIndex($offset, $max)) { + return false; + } + } + + return [] !== $indices; + } + + private function filterIndex(int $index, ?int $max = null): ?int + { + $max ??= count($this->members); + + return match (true) { + [] === $this->members, + 0 > $max + $index, + 0 > $max - $index - 1 => null, + 0 > $index => $max + $index, + default => $index, + }; + } + + /** + * @param ?callable(Item): (bool|string) $validate + * + * @throws SyntaxError|Violation|StructuredFieldError + */ + public function getByIndex(int $index, ?callable $validate = null): Item + { + $value = $this->members[$this->filterIndex($index) ?? throw InvalidOffset::dueToIndexNotFound($index)]; + if (null === $validate) { + return $value; + } + + if (true === ($exceptionMessage = $validate($value))) { + return $value; + } + + if (!is_string($exceptionMessage) || '' === trim($exceptionMessage)) { + $exceptionMessage = "The item at '{index}' whose value is '{value}' failed validation."; + } + + throw new Violation(strtr($exceptionMessage, ['{index}' => $index, '{value}' => $value->toHttpValue()])); + } + + public function first(): ?Item + { + return $this->members[0] ?? null; + } + + public function last(): ?Item + { + return $this->members[$this->filterIndex(-1)] ?? null; + } + + public function withParameters(StructuredFieldProvider|Parameters $parameters): static + { + if ($parameters instanceof StructuredFieldProvider) { + $parameters = $parameters->toStructuredField(); + if (!$parameters instanceof Parameters) { + throw new InvalidArgument('The '.StructuredFieldProvider::class.' must provide a '.Parameters::class.'; '.$parameters::class.' given.'); + } + } + + return $this->parameters->equals($parameters) ? $this : new self($this->members, $parameters); + } + + /** + * Inserts members at the beginning of the list. + */ + public function unshift( + StructuredFieldProvider|OuterList|Dictionary|InnerList|Parameters|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool ...$members + ): self { + $membersToAdd = array_reduce( + $members, + function (array $carry, $member) { + if ($member instanceof StructuredFieldProvider) { + $member = $member->toStructuredField(); + } + + return [...$carry, ...$member instanceof InnerList ? [...$member] : [$member]]; + }, + [] + ); + + return match (true) { + [] === $membersToAdd => $this, + default => new self([...array_values($membersToAdd), ...$this->members], $this->parameters), + }; + } + + /** + * Inserts members at the end of the list. + */ + public function push( + StructuredFieldProvider|OuterList|Dictionary|InnerList|Parameters|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool ...$members + ): self { + $membersToAdd = array_reduce( + $members, + function (array $carry, $member) { + if ($member instanceof StructuredFieldProvider) { + $member = $member->toStructuredField(); + } + + return [...$carry, ...$member instanceof InnerList ? [...$member] : [$member]]; + }, + [] + ); + + return match (true) { + [] === $membersToAdd => $this, + default => new self([...$this->members, ...array_values($membersToAdd)], $this->parameters), + }; + } + + /** + * Inserts members starting at the given index. + * + * @throws InvalidOffset If the index does not exist + */ + public function insert( + int $index, + StructuredFieldProvider|OuterList|Dictionary|InnerList|Parameters|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool ...$members + ): self { + $offset = $this->filterIndex($index) ?? throw InvalidOffset::dueToIndexNotFound($index); + + return match (true) { + 0 === $offset => $this->unshift(...$members), + count($this->members) === $offset => $this->push(...$members), + [] === $members => $this, + default => (function (array $newMembers) use ($offset, $members) { + array_splice($newMembers, $offset, 0, $members); + + return new self($newMembers, $this->parameters); + })($this->members), + }; + } + + public function replace( + int $index, + StructuredFieldProvider|OuterList|Dictionary|InnerList|Parameters|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool $member + ): self { + $offset = $this->filterIndex($index) ?? throw InvalidOffset::dueToIndexNotFound($index); + $member = Member::item($member); + + return match (true) { + $member->equals($this->members[$offset]) => $this, + default => new self(array_replace($this->members, [$offset => $member]), $this->parameters), + }; + } + + public function removeByIndices(int ...$indices): self + { + $max = count($this->members); + $indices = array_filter( + array_map(fn (int $index): ?int => $this->filterIndex($index, $max), $indices), + fn (?int $index): bool => null !== $index + ); + + return match (true) { + [] === $indices => $this, + count($indices) === $max => self::new(), + default => new self(array_filter( + $this->members, + fn (int $offset): bool => !in_array($offset, $indices, true), + ARRAY_FILTER_USE_KEY + ), $this->parameters), + }; + } + + /** + * @param int $offset + */ + public function offsetExists(mixed $offset): bool + { + return $this->hasIndices($offset); + } + + /** + * @param int $offset + */ + public function offsetGet(mixed $offset): Item + { + return $this->getByIndex($offset); + } + + public function offsetUnset(mixed $offset): void + { + throw new ForbiddenOperation(self::class.' instance can not be updated using '.ArrayAccess::class.' methods.'); + } + + public function offsetSet(mixed $offset, mixed $value): void + { + throw new ForbiddenOperation(self::class.' instance can not be updated using '.ArrayAccess::class.' methods.'); + } + + /** + * @param callable(Item, int): TMap $callback + * + * @template TMap + * + * @return Iterator<TMap> + */ + public function map(callable $callback): Iterator + { + foreach ($this->members as $offset => $member) { + yield ($callback)($member, $offset); + } + } + + /** + * @param callable(TInitial|null, Item, int=): TInitial $callback + * @param TInitial|null $initial + * + * @template TInitial + * + * @return TInitial|null + */ + public function reduce(callable $callback, mixed $initial = null): mixed + { + foreach ($this->members as $offset => $member) { + $initial = $callback($initial, $member, $offset); + } + + return $initial; + } + + /** + * @param callable(Item, int): bool $callback + */ + public function filter(callable $callback): self + { + $members = array_filter($this->members, $callback, ARRAY_FILTER_USE_BOTH); + if ($members === $this->members) { + return $this; + } + + return new self($members, $this->parameters); + } + + /** + * @param callable(Item, Item): int $callback + */ + public function sort(callable $callback): self + { + $members = $this->members; + uasort($members, $callback); + + return new self($members, $this->parameters); + } +} diff --git a/vendor/bakame/http-structured-fields/src/InvalidArgument.php b/vendor/bakame/http-structured-fields/src/InvalidArgument.php new file mode 100644 index 000000000..6c7c3344f --- /dev/null +++ b/vendor/bakame/http-structured-fields/src/InvalidArgument.php @@ -0,0 +1,11 @@ +<?php + +declare(strict_types=1); + +namespace Bakame\Http\StructuredFields; + +use InvalidArgumentException; + +final class InvalidArgument extends InvalidArgumentException implements StructuredFieldError +{ +} diff --git a/vendor/bakame/http-structured-fields/src/InvalidOffset.php b/vendor/bakame/http-structured-fields/src/InvalidOffset.php new file mode 100644 index 000000000..ab878d98d --- /dev/null +++ b/vendor/bakame/http-structured-fields/src/InvalidOffset.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +namespace Bakame\Http\StructuredFields; + +use OutOfBoundsException; + +final class InvalidOffset extends OutOfBoundsException implements StructuredFieldError +{ + private function __construct(string $message) + { + parent::__construct($message); + } + + public static function dueToIndexNotFound(string|int $index): self + { + if (is_string($index)) { + return new self('The member index can not be the string "'.$index.'".'); + } + + return new self('No member exists with the index "'.$index.'".'); + } + + public static function dueToKeyNotFound(string|int $key): self + { + if (is_int($key)) { + return new self('The member key can not be the integer "'.$key.'".'); + } + + return new self('No member exists with the key "'.$key.'".'); + } + + public static function dueToMemberNotFound(string|int $offset): self + { + return new self('No member exists with the '.(is_int($offset) ? 'index' : 'key').' "'.$offset.'".'); + } +} diff --git a/vendor/bakame/http-structured-fields/src/Item.php b/vendor/bakame/http-structured-fields/src/Item.php new file mode 100644 index 000000000..6d0434ee5 --- /dev/null +++ b/vendor/bakame/http-structured-fields/src/Item.php @@ -0,0 +1,502 @@ +<?php + +declare(strict_types=1); + +namespace Bakame\Http\StructuredFields; + +use BadMethodCallException; +use Bakame\Http\StructuredFields\Validation\Violation; +use DateTimeImmutable; +use DateTimeInterface; +use DateTimeZone; +use Exception; +use ReflectionMethod; +use Stringable; +use Throwable; +use TypeError; + +use function array_is_list; +use function count; +use function in_array; + +use const JSON_PRESERVE_ZERO_FRACTION; +use const PHP_ROUND_HALF_EVEN; + +/** + * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-3.3 + * + * @phpstan-import-type SfType from StructuredFieldProvider + * @phpstan-import-type SfItemInput from StructuredFieldProvider + * @phpstan-import-type SfItemPair from StructuredFieldProvider + * @phpstan-import-type SfTypeInput from StructuredFieldProvider + * + * @method static ?Item tryFromPair(array{0: SfItemInput, 1?: Parameters|iterable<array{0:string, 1:SfItemInput}>}|array<mixed> $pair) try to create a new instance from a Pair + * @method static ?Item tryFromRfc9651(Stringable|string $httpValue) try to create a new instance from a string using RFC9651 + * @method static ?Item tryFromRfc8941(Stringable|string $httpValue) try to create a new instance from a string using RFC8941 + * @method static ?Item tryFromHttpValue(Stringable|string $httpValue) try to create a new instance from a string + * @method static ?Item tryFromAssociative(Bytes|Token|DisplayString|DateTimeInterface|string|int|float|bool $value, StructuredFieldProvider|Parameters|iterable<string, SfItemInput> $parameters) try to create a new instance from a value and a parameters as associative construct + * @method static ?Item tryNew(mixed $value) try to create a new bare instance from a value + * @method static ?Item tryFromEncodedBytes(Stringable|string $value) try to create a new instance from an encoded byte sequence + * @method static ?Item tryFromDecodedBytes(Stringable|string $value) try to create a new instance from a decoded byte sequence + * @method static ?Item tryFromEncodedDisplayString(Stringable|string $value) try to create a new instance from an encoded display string + * @method static ?Item tryFromDecodedDisplayString(Stringable|string $value) try to create a new instance from a decoded display string + * @method static ?Item tryFromToken(Stringable|string $value) try to create a new instance from a token string + * @method static ?Item tryFromTimestamp(int $timestamp) try to create a new instance from a timestamp + * @method static ?Item tryFromDateFormat(string $format, string $datetime) try to create a new instance from a date format + * @method static ?Item tryFromDateString(string $datetime, DateTimeZone|string|null $timezone = null) try to create a new instance from a date string + * @method static ?Item tryFromDate(DateTimeInterface $datetime) try to create a new instance from a DateTimeInterface object + * @method static ?Item tryFromDecimal(int|float $value) try to create a new instance from a float + * @method static ?Item tryFromInteger(int|float $value) try to create a new instance from an integer + */ +final class Item +{ + use ParameterAccess; + + private readonly Token|Bytes|DisplayString|DateTimeImmutable|int|float|string|bool $value; + private readonly Parameters $parameters; + private readonly Type $type; + + private function __construct(Token|Bytes|DisplayString|DateTimeInterface|int|float|string|bool $value, ?Parameters $parameters = null) + { + if ($value instanceof DateTimeInterface && !$value instanceof DateTimeImmutable) { + $value = DateTimeImmutable::createFromInterface($value); + } + + $this->value = $value; + $this->parameters = $parameters ?? Parameters::new(); + $this->type = Type::fromVariable($value); + } + + /** + * @throws BadMethodCallException + */ + public static function __callStatic(string $name, array $arguments): ?self /* @phpstan-ignore-line */ + { + if (!str_starts_with($name, 'try')) { + throw new BadMethodCallException('The method "'.self::class.'::'.$name.'" does not exist.'); + } + + $namedConstructor = lcfirst(substr($name, strlen('try'))); + if (!method_exists(self::class, $namedConstructor)) { + throw new BadMethodCallException('The method "'.self::class.'::'.$name.'" does not exist.'); + } + + $method = new ReflectionMethod(self::class, $namedConstructor); + if (!$method->isPublic() || !$method->isStatic()) { + throw new BadMethodCallException('The method "'.self::class.'::'.$name.'" can not be accessed directly.'); + } + + try { + return self::$namedConstructor(...$arguments); /* @phpstan-ignore-line */ + } catch (Throwable) { /* @phpstan-ignore-line */ + return null; + } + } + + public static function fromRfc9651(Stringable|string $httpValue): self + { + return self::fromHttpValue($httpValue, Ietf::Rfc9651); + } + + public static function fromRfc8941(Stringable|string $httpValue): self + { + return self::fromHttpValue($httpValue, Ietf::Rfc8941); + } + + /** + * Returns a new instance from an HTTP Header or Trailer value string + * in compliance with a published RFC. + * + * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-3.3 + * + * @throws SyntaxError|Exception If the HTTP value can not be parsed + */ + public static function fromHttpValue(Stringable|string $httpValue, Ietf $rfc = Ietf::Rfc9651): self + { + return self::fromPair((new Parser($rfc))->parseItem($httpValue)); + } + + /** + * Returns a new instance from a value type and an iterable of key-value parameters. + * + * @param StructuredFieldProvider|Parameters|iterable<string, SfItemInput> $parameters + * + * @throws SyntaxError If the value or the parameters are not valid + */ + public static function fromAssociative( + Bytes|Token|DisplayString|DateTimeInterface|string|int|float|bool $value, + StructuredFieldProvider|Parameters|iterable $parameters + ): self { + if ($parameters instanceof StructuredFieldProvider) { + $parameters = $parameters->toStructuredField(); + if ($parameters instanceof Parameters) { + return new self($value, $parameters); + } + + throw new InvalidArgument('The '.StructuredFieldProvider::class.' must provide a '.Parameters::class.'; '.$parameters::class.' given.'); + } + + if (!$parameters instanceof Parameters) { + return new self($value, Parameters::fromAssociative($parameters)); + } + + return new self($value, $parameters); + } + + /** + * @param array{0: SfItemInput, 1?: Parameters|iterable<array{0:string, 1:SfItemInput}>}|array<mixed> $pair + * + * @throws SyntaxError If the pair or its content is not valid. + */ + public static function fromPair(array $pair): self + { + $nbElements = count($pair); + if (!in_array($nbElements, [1, 2], true) || !array_is_list($pair)) { + throw new SyntaxError('The pair must be represented by an non-empty array as a list containing exactly 1 or 2 members.'); + } + + if (1 === $nbElements) { + return new self($pair[0]); + } + + if ($pair[1] instanceof StructuredFieldProvider) { + $pair[1] = $pair[1]->toStructuredField(); + if ($pair[1] instanceof Parameters) { + return new self($pair[0], Parameters::fromPairs($pair[1])); + } + + throw new InvalidArgument('The '.StructuredFieldProvider::class.' must provide a '.Parameters::class.'; '.$pair[1]::class.' given.'); + } + + if (!$pair[1] instanceof Parameters) { + return new self($pair[0], Parameters::fromPairs($pair[1])); + } + + return new self($pair[0], $pair[1]); + } + + /** + * Returns a new bare instance from value. + * + * @param SfItemPair|SfItemInput $value + * + * @throws SyntaxError|TypeError If the value is not valid. + */ + public static function new(mixed $value): self + { + if (is_array($value)) { + return self::fromPair($value); + } + + return new self($value); /* @phpstan-ignore-line */ + } + + /** + * Returns a new instance from a string. + * + * @throws SyntaxError if the string is invalid + */ + public static function fromString(Stringable|string $value): self + { + return new self((string)$value); + } + + /** + * Returns a new instance from an encoded byte sequence and an iterable of key-value parameters. + * + * @throws SyntaxError if the sequence is invalid + */ + public static function fromEncodedBytes(Stringable|string $value): self + { + return new self(Bytes::fromEncoded($value)); + } + + /** + * Returns a new instance from a decoded byte sequence and an iterable of key-value parameters. + * + * @throws SyntaxError if the sequence is invalid + */ + public static function fromDecodedBytes(Stringable|string $value): self + { + return new self(Bytes::fromDecoded($value)); + } + + /** + * Returns a new instance from an encoded byte sequence and an iterable of key-value parameters. + * + * @throws SyntaxError if the sequence is invalid + */ + public static function fromEncodedDisplayString(Stringable|string $value): self + { + return new self(DisplayString::fromEncoded($value)); + } + + /** + * Returns a new instance from a decoded byte sequence and an iterable of key-value parameters. + * + * @throws SyntaxError if the sequence is invalid + */ + public static function fromDecodedDisplayString(Stringable|string $value): self + { + return new self(DisplayString::fromDecoded($value)); + } + + /** + * Returns a new instance from a Token and an iterable of key-value parameters. + * + * @throws SyntaxError if the token is invalid + */ + public static function fromToken(Stringable|string $value): self + { + return new self(Token::fromString($value)); + } + + /** + * Returns a new instance from a timestamp and an iterable of key-value parameters. + * + * @throws SyntaxError if the timestamp value is not supported + */ + public static function fromTimestamp(int $timestamp): self + { + return new self((new DateTimeImmutable())->setTimestamp($timestamp)); + } + + /** + * Returns a new instance from a date format its date string representation and an iterable of key-value parameters. + * + * @throws SyntaxError if the format is invalid + */ + public static function fromDateFormat(string $format, string $datetime): self + { + try { + $value = DateTimeImmutable::createFromFormat($format, $datetime); + } catch (Exception $exception) { + throw new SyntaxError('The date notation `'.$datetime.'` is incompatible with the date format `'.$format.'`.', 0, $exception); + } + + if (!$value instanceof DateTimeImmutable) { + throw new SyntaxError('The date notation `'.$datetime.'` is incompatible with the date format `'.$format.'`.'); + } + + return new self($value); + } + + /** + * Returns a new instance from a string parsable by DateTimeImmutable constructor, an optional timezone and an iterable of key-value parameters. + * + * @throws SyntaxError if the format is invalid + */ + public static function fromDateString(string $datetime, DateTimeZone|string|null $timezone = null): self + { + $timezone ??= date_default_timezone_get(); + if (!$timezone instanceof DateTimeZone) { + try { + $timezone = new DateTimeZone($timezone); + } catch (Throwable $exception) { + throw new SyntaxError('The timezone could not be instantiated.', 0, $exception); + } + } + + try { + return new self(new DateTimeImmutable($datetime, $timezone)); + } catch (Throwable $exception) { + throw new SyntaxError('Unable to create a '.DateTimeImmutable::class.' instance with the date notation `'.$datetime.'.`', 0, $exception); + } + } + + /** + * Returns a new instance from a DateTineInterface implementing object. + * + * @throws SyntaxError if the format is invalid + */ + public static function fromDate(DateTimeInterface $datetime): self + { + return new self($datetime); + } + + /** + * Returns a new instance from a float value. + * + * @throws SyntaxError if the format is invalid + */ + public static function fromDecimal(int|float $value): self + { + return new self((float)$value); + } + + /** + * Returns a new instance from an integer value. + * + * @throws SyntaxError if the format is invalid + */ + public static function fromInteger(int|float $value): self + { + return new self((int)$value); + } + + /** + * Returns a new instance for the boolean true type. + */ + public static function true(): self + { + return new self(true); + } + + /** + * Returns a new instance for the boolean false type. + */ + public static function false(): self + { + return new self(false); + } + + /** + * Returns the underlying value. + * If a validation rule is provided, an exception will be thrown + * if the validation rules does not return true. + * + * if the validation returns false then a default validation message will be return; otherwise the submitted message string will be returned as is. + * + * @param ?callable(SfType): (string|bool) $validate + * + * @throws Violation + */ + public function value(?callable $validate = null): Bytes|Token|DisplayString|DateTimeImmutable|string|int|float|bool + { + if (null === $validate) { + return $this->value; + } + + $exceptionMessage = $validate($this->value); + if (true === $exceptionMessage) { + return $this->value; + } + + if (!is_string($exceptionMessage) || '' === trim($exceptionMessage)) { + $exceptionMessage = "The item value '{value}' failed validation."; + } + + throw new Violation(strtr($exceptionMessage, ['{value}' => $this->serialize()])); + } + + public function type(): Type + { + return $this->type; + } + + /** + * Serialize the Item value according to RFC8941. + * + * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-4.1 + */ + public function toHttpValue(Ietf $rfc = Ietf::Rfc9651): string + { + return $this->serialize($rfc).$this->parameters->toHttpValue($rfc); + } + + /** + * Serialize the Item value according to RFC8941. + * + * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-4.1 + */ + private function serialize(Ietf $rfc = Ietf::Rfc9651): string + { + return match (true) { + !$rfc->supports($this->type) => throw MissingFeature::dueToLackOfSupport($this->type, $rfc), + $this->value instanceof DateTimeImmutable => '@'.$this->value->getTimestamp(), + $this->value instanceof Token => $this->value->toString(), + $this->value instanceof Bytes => ':'.$this->value->encoded().':', + $this->value instanceof DisplayString => '%"'.$this->value->encoded().'"', + is_int($this->value) => (string) $this->value, + is_float($this->value) => (string) json_encode(round($this->value, 3, PHP_ROUND_HALF_EVEN), JSON_PRESERVE_ZERO_FRACTION), + $this->value, + false === $this->value => '?'.($this->value ? '1' : '0'), + default => '"'.preg_replace('/(["\\\])/', '\\\$1', $this->value).'"', + }; + } + + public function toRfc9651(): string + { + return $this->toHttpValue(Ietf::Rfc9651); + } + + public function toRfc8941(): string + { + return $this->toHttpValue(Ietf::Rfc8941); + } + + public function __toString(): string + { + return $this->toHttpValue(); + } + + /** + * @return array{0:SfItemInput, 1:Parameters} + */ + public function toPair(): array + { + return [$this->value, $this->parameters]; + } + + public function equals(mixed $other): bool + { + return $other instanceof self && $other->toHttpValue() === $this->toHttpValue(); + } + + /** + * Apply the callback if the given "condition" is (or resolves to) true. + * + * @param (callable($this): bool)|bool $condition + * @param callable($this): (self|null) $onSuccess + * @param ?callable($this): (self|null) $onFail + */ + public function when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null): self + { + if (!is_bool($condition)) { + $condition = $condition($this); + } + + return match (true) { + $condition => $onSuccess($this), + null !== $onFail => $onFail($this), + default => $this, + } ?? $this; + } + + /** + * Returns a new instance with the newly associated value. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified value change. + * + * @throws SyntaxError If the value is invalid or not supported + */ + public function withValue(DateTimeInterface|Bytes|Token|DisplayString|string|int|float|bool $value): self + { + $isEqual = match (true) { + $this->value instanceof Bytes, + $this->value instanceof Token, + $this->value instanceof DisplayString => $this->value->equals($value), + $this->value instanceof DateTimeInterface && $value instanceof DateTimeInterface => $value->getTimestamp() === $this->value->getTimestamp(), + default => $value === $this->value, + }; + + if ($isEqual) { + return $this; + } + + return new self($value, $this->parameters); + } + + public function withParameters(StructuredFieldProvider|Parameters $parameters): static + { + if ($parameters instanceof StructuredFieldProvider) { + $parameters = $parameters->toStructuredField(); + if (!$parameters instanceof Parameters) { + throw new InvalidArgument('The '.StructuredFieldProvider::class.' must provide a '.Parameters::class.'; '.$parameters::class.' given.'); + } + } + + return $this->parameters->equals($parameters) ? $this : new self($this->value, $parameters); + } +} diff --git a/vendor/bakame/http-structured-fields/src/Key.php b/vendor/bakame/http-structured-fields/src/Key.php new file mode 100644 index 000000000..e1971426a --- /dev/null +++ b/vendor/bakame/http-structured-fields/src/Key.php @@ -0,0 +1,53 @@ +<?php + +namespace Bakame\Http\StructuredFields; + +use Stringable; + +use function preg_match; + +/** + * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-3.1.2 + * @internal normalize HTTP field key + */ +final class Key +{ + private function __construct(public readonly string $value) + { + } + + /** + * @throws SyntaxError If the string is not a valid HTTP value field key + */ + public static function from(Stringable|string|int $httpValue): self + { + $key = (string) $httpValue; + $instance = self::fromStringBeginning($key); + if ($instance->value !== $key) { + throw new SyntaxError('No valid http value key could be extracted from "'.$httpValue.'".'); + } + + return $instance; + } + + public static function tryFrom(Stringable|string|int $httpValue): ?self + { + try { + return self::from($httpValue); + } catch (SyntaxError $e) { + return null; + } + } + + /** + * @throws SyntaxError If the string does not start with a valid HTTP value field key + */ + public static function fromStringBeginning(string $httpValue): self + { + if (1 !== preg_match('/^(?<key>[a-z*][a-z\d.*_-]*)/', $httpValue, $found)) { + throw new SyntaxError('No valid http value key could be extracted from "'.$httpValue.'".'); + } + + return new self($found['key']); + } +} diff --git a/vendor/bakame/http-structured-fields/src/Member.php b/vendor/bakame/http-structured-fields/src/Member.php new file mode 100644 index 000000000..ee315b078 --- /dev/null +++ b/vendor/bakame/http-structured-fields/src/Member.php @@ -0,0 +1,116 @@ +<?php + +declare(strict_types=1); + +namespace Bakame\Http\StructuredFields; + +use function count; +use function in_array; +use function is_array; +use function is_iterable; + +/** + * @phpstan-import-type SfMemberInput from StructuredFieldProvider + * @phpstan-import-type SfItemInput from StructuredFieldProvider + * @phpstan-import-type SfItemPair from StructuredFieldProvider + * @phpstan-import-type SfInnerListPair from StructuredFieldProvider + * @phpstan-import-type SfTypeInput from StructuredFieldProvider + * + * @internal Validate containers member + */ +final class Member +{ + /** + * @param SfMemberInput $value + */ + public static function innerListOrItem(mixed $value): InnerList|Item + { + if ($value instanceof StructuredFieldProvider) { + $value = $value->toStructuredField(); + if ($value instanceof Item || $value instanceof InnerList) { + return $value; + } + + throw new InvalidArgument('The '.StructuredFieldProvider::class.' must provide a '.Item::class.' or an '.InnerList::class.'; '.$value::class.' given.'); + } + + return match (true) { + $value instanceof InnerList, + $value instanceof Item => $value, + is_iterable($value) => InnerList::new(...$value), + default => Item::new($value), + }; + } + + public static function innerListOrItemFromPair(mixed $value): InnerList|Item + { + if ($value instanceof StructuredFieldProvider) { + $value = $value->toStructuredField(); + if ($value instanceof Item || $value instanceof InnerList) { + return $value; + } + + throw new InvalidArgument('The '.StructuredFieldProvider::class.' must provide a '.Item::class.' or an '.InnerList::class.'; '.$value::class.' given.'); + } + + if ($value instanceof InnerList || $value instanceof Item) { + return $value; + } + + if (!is_array($value)) { + if (is_iterable($value)) { + throw new SyntaxError('The value must be an Item value not an iterable.'); + } + + return Item::new($value); /* @phpstan-ignore-line */ + } + + if (!array_is_list($value)) { + throw new SyntaxError('The pair must be represented by an array as a list.'); + } + + if ([] === $value) { + return InnerList::new(); + } + + if (!in_array(count($value), [1, 2], true)) { + throw new SyntaxError('The pair first member represents its value; the second member is its associated parameters.'); + } + + return is_iterable($value[0]) ? InnerList::fromPair($value) : Item::fromPair($value); + } + + /** + * @param SfItemInput|SfItemPair $value + */ + public static function item(mixed $value): Item + { + if ($value instanceof StructuredFieldProvider) { + $value = $value->toStructuredField(); + if (!$value instanceof Item) { + throw new InvalidArgument('The '.StructuredFieldProvider::class.' must provide a '.Item::class.'; '.$value::class.' given.'); + } + + return $value; + } + + if ($value instanceof Item) { + return $value; + } + + return Item::new($value); + } + + /** + * @param SfItemInput|SfItemPair $value + */ + public static function bareItem(mixed $value): Item + { + $bareItem = self::item($value); + if ($bareItem->parameters()->isNotEmpty()) { + throw new InvalidArgument('The "'.$bareItem::class.'" instance is not a Bare Item.'); + } + + return $bareItem; + } +} diff --git a/vendor/bakame/http-structured-fields/src/MissingFeature.php b/vendor/bakame/http-structured-fields/src/MissingFeature.php new file mode 100644 index 000000000..03bd8c05f --- /dev/null +++ b/vendor/bakame/http-structured-fields/src/MissingFeature.php @@ -0,0 +1,13 @@ +<?php + +declare(strict_types=1); + +namespace Bakame\Http\StructuredFields; + +final class MissingFeature extends SyntaxError +{ + public static function dueToLackOfSupport(Type $type, Ietf $rfc): self + { + return new self('The \''.$type->value.'\' type is not handled by '.strtoupper($rfc->name)); + } +} diff --git a/vendor/bakame/http-structured-fields/src/OuterList.php b/vendor/bakame/http-structured-fields/src/OuterList.php new file mode 100644 index 000000000..644f1990e --- /dev/null +++ b/vendor/bakame/http-structured-fields/src/OuterList.php @@ -0,0 +1,430 @@ +<?php + +declare(strict_types=1); + +namespace Bakame\Http\StructuredFields; + +use ArrayAccess; +use Bakame\Http\StructuredFields\Validation\Violation; +use Countable; +use DateTimeInterface; +use Exception; +use Iterator; +use IteratorAggregate; +use Stringable; + +use function array_filter; +use function array_map; +use function array_replace; +use function array_splice; +use function array_values; +use function count; +use function implode; +use function is_iterable; +use function uasort; + +use const ARRAY_FILTER_USE_BOTH; +use const ARRAY_FILTER_USE_KEY; + +/** + * @see https://www.rfc-editor.org/rfc/rfc9651.html#name-lists + * + * @phpstan-import-type SfMemberInput from StructuredFieldProvider + * @phpstan-import-type SfInnerListPair from StructuredFieldProvider + * @phpstan-import-type SfItemPair from StructuredFieldProvider + * + * @implements ArrayAccess<int, InnerList|Item> + * @implements IteratorAggregate<int, InnerList|Item> + */ +final class OuterList implements ArrayAccess, Countable, IteratorAggregate +{ + /** @var list<InnerList|Item> */ + private readonly array $members; + + /** + * @param SfMemberInput ...$members + */ + private function __construct( + iterable|StructuredFieldProvider|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool ...$members + ) { + $this->members = array_map(Member::innerListOrItem(...), array_values([...$members])); + } + + /** + * Returns an instance from an HTTP textual representation. + * + * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-3.1 + * + * @throws SyntaxError|Exception + */ + public static function fromHttpValue(Stringable|string $httpValue, Ietf $rfc = Ietf::Rfc9651): self + { + return self::fromPairs((new Parser($rfc))->parseList($httpValue)); /* @phpstan-ignore-line */ + } + + /** + * @param StructuredFieldProvider|iterable<SfInnerListPair|SfItemPair> $pairs + */ + public static function fromPairs(StructuredFieldProvider|iterable $pairs): self + { + if ($pairs instanceof StructuredFieldProvider) { + $pairs = $pairs->toStructuredField(); + } + + if (!is_iterable($pairs)) { + throw new InvalidArgument('The "'.$pairs::class.'" instance can not be used for creating a .'.self::class.' structured field.'); + } + + return match (true) { + $pairs instanceof OuterList, + $pairs instanceof InnerList => new self($pairs), + default => new self(...(function (iterable $pairs) { + foreach ($pairs as $member) { + yield Member::innerListOrItemFromPair($member); + } + })($pairs)), + }; + } + + /** + * @param StructuredFieldProvider|SfInnerListPair|SfItemPair|SfMemberInput ...$members + */ + public static function new(iterable|StructuredFieldProvider|InnerList|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool ...$members): self + { + return self::fromPairs($members); /* @phpstan-ignore-line*/ + } + + public static function fromRfc9651(Stringable|string $httpValue): self + { + return self::fromHttpValue($httpValue, Ietf::Rfc9651); + } + + public static function fromRfc8941(Stringable|string $httpValue): self + { + return self::fromHttpValue($httpValue, Ietf::Rfc8941); + } + + public function toHttpValue(Ietf $rfc = Ietf::Rfc9651): string + { + return implode(', ', array_map(fn (Item|InnerList $member): string => $member->toHttpValue($rfc), $this->members)); + } + + public function toRfc9651(): string + { + return $this->toHttpValue(Ietf::Rfc9651); + } + + public function toRfc8941(): string + { + return $this->toHttpValue(Ietf::Rfc8941); + } + + public function __toString(): string + { + return $this->toHttpValue(); + } + + public function equals(mixed $other): bool + { + return $other instanceof self && $other->toHttpValue() === $this->toHttpValue(); + } + + /** + * Apply the callback if the given "condition" is (or resolves to) true. + * + * @param (callable($this): bool)|bool $condition + * @param callable($this): (self|null) $onSuccess + * @param ?callable($this): (self|null) $onFail + */ + public function when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null): self + { + if (!is_bool($condition)) { + $condition = $condition($this); + } + + return match (true) { + $condition => $onSuccess($this), + null !== $onFail => $onFail($this), + default => $this, + } ?? $this; + } + + public function getIterator(): Iterator + { + yield from $this->members; + } + + public function count(): int + { + return count($this->members); + } + + public function isEmpty(): bool + { + return !$this->isNotEmpty(); + } + + public function isNotEmpty(): bool + { + return [] !== $this->members; + } + + /** + * @return array<int> + */ + public function indices(): array + { + return array_keys($this->members); + } + + public function hasIndices(int ...$indices): bool + { + $max = count($this->members); + foreach ($indices as $index) { + if (null === $this->filterIndex($index, $max)) { + return false; + } + } + + return [] !== $indices; + } + + private function filterIndex(int $index, ?int $max = null): ?int + { + $max ??= count($this->members); + + return match (true) { + [] === $this->members, + 0 > $max + $index, + 0 > $max - $index - 1 => null, + 0 > $index => $max + $index, + default => $index, + }; + } + + /** + * @param ?callable(InnerList|Item): (bool|string) $validate + * + * @throws SyntaxError|Violation|StructuredFieldError + */ + public function getByIndex(int $index, ?callable $validate = null): InnerList|Item + { + $value = $this->members[$this->filterIndex($index) ?? throw InvalidOffset::dueToIndexNotFound($index)]; + if (null === $validate) { + return $value; + } + + if (true === ($exceptionMessage = $validate($value))) { + return $value; + } + + if (!is_string($exceptionMessage) || '' === trim($exceptionMessage)) { + $exceptionMessage = "The member at position '{index}' whose value is '{value}' failed validation."; + } + + throw new Violation(strtr($exceptionMessage, ['{index}' => $index, '{value}' => $value->toHttpValue()])); + } + + public function first(): InnerList|Item|null + { + return $this->members[0] ?? null; + } + + public function last(): InnerList|Item|null + { + return $this->members[$this->filterIndex(-1)] ?? null; + } + + /** + * Inserts members at the beginning of the list. + * + * @param SfMemberInput ...$members + */ + public function unshift( + StructuredFieldProvider|InnerList|Item|iterable|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool ...$members + ): self { + $membersToAdd = array_reduce( + $members, + function (array $carry, $member) { + if ($member instanceof StructuredFieldProvider) { + $member = $member->toStructuredField(); + } + + return [...$carry, ...$member instanceof InnerList ? [...$member] : [$member]]; + }, + [] + ); + + return match (true) { + [] === $membersToAdd => $this, + default => new self(...array_values($membersToAdd), ...$this->members), + }; + } + + /** + * Inserts members at the end of the list. + * + * @param SfMemberInput ...$members + */ + public function push( + iterable|StructuredFieldProvider|InnerList|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool ...$members + ): self { + $membersToAdd = array_reduce( + $members, + function (array $carry, $member) { + if ($member instanceof StructuredFieldProvider) { + $member = $member->toStructuredField(); + } + + return [...$carry, ...$member instanceof InnerList ? [...$member] : [$member]]; + }, + [] + ); + + return match (true) { + [] === $membersToAdd => $this, + default => new self(...$this->members, ...array_values($membersToAdd)), + }; + } + + /** + * Inserts members starting at the given index. + * + * @param SfMemberInput ...$members + * + * @throws InvalidOffset If the index does not exist + */ + public function insert( + int $index, + iterable|StructuredFieldProvider|InnerList|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool ...$members + ): self { + $offset = $this->filterIndex($index) ?? throw InvalidOffset::dueToIndexNotFound($index); + + return match (true) { + 0 === $offset => $this->unshift(...$members), + count($this->members) === $offset => $this->push(...$members), + [] === $members => $this, + default => (function (array $newMembers) use ($offset, $members) { + array_splice($newMembers, $offset, 0, $members); + + return new self(...$newMembers); + })($this->members), + }; + } + + /** + * @param SfMemberInput $member + */ + public function replace( + int $index, + iterable|StructuredFieldProvider|InnerList|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool $member + ): self { + $offset = $this->filterIndex($index) ?? throw InvalidOffset::dueToIndexNotFound($index); + $member = Member::innerListOrItem($member); + + return match (true) { + $member->equals($this->members[$offset]) => $this, + default => new self(...array_replace($this->members, [$offset => $member])), + }; + } + + public function removeByIndices(int ...$indices): self + { + $max = count($this->members); + $offsets = array_filter( + array_map(fn (int $index): ?int => $this->filterIndex($index, $max), $indices), + fn (?int $index): bool => null !== $index + ); + + return match (true) { + [] === $offsets => $this, + $max === count($offsets) => new self(), + default => new self(...array_filter( + $this->members, + fn (int $index): bool => !in_array($index, $offsets, true), + ARRAY_FILTER_USE_KEY + )), + }; + } + + /** + * @param int $offset + */ + public function offsetExists(mixed $offset): bool + { + return $this->hasIndices($offset); + } + + /** + * @param int $offset + */ + public function offsetGet(mixed $offset): InnerList|Item + { + return $this->getByIndex($offset); + } + + public function offsetUnset(mixed $offset): void + { + throw new ForbiddenOperation(self::class.' instance can not be updated using '.ArrayAccess::class.' methods.'); + } + + public function offsetSet(mixed $offset, mixed $value): void + { + throw new ForbiddenOperation(self::class.' instance can not be updated using '.ArrayAccess::class.' methods.'); + } + + /** + * @param callable(InnerList|Item, int): TMap $callback + * + * @template TMap + * + * @return Iterator<TMap> + */ + public function map(callable $callback): Iterator + { + foreach ($this->members as $offset => $member) { + yield ($callback)($member, $offset); + } + } + + /** + * @param callable(TInitial|null, InnerList|Item, int): TInitial $callback + * @param TInitial|null $initial + * + * @template TInitial + * + * @return TInitial|null + */ + public function reduce(callable $callback, mixed $initial = null): mixed + { + foreach ($this->members as $offset => $member) { + $initial = $callback($initial, $member, $offset); + } + + return $initial; + } + + /** + * @param callable(InnerList|Item, int): bool $callback + */ + public function filter(callable $callback): self + { + $members = array_filter($this->members, $callback, ARRAY_FILTER_USE_BOTH); + if ($members === $this->members) { + return $this; + } + + return new self(...$members); + } + + /** + * @param callable(InnerList|Item, InnerList|Item): int $callback + */ + public function sort(callable $callback): self + { + $members = $this->members; + uasort($members, $callback); + + return new self(...$members); + } +} diff --git a/vendor/bakame/http-structured-fields/src/ParameterAccess.php b/vendor/bakame/http-structured-fields/src/ParameterAccess.php new file mode 100644 index 000000000..9c71f0955 --- /dev/null +++ b/vendor/bakame/http-structured-fields/src/ParameterAccess.php @@ -0,0 +1,260 @@ +<?php + +declare(strict_types=1); + +namespace Bakame\Http\StructuredFields; + +use Bakame\Http\StructuredFields\Validation\Violation; +use DateTimeImmutable; +use DateTimeInterface; + +/** + * Common manipulation methods used when interacting with an object + * with a Parameters instance attached to it. + * + * @phpstan-import-type SfType from StructuredFieldProvider + * @phpstan-import-type SfItemInput from StructuredFieldProvider + */ +trait ParameterAccess +{ + /** + * Returns a copy of the associated parameter instance. + */ + public function parameters(): Parameters + { + return $this->parameters; + } + + /** + * Returns the member value or null if no members value exists. + * + * @param ?callable(SfType): (bool|string) $validate + * + * @throws Violation if the validation fails + * + * @return SfType|null + */ + public function parameterByKey( + string $key, + ?callable $validate = null, + bool|string $required = false, + Bytes|Token|DisplayString|DateTimeImmutable|string|int|float|bool|null $default = null + ): Bytes|Token|DisplayString|DateTimeImmutable|string|int|float|bool|null { + return $this->parameters->valueByKey($key, $validate, $required, $default); + } + + /** + * Returns the member value and key as pair or an empty array if no members value exists. + * + * @param ?callable(SfType, string): (bool|string) $validate + * @param array{0:string, 1:SfType}|array{} $default + * + * @throws Violation if the validation fails + * + * @return array{0:string, 1:SfType}|array{} + */ + public function parameterByIndex( + int $index, + ?callable $validate = null, + bool|string $required = false, + array $default = [] + ): array { + return $this->parameters->valueByIndex($index, $validate, $required, $default); + } + + /** + * Returns a new instance with the newly associated parameter instance. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified parameter change. + */ + abstract public function withParameters(Parameters $parameters): static; + + /** + * Adds a member if its key is not present at the of the associated parameter instance or update the instance at the given key. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified parameter change. + * + * @param StructuredFieldProvider|Item|SfType $member + * + * @throws SyntaxError If the string key is not a valid + */ + public function addParameter( + string $key, + StructuredFieldProvider|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool $member + ): static { + return $this->withParameters($this->parameters()->add($key, $member)); + } + + /** + * Adds a member at the start of the associated parameter instance and deletes any previous reference to the key if present. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified parameter change. + * + * @param StructuredFieldProvider|Item|SfType $member + * + * @throws SyntaxError If the string key is not a valid + */ + public function prependParameter( + string $key, + StructuredFieldProvider|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool $member + ): static { + return $this->withParameters($this->parameters()->prepend($key, $member)); + } + + /** + * Adds a member at the end of the associated parameter instance and deletes any previous reference to the key if present. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified parameter change. + * + * @param StructuredFieldProvider|Item|SfType $member + * + * @throws SyntaxError If the string key is not a valid + */ + public function appendParameter( + string $key, + StructuredFieldProvider|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool $member + ): static { + return $this->withParameters($this->parameters()->append($key, $member)); + } + + /** + * Removes all parameters members associated with the list of submitted keys in the associated parameter instance. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified parameter change. + */ + public function withoutAnyParameter(): static + { + return $this->withParameters(Parameters::new()); + } + + /** + * Inserts pair at the end of the member list. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified parameter change. + * + * @param array{0:string, 1:SfItemInput} ...$pairs + */ + public function pushParameters(array ...$pairs): static + { + return $this->withParameters($this->parameters()->push(...$pairs)); + } + + /** + * Inserts pair at the beginning of the member list. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified parameter change. + * + * @param array{0:string, 1:SfItemInput} ...$pairs + */ + public function unshiftParameters(array ...$pairs): static + { + return $this->withParameters($this->parameters()->unshift(...$pairs)); + } + + /** + * Delete member based on their key. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified parameter change. + */ + public function withoutParameterByKeys(string ...$keys): static + { + return $this->withParameters($this->parameters()->removeByKeys(...$keys)); + } + + /** + * Delete member based on their offsets. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified parameter change. + */ + public function withoutParameterByIndices(int ...$indices): static + { + return $this->withParameters($this->parameters()->removeByIndices(...$indices)); + } + + /** + * Inserts members at the specified index. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified parameter change. + * + * @param array{0:string, 1:SfType} ...$pairs + */ + public function insertParameters(int $index, array ...$pairs): static + { + return $this->withParameters($this->parameters()->insert($index, ...$pairs)); + } + + /** + * Replace the member at the specified index. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified parameter change. + * + * @param array{0:string, 1:SfType} $pair + */ + public function replaceParameter(int $index, array $pair): static + { + return $this->withParameters($this->parameters()->replace($index, $pair)); + } + + /** + * Sort the object parameters by value using a callback. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified parameter change. + * + * @param callable(array{0:string, 1:Item}, array{0:string, 1:Item}): int $callback + */ + public function sortParameters(callable $callback): static + { + return $this->withParameters($this->parameters()->sort($callback)); + } + + /** + * Filter the object parameters using a callback. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified parameter change. + * + * @param callable(array{0:string, 1:Item}, int): bool $callback + */ + public function filterParameters(callable $callback): static + { + return $this->withParameters($this->parameters()->filter($callback)); + } + + /** + * Merges multiple instances using iterable pairs. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified changes. + * + * @param StructuredFieldProvider|Parameters|Dictionary|iterable<array{0:string, 1:SfItemInput}> ...$others + */ + public function mergeParametersByPairs(...$others): static + { + return $this->withParameters($this->parameters()->mergePairs(...$others)); + } + + /** + * Merges multiple instances using iterable associative. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified changes. + * + * @param StructuredFieldProvider|Dictionary|Parameters|iterable<string, SfItemInput> ...$others + */ + public function mergeParametersByAssociative(...$others): static + { + return $this->withParameters($this->parameters()->mergeAssociative(...$others)); + } +} diff --git a/vendor/bakame/http-structured-fields/src/Parameters.php b/vendor/bakame/http-structured-fields/src/Parameters.php new file mode 100644 index 000000000..9fd9a4e2b --- /dev/null +++ b/vendor/bakame/http-structured-fields/src/Parameters.php @@ -0,0 +1,763 @@ +<?php + +declare(strict_types=1); + +namespace Bakame\Http\StructuredFields; + +use ArrayAccess; +use Bakame\Http\StructuredFields\Validation\Violation; +use CallbackFilterIterator; +use Countable; +use DateTimeImmutable; +use DateTimeInterface; +use Exception; +use Iterator; +use IteratorAggregate; +use Stringable; + +use function array_key_exists; +use function array_keys; +use function array_map; +use function array_replace; +use function count; +use function implode; +use function is_int; +use function is_string; +use function trim; +use function uasort; + +/** + * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-3.1.2 + * + * @phpstan-import-type SfItemInput from StructuredFieldProvider + * @phpstan-import-type SfType from StructuredFieldProvider + * + * @implements ArrayAccess<string, Item> + * @implements IteratorAggregate<int, array{0:string, 1:Item}> + */ +final class Parameters implements ArrayAccess, Countable, IteratorAggregate +{ + /** @var array<string, Item> */ + private readonly array $members; + + /** + * @param iterable<string, SfItemInput> $members + */ + private function __construct(iterable $members = []) + { + $filteredMembers = []; + foreach ($members as $key => $member) { + $filteredMembers[Key::from($key)->value] = Member::bareItem($member); + } + + $this->members = $filteredMembers; + } + + /** + * Returns a new instance. + */ + public static function new(): self + { + return new self(); + } + + /** + * Returns a new instance from an associative iterable construct. + * + * its keys represent the dictionary entry key + * its values represent the dictionary entry value + * + * @param StructuredFieldProvider|iterable<string, SfItemInput> $members + */ + public static function fromAssociative(StructuredFieldProvider|iterable $members): self + { + if ($members instanceof StructuredFieldProvider) { + $structuredField = $members->toStructuredField(); + + return match (true) { + $structuredField instanceof Dictionary, + $structuredField instanceof Parameters => new self($structuredField->toAssociative()), + default => throw new InvalidArgument('The '.StructuredFieldProvider::class.' must provide a structured field container; '.$structuredField::class.' given.'), + }; + } + + return new self($members); + } + + /** + * Returns a new instance from a pair iterable construct. + * + * Each member is composed of an array with two elements + * the first member represents the instance entry key + * the second member represents the instance entry value + * + * @param StructuredFieldProvider|iterable<array{0:string, 1:SfItemInput}> $pairs + */ + public static function fromPairs(StructuredFieldProvider|iterable $pairs): self + { + if ($pairs instanceof StructuredFieldProvider) { + $pairs = $pairs->toStructuredField(); + } + + if (!is_iterable($pairs)) { + throw new InvalidArgument('The "'.$pairs::class.'" instance can not be used for creating a .'.self::class.' structured field.'); + } + + return match (true) { + $pairs instanceof Parameters, + $pairs instanceof Dictionary => new self($pairs->toAssociative()), + default => new self((function (iterable $pairs) { + foreach ($pairs as [$key, $member]) { + yield $key => $member; + } + })($pairs)), + }; + } + + /** + * Returns an instance from an HTTP textual representation. + * + * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-3.1.2 + * + * @throws SyntaxError|Exception If the string is not a valid + */ + public static function fromHttpValue(Stringable|string $httpValue, Ietf $rfc = Ietf::Rfc9651): self + { + return self::fromPairs((new Parser($rfc))->parseParameters($httpValue)); /* @phpstan-ignore-line */ + } + + public static function fromRfc9651(Stringable|string $httpValue): self + { + return self::fromHttpValue($httpValue, Ietf::Rfc9651); + } + + public static function fromRfc8941(Stringable|string $httpValue): self + { + return self::fromHttpValue($httpValue, Ietf::Rfc8941); + } + + public function toHttpValue(Ietf $rfc = Ietf::Rfc9651): string + { + $formatter = static fn (Item $member, string $offset): string => match ($member->value()) { + true => ';'.$offset, + default => ';'.$offset.'='.$member->toHttpValue($rfc), + }; + + return implode('', array_map($formatter, $this->members, array_keys($this->members))); + } + + public function toRfc9651(): string + { + return $this->toHttpValue(Ietf::Rfc9651); + } + + public function toRfc8941(): string + { + return $this->toHttpValue(Ietf::Rfc8941); + } + + public function __toString(): string + { + return $this->toHttpValue(); + } + + public function equals(mixed $other): bool + { + return $other instanceof self && $other->toHttpValue() === $this->toHttpValue(); + } + + /** + * Apply the callback if the given "condition" is (or resolves to) true. + * + * @param (callable($this): bool)|bool $condition + * @param callable($this): (self|null) $onSuccess + * @param ?callable($this): (self|null) $onFail + */ + public function when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null): self + { + if (!is_bool($condition)) { + $condition = $condition($this); + } + + return match (true) { + $condition => $onSuccess($this), + null !== $onFail => $onFail($this), + default => $this, + } ?? $this; + } + + public function count(): int + { + return count($this->members); + } + + public function isEmpty(): bool + { + return !$this->isNotEmpty(); + } + + public function isNotEmpty(): bool + { + return [] !== $this->members; + } + + /** + * @return Iterator<string, Item> + */ + public function toAssociative(): Iterator + { + yield from $this->members; + } + + /** + * @return Iterator<int, array{0:string, 1:Item}> + */ + public function getIterator(): Iterator + { + foreach ($this->members as $index => $member) { + yield [$index, $member]; + } + } + + /** + * @return array<string> + */ + public function keys(): array + { + return array_keys($this->members); + } + + /** + * Tells whether the instance contain a members at the specified offsets. + */ + public function hasKeys(string ...$keys): bool + { + foreach ($keys as $key) { + if (!array_key_exists($key, $this->members)) { + return false; + } + } + + return [] !== $keys; + } + + /** + * @param ?callable(SfType): (bool|string) $validate + * + * @throws Violation|InvalidOffset + */ + public function getByKey(string $key, ?callable $validate = null): Item + { + $value = $this->members[$key] ?? throw InvalidOffset::dueToKeyNotFound($key); + if (null === $validate || true === ($exceptionMessage = $validate($value->value()))) { + return $value; + } + + if (!is_string($exceptionMessage) || '' === trim($exceptionMessage)) { + $exceptionMessage = "The parameter '{key}' whose value is '{value}' failed validation."; + } + + throw new Violation(strtr($exceptionMessage, ['{key}' => $key, '{value}' => $value->toHttpValue()])); + } + + /** + * @return array<int> + */ + public function indices(): array + { + return array_keys($this->keys()); + } + + public function hasIndices(int ...$indices): bool + { + $max = count($this->members); + foreach ($indices as $index) { + if (null === $this->filterIndex($index, $max)) { + return false; + } + } + + return [] !== $indices; + } + + /** + * Filters and format instance index. + */ + private function filterIndex(int $index, int|null $max = null): int|null + { + $max ??= count($this->members); + + return match (true) { + [] === $this->members, + 0 > $max + $index, + 0 > $max - $index - 1 => null, + 0 > $index => $max + $index, + default => $index, + }; + } + + /** + * @param ?callable(SfType, string): (bool|string) $validate + * + * @throws InvalidOffset|Violation + * + * @return array{0:string, 1:Item} + */ + public function getByIndex(int $index, ?callable $validate = null): array + { + $foundOffset = $this->filterIndex($index) ?? throw InvalidOffset::dueToIndexNotFound($index); + foreach ($this as $offset => $pair) { + if ($offset === $foundOffset) { + break; + } + } + + if (!isset($pair)) { + throw InvalidOffset::dueToIndexNotFound($index); + } + + if (null === $validate || true === ($exceptionMessage = $validate($pair[1]->value(), $pair[0]))) { + return $pair; + } + + if (!is_string($exceptionMessage) || '' === trim($exceptionMessage)) { + $exceptionMessage = "The parameter at position '{index}' whose key is '{key}' with the value '{value}' failed validation."; + } + + throw new Violation(strtr($exceptionMessage, ['{index}' => $index, '{key}' => $pair[0], '{value}' => $pair[1]->toHttpValue()])); + } + + /** + * Returns the key associated with the given index or null otherwise. + */ + public function indexByKey(string $key): ?int + { + foreach ($this as $index => $member) { + if ($key === $member[0]) { + return $index; + } + } + + return null; + } + + /** + * Returns the index associated with the given key or null otherwise. + */ + public function keyByIndex(int $index): ?string + { + $index = $this->filterIndex($index); + if (null === $index) { + return null; + } + + foreach ($this as $offset => $member) { + if ($offset === $index) { + return $member[0]; + } + } + + return null; + } + + /** + * @return array{0:string, 1:Item}|array{} + */ + public function first(): array + { + try { + return $this->getByIndex(0); + } catch (InvalidOffset) { + return []; + } + } + + /** + * @return array{0:string, 1:Item}|array{} + */ + public function last(): array + { + try { + return $this->getByIndex(-1); + } catch (InvalidOffset) { + return []; + } + } + + /** + * Returns true only if the instance only contains the listed keys, false otherwise. + * + * @param array<string> $keys + */ + public function allowedKeys(array $keys): bool + { + foreach ($this->members as $key => $member) { + if (!in_array($key, $keys, true)) { + return false; + } + } + + return [] !== $keys; + } + + /** + * Returns the member value or null if no members value exists. + * + * @param ?callable(SfType): (bool|string) $validate + * + * @throws Violation if the validation fails + * + * @return SfType|null + */ + public function valueByKey( + string $key, + ?callable $validate = null, + bool|string $required = false, + Bytes|Token|DisplayString|DateTimeImmutable|string|int|float|bool|null $default = null + ): Bytes|Token|DisplayString|DateTimeImmutable|string|int|float|bool|null { + if (null !== $default && null === Type::tryFromVariable($default)) { + throw new SyntaxError('The default parameter is invalid.'); + } + + try { + return $this->getByKey($key, $validate)->value(); + } catch (InvalidOffset $exception) { + if (false === $required) { + return $default; + } + + $message = $required; + if (!is_string($message) || '' === trim($message)) { + $message = "The required parameter '{key}' is missing."; + } + + throw new Violation(strtr($message, ['{key}' => $key]), previous: $exception); + } + } + + /** + * Returns the member value and key as pair or an empty array if no members value exists. + * + * @param ?callable(SfType, string): (bool|string) $validate + * @param array{0:string, 1:SfType}|array{} $default + * + * @throws Violation if the validation fails + * + * @return array{0:string, 1:SfType}|array{} + */ + public function valueByIndex(int $index, ?callable $validate = null, bool|string $required = false, array $default = []): array + { + $default = match (true) { + [] === $default => [], + !array_is_list($default) => throw new SyntaxError('The pair must be represented by an array as a list.'), /* @phpstan-ignore-line */ + 2 !== count($default) => throw new SyntaxError('The pair first member is the key; its second member is its value.'), /* @phpstan-ignore-line */ + null === ($key = Key::tryFrom($default[0])?->value) => throw new SyntaxError('The pair first member is invalid.'), + null === ($value = Item::tryNew($default[1])?->value()) => throw new SyntaxError('The pair second member is invalid.'), + default => [$key, $value], + }; + + try { + $tuple = $this->getByIndex($index, $validate); + + return [$tuple[0], $tuple[1]->value()]; + } catch (InvalidOffset $exception) { + if (false === $required) { + return $default; + } + + $message = $required; + if (!is_string($message) || '' === trim($message)) { + $message = "The required parameter at position '{index}' is missing."; + } + + throw new Violation(strtr($message, ['{index}' => $index]), previous: $exception); + } + } + + /** + * @param StructuredFieldProvider|Item|SfType $member + */ + public function add( + string $key, + StructuredFieldProvider|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool $member + ): self { + $key = Key::from($key)->value; + $member = Member::bareItem($member); + $oldMember = $this->members[$key] ?? null; + if (null === $oldMember || !$oldMember->equals($member)) { + $members = $this->members; + $members[$key] = $member; + + return new self($members); + } + + return $this; + } + + /** + * @param array<string, Item> $members + */ + private function newInstance(array $members): self + { + foreach ($members as $offset => $member) { + if (!isset($this->members[$offset]) || !$this->members[$offset]->equals($member)) { + return new self($members); + } + } + + return $this; + } + + private function remove(string|int ...$offsets): self + { + if ([] === $this->members || [] === $offsets) { + return $this; + } + + $keys = array_keys($this->members); + $max = count($keys); + $reducer = fn (array $carry, string|int $key): array => match (true) { + is_string($key) && (false !== ($position = array_search($key, $keys, true))), + is_int($key) && (null !== ($position = $this->filterIndex($key, $max))) => [$position => true] + $carry, + default => $carry, + }; + + $indices = array_reduce($offsets, $reducer, []); + + return match (true) { + [] === $indices => $this, + $max === count($indices) => self::new(), + default => self::fromPairs((function (array $offsets) { + foreach ($this as $offset => $pair) { + if (!array_key_exists($offset, $offsets)) { + yield $pair; + } + } + })($indices)), + }; + } + + public function removeByIndices(int ...$indices): self + { + return $this->remove(...$indices); + } + + public function removeByKeys(string ...$keys): self + { + return $this->remove(...$keys); + } + + /** + * @param StructuredFieldProvider|Item|SfType $member + */ + public function append( + string $key, + StructuredFieldProvider|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool $member + ): self { + $key = Key::from($key)->value; + $member = Member::bareItem($member); + $members = $this->members; + unset($members[$key]); + $members[$key] = $member; + + return $this->newInstance($members); + } + + /** + * @param StructuredFieldProvider|Item|SfType $member + */ + public function prepend( + string $key, + StructuredFieldProvider|Item|Token|Bytes|DisplayString|DateTimeInterface|string|int|float|bool $member + ): self { + $key = Key::from($key)->value; + $member = Member::bareItem($member); + $members = $this->members; + unset($members[$key]); + + return $this->newInstance([$key => $member, ...$members]); + } + + /** + * @param array{0:string, 1:SfItemInput} ...$pairs + */ + public function push(array ...$pairs): self + { + return match (true) { + [] === $pairs => $this, + default => self::fromPairs((function (iterable $pairs) { + yield from $this->getIterator(); + yield from $pairs; + })($pairs)), + }; + } + + /** + * @param array{0:string, 1:SfItemInput} ...$pairs + */ + public function unshift(array ...$pairs): self + { + return match (true) { + [] === $pairs => $this, + default => self::fromPairs((function (iterable $pairs) { + yield from $pairs; + yield from $this->getIterator(); + })($pairs)), + }; + } + + /** + * @param array{0:string, 1:SfItemInput} ...$members + */ + public function insert(int $index, array ...$members): self + { + $offset = $this->filterIndex($index) ?? throw InvalidOffset::dueToIndexNotFound($index); + + return match (true) { + [] === $members => $this, + 0 === $offset => $this->unshift(...$members), + count($this->members) === $offset => $this->push(...$members), + default => (function (Iterator $newMembers) use ($offset, $members) { + $newMembers = iterator_to_array($newMembers); + array_splice($newMembers, $offset, 0, $members); + + return self::fromPairs($newMembers); + })($this->getIterator()), + }; + } + + /** + * @param array{0:string, 1:SfItemInput} $pair + */ + public function replace(int $index, array $pair): self + { + $offset = $this->filterIndex($index) ?? throw InvalidOffset::dueToIndexNotFound($index); + $pair[1] = Member::bareItem($pair[1]); + $pairs = iterator_to_array($this); + + return match (true) { + $pairs[$offset][0] === $pair[0] && $pairs[$offset][1]->equals($pair[1]) => $this, + default => self::fromPairs(array_replace($pairs, [$offset => $pair])), + }; + } + + /** + * @param StructuredFieldProvider|Dictionary|Parameters|iterable<string, SfItemInput> ...$others + */ + public function mergeAssociative(StructuredFieldProvider|iterable ...$others): self + { + $members = $this->members; + foreach ($others as $other) { + if ($other instanceof StructuredFieldProvider) { + $other = $other->toStructuredField(); + if (!$other instanceof Dictionary && !$other instanceof Parameters) { + throw new InvalidArgument('The "'.$other::class.'" instance can not be used for creating a .'.self::class.' structured field.'); + } + } + + if ($other instanceof self || $other instanceof Dictionary) { + $other = $other->toAssociative(); + } + + foreach ($other as $key => $value) { + $members[$key] = $value; + } + } + + return new self($members); + } + + /** + * @param StructuredFieldProvider|Parameters|Dictionary|iterable<array{0:string, 1:SfItemInput}> ...$others + */ + public function mergePairs(Dictionary|Parameters|StructuredFieldProvider|iterable ...$others): self + { + $members = $this->members; + foreach ($others as $other) { + if (!$other instanceof self) { + $other = self::fromPairs($other); + } + foreach ($other->toAssociative() as $key => $value) { + $members[$key] = $value; + } + } + + return new self($members); + } + + /** + * @param string $offset + */ + public function offsetExists(mixed $offset): bool + { + return $this->hasKeys($offset); + } + + /** + * @param string $offset + */ + public function offsetGet(mixed $offset): Item + { + return $this->getByKey($offset); + } + + public function offsetUnset(mixed $offset): void + { + throw new ForbiddenOperation(self::class.' instance can not be updated using '.ArrayAccess::class.' methods.'); + } + + public function offsetSet(mixed $offset, mixed $value): void + { + throw new ForbiddenOperation(self::class.' instance can not be updated using '.ArrayAccess::class.' methods.'); + } + + /** + * @param callable(array{0:string, 1:Item}, int): TMap $callback + * + * @template TMap + * + * @return Iterator<TMap> + */ + public function map(callable $callback): Iterator + { + foreach ($this as $offset => $pair) { + yield ($callback)($pair, $offset); + } + } + + /** + * @param callable(TInitial|null, array{0:string, 1:Item}, int): TInitial $callback + * @param TInitial|null $initial + * + * @template TInitial + * + * @return TInitial|null + */ + public function reduce(callable $callback, mixed $initial = null): mixed + { + foreach ($this as $offset => $pair) { + $initial = $callback($initial, $pair, $offset); + } + + return $initial; + } + + /** + * @param callable(array{0:string, 1:Item}, int): bool $callback + */ + public function filter(callable $callback): self + { + return self::fromPairs(new CallbackFilterIterator($this->getIterator(), $callback)); + } + + /** + * @param callable(array{0:string, 1:Item}, array{0:string, 1:Item}): int $callback + */ + public function sort(callable $callback): self + { + $members = iterator_to_array($this); + uasort($members, $callback); + + return self::fromPairs($members); + } +} diff --git a/vendor/bakame/http-structured-fields/src/Parser.php b/vendor/bakame/http-structured-fields/src/Parser.php new file mode 100644 index 000000000..4c7448c15 --- /dev/null +++ b/vendor/bakame/http-structured-fields/src/Parser.php @@ -0,0 +1,523 @@ +<?php + +declare(strict_types=1); + +namespace Bakame\Http\StructuredFields; + +use DateTimeImmutable; +use Exception; +use Stringable; + +use function in_array; +use function ltrim; +use function preg_match; +use function str_contains; +use function strlen; +use function substr; +use function trim; + +/** + * A class to parse HTTP Structured Fields from their HTTP textual representation according to RFC8941. + * + * Based on gapple\StructuredFields\Parser class in Structured Field Values for PHP v1.0.0. + * + * @link https://github.com/gapple/structured-fields/blob/v1.0.0/src/Parser.php + * + * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-4.2 + * + * @see Dictionary::fromHttpValue() + * @see Parameters::fromHttpValue() + * @see OuterList::fromHttpValue() + * @see InnerList::fromHttpValue() + * @see Item::fromHttpValue() + * + * @internal Do not use directly this class as it's behaviour and return type + * MAY change significantly even during a major release cycle. + * + * @phpstan-type SfValue Bytes|Token|DisplayString|DateTimeImmutable|string|int|float|bool + * @phpstan-type SfParameter array<array{0:string, 1:SfValue}> + * @phpstan-type SfItem array{0:SfValue, 1: SfParameter} + * @phpstan-type SfInnerList array{0:array<SfItem>, 1: SfParameter} + */ +final class Parser +{ + private const REGEXP_BYTES = '/^(?<sequence>:(?<byte>[a-z\d+\/=]*):)/i'; + private const REGEXP_BOOLEAN = '/^\?[01]/'; + private const REGEXP_DATE = '/^@(?<date>-?\d{1,15})(?:[^\d.]|$)/'; + private const REGEXP_DECIMAL = '/^-?\d{1,12}\.\d{1,3}$/'; + private const REGEXP_INTEGER = '/^-?\d{1,15}$/'; + private const REGEXP_TOKEN = "/^(?<token>[a-z*][a-z\d:\/!#\$%&'*+\-.^_`|~]*)/i"; + private const REGEXP_INVALID_CHARACTERS = "/[\r\t\n]|[^\x20-\x7E]/"; + private const REGEXP_VALID_NUMBER = '/^(?<number>-?\d+(?:\.\d+)?)(?:[^\d.]|$)/'; + private const REGEXP_VALID_SPACE = '/^(?<space>,[ \t]*)/'; + private const FIRST_CHARACTER_RANGE_NUMBER = '-1234567890'; + private const FIRST_CHARACTER_RANGE_TOKEN = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ*'; + + public function __construct(private readonly Ietf $rfc) + { + } + + /** + * Returns an Item as a PHP list array from an HTTP textual representation. + * + * @see https://www.rfc-editor.org/rfc/rfc9651.html#name-parsing-an-item + * + * + * @throws Exception|SyntaxError + * + * @return SfItem + */ + public function parseItem(Stringable|string $httpValue): array + { + $remainder = trim((string) $httpValue, ' '); + if ('' === $remainder || 1 === preg_match(self::REGEXP_INVALID_CHARACTERS, $remainder)) { + throw new SyntaxError("The HTTP textual representation \"$httpValue\" for an item contains invalid characters."); + } + + [$value, $offset] = $this->extractValue($remainder); + $remainder = substr($remainder, $offset); + if ('' !== $remainder && !str_contains($remainder, ';')) { + throw new SyntaxError("The HTTP textual representation \"$httpValue\" for an item contains invalid characters."); + } + + return [$value, $this->parseParameters($remainder)]; /* @phpstan-ignore-line */ + } + + /** + * Returns a Parameters ordered map container as a PHP list array from an HTTP textual representation. + * + * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-3.1.2 + * + * @throws SyntaxError|Exception + * + * @return array<SfParameter> + */ + public function parseParameters(Stringable|string $httpValue): array + { + $remainder = trim((string) $httpValue); + [$parameters, $offset] = $this->extractParametersValues($remainder); + if (strlen($remainder) !== $offset) { + throw new SyntaxError("The HTTP textual representation \"$httpValue\" for Parameters contains invalid characters."); + } + + return $parameters; /* @phpstan-ignore-line */ + } + + /** + * Returns an ordered list represented as a PHP list array from an HTTP textual representation. + * + * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-4.2.1 + * + * @throws SyntaxError|Exception + * + * @return array<SfInnerList|SfItem> + */ + public function parseList(Stringable|string $httpValue): array + { + $list = []; + $remainder = ltrim((string) $httpValue, ' '); + while ('' !== $remainder) { + [$list[], $offset] = $this->extractItemOrInnerList($remainder); + $remainder = self::removeCommaSeparatedWhiteSpaces($remainder, $offset); + } + + return $list; + } + + /** + * Returns a Dictionary represented as a PHP list array from an HTTP textual representation. + * + * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-4.2.2 + * + * @throws SyntaxError|Exception + * + * @return array<array{0:string, 1:SfInnerList|SfItem}> + */ + public function parseDictionary(Stringable|string $httpValue): array + { + $map = []; + $remainder = ltrim((string) $httpValue, ' '); + while ('' !== $remainder) { + $key = Key::fromStringBeginning($remainder)->value; + $remainder = substr($remainder, strlen($key)); + if ('' === $remainder || '=' !== $remainder[0]) { + $remainder = '=?1'.$remainder; + } + $member = [$key]; + + [$member[1], $offset] = $this->extractItemOrInnerList(substr($remainder, 1)); + $remainder = self::removeCommaSeparatedWhiteSpaces($remainder, ++$offset); + $map[] = $member; + } + + return $map; + } + + /** + * Returns an inner list represented as a PHP list array from an HTTP textual representation. + * + * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-4.2.1.2 + * + * @throws SyntaxError|Exception + * + * @return SfInnerList + */ + public function parseInnerList(Stringable|string $httpValue): array + { + $remainder = ltrim((string) $httpValue, ' '); + if ('(' !== $remainder[0]) { + throw new SyntaxError("The HTTP textual representation \"$httpValue\" for a inner list is missing a parenthesis."); + } + + [$list, $offset] = $this->extractInnerList($remainder); + $remainder = self::removeOptionalWhiteSpaces(substr($remainder, $offset)); + if ('' !== $remainder) { + throw new SyntaxError("The HTTP textual representation \"$httpValue\" for a inner list contains invalid data."); + } + + return $list; + } + + /** + * Filter optional white spaces before and after comma. + * + * @see https://tools.ietf.org/html/rfc7230#section-3.2.3 + */ + private static function removeCommaSeparatedWhiteSpaces(string $remainder, int $offset): string + { + $remainder = self::removeOptionalWhiteSpaces(substr($remainder, $offset)); + if ('' === $remainder) { + return ''; + } + + if (1 !== preg_match(self::REGEXP_VALID_SPACE, $remainder, $found)) { + throw new SyntaxError('The HTTP textual representation is missing an excepted comma.'); + } + + $remainder = substr($remainder, strlen($found['space'])); + + if ('' === $remainder) { + throw new SyntaxError('The HTTP textual representation has an unexpected end of line.'); + } + + return $remainder; + } + + /** + * Remove optional white spaces before field value. + * + * @see https://tools.ietf.org/html/rfc7230#section-3.2.3 + */ + private static function removeOptionalWhiteSpaces(string $httpValue): string + { + return ltrim($httpValue, " \t"); + } + + /** + * Returns an item or an inner list as a PHP list array from an HTTP textual representation. + * + * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-4.2.1.1 + * + * @throws SyntaxError|Exception + * + * @return array{0: SfInnerList|SfItem, 1:int} + */ + private function extractItemOrInnerList(string $httpValue): array + { + if ('(' === $httpValue[0]) { + return $this->extractInnerList($httpValue); + } + + [$item, $remainder] = $this->extractItem($httpValue); + + return [$item, strlen($httpValue) - strlen($remainder)]; + } + + /** + * Returns an inner list represented as a PHP list array from an HTTP textual representation and the consumed offset in a tuple. + * + * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-4.2.1.2 + * + * @throws SyntaxError|Exception + * + * @return array{0: SfInnerList, 1 :int} + */ + private function extractInnerList(string $httpValue): array + { + $list = []; + $remainder = substr($httpValue, 1); + while ('' !== $remainder) { + $remainder = ltrim($remainder, ' '); + + if (')' === $remainder[0]) { + $remainder = substr($remainder, 1); + [$parameters, $offset] = $this->extractParametersValues($remainder); + $remainder = substr($remainder, $offset); + + return [[$list, $parameters], strlen($httpValue) - strlen($remainder)]; + } + + [$list[], $remainder] = $this->extractItem($remainder); + + if ('' !== $remainder && !in_array($remainder[0], [' ', ')'], true)) { + throw new SyntaxError("The HTTP textual representation \"$httpValue\" for a inner list is using invalid characters."); + } + } + + throw new SyntaxError("The HTTP textual representation \"$httpValue\" for a inner list has an unexpected end of line."); + } + + /** + * Returns an item represented as a PHP array from an HTTP textual representation and the consumed offset in a tuple. + * + * @throws SyntaxError|Exception + * + * @return array{0:SfItem, 1:string} + */ + private function extractItem(string $remainder): array + { + [$value, $offset] = $this->extractValue($remainder); + $remainder = substr($remainder, $offset); + [$parameters, $offset] = $this->extractParametersValues($remainder); + + return [[$value, $parameters], substr($remainder, $offset)]; + } + + /** + * Returns an item value from an HTTP textual representation and the consumed offset in a tuple. + * + * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-4.2.3.1 + * + * @throws SyntaxError|Exception + * + * @return array{0:SfValue, 1:int} + */ + private function extractValue(string $httpValue): array + { + return match (true) { + '"' === $httpValue[0] => self::extractString($httpValue), + ':' === $httpValue[0] => self::extractBytes($httpValue), + '?' === $httpValue[0] => self::extractBoolean($httpValue), + '@' === $httpValue[0] => self::extractDate($httpValue, $this->rfc), + str_starts_with($httpValue, '%"') => self::extractDisplayString($httpValue, $this->rfc), + str_contains(self::FIRST_CHARACTER_RANGE_NUMBER, $httpValue[0]) => self::extractNumber($httpValue), + str_contains(self::FIRST_CHARACTER_RANGE_TOKEN, $httpValue[0]) => self::extractToken($httpValue), + default => throw new SyntaxError("The HTTP textual representation \"$httpValue\" for an item value is unknown or unsupported."), + }; + } + + /** + * Returns a parameters container represented as a PHP associative array from an HTTP textual representation and the consumed offset in a tuple. + * + * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-4.2.3.2 + * + * @throws SyntaxError|Exception + * + * @return array{0:SfParameter, 1:int} + */ + private function extractParametersValues(Stringable|string $httpValue): array + { + $map = []; + $httpValue = (string) $httpValue; + $remainder = $httpValue; + while ('' !== $remainder && ';' === $remainder[0]) { + $remainder = ltrim(substr($remainder, 1), ' '); + $key = Key::fromStringBeginning($remainder)->value; + $member = [$key, true]; + $remainder = substr($remainder, strlen($key)); + if ('' !== $remainder && '=' === $remainder[0]) { + $remainder = substr($remainder, 1); + [$member[1], $offset] = $this->extractValue($remainder); + $remainder = substr($remainder, $offset); + } + + $map[] = $member; + } + + return [$map, strlen($httpValue) - strlen($remainder)]; + } + + /** + * Returns a boolean from an HTTP textual representation and the consumed offset in a tuple. + * + * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-4.2.8 + * + * @return array{0:bool, 1:int} + */ + private static function extractBoolean(string $httpValue): array + { + return match (1) { + preg_match(self::REGEXP_BOOLEAN, $httpValue) => ['1' === $httpValue[1], 2], + default => throw new SyntaxError("The HTTP textual representation \"$httpValue\" for a Boolean contains invalid characters."), + }; + } + + /** + * Returns an int or a float from an HTTP textual representation and the consumed offset in a tuple. + * + * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-4.2.4 + * + * @return array{0:int|float, 1:int} + */ + private static function extractNumber(string $httpValue): array + { + if (1 !== preg_match(self::REGEXP_VALID_NUMBER, $httpValue, $found)) { + throw new SyntaxError("The HTTP textual representation \"$httpValue\" for a Number contains invalid characters."); + } + + return match (1) { + preg_match(self::REGEXP_DECIMAL, $found['number']) => [(float) $found['number'], strlen($found['number'])], + preg_match(self::REGEXP_INTEGER, $found['number']) => [(int) $found['number'], strlen($found['number'])], + default => throw new SyntaxError("The HTTP textual representation \"$httpValue\" for a Number contains too much digit."), + }; + } + + /** + * Returns DateTimeImmutable instance from an HTTP textual representation and the consumed offset in a tuple. + * + * @see https://httpwg.org/http-extensions/draft-ietf-httpbis-sfbis.html#name-dates + * + * @throws SyntaxError + * @throws Exception + * + * @return array{0:DateTimeImmutable, 1:int} + */ + private static function extractDate(string $httpValue, Ietf $rfc): array + { + if (!$rfc->supports(Type::Date)) { + throw MissingFeature::dueToLackOfSupport(Type::Date, $rfc); + } + + if (1 !== preg_match(self::REGEXP_DATE, $httpValue, $found)) { + throw new SyntaxError("The HTTP textual representation \"$httpValue\" for a Date contains invalid characters."); + } + + return [new DateTimeImmutable('@'.$found['date']), strlen($found['date']) + 1]; + } + + /** + * Returns a string from an HTTP textual representation and the consumed offset in a tuple. + * + * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-4.2.5 + * + * @return array{0:string, 1:int} + */ + private static function extractString(string $httpValue): array + { + $offset = 1; + $remainder = substr($httpValue, $offset); + $output = ''; + + if (1 === preg_match(self::REGEXP_INVALID_CHARACTERS, $remainder)) { + throw new SyntaxError("The HTTP textual representation \"$httpValue\" for a String contains an invalid end string."); + } + + while ('' !== $remainder) { + $char = $remainder[0]; + $offset += 1; + + if ('"' === $char) { + return [$output, $offset]; + } + + $remainder = substr($remainder, 1); + + if ('\\' !== $char) { + $output .= $char; + continue; + } + + $char = $remainder[0] ?? ''; + $offset += 1; + $remainder = substr($remainder, 1); + + if (!in_array($char, ['"', '\\'], true)) { + throw new SyntaxError("The HTTP textual representation \"$httpValue\" for a String contains an invalid end string."); + } + + $output .= $char; + } + + throw new SyntaxError("The HTTP textual representation \"$httpValue\" for a String contains an invalid end string."); + } + + /** + * Returns a string from an HTTP textual representation and the consumed offset in a tuple. + * + * @see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-sfbis#section-4.2.10 + * + * @return array{0:DisplayString, 1:int} + */ + private static function extractDisplayString(string $httpValue, Ietf $rfc): array + { + if (!$rfc->supports(Type::DisplayString)) { + throw MissingFeature::dueToLackOfSupport(Type::DisplayString, $rfc); + } + + $offset = 2; + $remainder = substr($httpValue, $offset); + $output = ''; + + if (1 === preg_match(self::REGEXP_INVALID_CHARACTERS, $remainder)) { + throw new SyntaxError("The HTTP textual representation \"$httpValue\" for a DisplayString contains an invalid character string."); + } + + while ('' !== $remainder) { + $char = $remainder[0]; + $offset += 1; + + if ('"' === $char) { + return [DisplayString::fromEncoded($output), $offset]; + } + + $remainder = substr($remainder, 1); + if ('%' !== $char) { + $output .= $char; + continue; + } + + $octet = substr($remainder, 0, 2); + $offset += 2; + if (1 === preg_match('/^[0-9a-f]]{2}$/', $octet)) { + throw new SyntaxError("The HTTP textual representation '$httpValue' for a DisplayString contains uppercased percent encoding sequence."); + } + + $remainder = substr($remainder, 2); + $output .= $char.$octet; + } + + throw new SyntaxError("The HTTP textual representation \"$httpValue\" for a DisplayString contains an invalid end string."); + } + + /** + * Returns a Token from an HTTP textual representation and the consumed offset in a tuple. + * + * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-4.2.6 + * + * @return array{0:Token, 1:int} + */ + private static function extractToken(string $httpValue): array + { + preg_match(self::REGEXP_TOKEN, $httpValue, $found); + + $token = $found['token'] ?? throw new SyntaxError("The HTTP textual representation \"$httpValue\" for a Token contains invalid characters."); + + return [Token::fromString($token), strlen($token)]; + } + + /** + * Returns a Byte Sequence from an HTTP textual representation and the consumed offset in a tuple. + * + * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-4.2.7 + * + * @return array{0:Bytes, 1:int} + */ + private static function extractBytes(string $httpValue): array + { + if (1 !== preg_match(self::REGEXP_BYTES, $httpValue, $found)) { + throw new SyntaxError("The HTTP textual representation \"$httpValue\" for a Byte Sequence contains invalid characters."); + } + + return [Bytes::fromEncoded($found['byte']), strlen($found['sequence'])]; + } +} diff --git a/vendor/bakame/http-structured-fields/src/StructuredFieldError.php b/vendor/bakame/http-structured-fields/src/StructuredFieldError.php new file mode 100644 index 000000000..fa823f6c8 --- /dev/null +++ b/vendor/bakame/http-structured-fields/src/StructuredFieldError.php @@ -0,0 +1,11 @@ +<?php + +declare(strict_types=1); + +namespace Bakame\Http\StructuredFields; + +use Throwable; + +interface StructuredFieldError extends Throwable +{ +} diff --git a/vendor/bakame/http-structured-fields/src/StructuredFieldProvider.php b/vendor/bakame/http-structured-fields/src/StructuredFieldProvider.php new file mode 100644 index 000000000..fd9e2be99 --- /dev/null +++ b/vendor/bakame/http-structured-fields/src/StructuredFieldProvider.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +namespace Bakame\Http\StructuredFields; + +use DateTimeImmutable; +use DateTimeInterface; + +/** + * @phpstan-type SfType Bytes|Token|DisplayString|DateTimeImmutable|string|int|float|bool + * @phpstan-type SfTypeInput SfType|DateTimeInterface + * @phpstan-type SfList InnerList|OuterList + * @phpstan-type SfOrderedMap Dictionary|Parameters + * @phpstan-type SfDataType SfList|SfOrderedMap|Item + * @phpstan-type SfItemInput SfTypeInput|SfDataType|StructuredFieldProvider + * @phpstan-type SfMemberInput iterable<SfItemInput>|SfItemInput + * @phpstan-type SfParameterInput iterable<array{0:string, 1?:SfItemInput}> + * @phpstan-type SfInnerListPair array{0:iterable<SfItemInput>, 1?:Parameters|SfParameterInput} + * @phpstan-type SfItemPair array{0:SfTypeInput, 1?:Parameters|SfParameterInput} + */ +interface StructuredFieldProvider +{ + /** + * Returns one of the StructuredField Data Type class. + */ + public function toStructuredField(): Dictionary|InnerList|Item|OuterList|Parameters; +} diff --git a/vendor/bakame/http-structured-fields/src/SyntaxError.php b/vendor/bakame/http-structured-fields/src/SyntaxError.php new file mode 100644 index 000000000..304671fe9 --- /dev/null +++ b/vendor/bakame/http-structured-fields/src/SyntaxError.php @@ -0,0 +1,11 @@ +<?php + +declare(strict_types=1); + +namespace Bakame\Http\StructuredFields; + +use InvalidArgumentException; + +class SyntaxError extends InvalidArgumentException implements StructuredFieldError +{ +} diff --git a/vendor/bakame/http-structured-fields/src/Token.php b/vendor/bakame/http-structured-fields/src/Token.php new file mode 100644 index 000000000..88f41dbe8 --- /dev/null +++ b/vendor/bakame/http-structured-fields/src/Token.php @@ -0,0 +1,52 @@ +<?php + +declare(strict_types=1); + +namespace Bakame\Http\StructuredFields; + +use Stringable; +use Throwable; + +use function preg_match; + +/** + * @see https://www.rfc-editor.org/rfc/rfc9651.html#name-tokens + */ +final class Token +{ + private function __construct(private readonly string $value) + { + if (1 !== preg_match("/^([a-z*][a-z\d:\/!#\$%&'*+\-.^_`|~]*)$/i", $this->value)) { + throw new SyntaxError('The token '.$this->value.' contains invalid characters.'); + } + } + + public function toString(): string + { + return $this->value; + } + + public static function tryFromString(Stringable|string $value): ?self + { + try { + return self::fromString($value); + } catch (Throwable) { + return null; + } + } + + public static function fromString(Stringable|string $value): self + { + return new self((string)$value); + } + + public function equals(mixed $other): bool + { + return $other instanceof self && $other->value === $this->value; + } + + public function type(): Type + { + return Type::Token; + } +} diff --git a/vendor/bakame/http-structured-fields/src/Type.php b/vendor/bakame/http-structured-fields/src/Type.php new file mode 100644 index 000000000..44eb8764d --- /dev/null +++ b/vendor/bakame/http-structured-fields/src/Type.php @@ -0,0 +1,88 @@ +<?php + +declare(strict_types=1); + +namespace Bakame\Http\StructuredFields; + +use DateTimeInterface; + +use function abs; +use function floor; +use function gettype; +use function is_float; +use function is_int; +use function is_object; +use function is_string; +use function preg_match; + +/** + * @see https://www.rfc-editor.org/rfc/rfc9651.html#section-3.3 + */ +enum Type: string +{ + private const MAXIMUM_INT = 999_999_999_999_999; + private const MAXIMUM_FLOAT = 999_999_999_999; + + case Integer = 'integer'; + case Decimal = 'decimal'; + case String = 'string'; + case Token = 'token'; + case Bytes = 'binary'; + case DisplayString = 'displaystring'; + case Boolean = 'boolean'; + case Date = 'date'; + + public function equals(mixed $other): bool + { + return match (true) { + $other instanceof Item => $other->type() === $this, + default => $other instanceof self && $other === $this, + }; + } + + public function isOneOf(mixed ...$other): bool + { + foreach ($other as $item) { + if ($this->equals($item)) { + return true; + } + } + + return false; + } + + /** + * @throws SyntaxError if the value can not be resolved into a supported HTTP structured field data type + */ + public static function fromVariable(Item|Token|DisplayString|Bytes|DateTimeInterface|int|float|bool|string $value): self + { + return self::tryFromVariable($value) ?? throw new SyntaxError(match (true) { + $value instanceof DateTimeInterface => 'The integer representation of a date is limited to 15 digits for a HTTP structured field date type.', + is_int($value) => 'The integer is limited to 15 digits for a HTTP structured field integer type.', + is_float($value) => 'The integer portion of decimals is limited to 12 digits for a HTTP structured field decimal type.', + is_string($value) => 'The string contains characters that are invalid for a HTTP structured field string type', + default => (is_object($value) ? 'An instance of "'.$value::class.'"' : 'A value of type "'.gettype($value).'"').' can not be used as an HTTP structured field value type.', + }); + } + + public static function tryFromVariable(mixed $variable): ?self + { + return match (true) { + $variable instanceof Item, + $variable instanceof Token, + $variable instanceof DisplayString, + $variable instanceof Bytes => $variable->type(), + $variable instanceof DateTimeInterface && self::MAXIMUM_INT >= abs($variable->getTimestamp()) => Type::Date, + is_int($variable) && self::MAXIMUM_INT >= abs($variable) => Type::Integer, + is_float($variable) && self::MAXIMUM_FLOAT >= abs(floor($variable)) => Type::Decimal, + is_bool($variable) => Type::Boolean, + is_string($variable) && 1 !== preg_match('/[^\x20-\x7f]/', $variable) => Type::String, + default => null, + }; + } + + public function supports(mixed $value): bool + { + return self::tryFromVariable($value)?->equals($this) ?? false; + } +} diff --git a/vendor/bakame/http-structured-fields/src/Validation/ErrorCode.php b/vendor/bakame/http-structured-fields/src/Validation/ErrorCode.php new file mode 100644 index 000000000..3f6ed43ae --- /dev/null +++ b/vendor/bakame/http-structured-fields/src/Validation/ErrorCode.php @@ -0,0 +1,26 @@ +<?php + +namespace Bakame\Http\StructuredFields\Validation; + +/** + * General Error Code-. + * + * When adding new codes the name MUST be prefixed with + * a `@` to avoid conflicting with parameters keys. + */ +enum ErrorCode: string +{ + case ItemFailedParsing = '@item.failed.parsing'; + case ItemValueFailedValidation = '@item.value.failed.validation'; + case ParametersFailedParsing = '@parameters.failed.parsing'; + case ParametersMissingConstraints = '@parameters.missing.constraints'; + case ParametersFailedCriteria = '@parameters.failed.criteria'; + + /** + * @return array<string> + */ + public static function list(): array + { + return array_map(fn (self $case) => $case->value, self::cases()); + } +} diff --git a/vendor/bakame/http-structured-fields/src/Validation/ItemValidator.php b/vendor/bakame/http-structured-fields/src/Validation/ItemValidator.php new file mode 100644 index 000000000..21d8cdd2f --- /dev/null +++ b/vendor/bakame/http-structured-fields/src/Validation/ItemValidator.php @@ -0,0 +1,103 @@ +<?php + +declare(strict_types=1); + +namespace Bakame\Http\StructuredFields\Validation; + +use Bakame\Http\StructuredFields\Item; +use Bakame\Http\StructuredFields\StructuredFieldProvider; +use Bakame\Http\StructuredFields\SyntaxError; +use Stringable; + +/** + * Structured field Item validator. + * + * @phpstan-import-type SfType from StructuredFieldProvider + */ +final class ItemValidator +{ + /** @var callable(SfType): (string|bool) */ + private mixed $valueConstraint; + private ParametersValidator $parametersConstraint; + + /** + * @param callable(SfType): (string|bool) $valueConstraint + */ + private function __construct( + callable $valueConstraint, + ParametersValidator $parametersConstraint, + ) { + $this->valueConstraint = $valueConstraint; + $this->parametersConstraint = $parametersConstraint; + } + + public static function new(): self + { + return new self(fn (mixed $value) => false, ParametersValidator::new()); + } + + /** + * Validates the Item value. + * + * On success populate the result item property + * On failure populates the result errors property + * + * @param callable(SfType): (string|bool) $constraint + */ + public function value(callable $constraint): self + { + return new self($constraint, $this->parametersConstraint); + } + + /** + * Validates the Item parameters as a whole. + * + * On failure populates the result errors property + */ + public function parameters(ParametersValidator $constraint): self + { + return new self($this->valueConstraint, $constraint); + } + + public function __invoke(Item|Stringable|string $item): bool|string + { + $result = $this->validate($item); + + return $result->isSuccess() ? true : (string) $result->errors; + } + + /** + * Validates the structured field Item. + */ + public function validate(Item|Stringable|string $item): Result + { + $violations = new ViolationList(); + if (!$item instanceof Item) { + try { + $item = Item::fromHttpValue($item); + } catch (SyntaxError $exception) { + $violations->add(ErrorCode::ItemFailedParsing->value, new Violation('The item string could not be parsed.', previous: $exception)); + + return Result::failed($violations); + } + } + + try { + $itemValue = $item->value($this->valueConstraint); + } catch (Violation $exception) { + $itemValue = null; + $violations->add(ErrorCode::ItemValueFailedValidation->value, $exception); + } + + $validate = $this->parametersConstraint->validate($item->parameters()); + $violations->addAll($validate->errors); + if ($violations->isNotEmpty()) { + return Result::failed($violations); + } + + /** @var ValidatedParameters $validatedParameters */ + $validatedParameters = $validate->data; + + return Result::success(new ValidatedItem($itemValue, $validatedParameters)); + } +} diff --git a/vendor/bakame/http-structured-fields/src/Validation/ParametersValidator.php b/vendor/bakame/http-structured-fields/src/Validation/ParametersValidator.php new file mode 100644 index 000000000..e851b9686 --- /dev/null +++ b/vendor/bakame/http-structured-fields/src/Validation/ParametersValidator.php @@ -0,0 +1,244 @@ +<?php + +declare(strict_types=1); + +namespace Bakame\Http\StructuredFields\Validation; + +use Bakame\Http\StructuredFields\Parameters; +use Bakame\Http\StructuredFields\StructuredFieldProvider; +use Bakame\Http\StructuredFields\SyntaxError; +use Stringable; + +/** + * Structured field Item validator. + * + * @phpstan-import-type SfType from StructuredFieldProvider + * + * @phpstan-type SfParameterKeyRule array{validate?:callable(SfType): (bool|string), required?:bool|string, default?:SfType|null} + * @phpstan-type SfParameterIndexRule array{validate?:callable(SfType, string): (bool|string), required?:bool|string, default?:array{0:string, 1:SfType}|array{}} + */ +final class ParametersValidator +{ + public const USE_KEYS = 1; + public const USE_INDICES = 2; + + /** @var ?callable(Parameters): (string|bool) */ + private mixed $criteria; + private int $type; + /** @var array<string, SfParameterKeyRule>|array<int, SfParameterIndexRule> */ + private array $filterConstraints; + + /** + * @param ?callable(Parameters): (string|bool) $criteria + * @param array<string, SfParameterKeyRule>|array<int, SfParameterIndexRule> $filterConstraints + */ + private function __construct( + ?callable $criteria = null, + int $type = self::USE_KEYS, + array $filterConstraints = [], + ) { + $this->criteria = $criteria; + $this->type = $type; + $this->filterConstraints = $filterConstraints; + } + + public static function new(): self + { + return new self(); + } + + /** + * Validates the Item parameters as a whole. + * + * On failure populates the result errors property + * + * @param ?callable(Parameters): (string|bool) $criteria + */ + public function filterByCriteria(?callable $criteria, int $type = self::USE_KEYS): self + { + return new self($criteria, [] === $this->filterConstraints ? $type : $this->type, $this->filterConstraints); + } + + /** + * Validate each parameters value per name. + * + * On success populate the result item property + * On failure populates the result errors property + * + * @param array<string, SfParameterKeyRule> $constraints + */ + public function filterByKeys(array $constraints): self + { + return new self($this->criteria, self::USE_KEYS, $constraints); + } + + /** + * Validate each parameters value per indices. + * + * On success populate the result item property + * On failure populates the result errors property + * + * @param array<int, SfParameterIndexRule> $constraints + */ + public function filterByIndices(array $constraints): self + { + return new self($this->criteria, self::USE_INDICES, $constraints); + } + + public function __invoke(Parameters|Stringable|string $parameters): bool|string + { + $result = $this->validate($parameters); + + return $result->isSuccess() ? true : (string) $result->errors; + } + + /** + * Validates the structured field Item. + */ + public function validate(Parameters|Stringable|string $parameters): Result + { + $violations = new ViolationList(); + if (!$parameters instanceof Parameters) { + try { + $parameters = Parameters::fromHttpValue($parameters); + } catch (SyntaxError $exception) { + $violations->add(ErrorCode::ParametersFailedParsing->value, new Violation('The parameters string could not be parsed.', previous: $exception)); + + return Result::failed($violations); + } + } + + if ([] === $this->filterConstraints && null === $this->criteria) { + $violations->add(ErrorCode::ParametersMissingConstraints->value, new Violation('The parameters constraints are missing.')); + } + + $parsedParameters = new ValidatedParameters(); + if ([] !== $this->filterConstraints) { + $parsedParameters = match ($this->type) { + self::USE_INDICES => $this->validateByIndices($parameters), + default => $this->validateByKeys($parameters), + }; + + if ($parsedParameters->isFailed()) { + $violations->addAll($parsedParameters->errors); + } else { + $parsedParameters = $parsedParameters->data; + } + } + + $errorMessage = $this->validateByCriteria($parameters); + if (!is_bool($errorMessage)) { + $violations->add(ErrorCode::ParametersFailedCriteria->value, new Violation($errorMessage)); + } + + /** @var ValidatedParameters $parsedParameters */ + $parsedParameters = $parsedParameters ?? new ValidatedParameters(); + if ([] === $this->filterConstraints && true === $errorMessage) { + $parsedParameters = new ValidatedParameters(match ($this->type) { + self::USE_KEYS => $this->toAssociative($parameters), + default => $this->toList($parameters), + }); + } + + return match ($violations->isNotEmpty()) { + true => Result::failed($violations), + default => Result::success($parsedParameters), + }; + } + + private function validateByCriteria(Parameters $parameters): bool|string + { + if (null === $this->criteria) { + return true; + } + + $errorMessage = ($this->criteria)($parameters); + if (true === $errorMessage) { + return true; + } + + if (!is_string($errorMessage) || '' === trim($errorMessage)) { + $errorMessage = 'The parameters constraints are not met.'; + } + + return $errorMessage; + } + + /** + * Validate the current parameter object using its keys and return the parsed values and the errors. + * + * @return Result<ValidatedParameters>|Result<null> + */ + private function validateByKeys(Parameters $parameters): Result /* @phpstan-ignore-line */ + { + $data = []; + $violations = new ViolationList(); + /** + * @var string $key + * @var SfParameterKeyRule $rule + */ + foreach ($this->filterConstraints as $key => $rule) { + try { + $data[$key] = $parameters->valueByKey($key, $rule['validate'] ?? null, $rule['required'] ?? false, $rule['default'] ?? null); + } catch (Violation $exception) { + $violations[$key] = $exception; + } + } + + return match ($violations->isNotEmpty()) { + true => Result::failed($violations), + default => Result::success(new ValidatedParameters($data)), + }; + } + + /** + * Validate the current parameter object using its indices and return the parsed values and the errors. + */ + public function validateByIndices(Parameters $parameters): Result + { + $data = []; + $violations = new ViolationList(); + /** + * @var int $index + * @var SfParameterIndexRule $rule + */ + foreach ($this->filterConstraints as $index => $rule) { + try { + $data[$index] = $parameters->valueByIndex($index, $rule['validate'] ?? null, $rule['required'] ?? false, $rule['default'] ?? []); + } catch (Violation $exception) { + $violations[$index] = $exception; + } + } + + return match ($violations->isNotEmpty()) { + true => Result::failed($violations), + default => Result::success(new ValidatedParameters($data)), + }; + } + + /** + * @return array<string,SfType> + */ + private function toAssociative(Parameters $parameters): array + { + $assoc = []; + foreach ($parameters as $parameter) { + $assoc[$parameter[0]] = $parameter[1]->value(); + } + + return $assoc; + } + + /** + * @return array<int, array{0:string, 1:SfType}> + */ + private function toList(Parameters $parameters): array + { + $list = []; + foreach ($parameters as $index => $parameter) { + $list[$index] = [$parameter[0], $parameter[1]->value()]; + } + + return $list; + } +} diff --git a/vendor/bakame/http-structured-fields/src/Validation/Result.php b/vendor/bakame/http-structured-fields/src/Validation/Result.php new file mode 100644 index 000000000..f765efda0 --- /dev/null +++ b/vendor/bakame/http-structured-fields/src/Validation/Result.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); + +namespace Bakame\Http\StructuredFields\Validation; + +final class Result +{ + private function __construct( + public readonly ValidatedParameters|ValidatedItem|null $data, + public readonly ViolationList $errors, + ) { + } + + public function isSuccess(): bool + { + return $this->errors->isEmpty(); + } + + public function isFailed(): bool + { + return $this->errors->isNotEmpty(); + } + + public static function success(ValidatedItem|ValidatedParameters $data): self + { + return new self($data, new ViolationList()); + } + + public static function failed(ViolationList $errors): self + { + return new self(null, $errors); + } +} diff --git a/vendor/bakame/http-structured-fields/src/Validation/ValidatedItem.php b/vendor/bakame/http-structured-fields/src/Validation/ValidatedItem.php new file mode 100644 index 000000000..df61a1aa8 --- /dev/null +++ b/vendor/bakame/http-structured-fields/src/Validation/ValidatedItem.php @@ -0,0 +1,19 @@ +<?php + +declare(strict_types=1); + +namespace Bakame\Http\StructuredFields\Validation; + +use Bakame\Http\StructuredFields\Bytes; +use Bakame\Http\StructuredFields\DisplayString; +use Bakame\Http\StructuredFields\Token; +use DateTimeImmutable; + +final class ValidatedItem +{ + public function __construct( + public readonly Bytes|Token|DisplayString|DateTimeImmutable|string|int|float|bool|null $value, + public readonly ValidatedParameters $parameters = new ValidatedParameters(), + ) { + } +} diff --git a/vendor/bakame/http-structured-fields/src/Validation/ValidatedParameters.php b/vendor/bakame/http-structured-fields/src/Validation/ValidatedParameters.php new file mode 100644 index 000000000..c7f5b5eb6 --- /dev/null +++ b/vendor/bakame/http-structured-fields/src/Validation/ValidatedParameters.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); + +namespace Bakame\Http\StructuredFields\Validation; + +use ArrayAccess; +use Bakame\Http\StructuredFields\ForbiddenOperation; +use Bakame\Http\StructuredFields\InvalidOffset; +use Bakame\Http\StructuredFields\StructuredFieldProvider; +use Countable; +use Iterator; +use IteratorAggregate; + +/** + * @phpstan-import-type SfType from StructuredFieldProvider + * + * @implements ArrayAccess<array-key, array{0:string, 1:SfType}|array{}|SfType|null> + * @implements IteratorAggregate<array-key, array{0:string, 1:SfType}|array{}|SfType|null> + */ +final class ValidatedParameters implements ArrayAccess, Countable, IteratorAggregate +{ + /** + * @param array<array-key, array{0:string, 1:SfType}|array{}|SfType|null> $values + */ + public function __construct( + private readonly array $values = [], + ) { + } + + public function count(): int + { + return count($this->values); + } + + public function getIterator(): Iterator + { + yield from $this->values; + } + + public function offsetExists($offset): bool + { + return array_key_exists($offset, $this->values); + } + + public function offsetGet($offset): mixed + { + return $this->offsetExists($offset) ? $this->values[$offset] : throw InvalidOffset::dueToMemberNotFound($offset); + } + + public function offsetUnset(mixed $offset): void + { + throw new ForbiddenOperation(self::class.' instance can not be updated using '.ArrayAccess::class.' methods.'); + } + + public function offsetSet(mixed $offset, mixed $value): void + { + throw new ForbiddenOperation(self::class.' instance can not be updated using '.ArrayAccess::class.' methods.'); + } + + /** + * @return array<array-key, array{0:string, 1:SfType}|array{}|SfType|null> + */ + public function all(): array + { + return $this->values; + } +} diff --git a/vendor/bakame/http-structured-fields/src/Validation/Violation.php b/vendor/bakame/http-structured-fields/src/Validation/Violation.php new file mode 100644 index 000000000..ec666de98 --- /dev/null +++ b/vendor/bakame/http-structured-fields/src/Validation/Violation.php @@ -0,0 +1,12 @@ +<?php + +declare(strict_types=1); + +namespace Bakame\Http\StructuredFields\Validation; + +use Bakame\Http\StructuredFields\StructuredFieldError; +use LogicException; + +final class Violation extends LogicException implements StructuredFieldError +{ +} diff --git a/vendor/bakame/http-structured-fields/src/Validation/ViolationList.php b/vendor/bakame/http-structured-fields/src/Validation/ViolationList.php new file mode 100644 index 000000000..8cbf5e741 --- /dev/null +++ b/vendor/bakame/http-structured-fields/src/Validation/ViolationList.php @@ -0,0 +1,169 @@ +<?php + +declare(strict_types=1); + +namespace Bakame\Http\StructuredFields\Validation; + +use ArrayAccess; +use Bakame\Http\StructuredFields\InvalidOffset; +use Countable; +use Iterator; +use IteratorAggregate; +use Stringable; +use TypeError; + +use function array_filter; +use function array_map; +use function count; +use function implode; +use function is_int; + +use const ARRAY_FILTER_USE_BOTH; + +/** + * @implements IteratorAggregate<array-key,Violation> + * @implements ArrayAccess<array-key,Violation> + */ +final class ViolationList implements IteratorAggregate, Countable, ArrayAccess, Stringable +{ + /** @var array<Violation> */ + private array $errors = []; + + /** + * @param iterable<array-key, Violation> $errors + */ + public function __construct(iterable $errors = []) + { + $this->addAll($errors); + } + + public function count(): int + { + return count($this->errors); + } + + public function getIterator(): Iterator + { + yield from $this->errors; + } + + public function __toString(): string + { + return implode(PHP_EOL, array_map(fn (Violation $e): string => $e->getMessage(), $this->errors)); + } + + /** + * @return array<array-key, string> + */ + public function summary(): array + { + return array_map(fn (Violation $e): string => $e->getMessage(), $this->errors); + } + + public function isEmpty(): bool + { + return [] === $this->errors; + } + + public function isNotEmpty(): bool + { + return ! $this->isEmpty(); + } + + /** + * @param string|int $offset + */ + public function offsetExists(mixed $offset): bool + { + return $this->has($offset); + } + + /** + * @param string|int $offset + * + * @return Violation + */ + public function offsetGet(mixed $offset): mixed + { + return $this->get($offset); + } + + /** + * @param string|int $offset + */ + public function offsetUnset(mixed $offset): void + { + unset($this->errors[$offset]); + } + + /** + * @param string|int|null $offset + * @param Violation $value + */ + public function offsetSet(mixed $offset, mixed $value): void + { + if (null === $offset) { + throw new TypeError('null can not be used as a valid offset value.'); + } + $this->add($offset, $value); + } + + public function has(string|int $offset): bool + { + if (is_int($offset)) { + return null !== $this->filterIndex($offset); + } + + return array_key_exists($offset, $this->errors); + } + + public function get(string|int $offset): Violation + { + return $this->errors[$this->filterIndex($offset) ?? throw InvalidOffset::dueToIndexNotFound($offset)]; + } + + public function add(string|int $offset, Violation $error): void + { + $this->errors[$offset] = $error; + } + + /** + * @param iterable<array-key, Violation> $errors + */ + public function addAll(iterable $errors): void + { + foreach ($errors as $offset => $error) { + $this->add($offset, $error); + } + } + + private function filterIndex(string|int $index, int|null $max = null): string|int|null + { + if (!is_int($index)) { + return $index; + } + + $max ??= count($this->errors); + + return match (true) { + [] === $this->errors, + 0 > $max + $index, + 0 > $max - $index - 1 => null, + 0 > $index => $max + $index, + default => $index, + }; + } + + /** + * @param callable(Violation, array-key): bool $callback + */ + public function filter(callable $callback): self + { + return new self(array_filter($this->errors, $callback, ARRAY_FILTER_USE_BOTH)); + } + + public function toException(): Violation + { + return new Violation((string) $this); + } +} |